diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f898826..4fbdc2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* `download_endpoint` - Add support for expiring URLs + ## 3.6.0 (2024-04-29) * Add Rack 3 support (@tomasc, @janko) diff --git a/doc/plugins/download_endpoint.md b/doc/plugins/download_endpoint.md index 0cb5a8f7..e820d77c 100644 --- a/doc/plugins/download_endpoint.md +++ b/doc/plugins/download_endpoint.md @@ -7,7 +7,7 @@ downloading uploaded files from specified storages. This can be useful when files from your storage isn't accessible over URL (e.g. database storages) or if you want to authenticate your downloads. -## Global Endpoint +## Global Endpoint You can configure the plugin with the path prefix which the endpoint will be mounted on. @@ -34,6 +34,7 @@ Links to the download endpoint are generated by calling ```rb uploaded_file.download_url #=> "/attachments/eyJpZCI6ImFkdzlyeTM..." ``` + ## Endpoint via Uploader You can also configure the plugin in the uploader directly - just make sure to mount it via your Uploader-class. @@ -52,8 +53,8 @@ Rails.application.routes.draw do end ``` -*Hint: For shrine versions 2.x -> ensure that you don't include the plugin -twice (globally and in your uploader class - see #408)* +_Hint: For shrine versions 2.x -> ensure that you don't include the plugin +twice (globally and in your uploader class - see #408)_ ## Calling from a controller @@ -69,6 +70,7 @@ Rails.application.routes.draw do get "/attachments/*rest", to: "downloads#image" end ``` + ```rb # app/controllers/downloads_controller.rb (Rails) class DownloadsController < ApplicationController @@ -131,6 +133,16 @@ plugin :download_endpoint, download_options: -> (uploaded_file, request) { } ``` +## Expiring download urls + +If you want to have URLs that expire after a certain time, you can use the `:expires_in` and `secret_key` options: + +```rb +plugin :download_endpoint, expires_in: 5 * 60, secret_key: "secret" +``` + +this will generate URLs that are signed with a signature valid for 5 minutes. + ## Performance considerations Streaming files through the app might impact the request throughput, depending @@ -162,7 +174,7 @@ Shrine.download_endpoint(disposition: "attachment") ## Plugin options | Name | Description | Default | -| :-------- | :---------- | :------ | +| :------------------ | :-------------------------------------------------------------------------------- | :------- | | `:disposition` | Whether browser should render the file `inline` or download it as an `attachment` | `inline` | | `:download_options` | Hash of storage-specific options passed to `Storage#open` | `{}` | | `:host` | URL host that will be added to download URLs | `nil` | diff --git a/lib/shrine/plugins/download_endpoint.rb b/lib/shrine/plugins/download_endpoint.rb index b46f0932..99a0aad2 100644 --- a/lib/shrine/plugins/download_endpoint.rb +++ b/lib/shrine/plugins/download_endpoint.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'openssl' +require 'base64' + class Shrine module Plugins # Documentation can be found on https://shrinerb.com/docs/plugins/download_endpoint @@ -65,14 +68,36 @@ def initialize(file) @file = file end - def call(host: self.host) - [host, *prefix, path].join("/") + def call(host: self.host, expires_in: nil) + path = file.urlsafe_dump(metadata: %w[filename size mime_type]) + + query = signature_as_query(path: path, expires_in: expires_in) + + path = [host, *prefix, path].join("/") + path += "?#{query}" if query + path end protected - def path - file.urlsafe_dump(metadata: %w[filename size mime_type]) + def signature_as_query(path:, expires_in:) + expires_in = default_expires_in if expires_in.nil? + raise(Error, "secret_key is required for expiring URLs") if !secret_key && expires_in + raise(Error, "expires_in is required for expiring URLs") if secret_key && !expires_in + + return nil unless expires_in + + expires_at = (Time.now + expires_in).to_i + signature = OpenSSL::HMAC.digest( + OpenSSL::Digest::SHA256.new, + secret_key, + "#{path}--#{expires_at}" + ) + + Rack::Utils.build_query( + signature: Base64.urlsafe_encode64(signature), + expires_at: expires_at + ) end def host @@ -83,6 +108,14 @@ def prefix options[:prefix] end + def default_expires_in + options[:expires_in] + end + + def secret_key + options[:secret_key] + end + def options file.shrine_class.opts[:download_endpoint] end @@ -129,6 +162,9 @@ def inspect def handle_request(request) _, serialized, * = request.path_info.split("/") + signature, expires_at = request.params.values_at("signature", "expires_at") + + check_signature!(serialized, signature, expires_at) if @secret_key uploaded_file = get_uploaded_file(serialized) @@ -189,6 +225,22 @@ def get_uploaded_file(serialized) bad_request!("Invalid serialized file") end + def check_signature!(serialized, signature, expires_at) + if expires_at && expires_at.to_i < Time.now.to_i + error!(400, "URL has expired") + end + + calculated_signature = OpenSSL::HMAC.digest( + OpenSSL::Digest::SHA256.new, + @secret_key, + "#{serialized}--#{expires_at}" + ) + + if !Rack::Utils.secure_compare(signature, Base64.urlsafe_encode64(calculated_signature)) + error!(403, "Signature does not match") + end + end + def not_found! error!(404, "File Not Found") end diff --git a/test/plugin/download_endpoint_test.rb b/test/plugin/download_endpoint_test.rb index 43f9c52e..b78156bc 100644 --- a/test/plugin/download_endpoint_test.rb +++ b/test/plugin/download_endpoint_test.rb @@ -25,10 +25,42 @@ def endpoint assert_equal 200, response.status assert_equal @uploaded_file.read, response.body assert_equal @uploaded_file.size.to_s, response.headers["Content-Length"] - assert_equal @uploaded_file.mime_type, response.headers["COntent-Type"] + assert_equal @uploaded_file.mime_type, response.headers["Content-Type"] assert_equal ContentDisposition.inline(@uploaded_file.original_filename), response.headers["Content-Disposition"] end + it "raise error if using expires_in without any secret_key" do + io = fakeio("a" * 16*1024 + "b" * 16*1024 + "c" * 4*1024, content_type: "text/plain", filename: "content.txt") + @uploaded_file = @uploader.upload(io) + assert_raises Shrine::Error, "secret_key is required for expiring URLs" do + @uploaded_file.download_url(expires_in: 1000) + end + end + + it "returns a file response with expiring url" do + @uploader = uploader { plugin :download_endpoint, secret_key: SecureRandom.hex(64) } + @shrine = @uploader.class + io = fakeio("a" * 16*1024 + "b" * 16*1024 + "c" * 4*1024, content_type: "text/plain", filename: "content.txt") + @uploaded_file = @uploader.upload(io) + response = app.get(@uploaded_file.download_url(expires_in: 1000)) + + assert_equal 200, response.status + assert_equal @uploaded_file.read, response.body + assert_equal @uploaded_file.size.to_s, response.headers["Content-Length"] + assert_equal @uploaded_file.mime_type, response.headers["Content-Type"] + assert_equal ContentDisposition.inline(@uploaded_file.original_filename), response.headers["Content-Disposition"] + end + + it "does not return a file if expired" do + @uploader = uploader { plugin :download_endpoint, secret_key: SecureRandom.hex(64) } + @shrine = @uploader.class + io = fakeio("a" * 16*1024 + "b" * 16*1024 + "c" * 4*1024, content_type: "text/plain", filename: "content.txt") + @uploaded_file = @uploader.upload(io) + response = app.get(@uploaded_file.download_url(expires_in: -1)) + + assert_equal 400, response.status + end + it "applies :download_options hash" do @shrine.plugin :download_endpoint, download_options: { foo: "bar" } @uploaded_file.storage.expects(:open).with(@uploaded_file.id, foo: "bar").returns(StringIO.new("options")) @@ -138,6 +170,34 @@ def endpoint url2 = @uploaded_file.url assert_equal url1, url2 end + it "returns same download_url regardless of metadata order" do + @uploaded_file.data["metadata"] = { "filename" => "a", "mime_type" => "b", "size" => "c" } + url1 = @uploaded_file.download_url + @uploaded_file.data["metadata"] = { "mime_type" => "b", "size" => "c", "filename" => "a" } + url2 = @uploaded_file.download_url + assert_equal url1, url2 + end + + it "returns signature and expires_at when configured" do + secret = "A" * 64 + @uploader = uploader { plugin :download_endpoint, secret_key: secret } + @shrine = @uploader.class + @uploaded_file = @uploader.upload(fakeio) + + url = @uploaded_file.download_url(expires_in: 1000) + + uri = URI.parse(url) + path = uri.path.split("/").last + query = URI.decode_www_form(uri.query).to_h + signature, expires_at = query.values_at("signature", "expires_at") + + calculated_signature = OpenSSL::HMAC.digest( + OpenSSL::Digest::SHA256.new, + secret, + "#{path}--#{expires_at}" + ) + assert_equal Base64.urlsafe_decode64(signature), calculated_signature + end it "returns 400 on invalid serialized file" do response = app.get("/dontwork")