From 41a838104e4ba3def073f0c841a2039e8a87c6b5 Mon Sep 17 00:00:00 2001 From: Andrew White Date: Wed, 9 Oct 2024 05:33:05 +0100 Subject: [PATCH] Add engine for handling uploads domain The serving is only used in development - in production it hits CloudFront. --- .env.example | 1 + .env.test | 1 + .github/workflows/build.yml | 1 + .rspec | 1 + Gemfile | 1 + Gemfile.lock | 7 +++ Makefile | 5 +- app/models/document.rb | 2 +- config/application.rb | 1 + config/routes.rb | 4 ++ .../bops_uploads/application_controller.rb | 6 ++ .../bops_uploads/files_controller.rb | 63 +++++++++++++++++++ engines/bops_uploads/bin/rails | 19 ++++++ engines/bops_uploads/bops_uploads.gemspec | 16 +++++ engines/bops_uploads/config/routes.rb | 19 ++++++ engines/bops_uploads/lib/bops_uploads.rb | 11 ++++ .../bops_uploads/lib/bops_uploads/engine.rb | 7 +++ .../bops_uploads/spec/bops_uploads_helper.rb | 9 +++ .../bops_uploads/spec/requests/files_spec.rb | 38 +++++++++++ lib/constraints/subdomain.rb | 8 +++ spec/support/show_exceptions.rb | 19 ++++++ 21 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 engines/bops_uploads/app/controllers/bops_uploads/application_controller.rb create mode 100644 engines/bops_uploads/app/controllers/bops_uploads/files_controller.rb create mode 100755 engines/bops_uploads/bin/rails create mode 100644 engines/bops_uploads/bops_uploads.gemspec create mode 100644 engines/bops_uploads/config/routes.rb create mode 100644 engines/bops_uploads/lib/bops_uploads.rb create mode 100644 engines/bops_uploads/lib/bops_uploads/engine.rb create mode 100644 engines/bops_uploads/spec/bops_uploads_helper.rb create mode 100644 engines/bops_uploads/spec/requests/files_spec.rb create mode 100644 spec/support/show_exceptions.rb diff --git a/.env.example b/.env.example index 374870a7c9..7f7d999db2 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ OTP_SECRET_ENCRYPTION_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx NOTIFY_API_KEY=xxxxx GROUPED_PROPOSAL_DETAILS_FEATURE=enabled GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX +UPLOADS_BASE_URL=http://uploads.bops.localhost:3000 diff --git a/.env.test b/.env.test index 6d701642f7..2ad85afa8d 100644 --- a/.env.test +++ b/.env.test @@ -2,3 +2,4 @@ STAGING_API_URL="bops-staging.services" STAGING_API_BEARER="fjisdfjsdiofjdsoi" OS_VECTOR_TILES_API_KEY="testtest" NOTIFY_LETTER_API_KEY='testtest' +UPLOADS_BASE_URL=http://uploads.example.com diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10e5bfce5e..317e60b378 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,6 +77,7 @@ jobs: - { group: "bops_api", module: "engines" } - { group: "bops_config", module: "engines" } - { group: "bops_core", module: "engines" } + - { group: "bops_uploads", module: "engines" } fail-fast: false with: name: "${{matrix.specs.group}}" diff --git a/.rspec b/.rspec index 9d5d148e15..74493f54e7 100644 --- a/.rspec +++ b/.rspec @@ -2,4 +2,5 @@ -I engines/bops_admin/spec -I engines/bops_api/spec -I engines/bops_config/spec +-I engines/bops_uploads/spec --require spec_helper diff --git a/Gemfile b/Gemfile index a669f78e39..d5db823819 100644 --- a/Gemfile +++ b/Gemfile @@ -56,6 +56,7 @@ gem "bops_core", path: "engines/bops_core" gem "bops_admin", path: "engines/bops_admin" gem "bops_api", path: "engines/bops_api" gem "bops_config", path: "engines/bops_config" +gem "bops_uploads", path: "engines/bops_uploads" group :development, :test do gem "brakeman", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 1eb28b1a4e..bb8afff89d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -28,6 +28,12 @@ PATH pagy rails (>= 7.1.3, < 7.2) +PATH + remote: engines/bops_uploads + specs: + bops_uploads (0.1.0) + bops_core (= 0.1.0) + GEM remote: https://rubygems.org/ specs: @@ -668,6 +674,7 @@ DEPENDENCIES bops_api! bops_config! bops_core! + bops_uploads! brakeman bullet business_time diff --git a/Makefile b/Makefile index 9a6c01f6db..bbfeb0e70e 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,10 @@ config-specs: core-specs: $(DOCKER-RUN) console rspec engines/bops_core/spec -engine-specs: api-specs admin-specs config-specs core-specs +uploads-specs: + $(DOCKER-RUN) console rspec engines/bops_uploads/spec + +engine-specs: api-specs admin-specs config-specs core-specs uploads-specs rspec: $(DOCKER-RUN) console rspec diff --git a/app/models/document.rb b/app/models/document.rb index 238fb7c8a7..fc89340ccb 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -24,7 +24,7 @@ class NotArchiveableError < StandardError; end inverse_of: false delegate :audits, to: :planning_application - delegate :representable?, to: :file + delegate :blob, :representable?, to: :file include Auditable diff --git a/config/application.rb b/config/application.rb index d8e9919c69..6a6ac465ab 100644 --- a/config/application.rb +++ b/config/application.rb @@ -74,6 +74,7 @@ class Application < Rails::Application config.planx_file_production_api_key = ENV["PLANX_FILE_PRODUCTION_API_KEY"] config.staging_api_bearer = ENV["STAGING_API_BEARER"] config.staging_api_url = ENV["STAGING_API_URL"] + config.uploads_base_url = ENV["UPLOADS_BASE_URL"] end def self.env diff --git a/config/routes.rb b/config/routes.rb index ba15d2d31f..c9792481a4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -378,6 +378,10 @@ mount BopsConfig::Engine, at: "/", as: :bops_config end + constraints Constraints::UploadsSubdomain do + mount BopsUploads::Engine, at: "/", as: :bops_uploads + end + direct :rails_public_blob do |blob| if (cdn_host = ENV["CDN_HOST"]) # Use an environment variable instead of hard-coding the CDN host diff --git a/engines/bops_uploads/app/controllers/bops_uploads/application_controller.rb b/engines/bops_uploads/app/controllers/bops_uploads/application_controller.rb new file mode 100644 index 0000000000..7472e75fc9 --- /dev/null +++ b/engines/bops_uploads/app/controllers/bops_uploads/application_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module BopsUploads + class ApplicationController < ActionController::Base + end +end diff --git a/engines/bops_uploads/app/controllers/bops_uploads/files_controller.rb b/engines/bops_uploads/app/controllers/bops_uploads/files_controller.rb new file mode 100644 index 0000000000..6f166dd015 --- /dev/null +++ b/engines/bops_uploads/app/controllers/bops_uploads/files_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module BopsUploads + class FilesController < ApplicationController + before_action :set_service + before_action :set_blob + + def show + serve_file(blob_path, content_type:, disposition:) + rescue Errno::ENOENT + head :not_found + end + + private + + def set_service + @service = ActiveStorage::Blob.service + end + + def set_blob + @blob = ActiveStorage::Blob.find_by!(key: params[:key]) + end + + def blob_path + @service.path_for(@blob.key) + end + + def forcibly_serve_as_binary? + ActiveStorage.content_types_to_serve_as_binary.include?(@blob.content_type) + end + + def allowed_inline? + ActiveStorage.content_types_allowed_inline.include?(@blob.content_type) + end + + def content_type + forcibly_serve_as_binary? ? ActiveStorage.binary_content_type : @blob.content_type + end + + def disposition + if forcibly_serve_as_binary? || !allowed_inline? + :attachment + else + :inline + end + end + + def serve_file(path, content_type:, disposition:) + ::Rack::Files.new(nil).serving(request, path).tap do |(status, headers, body)| + self.status = status + self.response_body = body + + headers.each do |name, value| + response.headers[name] = value + end + + response.headers.except!("X-Cascade", "x-cascade") if status == 416 + response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE + response.headers["Content-Disposition"] = disposition || DEFAULT_SEND_FILE_DISPOSITION + end + end + end +end diff --git a/engines/bops_uploads/bin/rails b/engines/bops_uploads/bin/rails new file mode 100755 index 0000000000..a93b10060a --- /dev/null +++ b/engines/bops_uploads/bin/rails @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path("..", __dir__) +ENGINE_PATH = File.expand_path("../lib/bops_uploads/engine", __dir__) +APP_PATH = File.expand_path("../../../config/application", __dir__) + +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) + +require "rails" + +require "active_model/railtie" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "rails/engine/commands" diff --git a/engines/bops_uploads/bops_uploads.gemspec b/engines/bops_uploads/bops_uploads.gemspec new file mode 100644 index 0000000000..582d2c132a --- /dev/null +++ b/engines/bops_uploads/bops_uploads.gemspec @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = "bops_uploads" + spec.version = "0.1.0" + spec.authors = ["Unboxed Consulting Ltd"] + spec.email = ["bops@unboxedconsulting.com"] + spec.homepage = "https://unboxed.co/" + spec.summary = "Provides the uploads facility for the BOPS system" + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + Dir["{app,config,db,lib}/**/*"] + end + + spec.add_dependency "bops_core", "0.1.0" +end diff --git a/engines/bops_uploads/config/routes.rb b/engines/bops_uploads/config/routes.rb new file mode 100644 index 0000000000..3cf93c80a3 --- /dev/null +++ b/engines/bops_uploads/config/routes.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +BopsUploads::Engine.routes.draw do + get "/:key", to: "files#show", as: "file" +end + +Rails.application.routes.draw do + direct :uploaded_file do |blob, options| + next "" if blob.blank? + + bops_uploads.file_url(blob.key, host: Rails.configuration.uploads_base_url) + end + + resolve("ActiveStorage::Attachment") { |attachment, options| route_for(:uploaded_file, attachment.blob, options) } + resolve("ActiveStorage::Blob") { |blob, options| route_for(:uploaded_file, blob, options) } + resolve("ActiveStorage::Preview") { |preview, options| route_for(:uploaded_file, preview, options) } + resolve("ActiveStorage::VariantWithRecord") { |variant, options| route_for(:uploaded_file, variant, options) } + resolve("ActiveStorage::Variant") { |variant, options| route_for(:uploaded_file, variant, options) } +end diff --git a/engines/bops_uploads/lib/bops_uploads.rb b/engines/bops_uploads/lib/bops_uploads.rb new file mode 100644 index 0000000000..658e16cdb4 --- /dev/null +++ b/engines/bops_uploads/lib/bops_uploads.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "bops_uploads/engine" + +module BopsUploads + class << self + def env + ActiveSupport::StringInquirer.new(ENV.fetch("BOPS_ENVIRONMENT", "development")) + end + end +end diff --git a/engines/bops_uploads/lib/bops_uploads/engine.rb b/engines/bops_uploads/lib/bops_uploads/engine.rb new file mode 100644 index 0000000000..b97e1c7e6e --- /dev/null +++ b/engines/bops_uploads/lib/bops_uploads/engine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module BopsUploads + class Engine < ::Rails::Engine + isolate_namespace BopsUploads + end +end diff --git a/engines/bops_uploads/spec/bops_uploads_helper.rb b/engines/bops_uploads/spec/bops_uploads_helper.rb new file mode 100644 index 0000000000..32b68168e0 --- /dev/null +++ b/engines/bops_uploads/spec/bops_uploads_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.configure do |config| + config.before type: :request do + host!("uploads.bops.services") + end +end diff --git a/engines/bops_uploads/spec/requests/files_spec.rb b/engines/bops_uploads/spec/requests/files_spec.rb new file mode 100644 index 0000000000..a78d4e2fd2 --- /dev/null +++ b/engines/bops_uploads/spec/requests/files_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "bops_uploads_helper" + +RSpec.describe "Downloading files", show_exceptions: true do + context "when a blob exists" do + let(:document) { create(:document) } + let(:blob) { document.file } + let(:key) { blob.key } + let(:path) { blob.service.path_for(blob.key) } + + it "returns 200 OK" do + get "/#{key}" + expect(response).to have_http_status(:ok) + expect(response.content_type).to eq("image/png") + end + + context "but the file is missing" do + before do + File.unlink(path) + end + + it "returns 404 Not Found" do + get "/#{key}" + expect(response).to have_http_status(:not_found) + end + end + end + + context "when a blob doesn't exist" do + let(:key) { SecureRandom.base36(28) } + + it "returns 404 Not Found" do + get "/#{key}" + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/lib/constraints/subdomain.rb b/lib/constraints/subdomain.rb index a2e92e2347..5fa81308f0 100644 --- a/lib/constraints/subdomain.rb +++ b/lib/constraints/subdomain.rb @@ -24,4 +24,12 @@ def matches?(request) end end end + + class UploadsSubdomain + class << self + def matches?(request) + request.subdomain == "uploads" + end + end + end end diff --git a/spec/support/show_exceptions.rb b/spec/support/show_exceptions.rb new file mode 100644 index 0000000000..73e700b93b --- /dev/null +++ b/spec/support/show_exceptions.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.around(:each, type: :request) do |example| + if example.metadata.key?(:show_exceptions) + begin + env_config = Rails.application.env_config + show_exceptions = env_config["action_dispatch.show_exceptions"] + env_config["action_dispatch.show_exceptions"] = example.metadata[:show_exceptions] ? :all : :none + + example.run + ensure + env_config["action_dispatch.show_exceptions"] = show_exceptions + end + else + example.run + end + end +end