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'