diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index c6e37af52..1751ca4f0 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -12,6 +12,14 @@ class CalloutsController < ApplicationController # above the app-controlled content, plus any resources linked to the row. before_action :set_builtin_content, only: %i[ payment scholarship certificate videoconference ] + # The single-resource page previews a PDF in an (object-src). The + # global policy blocks / with object_src :none; relax to :self + # only here, matching ResourcesController#show. The blob streams same-origin + # via proxy mode, so :self covers it. + content_security_policy(only: :resource) do |policy| + policy.object_src :self + end + # Hidden Resource (by title) backing the handout links, in display order. # Missing ones (e.g. not seeded in an environment) are silently skipped. HANDOUT_RESOURCE_TITLES = [ @@ -179,11 +187,18 @@ def set_event # materialized callout row for this action's magic_key. Nil/empty when the # event hasn't materialized the card. Payment renders its own document list # (W-9 + invoice/receipt) in its Documents section, so it skips the generic - # inline resource list here. + # resource list here. Resources render as callout cards linking to their own + # registrant page (PDF preview + download), each returning to this callout — + # never inline. def set_builtin_content callout = @event.registration_ticket_callouts.find_by(magic_key: action_name) @builtin_intro = callout&.description.presence - @builtin_resources = callout && action_name != "payment" ? callout.resources.to_a : [] + resources = callout && action_name != "payment" ? callout.resources.to_a : [] + @builtin_resource_cards = resources.map do |resource| + resource_card(icon: "fa-solid fa-file-lines", title: resource.title, + subtitle: "Open this document", + href: registration_resource_path(@event_registration.slug, resource, return_to: action_name), target: nil) + end end # Update form submission if ce record is updated via callout diff --git a/app/controllers/registration_ticket_callouts_controller.rb b/app/controllers/registration_ticket_callouts_controller.rb index 61cbd9d5f..1dcc0dac3 100644 --- a/app/controllers/registration_ticket_callouts_controller.rb +++ b/app/controllers/registration_ticket_callouts_controller.rb @@ -21,6 +21,7 @@ def show return end + @resource_cards = build_resource_cards @event = @event.decorate end @@ -40,6 +41,24 @@ def update private + # This callout's resources as clickable cards linking to each resource's own + # page (never inline). A registrant reaching the page carries their ticket slug + # in `reg`, so links go to the in-ticket registrant resource page returning here; + # without a slug (e.g. an admin preview) they fall back to the resource's own page. + def build_resource_cards + reg_slug = params[:reg].presence + @callout.resources.map do |resource| + href = if reg_slug + registration_resource_path(reg_slug, resource, return_to: "callout", callout_id: @callout.id) + else + resource_path(resource) + end + MagicTicketCallouts::Card.new(icon_class: "fa-solid fa-file-lines", color: "blue", + title: resource.title, subtitle: "Open this document", + href:, target: nil, trailing_icon: "fa-solid fa-arrow-right") + end + end + def set_event @event = Event.find(params[:event_id]) end diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index c5196b859..deb644e4d 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -3,6 +3,14 @@ class ResourcesController < ApplicationController skip_before_action :authenticate_user!, only: [ :index, :show, :download ] + # The show page previews a PDF in an , which is governed by object-src. + # The global policy sets object_src :none to block legacy / + # injection app-wide; relax it to :self only here, where the one legitimate PDF + # embed lives. The blob streams same-origin via proxy mode, so :self is enough. + content_security_policy(only: :show) do |policy| + policy.object_src :self + end + def index authorize! @author = Person.find_by(id: params[:author_id]) if params[:author_id].present? diff --git a/app/views/assets/_resource_display.html.erb b/app/views/assets/_resource_display.html.erb index 174f80fe0..5069f6c3a 100644 --- a/app/views/assets/_resource_display.html.erb +++ b/app/views/assets/_resource_display.html.erb @@ -1,9 +1,9 @@ <% file = resource.downloadable_asset&.file&.attached? ? resource.downloadable_asset.file : resource.display_image %> <% if file.respond_to?(:content_type) && file.content_type == "application/pdf" %> -
+
<% back_path = local_assigns.fetch(:back_path) { registration_ticket_path(@event_registration.slug) } %> <% back_label = local_assigns.fetch(:back_label, "Back to ticket") %> +<%# Most callout pages are a narrow card; the single-resource page passes a wider + value so a multi-page PDF preview has room for the browser's page sidebar. %> +<% container_width = local_assigns.fetch(:container_width, "max-w-3xl") %> <% if back_path %> -
+
<%= link_to back_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> <%= back_label %> @@ -16,7 +19,7 @@
<% end %> -
+
@@ -39,10 +42,10 @@ <% end %> <%= yield %> - <% if @builtin_resources.present? %> -
- <% @builtin_resources.each do |resource| %> - <%= render "events/callouts/resource_body", resource: resource.decorate %> + <% if @builtin_resource_cards.present? %> +
+ <% @builtin_resource_cards.each do |card| %> + <%= render "event_registrations/callout_card", callout: card %> <% end %>
<% end %> diff --git a/app/views/events/callouts/resource.html.erb b/app/views/events/callouts/resource.html.erb index ac09fcfe6..3d5053eaf 100644 --- a/app/views/events/callouts/resource.html.erb +++ b/app/views/events/callouts/resource.html.erb @@ -1,18 +1,32 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "#{@resource.title} — #{@event.title}") %> <%# Eyebrow and header reflect the callout the registrant came from - (handouts/payment): the header reads "Handouts detail" / "Payment detail" with - the resource's own title shown inside the card. Reached directly, it falls - back to the ticket eyebrow and the resource title as the header. %> + (handouts/payment/a built-in or custom callout): the header reads + " detail" with the resource's own title shown inside the card. + Reached directly, it falls back to the ticket eyebrow and the resource title + as the header. %> <% back_path, back_label, detail_label = case params[:return_to] when "handouts" [ registration_handouts_path(@event_registration.slug), "Back to handouts", "Handouts detail" ] when "payment" [ registration_payment_path(@event_registration.slug), "Back to payment", "Payment detail" ] + when "scholarship" + [ registration_scholarship_path(@event_registration.slug), "Back to scholarship", "Scholarship detail" ] + when "certificate" + [ registration_certificate_path(@event_registration.slug), "Back to certificate", "Certificate detail" ] + when "videoconference" + [ registration_videoconference_path(@event_registration.slug), "Back to videoconference", "Videoconference detail" ] + when "callout" + callout = @event.registration_ticket_callouts.find_by(id: params[:callout_id]) + if callout + [ event_registration_ticket_callout_path(@event, callout, reg: @event_registration.slug), "Back to #{callout.title}", "#{callout.title} detail" ] + else + [ registration_ticket_path(@event_registration.slug), "Back to ticket", nil ] + end else [ registration_ticket_path(@event_registration.slug), "Back to ticket", nil ] end %> -<%= render layout: "events/callouts/callout_page", locals: { title: detail_label || @resource.title, back_path: back_path, back_label: back_label } do %> +<%= render layout: "events/callouts/callout_page", locals: { title: detail_label || @resource.title, back_path: back_path, back_label: back_label, container_width: "max-w-5xl" } do %> <% if detail_label %>

