Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/lead_submissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def check
submissions = current_user.lead_submissions.where(organization_lead_form_id: form_ids)
.pluck(:organization_lead_form_id, :created_at)
result = submissions.to_h { |form_id, created_at| [form_id.to_s, created_at.iso8601] }
render json: result
render json: result.merge("csrf_token" => form_authenticity_token)
end

def create
Expand Down
54 changes: 54 additions & 0 deletions app/liquid_tags/org_lead_gate_tag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# A lead-generation gate for organization pages. The enclosed HTML remains in
# the public page source, so this must not be used as an authorization boundary.
class OrgLeadGateTag < Liquid::Block
PARTIAL = "liquids/org_lead_gate".freeze
VALID_CONTEXTS = %w[Organization].freeze

def initialize(_tag_name, input, parse_context)
super
source = parse_context.partial_options[:source]
validate_source(source)
@form = find_form(input)

return if @form.organization_id == source.id

raise StandardError, I18n.t("liquid_tags.org_lead_form_tag.wrong_organization")
end

def render(context)
ApplicationController.render(
partial: PARTIAL,
locals: { form: @form, gated_content: super },
)
end

private

def find_form(input)
form_id = Integer(input.strip, exception: false)
unless form_id&.positive?
raise StandardError, I18n.t("liquid_tags.org_lead_gate_tag.invalid_id")
end

form = OrganizationLeadForm.find_by(id: form_id)
raise StandardError, I18n.t("liquid_tags.org_lead_form_tag.not_found") unless form
raise StandardError, I18n.t("liquid_tags.org_lead_form_tag.inactive") unless form.active?

form
end

def validate_source(source)
unless source
raise LiquidTags::Errors::InvalidParseContext,
I18n.t("liquid_tags.liquid_tag_base.no_source_found")
end

return if VALID_CONTEXTS.include?(source.class.name)

valid_contexts = VALID_CONTEXTS.map(&:pluralize).join(", ")
error_message = I18n.t("liquid_tags.liquid_tag_base.invalid_context", valid: valid_contexts)
raise LiquidTags::Errors::InvalidParseContext, error_message
end
end

Liquid::Template.register_tag("org_lead_gate", OrgLeadGateTag)
6 changes: 5 additions & 1 deletion app/services/html/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ def wrap_all_images_in_links
doc.search("p img").each do |image|
next if image.parent.name == "a"

image.swap("<a href='#{image.attr('src')}' class='article-body-image-wrapper'>#{image}</a>")
link = doc.document.create_element("a")
link["href"] = image.attr("src").to_s
link["class"] = "article-body-image-wrapper"
image.replace(link)
link.add_child(image)
end

@html = doc.to_html
Expand Down
101 changes: 101 additions & 0 deletions app/views/liquids/_org_lead_gate.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<div class="ltag-org-lead-gate crayons-card p-6 my-4" data-org-lead-gate data-lead-form-id="<%= form.id %>">
<div data-org-lead-gate-prompt>
<h3 class="crayons-subtitle-2 mb-2"><%= form.title %></h3>
<% if form.description.present? %>
<p class="color-base-70 mb-3"><%= form.description %></p>
<% end %>

<div data-org-lead-gate-signed-out hidden>
<p class="color-base-70 mb-3"><%= I18n.t("liquid_tags.org_lead_gate_tag.sign_in_required", community: Settings::Community.community_name) %></p>
<a href="/enter" class="crayons-btn" data-no-instant><%= I18n.t("liquid_tags.org_lead_gate_tag.sign_in") %></a>
</div>

<div data-org-lead-gate-signed-in hidden>
<button type="button" class="crayons-btn" data-org-lead-gate-submit><%= form.button_text %></button>
<span class="fs-s color-base-60 ml-2" role="status" aria-live="polite" data-org-lead-gate-status hidden></span>
<p class="fs-s color-base-60 mt-3"><%= I18n.t("liquid_tags.org_lead_gate_tag.data_shared") %></p>
</div>
</div>

<template data-org-lead-gate-content><%= gated_content.html_safe %></template>
<div data-org-lead-gate-unlocked hidden></div>
</div>

