From 7cdf1b826212165377d941586d3374588a076e3e Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 6 Aug 2026 11:09:33 +1000
Subject: [PATCH 1/5] refactor: prepare unit content for direct file serving
- extract content archives during upload instead of at request time
- replace URL-based content tokens with scoped authentication cookies
- rewrite asset paths once during extraction
- generate stable file-serving URLs for extracted content instead of query params
---
app/api/authentication_api.rb | 7 +-
app/api/entities/unit_entity.rb | 3 +
app/api/unit_contents_api.rb | 190 ++--------------------
app/helpers/authentication_helpers.rb | 34 +++-
app/models/unit_content_site.rb | 219 +++++++++++++++++++++++++-
lib/tasks/unit_content_sites.rake | 19 +++
6 files changed, 287 insertions(+), 185 deletions(-)
create mode 100644 lib/tasks/unit_content_sites.rake
diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb
index e7f446278..4bdd4aaf3 100644
--- a/app/api/authentication_api.rb
+++ b/app/api/authentication_api.rb
@@ -444,6 +444,7 @@ class AuthenticationApi < Grape::API
end
delete '/auth' do
user = User.find_by(username: headers['username'] || headers['Username'])
+ signing_out_user = user
token = user&.token_for_text?(headers['auth-token'] || headers['Auth-Token'], :general)
if token.present?
@@ -456,6 +457,7 @@ class AuthenticationApi < Grape::API
user_param = cookies['username']
user = User.find_by(username: user_param)
+ signing_out_user ||= user
token = user&.token_for_text?(auth_param, :refresh_token)
if token.present?
logger.info "Destroy refresh token for #{user.username} from #{request.ip}"
@@ -465,6 +467,8 @@ class AuthenticationApi < Grape::API
# Remove the refresh token cookie - if remember is false
set_refresh_cookie_in_response(false) unless params[:remember]
+ signing_out_user&.auth_tokens&.where(token_type: :content)&.destroy_all
+ set_content_cookie_in_response
present nil
end
@@ -494,7 +498,8 @@ class AuthenticationApi < Grape::API
token = current_user.generate_content_authentication_token!
end
- present :content_auth_token, token.authentication_token
+ set_content_cookie_in_response(token)
+ present :content_access, true
end
end
diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb
index c820f2e36..d7d21eaad 100644
--- a/app/api/entities/unit_entity.rb
+++ b/app/api/entities/unit_entity.rb
@@ -44,6 +44,9 @@ def can_read_unit_config?(my_role)
expose :grade_values
expose :grade_definitions
expose :has_main_content_site?, as: :has_main_content_site, unless: :summary_only
+ expose :main_content_site_id, unless: :summary_only do |unit|
+ unit.unit_content_sites.find_by(is_main: true)&.id
+ end
expose :unit_content_links,
as: :content_links,
using: UnitContentLinkEntity,
diff --git a/app/api/unit_contents_api.rb b/app/api/unit_contents_api.rb
index aee17fbd4..88b110f22 100644
--- a/app/api/unit_contents_api.rb
+++ b/app/api/unit_contents_api.rb
@@ -1,8 +1,6 @@
require 'grape'
require 'entities/unit_content_link_entity'
require 'entities/unit_content_site_entity'
-require 'mime/types'
-require 'uri'
class UnitContentsApi < Grape::API
helpers AuthenticationHelpers
@@ -11,193 +9,16 @@ class UnitContentsApi < Grape::API
helpers MimeCheckHelpers
helpers do
- def unit_content_link_for_route(unit, content_route)
- normalized_route = "/#{content_route.to_s.gsub(%r{\A/+|/+\z}, '')}"
- normalized_route = '/' if normalized_route.blank?
-
- unit.unit_content_links
- .where.not(context_type: 'task_definition_resource')
- .find_by(route: normalized_route)
- end
-
def authorise_unit_content_management!(unit)
return if authorise?(current_user, unit, :manage_unit_content) ||
authorise?(current_user, User, :admin_units)
error!({ error: 'Not authorised to manage unit content' }, 403)
end
-
- def unit_content_reference_url(
- unit_id,
- site_id,
- reference,
- current_path,
- username,
- content_token
- )
- return reference if reference.blank? || reference.match?(%r{\A(?:[a-z][a-z0-9+.-]*:|//|#)}i)
-
- reference_path = reference.split(/[?#]/, 2).first
- resolved_path =
- if reference_path.start_with?('/')
- reference_path
- else
- File.expand_path(reference_path, "/#{File.dirname(current_path)}")
- end
-
- query = Rack::Utils.build_query(
- content_route: resolved_path,
- content_site_id: site_id,
- username: username,
- content_token: content_token
- )
- fragment = reference.include?('#') ? "##{reference.split('#', 2).last}" : ''
-
- "/api/units/#{unit_id}/content?#{query}#{fragment}"
- end
-
- def rewrite_unit_content_response(
- contents,
- content_type,
- unit_id,
- site_id,
- current_path,
- username,
- content_token
- )
- rewrite_reference = lambda do |reference|
- unit_content_reference_url(
- unit_id,
- site_id,
- reference,
- current_path,
- username,
- content_token
- )
- end
-
- case content_type
- when 'text/html'
- contents = contents.gsub(
- /(<(?:iframe|img|link|script|source|video|audio)\b[^>]*?\b(?:href|poster|src)=)(["'])([^"']+)\2/i
- ) do
- "#{Regexp.last_match(1)}#{Regexp.last_match(2)}" \
- "#{rewrite_reference.call(Regexp.last_match(3))}#{Regexp.last_match(2)}"
- end
- contents.gsub(/\bsrcset=(["'])([^"']+)\1/i) do
- quote = Regexp.last_match(1)
- srcset = Regexp.last_match(2).split(',').map do |entry|
- reference, descriptor = entry.strip.split(/\s+/, 2)
- [rewrite_reference.call(reference), descriptor].compact.join(' ')
- end.join(', ')
-
- "srcset=#{quote}#{srcset}#{quote}"
- end
- when 'text/css'
- contents.gsub(/url\((["']?)([^"')]+)\1\)/i) do
- quote = Regexp.last_match(1)
- "url(#{quote}#{rewrite_reference.call(Regexp.last_match(2))}#{quote})"
- end
- when 'text/javascript', 'application/javascript'
- contents = contents.gsub(
- /\b((?:import|export)(?:\s*[^"']*?\s*from\s*)?\s*)(["'])([^"']+)\2/
- ) do
- "#{Regexp.last_match(1)}#{Regexp.last_match(2)}" \
- "#{rewrite_reference.call(Regexp.last_match(3))}#{Regexp.last_match(2)}"
- end
- contents.gsub(/\b(import\s*\(\s*)(["'])([^"']+)\2(\s*\))/) do
- "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{rewrite_reference.call(Regexp.last_match(3))}" \
- "#{Regexp.last_match(2)}#{Regexp.last_match(4)}"
- end
- else
- contents
- end
- end
end
before do
- if request.path.match?(%r{/units/\d+/content\z})
- authenticated?(:content)
- else
- authenticated?
- end
- end
-
- desc 'Get a unit content route'
- params do
- optional :content_route, type: String, desc: 'The content route being loaded'
- optional :content_site_id, type: Integer, desc: 'Specific content site to load'
- requires :username, type: String, desc: 'Username associated with the scoped content token'
- requires :content_token, type: String, desc: 'Scoped content authentication token'
- end
- get '/units/:id/content' do
- unit = Unit.find(params[:id])
-
- unless authorise?(current_user, unit, :get_unit) || authorise?(current_user, User, :admin_units)
- error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403)
- end
-
- link = nil
- site = if params[:content_site_id].present?
- unit.unit_content_sites.find(params[:content_site_id])
- else
- link = unit_content_link_for_route(unit, params[:content_route])
- link&.unit_content_site ||
- unit.unit_content_sites.find_by(is_main: true)
- end
-
- error!({ error: 'Unit content archive is not configured' }, 404) unless site
-
- error!({ error: 'Unit content archive is not available' }, 404) unless File.exist?(site.archive_path)
-
- content_route = URI::DEFAULT_PARSER.unescape(params[:content_route].presence || '/')
- route_parts = content_route.split('/').reject(&:blank?)
- error!({ error: 'Invalid unit content route' }, 422) if route_parts.any? { |part| ['.', '..'].include?(part) }
-
- root_parts = site.root_dir.to_s.split('/').reject(&:blank?)
- requested_entry_path = (root_parts + route_parts).join('/')
- archive_entry_paths = [
- requested_entry_path,
- (root_parts + route_parts + ['index.html']).join('/')
- ].uniq
- archive_entry = nil
- file_contents = nil
-
- Zip::File.open(site.archive_path) do |zip_file|
- archive_entry = archive_entry_paths.filter_map { |path| zip_file.find_entry(path) }.find(&:file?)
- file_contents = archive_entry.get_input_stream.read if archive_entry
- end
-
- error!({ error: "Unit content route '#{content_route}' is not available" }, 404) unless archive_entry
-
- response_content_type = MIME::Types.type_for(archive_entry.name).first&.content_type
- response_content_type ||= 'application/octet-stream'
- root_prefix = root_parts.join('/')
- current_path = archive_entry.name.delete_prefix("#{root_prefix}/")
- file_contents = rewrite_unit_content_response(
- file_contents,
- response_content_type,
- unit.id,
- site.id,
- current_path,
- params[:username],
- params[:content_token]
- )
-
- content_type response_content_type
- header['Content-Disposition'] = "inline; filename=#{File.basename(archive_entry.name)}"
- header['X-Content-Site-Id'] = site.id.to_s
- header['X-Content-Route'] = link&.route || content_route
- header['X-Content-Root-Dir'] = site.root_dir
- header['Access-Control-Expose-Headers'] =
- 'Content-Disposition,X-Content-Site-Id,X-Content-Route,X-Content-Root-Dir'
- header['Cache-Control'] = 'no-cache, no-store, must-revalidate'
- header['Referrer-Policy'] = 'strict-origin'
- env['api.format'] = :binary
-
- body file_contents
- rescue Zip::Error
- error!({ error: 'Unit content archive is invalid' }, 422)
+ authenticated?
end
desc 'List unit content sites'
@@ -298,7 +119,16 @@ def rewrite_unit_content_response(
update_params.except!(:root_dir)
end
+ previous_root_dir = site.root_dir
site.update!(update_params)
+ if file.blank? && update_params.key?(:root_dir)
+ begin
+ site.extract_for_serving!
+ rescue StandardError
+ site.update!(root_dir: previous_root_dir)
+ raise
+ end
+ end
present site, with: Entities::UnitContentSiteEntity, include_file_paths: true
end
diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb
index 1b59bfffb..db3df3b9f 100644
--- a/app/helpers/authentication_helpers.rb
+++ b/app/helpers/authentication_helpers.rb
@@ -7,6 +7,7 @@
# This is used by the grape api.
#
module AuthenticationHelpers
+ CONTENT_TOKEN_COOKIE = 'content_token'.freeze
# private functions
# Check that the user and token are valid
@@ -73,12 +74,16 @@ def authenticated_via_refresh_token?
# :cookie - from the request cookie
# @return [String, String] The username and token
def get_user_and_token_from(source)
- if source == :header
+ case source
+ when :header
user_param = headers['username'] || headers['Username'] || params['username']
auth_param = headers['auth-token'] || headers['Auth-Token'] || params['authToken'] || headers['Auth_Token'] || headers['auth_token'] || params['auth_token'] || params['Auth_Token']
- elsif source == :cookie
+ when :cookie
user_param = cookies['username']
auth_param = cookies['refresh_token']
+ when :content_cookie
+ user_param = cookies['username']
+ auth_param = cookies[CONTENT_TOKEN_COOKIE]
else
# Default to nil
user_param = nil
@@ -124,6 +129,31 @@ def current_user
User.eager_load(:role, :auth_tokens).find_by(username: username)
end
+ def set_content_cookie_in_response(token = nil)
+ domain = Doubtfire::Application.config.institution[:cookie_domain]
+ common_options = {
+ domain: domain,
+ path: '/api/units/',
+ secure: request.ssl? || Rails.env.production?,
+ same_site: :strict,
+ httponly: true
+ }
+
+ if token.present?
+ cookies['username'] = common_options.merge(
+ value: current_user.username,
+ expires: token.auth_token_expiry
+ )
+ cookies[CONTENT_TOKEN_COOKIE] = common_options.merge(
+ value: token.authentication_token,
+ expires: token.auth_token_expiry
+ )
+ else
+ cookies.delete('username', **common_options)
+ cookies.delete(CONTENT_TOKEN_COOKIE, **common_options)
+ end
+ end
+
#
# Add the required auth_token to each of the routes for the provided
# Grape::API.
diff --git a/app/models/unit_content_site.rb b/app/models/unit_content_site.rb
index 3b1a02ecf..aa7dcfbcc 100644
--- a/app/models/unit_content_site.rb
+++ b/app/models/unit_content_site.rb
@@ -1,12 +1,19 @@
require 'fileutils'
require 'digest/sha1'
+require 'cgi'
+require 'pathname'
require 'securerandom'
require 'set'
+require 'uri'
require 'zip'
class UnitContentSite < ApplicationRecord
include FileHelper
+ MAX_ARCHIVE_ENTRIES = 20_000
+ MAX_EXTRACTED_BYTES = 2.gigabytes
+ MAX_ENTRY_BYTES = 256.megabytes
+
belongs_to :unit
has_many :unit_content_links, dependent: :destroy
@@ -14,7 +21,7 @@ class UnitContentSite < ApplicationRecord
validates :name, uniqueness: { scope: :unit_id, case_sensitive: false }
validates :root_dir, presence: true
- after_destroy :delete_archive
+ after_destroy :delete_content_files
def self.archive_dir_for(unit)
File.join(FileHelper.unit_dir(unit), 'content_sites')
@@ -38,11 +45,17 @@ def self.store_upload!(unit, file, name: nil)
)
FileUtils.cp file[:tempfile].path, site.archive_path
+ site.extract_for_serving!
site
+ rescue StandardError
+ site&.destroy
+ raise
end
def replace_upload!(file, root_dir: nil)
original_archive_path = archive_path
+ original_filename_before_replace = original_filename
+ original_root_dir = self.root_dir
replacement_original_filename = file[:filename] || file[:name] || original_filename
replacement_archive_path = File.join(
self.class.archive_dir_for(unit),
@@ -60,9 +73,17 @@ def replace_upload!(file, root_dir: nil)
archive_path: replacement_archive_path,
root_dir: replacement_root_dir
)
+ extract_for_serving!
FileUtils.rm_f original_archive_path if original_archive_path.present?
self
rescue StandardError
+ if persisted? && original_archive_path.present?
+ update!(
+ archive_path: original_archive_path,
+ original_filename: original_filename_before_replace,
+ root_dir: original_root_dir
+ )
+ end
FileUtils.rm_f replacement_archive_path if replacement_archive_path.present?
raise
end
@@ -161,8 +182,201 @@ def extract_file(path)
nil
end
+ def served_dir
+ File.join(File.dirname(archive_path), 'served', id.to_s)
+ end
+
+ def public_files_path
+ "/api/units/#{unit_id}/content/sites/#{id}/files"
+ end
+
+ def served_file_path(route)
+ relative_route = normalized_content_route(route)
+ return nil unless relative_route
+
+ candidates = [relative_route, File.join(relative_route, 'index.html')].uniq
+ candidates.each do |candidate|
+ path = File.join(served_dir, candidate)
+ return path if File.file?(path)
+ end
+
+ nil
+ end
+
+ def extract_for_serving!
+ raise Errno::ENOENT, "Unit content archive not found: #{archive_path}" unless File.file?(archive_path)
+
+ parent_dir = File.dirname(served_dir)
+ FileUtils.mkdir_p(parent_dir)
+ staging_dir = File.join(parent_dir, ".#{id}-#{SecureRandom.hex(8)}")
+ backup_dir = File.join(parent_dir, ".#{id}-backup-#{SecureRandom.hex(8)}")
+ FileUtils.mkdir_p(staging_dir)
+
+ extract_archive_into!(staging_dir)
+ rewrite_extracted_content!(staging_dir)
+
+ FileUtils.mv(served_dir, backup_dir) if File.exist?(served_dir)
+ FileUtils.mv(staging_dir, served_dir)
+ FileUtils.rm_rf(backup_dir)
+ served_dir
+ rescue StandardError
+ FileUtils.mv(backup_dir, served_dir) if File.exist?(backup_dir) && !File.exist?(served_dir)
+ raise
+ ensure
+ FileUtils.rm_rf(staging_dir) if defined?(staging_dir) && File.exist?(staging_dir)
+ FileUtils.rm_rf(backup_dir) if defined?(backup_dir) && File.exist?(backup_dir) && File.exist?(served_dir)
+ end
+
private
+ def extract_archive_into!(destination)
+ entry_count = 0
+ extracted_bytes = 0
+ root_prefix = normalized_root_dir
+
+ Zip::File.open(archive_path) do |zip|
+ zip.each do |entry|
+ next if entry.directory?
+ raise Zip::Error, 'Unit content archive contains a symbolic link' if entry.respond_to?(:symlink?) && entry.symlink?
+
+ entry_count += 1
+ raise Zip::Error, 'Unit content archive contains too many files' if entry_count > MAX_ARCHIVE_ENTRIES
+ raise Zip::Error, 'Unit content archive contains an oversized file' if entry.size > MAX_ENTRY_BYTES
+
+ extracted_bytes += entry.size
+ raise Zip::Error, 'Unit content archive is too large when extracted' if extracted_bytes > MAX_EXTRACTED_BYTES
+
+ entry_path = safe_archive_entry_path(entry.name)
+ next unless entry_path
+ next unless root_prefix.blank? || entry_path.start_with?("#{root_prefix}/")
+
+ relative_path = root_prefix.blank? ? entry_path : entry_path.delete_prefix("#{root_prefix}/")
+ next if relative_path.blank?
+
+ output_path = File.join(destination, relative_path)
+ FileUtils.mkdir_p(File.dirname(output_path))
+ entry.get_input_stream do |input|
+ File.open(output_path, 'wb') { |output| IO.copy_stream(input, output) }
+ end
+ end
+ end
+ end
+
+ def safe_archive_entry_path(entry_name)
+ normalized = entry_name.to_s.tr('\\', '/')
+ parts = normalized.split('/').reject(&:blank?)
+ raise Zip::Error, 'Unit content archive contains an unsafe path' if normalized.start_with?('/') || parts.blank?
+ raise Zip::Error, 'Unit content archive contains an unsafe path' if parts.any? { |part| ['.', '..'].include?(part) }
+ return nil if parts.any? { |part| self.class.ignored_archive_path?(part) }
+
+ parts.join('/')
+ end
+
+ def rewrite_extracted_content!(root)
+ Dir.glob(File.join(root, '**', '*'), File::FNM_DOTMATCH).each do |path|
+ next unless File.file?(path)
+
+ relative_path = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
+ contents = File.binread(path)
+ rewritten = rewrite_contents(contents, relative_path)
+ File.binwrite(path, rewritten) unless rewritten.equal?(contents) || rewritten == contents
+ end
+ end
+
+ def rewrite_contents(contents, relative_path)
+ extension = File.extname(relative_path).downcase
+
+ case extension
+ when '.html', '.htm'
+ rewrite_html(contents, relative_path)
+ when '.css'
+ rewrite_css(contents)
+ when '.js', '.mjs'
+ rewrite_javascript(contents)
+ else
+ contents
+ end
+ rescue Encoding::CompatibilityError, Encoding::InvalidByteSequenceError
+ contents
+ end
+
+ def rewrite_html(contents, relative_path)
+ result = contents.dup.force_encoding(Encoding::UTF_8)
+ return contents unless result.valid_encoding?
+
+ base_path = "#{public_files_path}/#{url_path(File.dirname(relative_path))}/".gsub(%r{/+}, '/')
+ base_path = "#{public_files_path}/" if File.dirname(relative_path) == '.'
+ base_tag = %()
+
+ if result.match?(/
]*>/i)
+ result.sub!(/(]*>)/i, "\\1#{base_tag}")
+ else
+ result.prepend(base_tag)
+ end
+
+ result.gsub!(
+ %r{(<(?:iframe|img|link|script|source|video|audio)\b[^>]*?\b(?:href|poster|src)=)(["'])(/(?!/)[^"']*)\2}i
+ ) do
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{canonical_reference(Regexp.last_match(3))}#{Regexp.last_match(2)}"
+ end
+
+ result.gsub!(/\bsrcset=(["'])([^"']+)\1/i) do
+ quote = Regexp.last_match(1)
+ srcset = Regexp.last_match(2).split(',').map do |item|
+ reference, descriptor = item.strip.split(/\s+/, 2)
+ reference = canonical_reference(reference) if reference&.start_with?('/') && !reference.start_with?('//')
+ [reference, descriptor].compact.join(' ')
+ end.join(', ')
+ "srcset=#{quote}#{srcset}#{quote}"
+ end
+
+ result
+ end
+
+ def rewrite_css(contents)
+ result = contents.dup.force_encoding(Encoding::UTF_8)
+ return contents unless result.valid_encoding?
+
+ result.gsub(%r{url\((["']?)(/(?!/)[^"')]+)\1\)}i) do
+ quote = Regexp.last_match(1)
+ "url(#{quote}#{canonical_reference(Regexp.last_match(2))}#{quote})"
+ end
+ end
+
+ def rewrite_javascript(contents)
+ result = contents.dup.force_encoding(Encoding::UTF_8)
+ return contents unless result.valid_encoding?
+
+ result = result.gsub(
+ %r{\b((?:import|export)(?:\s*[^"']*?\s*from\s*)?\s*)(["'])(/(?!/)[^"']+)\2}
+ ) do
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{canonical_reference(Regexp.last_match(3))}#{Regexp.last_match(2)}"
+ end
+ result.gsub(%r{\b(import\s*\(\s*)(["'])(/(?!/)[^"']+)\2(\s*\))}) do
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{canonical_reference(Regexp.last_match(3))}" \
+ "#{Regexp.last_match(2)}#{Regexp.last_match(4)}"
+ end
+ end
+
+ def canonical_reference(reference)
+ path, suffix = reference.split(/(?=[?#])/, 2)
+ "#{public_files_path}#{url_path(path)}#{suffix}"
+ end
+
+ def url_path(path)
+ path.to_s.split('/').map { |part| URI::DEFAULT_PARSER.escape(part) }.join('/')
+ end
+
+ def normalized_content_route(route)
+ decoded = URI::DEFAULT_PARSER.unescape(route.to_s).tr('\\', '/')
+ parts = decoded.split('/').reject(&:blank?)
+ return nil if parts.any? { |part| ['.', '..'].include?(part) }
+
+ parts.join('/').presence || 'index.html'
+ rescue ArgumentError
+ nil
+ end
+
def archive_entry_name(path)
relative_path = path.to_s.gsub(%r{\A/+|/+\z}, '')
return nil if relative_path.blank?
@@ -174,7 +388,8 @@ def normalized_root_dir
root_dir.to_s.gsub(%r{\A/+|/+\z}, '')
end
- def delete_archive
+ def delete_content_files
FileUtils.rm_f archive_path if archive_path.present?
+ FileUtils.rm_rf served_dir if id.present?
end
end
diff --git a/lib/tasks/unit_content_sites.rake b/lib/tasks/unit_content_sites.rake
new file mode 100644
index 000000000..49ab0c756
--- /dev/null
+++ b/lib/tasks/unit_content_sites.rake
@@ -0,0 +1,19 @@
+namespace :unit_content_sites do
+ desc 'Extract and prepare all existing unit content archives for direct serving'
+ task extract_all: :environment do
+ failures = []
+
+ UnitContentSite.includes(:unit).find_each do |site|
+ print "Extracting unit content site #{site.id}... "
+ site.extract_for_serving!
+ puts 'done'
+ rescue StandardError => e
+ puts "failed (#{e.message})"
+ failures << [site.id, e.message]
+ end
+
+ next if failures.empty?
+
+ abort "Failed to extract #{failures.length} unit content site(s): #{failures.map(&:first).join(', ')}"
+ end
+end
From f62ba5e1abbcbfa8c96cca71b74d8c209ef9e98d Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 6 Aug 2026 11:44:59 +1000
Subject: [PATCH 2/5] feat: authorise and serve extracted unit content for
caddy serving
---
...tent_download_authorizations_controller.rb | 133 ++++++++++++++++++
config/environments/development.rb | 3 +
config/routes.rb | 5 +
3 files changed, 141 insertions(+)
create mode 100644 app/controllers/unit_content_download_authorizations_controller.rb
diff --git a/app/controllers/unit_content_download_authorizations_controller.rb b/app/controllers/unit_content_download_authorizations_controller.rb
new file mode 100644
index 000000000..e7d8225df
--- /dev/null
+++ b/app/controllers/unit_content_download_authorizations_controller.rb
@@ -0,0 +1,133 @@
+require 'mime/types'
+require 'pathname'
+require 'rack/files'
+require 'uri'
+
+class UnitContentDownloadAuthorizationsController < ApplicationController
+ include AuthorisationHelpers
+
+ skip_after_action :verify_same_origin_request, only: :serve
+
+ CONTENT_PATH = %r{\A/api/units/(?\d+)/content/sites/(?\d+)/files(?/[^?]*)?(?:\?.*)?\z}
+ INTERNAL_SECRET_HEADER = 'X-OnTrack-Download-Auth'.freeze
+ ORIGINAL_URI_HEADER = 'X-Forwarded-Uri'.freeze
+
+ def show
+ return head :not_found unless trusted_caddy_request?
+
+ route_params = CONTENT_PATH.match(request.headers[ORIGINAL_URI_HEADER].to_s)
+ return head :not_found unless route_params
+
+ result = authorised_content(
+ unit_id: route_params[:unit_id],
+ site_id: route_params[:site_id],
+ route: route_params[:route]
+ )
+ return head result unless result.is_a?(Hash)
+
+ content_type = content_type_for(result[:path])
+ disposition = ActionDispatch::Http::ContentDisposition.format(
+ disposition: 'inline',
+ filename: result[:path].basename.to_s
+ )
+
+ response.set_header('X-OnTrack-File', result[:relative_path])
+ response.set_header('X-OnTrack-Content-Disposition', disposition)
+ response.set_header('X-OnTrack-Content-Type', content_type)
+ response.set_header('X-OnTrack-Content-Site-Id', result[:site].id.to_s)
+ head :ok
+ end
+
+ def serve
+ result = authorised_content(
+ unit_id: params[:unit_id],
+ site_id: params[:site_id],
+ route: params[:route]
+ )
+ return head result unless result.is_a?(Hash)
+
+ disposition = ActionDispatch::Http::ContentDisposition.format(
+ disposition: 'inline',
+ filename: result[:path].basename.to_s
+ )
+ file_server = Rack::Files.new(
+ nil,
+ {
+ 'accept-ranges' => 'bytes',
+ 'cache-control' => 'private, no-cache',
+ 'content-disposition' => disposition,
+ 'x-content-site-id' => result[:site].id.to_s
+ },
+ content_type_for(result[:path])
+ )
+ file_status, file_headers, file_body = file_server.serving(request, result[:path].to_s)
+ self.status = file_status
+ file_headers.each { |name, value| response.set_header(name, value) }
+ self.response_body = file_body
+ end
+
+ private
+
+ def trusted_caddy_request?
+ expected = ENV.fetch('DF_CADDY_DOWNLOAD_AUTH_SECRET', '')
+ provided = request.headers[INTERNAL_SECRET_HEADER].to_s
+ expected.present? && provided.present? && ActiveSupport::SecurityUtils.secure_compare(provided, expected)
+ end
+
+ def authenticated_content_user
+ username = request.cookies['username'].to_s
+ token_text = request.cookies[AuthenticationHelpers::CONTENT_TOKEN_COOKIE].to_s
+ return nil if username.blank? || token_text.blank?
+
+ user = User.eager_load(:role).find_by(username: username)
+ return nil unless user
+
+ token = user.auth_tokens.where(token_type: :content).detect do |candidate|
+ ActiveSupport::SecurityUtils.secure_compare(candidate.authentication_token, token_text)
+ end
+ return nil unless token
+
+ if token.auth_token_expiry <= Time.zone.now
+ token.destroy!
+ return nil
+ end
+
+ user
+ end
+
+ def authorised_content(unit_id:, site_id:, route:)
+ user = authenticated_content_user
+ return :unauthorized unless user
+
+ unit = Unit.find_by(id: unit_id)
+ return :not_found unless unit
+ return :forbidden unless authorise?(user, unit, :get_unit) || authorise?(user, User, :admin_units)
+
+ site = unit.unit_content_sites.find_by(id: site_id)
+ return :not_found unless site
+
+ file_path = site.served_file_path(route.presence || '/')
+ resolved_path, relative_path = authorised_file_path(file_path, site.served_dir)
+ return :not_found unless resolved_path
+
+ { site: site, path: resolved_path, relative_path: relative_path }
+ end
+
+ def content_type_for(path)
+ MIME::Types.type_for(path.to_s).first&.content_type || 'application/octet-stream'
+ end
+
+ def authorised_file_path(file_path, site_root)
+ return [nil, nil] if file_path.blank? || !File.file?(file_path)
+
+ student_work_root = Pathname.new(Doubtfire::Application.config.student_work_dir).realpath
+ root = Pathname.new(site_root).realpath
+ resolved = Pathname.new(file_path).realpath
+ return [nil, nil] unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}")
+ return [nil, nil] unless resolved.to_s.start_with?("#{student_work_root}#{File::SEPARATOR}")
+
+ [resolved, resolved.relative_path_from(student_work_root).to_s]
+ rescue Errno::ENOENT, Errno::EACCES
+ [nil, nil]
+ end
+end
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 05d01df74..7186003ec 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -2,6 +2,9 @@
Doubtfire::Application.configure do
# Settings specified here will take precedence over those in config/application.rb.
+ # Accept requests forwarded by the local HTTPS Caddy development proxy.
+ config.hosts << 'ontrack.dev'
+
# In the development environment your application's code is reloaded any time
# it changes. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
diff --git a/config/routes.rb b/config/routes.rb
index ea52a7900..a2917505a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -5,6 +5,11 @@
get 'api/submission/unit/:id/task_definitions/:task_def_id/download_submissions', to: 'task_downloads#index'
get 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs', to: 'task_submission_pdfs#index'
get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index'
+ get 'api/internal/downloads/unit-content', to: 'unit_content_download_authorizations#show'
+ get 'api/units/:unit_id/content/sites/:site_id/files', to: 'unit_content_download_authorizations#serve'
+ get 'api/units/:unit_id/content/sites/:site_id/files/*route',
+ to: 'unit_content_download_authorizations#serve',
+ format: false
mount ApiRoot => '/'
mount GrapeSwaggerRails::Engine => '/api/docs'
From e921fb26a32b549603706d962903e1484be77f2a Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 6 Aug 2026 11:53:57 +1000
Subject: [PATCH 3/5] feat: authorise student submission files for caddy
serving
---
app/api/submission/portfolio_evidence_api.rb | 3 +
...sion_download_authorizations_controller.rb | 84 +++++++++++++++++++
config/routes.rb | 1 +
3 files changed, 88 insertions(+)
create mode 100644 app/controllers/submission_download_authorizations_controller.rb
diff --git a/app/api/submission/portfolio_evidence_api.rb b/app/api/submission/portfolio_evidence_api.rb
index 8a6d36fe8..e7c384cda 100644
--- a/app/api/submission/portfolio_evidence_api.rb
+++ b/app/api/submission/portfolio_evidence_api.rb
@@ -114,6 +114,9 @@ def self.logger
optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.'
end
get '/projects/:id/task_def_id/:task_definition_id/submission' do
+ # Requests through Caddy are intercepted before the general /api proxy.
+ # Rails authorises those requests via SubmissionDownloadAuthorizationsController,
+ # then Caddy serves the PDF. This remains the direct-Rails fallback path.
project = Project.eager_load(:unit).find(params[:id])
task_definition = project.unit.task_definitions.select(:id, :name, :abbreviation).find(params[:task_definition_id])
diff --git a/app/controllers/submission_download_authorizations_controller.rb b/app/controllers/submission_download_authorizations_controller.rb
new file mode 100644
index 000000000..0604b68ba
--- /dev/null
+++ b/app/controllers/submission_download_authorizations_controller.rb
@@ -0,0 +1,84 @@
+require 'pathname'
+
+class SubmissionDownloadAuthorizationsController < ApplicationController
+ include AuthenticationHelpers
+ include AuthorisationHelpers
+
+ DOWNLOAD_PATH = %r{\A/api/projects/(?\d+)/task_def_id/(?\d+)/(?submission|submission_files)(?:\?(?.*))?\z}
+ INTERNAL_SECRET_HEADER = 'X-OnTrack-Download-Auth'.freeze
+ ORIGINAL_URI_HEADER = 'X-Forwarded-Uri'.freeze
+
+ def show
+ return head :not_found unless trusted_caddy_request?
+ return head :unauthorized unless authenticated_for_download?
+
+ route_params = DOWNLOAD_PATH.match(request.headers[ORIGINAL_URI_HEADER].to_s)
+ return head :not_found unless route_params
+
+ project = Project.find_by(id: route_params[:project_id])
+ return head :not_found unless project
+
+ task_definition = project.unit.task_definitions.find_by(id: route_params[:task_definition_id])
+ return head :not_found unless task_definition
+ return head :forbidden unless authorise?(current_user, project, :get_submission)
+
+ task = project.task_for_task_definition(task_definition)
+ return head :not_found unless task
+
+ file_path, filename, content_type, disposition_type = download_metadata(task, task_definition, project, route_params)
+ resolved_path, relative_path = authorised_file_path(file_path)
+ return head :not_found unless resolved_path
+
+ disposition = ActionDispatch::Http::ContentDisposition.format(disposition: disposition_type, filename: filename)
+
+ response.set_header('X-OnTrack-File', relative_path)
+ response.set_header('X-OnTrack-Content-Disposition', disposition)
+ response.set_header('X-OnTrack-Content-Type', content_type)
+
+ head :ok
+ end
+
+ private
+
+ def trusted_caddy_request?
+ expected = ENV.fetch('DF_CADDY_DOWNLOAD_AUTH_SECRET', '')
+ provided = request.headers[INTERNAL_SECRET_HEADER].to_s
+ return false if expected.blank? || provided.blank?
+
+ ActiveSupport::SecurityUtils.secure_compare(provided, expected)
+ end
+
+ def authenticated_for_download?
+ username, token = get_user_and_token_from(:header)
+ user_auth_token_type(username, token, :general) == :valid
+ end
+
+ def download_metadata(task, task_definition, project, route_params)
+ if route_params[:kind] == 'submission_files'
+ filename = FileHelper.sanitized_filename("#{project.student.username}-#{task_definition.abbreviation}.zip")
+ [FileHelper.zip_file_path_for_done_task(task), filename, 'application/octet-stream', 'attachment']
+ else
+ filename = FileHelper.sanitized_filename("#{task_definition.abbreviation}.pdf")
+ disposition = attachment_requested?(route_params[:query]) ? 'attachment' : 'inline'
+ [task.final_pdf_path, filename, 'application/pdf', disposition]
+ end
+ end
+
+ def attachment_requested?(query)
+ ActiveModel::Type::Boolean.new.cast(Rack::Utils.parse_nested_query(query.to_s)['as_attachment'])
+ end
+
+ def authorised_file_path(file_path)
+ return [nil, nil] if file_path.blank? || !File.file?(file_path)
+
+ root = Pathname.new(Doubtfire::Application.config.student_work_dir).realpath
+ resolved = Pathname.new(file_path).realpath
+ root_prefix = "#{root}#{File::SEPARATOR}"
+
+ return [nil, nil] unless resolved.to_s.start_with?(root_prefix)
+
+ [resolved, resolved.relative_path_from(root).to_s]
+ rescue Errno::ENOENT, Errno::EACCES
+ [nil, nil]
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index a2917505a..a17128c83 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -5,6 +5,7 @@
get 'api/submission/unit/:id/task_definitions/:task_def_id/download_submissions', to: 'task_downloads#index'
get 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs', to: 'task_submission_pdfs#index'
get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index'
+ get 'api/internal/downloads/submission', to: 'submission_download_authorizations#show'
get 'api/internal/downloads/unit-content', to: 'unit_content_download_authorizations#show'
get 'api/units/:unit_id/content/sites/:site_id/files', to: 'unit_content_download_authorizations#serve'
get 'api/units/:unit_id/content/sites/:site_id/files/*route',
From 0b4dd346e546fc9da9e5f69ca614d62f5da730ed Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 6 Aug 2026 11:56:01 +1000
Subject: [PATCH 4/5] feat: authorise portfolio archives for caddy serving
---
...olio_download_authorizations_controller.rb | 117 ++++++++++++++++++
config/routes.rb | 2 +
2 files changed, 119 insertions(+)
create mode 100644 app/controllers/portfolio_download_authorizations_controller.rb
diff --git a/app/controllers/portfolio_download_authorizations_controller.rb b/app/controllers/portfolio_download_authorizations_controller.rb
new file mode 100644
index 000000000..7d65d11d1
--- /dev/null
+++ b/app/controllers/portfolio_download_authorizations_controller.rb
@@ -0,0 +1,117 @@
+require 'pathname'
+
+class PortfolioDownloadAuthorizationsController < ApplicationController
+ include AuthenticationHelpers
+ include AuthorisationHelpers
+
+ # API authentication for this action is supplied by the Auth-Token header.
+ # A cross-origin form cannot forge that custom header.
+ skip_forgery_protection only: :create
+
+ DOWNLOAD_PATH = %r{\A/api/submission/unit/(?\d+)/portfolio(?:\?.*)?\z}
+ INTERNAL_SECRET_HEADER = 'X-OnTrack-Download-Auth'.freeze
+ ORIGINAL_URI_HEADER = 'X-Forwarded-Uri'.freeze
+ DOWNLOAD_COOKIE = 'ontrack_portfolio_download'.freeze
+ DOWNLOAD_COOKIE_LIFETIME = 2.minutes
+
+ def show
+ return head :not_found unless trusted_caddy_request?
+
+ route_params = DOWNLOAD_PATH.match(request.headers[ORIGINAL_URI_HEADER].to_s)
+ return head :not_found unless route_params
+ return head :unauthorized unless authenticate_download(route_params[:unit_id])
+
+ unit = Unit.find_by(id: route_params[:unit_id])
+ return head :not_found unless unit
+ return head :forbidden unless authorise?(@download_user, unit, :get_students)
+
+ _resolved_path, relative_path = authorised_file_path(unit.get_portfolio_zip_filename(@download_user))
+ return head :not_found unless relative_path
+
+ disposition = ActionDispatch::Http::ContentDisposition.format(
+ disposition: 'attachment',
+ filename: download_filename(unit)
+ )
+
+ response.set_header('X-OnTrack-File', relative_path)
+ response.set_header('X-OnTrack-Content-Disposition', disposition)
+ response.set_header('X-OnTrack-Content-Type', 'application/zip')
+
+ head :ok
+ end
+
+ # Exchange the normal Auth-Token header for a very short-lived, HTTP-only
+ # cookie. This allows the browser to start a native streaming download, which
+ # cannot attach Angular's custom authentication headers.
+ def create
+ @download_user = authenticated_header_user
+ return head :unauthorized unless @download_user
+
+ unit = Unit.find_by(id: params[:id])
+ return head :not_found unless unit
+ return head :forbidden unless authorise?(@download_user, unit, :get_students)
+ return head :not_found unless authorised_file_path(unit.get_portfolio_zip_filename(@download_user)).first
+
+ expires_at = Time.current + DOWNLOAD_COOKIE_LIFETIME
+ cookies.encrypted[DOWNLOAD_COOKIE] = {
+ value: { user_id: @download_user.id, unit_id: unit.id, expires_at: expires_at.to_i }.to_json,
+ expires: expires_at,
+ domain: Doubtfire::Application.config.institution[:cookie_domain],
+ path: "/api/submission/unit/#{unit.id}/portfolio",
+ secure: request.ssl? || Rails.env.production?,
+ httponly: true,
+ same_site: :strict
+ }
+
+ head :no_content
+ end
+
+ private
+
+ def trusted_caddy_request?
+ expected = ENV.fetch('DF_CADDY_DOWNLOAD_AUTH_SECRET', '')
+ provided = request.headers[INTERNAL_SECRET_HEADER].to_s
+ return false if expected.blank? || provided.blank?
+
+ ActiveSupport::SecurityUtils.secure_compare(provided, expected)
+ end
+
+ def authenticated_header_user
+ username, token = get_user_and_token_from(:header)
+ return unless user_auth_token_type(username, token, :general) == :valid
+
+ current_user
+ end
+
+ def authenticate_download(unit_id)
+ @download_user = authenticated_header_user
+ return true if @download_user
+
+ payload = JSON.parse(cookies.encrypted[DOWNLOAD_COOKIE].to_s)
+ return false unless payload['unit_id'].to_s == unit_id.to_s
+ return false unless payload['expires_at'].to_i > Time.current.to_i
+
+ @download_user = User.find_by(id: payload['user_id'])
+ @download_user.present?
+ rescue JSON::ParserError
+ false
+ end
+
+ def download_filename(unit)
+ download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-portfolios-#{unit.code}-#{@download_user.username}"
+ "#{FileHelper.sanitized_filename(download_id.tr('\\/', '-'))}.zip"
+ end
+
+ def authorised_file_path(file_path)
+ return [nil, nil] if file_path.blank? || !File.file?(file_path)
+
+ root = Pathname.new(Doubtfire::Application.config.student_work_dir).realpath
+ resolved = Pathname.new(file_path).realpath
+ root_prefix = "#{root}#{File::SEPARATOR}"
+ return [nil, nil] unless resolved.to_s.start_with?(root_prefix)
+
+ [resolved, resolved.relative_path_from(root).to_s]
+ rescue Errno::ENOENT, Errno::EACCES
+ [nil, nil]
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index a17128c83..a8e3f8990 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -6,6 +6,8 @@
get 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs', to: 'task_submission_pdfs#index'
get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index'
get 'api/internal/downloads/submission', to: 'submission_download_authorizations#show'
+ get 'api/internal/downloads/portfolio', to: 'portfolio_download_authorizations#show'
+ post 'api/submission/unit/:id/portfolio/access', to: 'portfolio_download_authorizations#create'
get 'api/internal/downloads/unit-content', to: 'unit_content_download_authorizations#show'
get 'api/units/:unit_id/content/sites/:site_id/files', to: 'unit_content_download_authorizations#serve'
get 'api/units/:unit_id/content/sites/:site_id/files/*route',
From c4596c17a1bcf4ee243ef978d7ebc53fa4a28421 Mon Sep 17 00:00:00 2001
From: b0ink <40929320+b0ink@users.noreply.github.com>
Date: Thu, 6 Aug 2026 13:37:04 +1000
Subject: [PATCH 5/5] feat: authorise PDF and attachment files for caddy
serving
---
...file_download_authorizations_controller.rb | 192 ++++++++++++++++++
config/routes.rb | 1 +
2 files changed, 193 insertions(+)
create mode 100644 app/controllers/pdf_file_download_authorizations_controller.rb
diff --git a/app/controllers/pdf_file_download_authorizations_controller.rb b/app/controllers/pdf_file_download_authorizations_controller.rb
new file mode 100644
index 000000000..03ca9bddc
--- /dev/null
+++ b/app/controllers/pdf_file_download_authorizations_controller.rb
@@ -0,0 +1,192 @@
+require 'pathname'
+
+class PdfFileDownloadAuthorizationsController < ApplicationController
+ include AuthenticationHelpers
+ include AuthorisationHelpers
+
+ INTERNAL_SECRET_HEADER = 'X-OnTrack-Download-Auth'.freeze
+ ORIGINAL_URI_HEADER = 'X-Forwarded-Uri'.freeze
+
+ TASK_SHEET_PATH = %r{\A/api/units/(?\d+)/task_definitions/(?\d+)/task_pdf(?:\.json)?(?:\?(?.*))?\z}
+ PORTFOLIO_PATH = %r{\A/api/submission/project/(?\d+)/portfolio(?:\?(?.*))?\z}
+ SIMILARITY_PATH = %r{\A/api/tasks/(?\d+)/similarities/(?\d+)/contents/(?\d+)(?:\?(?.*))?\z}
+ COMMENT_PATH = %r{\A/api/projects/(?\d+)/task_def_id/(?\d+)/comments/(?\d+)(?:\?(?.*))?\z}
+ ENGAGEMENT_PATH = %r{\A/api/projects/(?\d+)/engagements/(?\d+)/attachment(?:\?(?.*))?\z}
+
+ def show
+ return head :not_found unless trusted_caddy_request?
+ return head :unauthorized unless authenticated_for_download?
+
+ status, download = find_download(request.headers[ORIGINAL_URI_HEADER].to_s)
+ return head status unless status == :ok
+
+ _resolved_path, relative_path = authorised_file_path(download[:path])
+ return head :not_found unless relative_path
+
+ disposition = ActionDispatch::Http::ContentDisposition.format(
+ disposition: attachment_requested?(download[:query]) ? 'attachment' : 'inline',
+ filename: FileHelper.sanitized_filename(download[:filename])
+ )
+
+ response.set_header('X-OnTrack-File', relative_path)
+ response.set_header('X-OnTrack-Content-Disposition', disposition)
+ response.set_header('X-OnTrack-Content-Type', download[:content_type])
+
+ head :ok
+ end
+
+ private
+
+ def trusted_caddy_request?
+ expected = ENV.fetch('DF_CADDY_DOWNLOAD_AUTH_SECRET', '')
+ provided = request.headers[INTERNAL_SECRET_HEADER].to_s
+ return false if expected.blank? || provided.blank?
+
+ ActiveSupport::SecurityUtils.secure_compare(provided, expected)
+ end
+
+ def authenticated_for_download?
+ username, token = get_user_and_token_from(:header)
+ user_auth_token_type(username, token, :general) == :valid
+ end
+
+ def find_download(uri)
+ return task_sheet_download(Regexp.last_match) if TASK_SHEET_PATH.match(uri)
+ return portfolio_download(Regexp.last_match) if PORTFOLIO_PATH.match(uri)
+ return similarity_download(Regexp.last_match) if SIMILARITY_PATH.match(uri)
+ return comment_download(Regexp.last_match) if COMMENT_PATH.match(uri)
+ return engagement_download(Regexp.last_match) if ENGAGEMENT_PATH.match(uri)
+
+ [:not_found, nil]
+ end
+
+ def task_sheet_download(route)
+ unit = Unit.find_by(id: route[:unit_id])
+ return [:not_found, nil] unless unit
+
+ task_definition = unit.task_definitions.find_by(id: route[:task_definition_id])
+ return [:not_found, nil] unless task_definition
+ return [:forbidden, nil] unless authorise?(current_user, unit, :get_unit)
+ return [:not_found, nil] unless task_definition.has_task_sheet?
+
+ [:ok, {
+ path: task_definition.task_sheet(false),
+ filename: "#{unit.code}-#{task_definition.abbreviation}.pdf",
+ content_type: 'application/pdf',
+ query: route[:query]
+ }]
+ end
+
+ def portfolio_download(route)
+ project = Project.find_by(id: route[:project_id])
+ return [:not_found, nil] unless project
+ return [:forbidden, nil] unless authorise?(current_user, project, :get_submission)
+
+ [:ok, {
+ path: project.portfolio_path,
+ filename: "#{project.unit.code}-#{project.student.username}-portfolio.pdf",
+ content_type: 'application/pdf',
+ query: route[:query]
+ }]
+ end
+
+ def similarity_download(route)
+ task = Task.find_by(id: route[:task_id])
+ return [:not_found, nil] unless task
+ return [:forbidden, nil] unless authorise?(current_user, task, :view_plagiarism)
+
+ similarity = task.task_similarities.find_by(id: route[:similarity_id])
+ return [:not_found, nil] unless similarity
+
+ if similarity.is_a?(MossTaskSimilarity)
+ moss_similarity_download(similarity, route)
+ elsif similarity.is_a?(TiiTaskSimilarity)
+ [:ok, {
+ path: similarity.similarity_pdf_path,
+ filename: "similarity-#{similarity.id}.pdf",
+ content_type: 'application/pdf',
+ query: route[:query]
+ }]
+ else
+ [:not_found, nil]
+ end
+ end
+
+ def moss_similarity_download(similarity, route)
+ selected_similarity = if route[:index] == '0'
+ similarity
+ elsif route[:index] == '1' && authorise?(current_user, similarity.other_task, :view_plagiarism)
+ similarity.other_similarity
+ end
+ return [:not_found, nil] unless selected_similarity
+
+ [:ok, {
+ path: FileHelper.path_to_plagarism_html(selected_similarity),
+ filename: "#{selected_similarity.student.username}_#{selected_similarity.other_student&.username}_#{selected_similarity.pct}.html",
+ content_type: 'text/html',
+ query: route[:query]
+ }]
+ end
+
+ def comment_download(route)
+ project = Project.find_by(id: route[:project_id])
+ return [:not_found, nil] unless project
+ return [:forbidden, nil] unless authorise?(current_user, project, :get)
+
+ task_definition = project.unit.task_definitions.find_by(id: route[:task_definition_id])
+ return [:not_found, nil] unless task_definition
+
+ task = project.task_for_task_definition(task_definition)
+ return [:not_found, nil] unless task
+
+ comment = task.comments.find_by(id: route[:comment_id])
+ return [:not_found, nil] unless comment && %w[audio image pdf].include?(comment.content_type)
+
+ SessionTracker.record_assessment_activity(
+ action: 'get-comment-attachment',
+ user: current_user,
+ project: project,
+ ip_address: request.ip,
+ task: task
+ )
+
+ [:ok, {
+ path: comment.attachment_path,
+ filename: comment.attachment_file_name,
+ content_type: comment.attachment_mime_type,
+ query: route[:query]
+ }]
+ end
+
+ def engagement_download(route)
+ project = Project.find_by(id: route[:project_id])
+ return [:not_found, nil] unless project
+ return [:forbidden, nil] unless authorise?(current_user, project, :get_engagements)
+
+ engagement = project.engagements.find_by(id: route[:engagement_id])
+ return [:not_found, nil] unless engagement&.attachment?
+
+ [:ok, {
+ path: engagement.attachment_path,
+ filename: engagement.attachment_file_name,
+ content_type: engagement.attachment_mime_type,
+ query: route[:query]
+ }]
+ end
+
+ def attachment_requested?(query)
+ ActiveModel::Type::Boolean.new.cast(Rack::Utils.parse_nested_query(query.to_s)['as_attachment'])
+ end
+
+ def authorised_file_path(file_path)
+ return [nil, nil] if file_path.blank? || !File.file?(file_path)
+
+ root = Pathname.new(Doubtfire::Application.config.student_work_dir).realpath
+ resolved = Pathname.new(file_path).realpath
+ return [nil, nil] unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}")
+
+ [resolved, resolved.relative_path_from(root).to_s]
+ rescue Errno::ENOENT, Errno::EACCES
+ [nil, nil]
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index a8e3f8990..5532f7e52 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -7,6 +7,7 @@
get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index'
get 'api/internal/downloads/submission', to: 'submission_download_authorizations#show'
get 'api/internal/downloads/portfolio', to: 'portfolio_download_authorizations#show'
+ get 'api/internal/downloads/pdf-file', to: 'pdf_file_download_authorizations#show'
post 'api/submission/unit/:id/portfolio/access', to: 'portfolio_download_authorizations#create'
get 'api/internal/downloads/unit-content', to: 'unit_content_download_authorizations#show'
get 'api/units/:unit_id/content/sites/:site_id/files', to: 'unit_content_download_authorizations#serve'