<%= @resource.title %>

<% end %> diff --git a/app/views/registration_ticket_callouts/show.html.erb b/app/views/registration_ticket_callouts/show.html.erb index d3ee20fa4..2b7c393c6 100644 --- a/app/views/registration_ticket_callouts/show.html.erb +++ b/app/views/registration_ticket_callouts/show.html.erb @@ -13,9 +13,11 @@
<% end %> - <% @callout.resources.each do |resource| %> -
- <%= render "events/callouts/resource_body", resource: resource.decorate %> + <% if @resource_cards.any? %> +
+ <% @resource_cards.each do |card| %> + <%= render "event_registrations/callout_card", callout: card %> + <% end %>
<% end %> <% end %> diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index cb9732c1f..54cd21ceb 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -49,7 +49,7 @@ describe "GET /registration/:slug/videoconference" do let(:event) { create(:event, videoconference_url: "https://example.com/zoom") } - it "renders resources linked to the built-in callout below the content" do + it "links resources on the built-in callout to their own page, not inline" do resource = create(:resource) create(:downloadable_asset, owner: resource) create(:registration_ticket_callout, event:, magic_key: "videoconference", @@ -58,7 +58,77 @@ get registration_videoconference_path(registration.slug) expect(response).to have_http_status(:success) - expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) + expect(response.body).to include(registration_resource_path(registration.slug, resource, return_to: "videoconference")) + # The file itself is only shown on the resource's own page, not embedded here. + expect(response.body).not_to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) + end + end + + # The single-resource page is where a document is actually shown: the inline + # preview and download button live here, not on the callout list pages that + # link to it. + describe "GET /registration/:slug/resource/:resource_id" do + let(:event) { create(:event) } + + context "with a PDF resource" do + let(:resource) { create(:resource) } + + before { create(:downloadable_asset, owner: resource) } + + it "shows the PDF inline preview and a download button" do + get registration_resource_path(registration.slug, resource) + + expect(response).to have_http_status(:success) + expect(response.body).to include("type=\"application/pdf\"") + # Streamed same-origin via proxy mode so object-src :self covers it. + expect(response.body).to include(rails_storage_proxy_path(resource.downloadable_asset.file, disposition: :inline)) + expect(response.body).to include("fa-download") + end + + it "relaxes object-src to :self for the PDF preview" do + get registration_resource_path(registration.slug, resource) + + expect(response.headers["Content-Security-Policy-Report-Only"]).to include("object-src 'self'") + end + end + + context "with a non-PDF resource" do + let(:resource) { create(:resource) } + + before { create(:downloadable_asset, :with_image, owner: resource) } + + it "renders the preview instead of an inline PDF viewer" do + get registration_resource_path(registration.slug, resource) + + expect(response).to have_http_status(:success) + expect(response.body).not_to include("type=\"application/pdf\"") + end + end + + context "eyebrow" do + let(:resource) { create(:resource) } + + it "returns to the built-in callout it came from" do + get registration_resource_path(registration.slug, resource, return_to: "videoconference") + + expect(response.body).to include(registration_videoconference_path(registration.slug)) + end + + it "returns to the custom callout it came from" do + callout = create(:registration_ticket_callout, event:, title: "Parking") + + get registration_resource_path(registration.slug, resource, return_to: "callout", callout_id: callout.id) + + expect(response.body).to include(event_registration_ticket_callout_path(event, callout, reg: registration.slug)) + expect(response.body).to include("Back to Parking") + end + + it "falls back to the ticket when reached directly" do + get registration_resource_path(registration.slug, resource) + + expect(response.body).to include(registration_ticket_path(registration.slug)) + expect(response.body).to include("Back to ticket") + end end end diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index fda9eb9a7..d82f11e08 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -43,7 +43,7 @@ get event_registration_ticket_callout_path(event, callout) expect(response).to have_http_status(:ok) - expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) + expect(response.body).to include(resource_path(resource)) end it "does not find a callout belonging to a different event" do @@ -71,7 +71,7 @@ expect(response).to redirect_to(event_path(event)) end - context "when linked to a resource with a downloadable file" do + context "when linked to a resource" do let(:resource) { create(:resource) } let(:callout) do create(:registration_ticket_callout, event:, title: "Workbook", @@ -80,41 +80,27 @@ before { create(:downloadable_asset, owner: resource) } - it "renders the callout content above the resource display and a download button" do + it "renders the callout content and links the resource to its own page, not inline" do get event_registration_ticket_callout_path(event, callout) expect(response).to have_http_status(:ok) expect(response.body).to include("Read this first.") - expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) - end - end - - context "when linked to a PDF resource" do - let(:resource) { create(:resource) } - let(:callout) { create(:registration_ticket_callout, event:, resource:, description: "") } - - before { create(:downloadable_asset, owner: resource) } - - it "shows the PDF in the browser's inline viewer" do - get event_registration_ticket_callout_path(event, callout) - - expect(response).to have_http_status(:ok) - expect(response.body).to include("type=\"application/pdf\"") - expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, disposition: :inline)) + expect(response.body).to include(resource_path(resource)) + # The document itself is only shown on the resource's own page. + expect(response.body).not_to include("type=\"application/pdf\"") + expect(response.body).not_to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) end - end - - context "when linked to a non-PDF resource" do - let(:resource) { create(:resource) } - let(:callout) { create(:registration_ticket_callout, event:, resource:, description: "") } - before { create(:downloadable_asset, :with_image, owner: resource) } + it "links to the in-ticket registrant page when a registration slug is present" do + registration = create(:event_registration, event:) - it "renders the preview instead of an inline PDF viewer" do - get event_registration_ticket_callout_path(event, callout) + get event_registration_ticket_callout_path(event, callout, reg: registration.slug) - expect(response).to have_http_status(:ok) - expect(response.body).not_to include("type=\"application/pdf\"") + # Path + params asserted separately: the href's query string is + # HTML-escaped (&), so the full URL won't match as one substring. + expect(response.body).to include(registration_resource_path(registration.slug, resource)) + expect(response.body).to include("return_to=callout") + expect(response.body).to include("callout_id=#{callout.id}") end end @@ -125,11 +111,11 @@ description: "

Read this first.

", resource:) end - it "does not render a download button" do + it "still links the resource to its own page" do get event_registration_ticket_callout_path(event, callout) expect(response).to have_http_status(:ok) - expect(response.body).not_to include("fa-download") + expect(response.body).to include(resource_path(resource)) end end end diff --git a/spec/requests/resources_spec.rb b/spec/requests/resources_spec.rb index 178d204d8..4f5187a74 100644 --- a/spec/requests/resources_spec.rb +++ b/spec/requests/resources_spec.rb @@ -74,6 +74,20 @@ expect(response).to have_http_status(:ok) end + + it "relaxes object-src to :self for the PDF preview" do + get resource_url(resource) + + expect(response.headers["Content-Security-Policy-Report-Only"]).to include("object-src 'self'") + end + end + end + + describe "GET /index (non-preview action)" do + it "keeps the strict global object-src 'none'" do + get resources_url + + expect(response.headers["Content-Security-Policy-Report-Only"]).to include("object-src 'none'") end end