<script>
(function() {
document.querySelectorAll('[data-org-lead-gate]:not([data-org-lead-gate-initialized])').forEach(function(gate) {
gate.setAttribute('data-org-lead-gate-initialized', 'true');

var formId = gate.getAttribute('data-lead-form-id');
var signedOutView = gate.querySelector('[data-org-lead-gate-signed-out]');
var signedInView = gate.querySelector('[data-org-lead-gate-signed-in]');
var submitButton = gate.querySelector('[data-org-lead-gate-submit]');
var status = gate.querySelector('[data-org-lead-gate-status]');
var submitLabel = submitButton.textContent;
var csrfToken;

function unlock() {
var content = gate.querySelector('[data-org-lead-gate-content]');
var target = gate.querySelector('[data-org-lead-gate-unlocked]');
target.appendChild(content.content.cloneNode(true));
gate.querySelector('[data-org-lead-gate-prompt]').hidden = true;
target.hidden = false;
}

function showError(message, retryable) {
status.textContent = message;
status.hidden = false;
submitButton.textContent = submitLabel;
submitButton.disabled = !retryable;
}

if (document.body.getAttribute('data-user-status') !== 'logged-in') {
signedOutView.hidden = false;
return;
}

fetch('/lead_submissions/check?form_ids=' + formId, { credentials: 'same-origin' })
.then(function(response) {
if (!response.ok) throw new Error('Unable to check lead submission');
return response.json();
})
.then(function(data) {
csrfToken = data.csrf_token;
if (!csrfToken) throw new Error('Unable to check lead submission');

if (data[formId]) {
unlock();
} else {
signedInView.hidden = false;
}
})
.catch(function() {
signedInView.hidden = false;
showError('<%= j I18n.t("liquid_tags.org_lead_form_tag.error") %>', false);
});

submitButton.addEventListener('click', function() {
submitButton.disabled = true;
submitButton.textContent = '<%= j I18n.t("liquid_tags.org_lead_form_tag.submitting") %>';
status.hidden = true;

fetch('/lead_submissions', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ organization_lead_form_id: formId })
}).then(function(response) {
return response.json().then(function(data) {
if (!response.ok || !data.success) throw new Error(data.error);
unlock();
});
}).catch(function() {
showError('<%= j I18n.t("liquid_tags.org_lead_form_tag.error") %>', true);
});
});
});
})();
</script>
2 changes: 1 addition & 1 deletion config/fastly/snippets/safe_params_list.vcl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import querystring;
sub vcl_recv {
# return this URL with only the parameters that match this regular expression
if (req.url !~ "/ahoy/" && req.url !~ "/admin/" && req.url !~ "/search/" && req.url !~ "/bulk_show" && !(req.url ~ "^/api" && req.http.api-key)) {
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|email|filter|followable_id|followable_type|forem_owner_secret|fork_id|i|key|message_offset|name|oauth_token|oauth_verifier|offset|onboarding|org_id|organization_id|p|page|per_page|p_id|placement_area|prefill|preview|purchaser|q|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|source_id|source_type|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb|invitation_slug|period|comments_sort|billboard|controller_action|bb_test_placement_area|cookies_allowed|members|bb_test_id|item|mode|month|page_id|token|passed_domain)$");
set req.url = querystring.regfilter_except(req.url, "^(a_id|args|article_id|article_ids|articles|asc|callback_url|category|client_id|code|collection_id|commentable_id|commentable_type|confirmation_token|created_at|end|email|filter|followable_id|followable_type|forem_owner_secret|fork_id|form_ids|i|key|message_offset|name|oauth_token|oauth_verifier|offset|onboarding|org_id|organization_id|p|page|per_page|p_id|placement_area|prefill|preview|purchaser|q|reactable_ids|redirect_uri|reported_url|reporter_username|response_type|scope|search|signature|sort|source_id|source_type|start|state|status|tag|tag_list|top|type_of|url|username|invitation_token|reset_password_token|ut|verb|invitation_slug|period|comments_sort|billboard|controller_action|bb_test_placement_area|cookies_allowed|members|bb_test_id|item|mode|month|page_id|token|passed_domain)$");
}
}
5 changes: 5 additions & 0 deletions config/locales/liquid_tags/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,10 @@ en:
field_job_title: Job Title
name_email_required: Name and email are required.
wrong_organization: This lead form does not belong to this organization.
org_lead_gate_tag:
invalid_id: "Invalid lead form ID. Use: {% org_lead_gate ID %}...{% endorg_lead_gate %}"
sign_in_required: Sign in with your %{community} account to submit this form and unlock the content.
sign_in: Sign in to unlock
data_shared: "Shares your name, email, username, company, and title with this organization."
youtube_tag:
invalid_youtube_id: Invalid YouTube ID or URL
5 changes: 5 additions & 0 deletions config/locales/liquid_tags/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,10 @@ fr:
field_job_title: Poste
name_email_required: Le nom et l'e-mail sont obligatoires.
wrong_organization: Ce formulaire de leads n'appartient pas 脿 cette organisation.
org_lead_gate_tag:
invalid_id: "ID de formulaire de leads non valide. Utilisez : {% org_lead_gate ID %}...{% endorg_lead_gate %}"
sign_in_required: Connectez-vous avec votre compte %{community} pour envoyer ce formulaire et d茅verrouiller le contenu.
sign_in: Se connecter pour d茅verrouiller
data_shared: "Partage votre nom, e-mail, nom d鈥檜tilisateur, entreprise et poste avec cette organisation."
youtube_tag:
invalid_youtube_id: ID ou URL YouTube non valide
5 changes: 5 additions & 0 deletions config/locales/liquid_tags/pt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,10 @@ pt:
field_job_title: Cargo
name_email_required: Nome e e-mail s茫o obrigat贸rios.
wrong_organization: Este formul谩rio de leads n茫o pertence a esta organiza莽茫o.
org_lead_gate_tag:
invalid_id: "ID de formul谩rio de leads inv谩lido. Use: {% org_lead_gate ID %}...{% endorg_lead_gate %}"
sign_in_required: Entre com sua conta %{community} para enviar este formul谩rio e desbloquear o conte煤do.
sign_in: Entrar para desbloquear
data_shared: "Compartilha seu nome, e-mail, nome de usu谩rio, empresa e cargo com esta organiza莽茫o."
youtube_tag:
invalid_youtube_id: ID do YouTube inv谩lido
86 changes: 86 additions & 0 deletions spec/liquid_tags/org_lead_gate_tag_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
require "rails_helper"

