From 98c350271bdcce96c7f07ed49abc6eee85e2ba3a Mon Sep 17 00:00:00 2001 From: Eric O Date: Tue, 20 Feb 2024 18:37:58 -0500 Subject: [PATCH] WIP --- app/controllers/image_proxy_controller.rb | 24 ++++++ app/frontend/entrypoints/application.js | 58 +++++++++++++- app/jobs/update_image_service_job.rb | 4 +- .../concerns/digital_object/dynamic_field.rb | 12 +++ config/environments/development.rb | 4 + config/routes.rb | 2 + public/image-auth-service-worker.js | 79 +++++++++++++++++++ 7 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 app/controllers/image_proxy_controller.rb create mode 100644 public/image-auth-service-worker.js diff --git a/app/controllers/image_proxy_controller.rb b/app/controllers/image_proxy_controller.rb new file mode 100644 index 000000000..bdd6a711a --- /dev/null +++ b/app/controllers/image_proxy_controller.rb @@ -0,0 +1,24 @@ +class ImageProxyController < ApplicationController + include ActionController::Live + + def raster + remote_url = "#{IMAGE_SERVER_CONFIG[:url]}/iiif/2/#{params[:path]}.#{params[:format]}" + uri = URI(remote_url) + Net::HTTP.start(uri.host, uri.port) do |http| + request = Net::HTTP::Get.new(uri) + request['Authorization'] = "Bearer #{IMAGE_SERVER_CONFIG[:token]}" + + http.request request do |remote_response| + response.headers['Content-Length'] = remote_response['Content-Length'] + response.headers['Content-Type'] = remote_response['Content-Type'] + response.status = :ok + + remote_response.read_body do |chunk| + response.stream.write chunk + end + end + ensure + response.stream.close + end + end +end diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index b679e2abf..189896e1f 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -11,7 +11,7 @@ console.log('Vite ⚡️ Rails') // If you want to use .jsx or .tsx, add the extension: // <%= vite_javascript_tag 'application.jsx' %> -console.log('Visit the guide for more information: ', 'https://vite-ruby.netlify.app/guide/rails') +// console.log('Visit the guide for more information: ', 'https://vite-ruby.netlify.app/guide/rails') // Example: Load Rails libraries in Vite. // @@ -26,3 +26,59 @@ console.log('Visit the guide for more information: ', 'https://vite-ruby.netlify // Example: Import a stylesheet in app/frontend/index.css // import '~/index.css' + +const registerImageAuthServiceWorker = async () => { + // console.log('registerServiceWorker called'); + + // Code below only runs if the browser supports service workers + if ("serviceWorker" in navigator) { + try { + const workerScriptPath = "/image-auth-service-worker.js"; + + let registration = await navigator.serviceWorker.register(workerScriptPath, { + scope: "/", + }); + await navigator.serviceWorker.ready; + + // registration.active.postMessage("Test message sent immediately after creation"); + + // The `if` statement below handles browser hard refreshes, where the service worker doesn't + // actually start running after a hard refresh. This triggers a regular refresh of the page, + // which starts the service worker properly. + if (registration.active && !navigator.serviceWorker.controller) { + // Perform a soft reload to load everything from the SW and get + // a consistent set of resources. + window.location.reload(); + } + + registration.active.postMessage({ + type: 'setImageServerUrl', + url: Hyacinth.imageServerUrl, + }); + + window.addEventListener('registerAuthToken', (e) => { + console.log("Received registerAuthToken event"); + registration.active.postMessage({ + type: 'registerAuthToken', + identifier: e.detail.identifier, + token: e.detail.token + }); + }); + } catch (error) { + console.error(`Service worker registration failed with error: ${error}`); + } + } + else { + console.log("Unable to create a service worker because navigator.serviceWorker returned a falsy value."); + } +}; +registerImageAuthServiceWorker(); + +setTimeout(() => { + window.dispatchEvent(new CustomEvent('registerAuthToken', { + detail: { + identifier: 'test:h18931zcsk', + token: 'public' + } + })); +}, 1000); diff --git a/app/jobs/update_image_service_job.rb b/app/jobs/update_image_service_job.rb index 8e14f43a1..843f73796 100644 --- a/app/jobs/update_image_service_job.rb +++ b/app/jobs/update_image_service_job.rb @@ -25,7 +25,9 @@ def payload_for_image_service_update_request(asset) source_uri: image_source_uri_for_digital_object(asset), featured_region: asset.featured_region, # Supply pcdm type for MAIN resource (not access / poster) - pcdm_type: asset.pcdm_type + pcdm_type: asset.pcdm_type, + # We need to let the image server know whether or not this is a protected image + has_view_limitation: asset.has_view_limitation_field? } } end diff --git a/app/models/concerns/digital_object/dynamic_field.rb b/app/models/concerns/digital_object/dynamic_field.rb index bc8789c24..0a94e38aa 100644 --- a/app/models/concerns/digital_object/dynamic_field.rb +++ b/app/models/concerns/digital_object/dynamic_field.rb @@ -203,4 +203,16 @@ def recursively_generate_csv_style_flattened_dynamic_field_data(df_data, omit_bl flat_hash end end + + # Returns true if this DigitalObject has at least one field value + # for a DynamicField::Type::VIEW_LIMITATION field. + def has_view_limitation_field? + view_limitation_field_string_keys = ::DynamicField.where(dynamic_field_type: DynamicField::Type::VIEW_LIMITATION).pluck(:string_key) + view_limitation_field_string_keys.each do |view_limitation_field_string_key| + results = Hyacinth::Utils::HashUtils.find_nested_hashes_that_contain_key(self.dynamic_field_data, view_limitation_field_string_key) + return true if results.present? + end + + false + end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 1fba5bee7..7c118bda3 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,6 +3,8 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + config.log_level = :error + # In the development environment your application's code is reloaded on # every request. 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. @@ -73,4 +75,6 @@ # Uncomment if you wish to allow Action Cable access from any origin. # config.action_cable.disable_request_forgery_protection = true + + config.hosts << "hyacinth-local.library.columbia.edu:3000" end diff --git a/config/routes.rb b/config/routes.rb index 902edb5eb..93d5c0dda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ require 'resque/server' Rails.application.routes.draw do + get '/image_proxy/iiif/2/*path', to: 'image_proxy#raster', constraints: {all: /.+/} + resources :csv_exports, only: [:index, :create, :show, :destroy] do member do get 'download' diff --git a/public/image-auth-service-worker.js b/public/image-auth-service-worker.js new file mode 100644 index 000000000..2f359928b --- /dev/null +++ b/public/image-auth-service-worker.js @@ -0,0 +1,79 @@ +console.log("Image auth service worker started running at: " + new Date().getTime()); + +let imageServerUrl = null; +const pidsToTokens = new Map(); +const iiifUrlWithRequiredAuthRegex = /.+\/iiif\/2\/(standard|limited)\/([^/]+)\/.+/; + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function registerAuthToken(identifier, token) { + // TODO: Also clear out some old tokens (with addedAt time that's far in the past) so that + // the pidsToTokens Map doesn't grow indefinitely. + pidsToTokens.set( + identifier, + { + token: token, + addedAt: Date.now() + } + ); +}; + +async function checkForAuthToken(identifier) { + console.log(`Checking for ${identifier}`); + const start = Date.now(); + const checkDelayTimes = [0, 100, 3000]; // [100, 200] + for (const delayTime of checkDelayTimes) { + await sleep(delayTime); + console.log(`Checking for ${identifier} in pidsToTokens Map after ${delayTime} millisecond delay.`); + if (pidsToTokens.has(identifier)) { + return pidsToTokens.get(identifier); + } + } + return null; +} + +async function fetchWithAuthorizationHeader(request) { + + console.log(`Adding header to request: ${request.url}`); + const identifier = request.url.match(iiifUrlWithRequiredAuthRegex)[2]; + + // Check if there is a token available for this identifier + const entry = await checkForAuthToken(identifier); + const headers = new Headers(request.headers); + if (entry) { + console.log(`Making request with token ${entry.token}`); + headers.set('Authorization', `Bearer ${entry ? entry.token : null}`); + } + + const newRequest = new Request(request, { + mode: 'cors', + credentials: 'omit', + headers: headers + }); + + return fetch(newRequest); +} + +self.addEventListener('fetch', function (event) { + const url = event.request.url; + // console.log(`imageServerUrl: ${imageServerUrl}`); + // console.log(`Intercepting fetch request for: ${url}`); + if (url.startsWith(imageServerUrl) && url.match(iiifUrlWithRequiredAuthRegex)) { + event.respondWith(fetchWithAuthorizationHeader(event.request)); + } +}); + +self.addEventListener("message", (event) => { + const { data } = event; + console.log('Service worker received message: ', data); + + if (data.type == 'registerAuthToken') { + registerAuthToken(data.identifier, data.token); + } + + if (data.type == 'setImageServerUrl') { + imageServerUrl = data.url; + } +});