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
19 changes: 17 additions & 2 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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> (object-src). The
# global policy blocks <object>/<embed> 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 = [
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions app/controllers/registration_ticket_callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def show
return
end

@resource_cards = build_resource_cards
@event = @event.decorate
end

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/controllers/resources_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ class ResourcesController < ApplicationController

skip_before_action :authenticate_user!, only: [ :index, :show, :download ]

# The show page previews a PDF in an <object>, which is governed by object-src.
# The global policy sets object_src :none to block legacy <object>/<embed>
# 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?
Expand Down
4 changes: 2 additions & 2 deletions app/views/assets/_resource_display.html.erb
Original file line number Diff line number Diff line change
@@ -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" %>
<div class="max-w-3xl mx-auto h-[95vh] rounded-lg border border-gray-200 overflow-hidden">
<div class="max-w-5xl mx-auto h-[95vh] rounded-lg border border-gray-200 overflow-hidden">
<object
data="<%= rails_blob_path(file, disposition: :inline) %>"
data="<%= rails_storage_proxy_path(file, disposition: :inline) %>"
type="application/pdf"
class="w-full h-full"
aria-label="<%= resource.title %> (PDF preview)"
Expand Down
15 changes: 9 additions & 6 deletions app/views/events/callouts/_callout_page.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@
ticket"; pass a `back_label` local to point it at a different origin. %>
<% 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 %>
<div class="max-w-3xl mx-auto px-4 pt-4">
<div class="<%= container_width %> mx-auto px-4 pt-4">
<%= 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 %>
<i class="fa-solid fa-arrow-left text-xs"></i> <%= back_label %>
<% end %>
</div>
<% end %>

<div class="max-w-3xl mx-auto pb-12 pt-6 px-4">
<div class="<%= container_width %> mx-auto pb-12 pt-6 px-4">
<div class="bg-white rounded-2xl shadow-xl ring-1 ring-black/5 overflow-hidden">
<div class="relative bg-gradient-to-r from-purple-950 to-purple-700 text-white px-6 sm:px-8 py-5">
<div class="flex items-center gap-5">
Expand All @@ -39,10 +42,10 @@
<% end %>
<%= yield %>

<% if @builtin_resources.present? %>
<div class="mt-8 space-y-6">
<% @builtin_resources.each do |resource| %>
<%= render "events/callouts/resource_body", resource: resource.decorate %>
<% if @builtin_resource_cards.present? %>
<div class="mt-8 space-y-3">
<% @builtin_resource_cards.each do |card| %>
<%= render "event_registrations/callout_card", callout: card %>
<% end %>
</div>
<% end %>
Expand Down
22 changes: 18 additions & 4 deletions app/views/events/callouts/resource.html.erb
Original file line number Diff line number Diff line change
@@ -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
"<Origin> 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 %>
<h2 class="text-lg font-semibold text-gray-800 mb-4"><%= @resource.title %></h2>
<% end %>
Expand Down
8 changes: 5 additions & 3 deletions app/views/registration_ticket_callouts/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
</div>
<% end %>

<% @callout.resources.each do |resource| %>
<div class="mt-6">
<%= render "events/callouts/resource_body", resource: resource.decorate %>
<% if @resource_cards.any? %>
<div class="mt-6 space-y-3">
<% @resource_cards.each do |card| %>
<%= render "event_registrations/callout_card", callout: card %>
<% end %>
</div>
<% end %>
<% end %>
74 changes: 72 additions & 2 deletions spec/requests/events/callouts_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 <object> 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

Expand Down
48 changes: 17 additions & 31 deletions spec/requests/events/registration_ticket_callouts_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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 (&amp;), 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

Expand All @@ -125,11 +111,11 @@
description: "<p>Read this first.</p>", 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
Expand Down
14 changes: 14 additions & 0 deletions spec/requests/resources_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@

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

it "relaxes object-src to :self for the PDF <object> 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

Expand Down