RSpec.describe OrgLeadGateTag, type: :liquid_tag do
let(:organization) { create(:organization) }
let(:lead_form) { create(:organization_lead_form, organization: organization, title: "Watch the recording") }
let(:liquid_tag_options) { { source: organization, user: nil } }

def parse_tag(input = lead_form.id.to_s, content: "<p>Gated recording</p>", options: liquid_tag_options)
Liquid::Template.parse(
"{% org_lead_gate #{input} %}#{content}{% endorg_lead_gate %}",
options,
)
end

before do
Liquid::Template.register_tag("org_lead_gate", described_class)
end

it "renders a signed-in lead gate with deferred content" do
rendered = parse_tag.render

expect(rendered).to include("ltag-org-lead-gate")
expect(rendered).to include("Watch the recording")
expect(rendered).to include("data-org-lead-gate-submit")
expect(rendered).to include("name, email, username, company, and title")
expect(rendered).to include('role="status" aria-live="polite"')
expect(rendered).to include("<template data-org-lead-gate-content><p>Gated recording</p></template>")
expect(rendered).not_to include('input name="email"')
end

it "checks for an existing authenticated submission before showing the form" do
rendered = parse_tag.render

expect(rendered).to include("/lead_submissions/check?form_ids=")
expect(rendered).to include("csrfToken = data.csrf_token")
expect(rendered).to include("if (data[formId])")
expect(rendered).to include("document.body.getAttribute('data-user-status') !== 'logged-in'")
end

it "does not enable submission when the authenticated check fails" do
rendered = parse_tag.render

expect(rendered).to include("submitButton.disabled = !retryable")
expect(rendered).to include("showError('Something went wrong. Please try again.', false)")
end

