Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

download_endpoint: Adds support for expiring URLs #708

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

* `download_endpoint` - Add support for expiring URLs

## 3.6.0 (2024-04-29)

* Add Rack 3 support (@tomasc, @janko)
Expand Down
20 changes: 16 additions & 4 deletions doc/plugins/download_endpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
60 changes: 56 additions & 4 deletions lib/shrine/plugins/download_endpoint.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion test/plugin/download_endpoint_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 do
@uploaded_file.download_url(expires_in: 1000)
end
davidwessman marked this conversation as resolved.
Show resolved Hide resolved
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"))
Expand Down Expand Up @@ -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 = SecureRandom.hex(64)
davidwessman marked this conversation as resolved.
Show resolved Hide resolved
@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")
Expand Down