it "does not expose raw browser errors when submission fails" do
rendered = parse_tag.render

expect(rendered).to include("showError('Something went wrong. Please try again.', true)")
expect(rendered).not_to include("showError(error.message")
end

it "preserves the deferred content through the Markdown renderer" do
markdown = "{% org_lead_gate #{lead_form.id} %}**Gated recording**{% endorg_lead_gate %}"
rendered = MarkdownProcessor::Parser.new(markdown, source: organization).finalize

expect(rendered).to include("<template data-org-lead-gate-content><strong>Gated recording</strong></template>")
end

it "rejects a non-numeric form ID" do
expect { parse_tag("abc") }
.to raise_error(StandardError, I18n.t("liquid_tags.org_lead_gate_tag.invalid_id"))
end

it "rejects an inactive form" do
lead_form.update!(active: false)

expect { parse_tag }
.to raise_error(StandardError, I18n.t("liquid_tags.org_lead_form_tag.inactive"))
end

it "rejects a form owned by another organization" do
other_form = create(:organization_lead_form)

expect { parse_tag(other_form.id.to_s) }
.to raise_error(StandardError, I18n.t("liquid_tags.org_lead_form_tag.wrong_organization"))
end

it "rejects use outside an organization page" do
options = { source: build(:billboard), user: nil }

expect { parse_tag(options: options) }
.to raise_error(LiquidTags::Errors::InvalidParseContext)
end
end
23 changes: 23 additions & 0 deletions spec/requests/lead_submissions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@
let(:lead_form) { create(:organization_lead_form, organization: organization) }
let(:user) { create(:user) }

describe "GET /lead_submissions/check" do
before { sign_in user }

it "requires a signed-in user" do
sign_out user

get "/lead_submissions/check", params: { form_ids: lead_form.id }, as: :json

expect(response).to have_http_status(:unauthorized)
end

it "returns the current user's submissions in the existing shape and a fresh CSRF token" do
submission = create(:lead_submission, organization_lead_form: lead_form, user: user)

get "/lead_submissions/check", params: { form_ids: lead_form.id }, as: :json

expect(response).to have_http_status(:ok)
expect(response.parsed_body[lead_form.id.to_s]).to eq(submission.created_at.iso8601)
expect(response.parsed_body["csrf_token"]).to be_present
expect(response.parsed_body).not_to have_key("submissions")
end
end

describe "POST /lead_submissions" do
context "when signed in" do
before { sign_in user }
Expand Down
26 changes: 24 additions & 2 deletions spec/services/html/parser_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,31 @@
end

it "wraps image in link" do
html = "<p><img src='https://image.com/image.jpg'></p"
html = "<p><img src='https://image.com/image.jpg'></p>"
parsed_html = described_class.new(html).wrap_all_images_in_links.html
expect(parsed_html).to include("<a")
expected = "<a href=\"https://image.com/image.jpg\" class=\"article-body-image-wrapper\">" \
"<img src=\"https://image.com/image.jpg\"></a>"
expect(parsed_html).to include(expected)
end

it "does not wrap images already inside a link" do
html = "<p><a href='https://example.com'><img src='https://image.com/image.jpg'></a></p>"
parsed_html = described_class.new(html).wrap_all_images_in_links.html
doc = Nokogiri::HTML.fragment(parsed_html)
expect(doc.css("a").size).to eq(1)
expect(doc.at_css("a")["href"]).to eq("https://example.com")
end

it "safely escapes src attributes containing quotes without injecting event handlers" do
html = "<p><img src=\"https://github.com/any/repo/badge.svg?x=&#x27;onmouseover=&#x27;alert(1)\" alt=\"B\"></p>"
parsed_html = described_class.new(html).wrap_all_images_in_links.html
doc = Nokogiri::HTML.fragment(parsed_html)

link = doc.at_css("a")
expect(link).to be_present
expect(link["onmouseover"]).to be_nil
expect(link["class"]).to eq("article-body-image-wrapper")
expect(doc.xpath("//*[@onmouseover]")).to be_empty
end
end

Expand Down
Loading