gemstack-storage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +28 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- metadata +80 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 596b95da7b39fb9d21a5e099b2aed7fb1c9606cb08e94023f10e164ca4b4e87a
|
|
4
|
+
data.tar.gz: 202413346a690ec6ad8cc5a3b404fa579eac9ffaddbea26133bd565e50e30d6a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a0ca9969b8f7cd2a06889d098d1b9cb5607c67e593eb90f69c315b45299a8751a4ff528cff98ff9ce28c4c1d49bad47c8cdfae9c13346c62ec1ca55cd66e7ce3
|
|
7
|
+
data.tar.gz: 43f73ed8482c2f7e680009840a35faa6caaf453a28b843f631025493054e193320ad5811b737410f0dfa979ed683c3594623fcc0aaad358f4471d19891b81ed4
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# gemstack-storage
|
|
2
|
+
|
|
3
|
+
GemStack storage: disk and S3 services, signed URLs, direct uploads.
|
|
4
|
+
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
7
|
+
version.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Optional module — `gemstack add storage`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "gemstack-storage", "~> 0.1"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/storage.md)
|
|
20
|
+
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
21
|
+
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
22
|
+
|
|
23
|
+
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
24
|
+
(this gem lives in `gems/gemstack-storage`).
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Storage
|
|
5
|
+
# Serves the :disk service's signed URLs:
|
|
6
|
+
# PUT <path>/<token> stores the request body (type and size as signed)
|
|
7
|
+
# GET <path>/<token> streams the file
|
|
8
|
+
# Unsigned, expired or tampered tokens get 404/403; nothing else is exposed.
|
|
9
|
+
class Endpoint
|
|
10
|
+
def initialize(app) = @app = app
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
prefix = "#{Storage.config.path}/"
|
|
14
|
+
path = env[Rack::PATH_INFO].to_s
|
|
15
|
+
return @app.call(env) unless path.start_with?(prefix)
|
|
16
|
+
|
|
17
|
+
token = path.delete_prefix(prefix)
|
|
18
|
+
case env[Rack::REQUEST_METHOD]
|
|
19
|
+
when "PUT" then put(env, token)
|
|
20
|
+
when "GET", "HEAD" then get(env, token)
|
|
21
|
+
else raise MethodNotAllowed.new(headers: { "allow" => "GET, HEAD, PUT" })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def service
|
|
28
|
+
service = Storage.service
|
|
29
|
+
raise NotFound unless service.is_a?(Services::Disk)
|
|
30
|
+
|
|
31
|
+
service
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def put(env, token)
|
|
35
|
+
claims = Storage.verify(token, purpose: "disk-put") or raise Forbidden.new("Invalid or expired upload URL",
|
|
36
|
+
code: "invalid_signature")
|
|
37
|
+
check_headers!(env, claims)
|
|
38
|
+
service.upload(claims["k"], LimitedReader.new(env["rack.input"], claims["n"]))
|
|
39
|
+
unless service.size(claims["k"]) == claims["n"]
|
|
40
|
+
service.delete(claims["k"])
|
|
41
|
+
raise BadRequest.new("The file is smaller than declared", code: "size_mismatch")
|
|
42
|
+
end
|
|
43
|
+
[201, { "content-length" => "0" }, []]
|
|
44
|
+
rescue LimitedReader::TooLarge
|
|
45
|
+
FileUtils.rm_f("#{service.path_for(claims["k"])}.part")
|
|
46
|
+
raise PayloadTooLarge.new("The file is larger than declared", code: "size_mismatch")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_headers!(env, claims)
|
|
50
|
+
type = env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
|
|
51
|
+
if type != claims["t"]
|
|
52
|
+
raise BadRequest.new("Content-Type must be #{claims["t"]}",
|
|
53
|
+
code: "content_type_mismatch")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
length = Integer(env["CONTENT_LENGTH"].to_s, exception: false)
|
|
57
|
+
return unless length && length != claims["n"]
|
|
58
|
+
|
|
59
|
+
raise BadRequest.new("Content-Length must be #{claims["n"]}", code: "size_mismatch")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get(env, token)
|
|
63
|
+
claims = Storage.verify(token, purpose: "disk-get") or raise NotFound
|
|
64
|
+
file = service.path_for(claims["k"])
|
|
65
|
+
raise NotFound unless File.file?(file)
|
|
66
|
+
|
|
67
|
+
disposition = claims["d"] == "inline" ? "inline" : "attachment"
|
|
68
|
+
disposition += "; filename=\"#{claims["f"].gsub(/["\\\r\n]/, "")}\"" if claims["f"]
|
|
69
|
+
headers = {
|
|
70
|
+
"content-type" => Storage.content_type_for(claims["k"]), "content-length" => File.size(file).to_s,
|
|
71
|
+
"content-disposition" => disposition, "cache-control" => "private, max-age=300",
|
|
72
|
+
# Uploaded files are untrusted: never sniffed, never scripted.
|
|
73
|
+
"x-content-type-options" => "nosniff", "content-security-policy" => "default-src 'none'; sandbox"
|
|
74
|
+
}
|
|
75
|
+
body = env[Rack::REQUEST_METHOD] == "HEAD" ? [] : FileBody.new(file)
|
|
76
|
+
[200, headers, body]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reads at most `limit` bytes; more means the client lied about the size.
|
|
80
|
+
class LimitedReader
|
|
81
|
+
class TooLarge < StandardError; end
|
|
82
|
+
|
|
83
|
+
def initialize(io, limit)
|
|
84
|
+
@io = io
|
|
85
|
+
@left = limit
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read(length = nil, buffer = nil)
|
|
89
|
+
chunk = @io.read(length || 65_536, buffer)
|
|
90
|
+
return chunk if chunk.nil?
|
|
91
|
+
|
|
92
|
+
@left -= chunk.bytesize
|
|
93
|
+
raise TooLarge if @left.negative?
|
|
94
|
+
|
|
95
|
+
chunk
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class FileBody
|
|
100
|
+
def initialize(path) = @path = path
|
|
101
|
+
|
|
102
|
+
def each
|
|
103
|
+
File.open(@path, "rb") do |file|
|
|
104
|
+
while (chunk = file.read(65_536))
|
|
105
|
+
yield chunk
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Storage
|
|
7
|
+
module Services
|
|
8
|
+
# Files on the local disk. Signed URLs point at Storage::Endpoint, so
|
|
9
|
+
# the browser code is identical to S3's: PUT to upload, GET to download.
|
|
10
|
+
class Disk
|
|
11
|
+
attr_reader :root, :path
|
|
12
|
+
|
|
13
|
+
def initialize(root:, path:)
|
|
14
|
+
@root = File.expand_path(root)
|
|
15
|
+
@path = path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def upload(key, io, content_type: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
19
|
+
target = path_for(key)
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
21
|
+
File.open("#{target}.part", "wb") { |file| IO.copy_stream(io, file) }
|
|
22
|
+
File.rename("#{target}.part", target)
|
|
23
|
+
key
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def download(key) = File.binread(path_for(key))
|
|
27
|
+
def exist?(key) = File.file?(path_for(key))
|
|
28
|
+
|
|
29
|
+
def delete(key)
|
|
30
|
+
FileUtils.rm_f(path_for(key))
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def size(key) = File.size(path_for(key))
|
|
35
|
+
|
|
36
|
+
def presigned_upload(key, content_type:, byte_size:, expires_in:)
|
|
37
|
+
token = Storage.sign({ "k" => key, "t" => content_type, "n" => byte_size }, purpose: "disk-put",
|
|
38
|
+
expires_in: expires_in)
|
|
39
|
+
{ url: "#{path}/#{token}", method: "PUT", headers: { "content-type" => content_type } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def url(key, expires_in:, disposition:, filename: nil)
|
|
43
|
+
token = Storage.sign({ "k" => key, "d" => disposition, "f" => filename }, purpose: "disk-get",
|
|
44
|
+
expires_in: expires_in)
|
|
45
|
+
"#{path}/#{token}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Keys never escape the root (no "..", no absolute paths).
|
|
49
|
+
def path_for(key)
|
|
50
|
+
key = key.to_s
|
|
51
|
+
if key.empty? || key.start_with?("/") || key.split("/").any? { |part| part.empty? || part.start_with?(".") }
|
|
52
|
+
raise ArgumentError, "invalid storage key #{key.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
File.join(root, key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "aws-sdk-s3"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
raise GemStack::ConfigurationError, "config.storage.service = :s3 needs gem \"aws-sdk-s3\" in the Gemfile"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module GemStack
|
|
10
|
+
module Storage
|
|
11
|
+
module Services
|
|
12
|
+
# Amazon S3 and S3-compatible services (Cloudflare R2, MinIO, …).
|
|
13
|
+
# Browsers PUT directly to a presigned URL; its signature covers the
|
|
14
|
+
# content type and exact size, so neither can be changed.
|
|
15
|
+
#
|
|
16
|
+
# The bucket needs a CORS rule allowing PUT from your site (docs/storage.md).
|
|
17
|
+
class S3
|
|
18
|
+
attr_reader :bucket, :client
|
|
19
|
+
|
|
20
|
+
def initialize(bucket:, region:, endpoint: nil, client: nil, **options)
|
|
21
|
+
raise ConfigurationError, "set S3_BUCKET (config.storage.bucket) to use the :s3 storage service" if
|
|
22
|
+
bucket.to_s.empty? && client.nil?
|
|
23
|
+
|
|
24
|
+
@bucket = bucket
|
|
25
|
+
options = { region: region, endpoint: endpoint,
|
|
26
|
+
force_path_style: !endpoint.nil? || nil }.compact.merge(options)
|
|
27
|
+
@client = client || Aws::S3::Client.new(**options)
|
|
28
|
+
@presigner = Aws::S3::Presigner.new(client: @client)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def upload(key, io, content_type:)
|
|
32
|
+
client.put_object(bucket: bucket, key: key, body: io, content_type: content_type)
|
|
33
|
+
key
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def download(key) = client.get_object(bucket: bucket, key: key).body.read
|
|
37
|
+
|
|
38
|
+
def exist?(key)
|
|
39
|
+
client.head_object(bucket: bucket, key: key)
|
|
40
|
+
true
|
|
41
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(key)
|
|
46
|
+
client.delete_object(bucket: bucket, key: key)
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def presigned_upload(key, content_type:, byte_size:, expires_in:)
|
|
51
|
+
url = @presigner.presigned_url(:put_object, bucket: bucket, key: key, content_type: content_type,
|
|
52
|
+
content_length: byte_size, expires_in: expires_in,
|
|
53
|
+
whitelist_headers: ["content-length"]) # sign the size too
|
|
54
|
+
{ url: url, method: "PUT", headers: { "content-type" => content_type } }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def url(key, expires_in:, disposition:, filename: nil)
|
|
58
|
+
header = filename ? "#{disposition}; filename=\"#{filename.to_s.gsub(/["\\\r\n]/, "")}\"" : disposition
|
|
59
|
+
@presigner.presigned_url(:get_object, bucket: bucket, key: key, expires_in: expires_in,
|
|
60
|
+
response_content_disposition: header)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/storage"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
module Storage
|
|
8
|
+
# Tests store files on disk in a temporary directory, cleared after each test.
|
|
9
|
+
module Testing
|
|
10
|
+
def before_setup
|
|
11
|
+
super
|
|
12
|
+
@gemstack_storage_root = Dir.mktmpdir("gemstack-storage")
|
|
13
|
+
Storage.service = Services::Disk.new(root: @gemstack_storage_root, path: Storage.config.path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def after_teardown
|
|
17
|
+
FileUtils.rm_rf(@gemstack_storage_root) if @gemstack_storage_root
|
|
18
|
+
Storage.reset!
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Stores a file as if the browser had uploaded it; returns its signed_id.
|
|
23
|
+
def upload_fixture(content, filename: "file.png", content_type: "image/png")
|
|
24
|
+
key = Storage.generate_key("uploads", filename, content_type)
|
|
25
|
+
Storage.upload(key, StringIO.new(content), content_type: content_type)
|
|
26
|
+
Storage.sign(key, purpose: "upload")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "gemstack/core"
|
|
8
|
+
require "gemstack/http"
|
|
9
|
+
|
|
10
|
+
module GemStack
|
|
11
|
+
# File storage (DECISIONS D-053): files go from the browser straight to
|
|
12
|
+
# the storage service, never through a Ruby process.
|
|
13
|
+
#
|
|
14
|
+
# # POST /api/uploads → presigned upload for the browser
|
|
15
|
+
# upload = GemStack::Storage.direct_upload(filename: "cat.png", content_type: "image/png", byte_size: 52_000)
|
|
16
|
+
# # browser: PUT the file to upload[:url] with upload[:headers]
|
|
17
|
+
# # later: product.update(image_key: GemStack::Storage.key_for_signed_id(params[:image]))
|
|
18
|
+
# GemStack::Storage.url(product.image_key) # short-lived signed download URL
|
|
19
|
+
#
|
|
20
|
+
# Services: :disk (development and tests; files under storage/, served and
|
|
21
|
+
# accepted by a signed endpoint at /api/storage) and :s3 (S3, R2, MinIO…).
|
|
22
|
+
module Storage
|
|
23
|
+
class Config < Settings
|
|
24
|
+
# :disk, :s3, or a service object.
|
|
25
|
+
setting :service, default: -> { ENV.fetch("STORAGE_SERVICE", GemStack.env.production? ? "s3" : "disk").to_sym }
|
|
26
|
+
setting :root, default: -> { File.join(GemStack.config.root, "storage", GemStack.env.test? ? "test" : "") }
|
|
27
|
+
# Where the disk service's signed URLs point (handled by Storage::Endpoint).
|
|
28
|
+
setting :path, default: -> { "#{GemStack.config.http.api_path}/storage" }
|
|
29
|
+
setting :bucket, default: -> { ENV.fetch("S3_BUCKET", nil) }
|
|
30
|
+
setting :region, default: -> { ENV.fetch("AWS_REGION", ENV.fetch("S3_REGION", "us-east-1")) }
|
|
31
|
+
# For S3-compatible services: https://<account>.r2.cloudflarestorage.com, http://localhost:9000
|
|
32
|
+
setting :endpoint, default: -> { ENV.fetch("S3_ENDPOINT", nil) }
|
|
33
|
+
# Credentials come from the usual AWS chain (AWS_ACCESS_KEY_ID…, instance roles) unless set.
|
|
34
|
+
setting :s3_options, default: {}
|
|
35
|
+
setting :url_expires_in, default: 300
|
|
36
|
+
setting :max_upload_size, default: 10 * 1024 * 1024
|
|
37
|
+
# MIME types accepted by direct_upload ("image/*" style wildcards).
|
|
38
|
+
# SVG and HTML are excluded on purpose: they can carry scripts.
|
|
39
|
+
setting :allowed_content_types, default: %w[image/png image/jpeg image/gif image/webp image/avif application/pdf]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class InvalidUpload < ValidationError; end
|
|
43
|
+
|
|
44
|
+
# Served inline by browsers; everything else is sent as an attachment.
|
|
45
|
+
INLINE_TYPES = %w[image/png image/jpeg image/gif image/webp image/avif application/pdf text/plain].freeze
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def config = GemStack.config.storage
|
|
49
|
+
|
|
50
|
+
def service
|
|
51
|
+
@service ||= build_service(config.service)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_writer :service
|
|
55
|
+
|
|
56
|
+
def reset! = @service = nil
|
|
57
|
+
|
|
58
|
+
# Validates an upload request and returns what the browser needs:
|
|
59
|
+
# { key:, signed_id:, url:, method: "PUT", headers: {…} }
|
|
60
|
+
def direct_upload(filename:, content_type:, byte_size:, prefix: "uploads")
|
|
61
|
+
content_type = content_type.to_s.downcase.split(";").first.to_s.strip
|
|
62
|
+
byte_size = Integer(byte_size, exception: false)
|
|
63
|
+
validate_upload!(content_type, byte_size)
|
|
64
|
+
|
|
65
|
+
key = generate_key(prefix, filename, content_type)
|
|
66
|
+
upload = service.presigned_upload(key, content_type: content_type, byte_size: byte_size,
|
|
67
|
+
expires_in: config.url_expires_in)
|
|
68
|
+
{ key: key, signed_id: sign(key, purpose: "upload"), **upload }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The key behind a signed_id from direct_upload; 422 when tampered with
|
|
72
|
+
# (so clients can't point records at other people's files).
|
|
73
|
+
def key_for_signed_id(signed_id)
|
|
74
|
+
verify(signed_id, purpose: "upload") ||
|
|
75
|
+
raise(InvalidUpload.new("Invalid upload reference", errors: { file: ["is invalid"] }))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def url(key, expires_in: config.url_expires_in, disposition: nil, filename: nil)
|
|
79
|
+
service.url(key, expires_in: expires_in, disposition: disposition || disposition_for(key), filename: filename)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def upload(key, io, content_type:) = service.upload(key, io, content_type: content_type)
|
|
83
|
+
def download(key) = service.download(key)
|
|
84
|
+
def delete(key) = service.delete(key)
|
|
85
|
+
def exist?(key) = service.exist?(key)
|
|
86
|
+
|
|
87
|
+
def allowed_content_type?(type)
|
|
88
|
+
Array(config.allowed_content_types).any? do |pattern|
|
|
89
|
+
pattern.end_with?("/*") ? type.start_with?(pattern.delete_suffix("*")) : type == pattern
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Tamper-proof, expiring tokens (HMAC with GemStack.key_for("storage")).
|
|
94
|
+
def sign(value, purpose:, expires_in: nil)
|
|
95
|
+
payload = { "v" => value, "p" => purpose }
|
|
96
|
+
payload["e"] = Time.now.to_i + expires_in if expires_in
|
|
97
|
+
data = [JSON.generate(payload)].pack("m0").tr("+/", "-_").delete("=")
|
|
98
|
+
"#{data}--#{digest(data)}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def verify(token, purpose:)
|
|
102
|
+
data, mac = token.to_s.split("--", 2)
|
|
103
|
+
return nil unless data && mac && OpenSSL.fixed_length_secure_compare(digest(data), mac)
|
|
104
|
+
|
|
105
|
+
payload = JSON.parse(data.tr("-_", "+/").ljust((data.length + 3) & ~3, "=").unpack1("m0"))
|
|
106
|
+
return nil unless payload["p"] == purpose
|
|
107
|
+
return nil if payload["e"] && payload["e"] < Time.now.to_i
|
|
108
|
+
|
|
109
|
+
payload["v"]
|
|
110
|
+
rescue ArgumentError, JSON::ParserError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Keys are ours, not the client's: uploads/2026/09/<uuid>/<clean name>.
|
|
115
|
+
# The extension always matches the validated content type, so a file
|
|
116
|
+
# declared as image/png can't be served as HTML because it's named x.html.
|
|
117
|
+
def generate_key(prefix, filename, content_type)
|
|
118
|
+
base = File.basename(filename.to_s, ".*").unicode_normalize(:nfkc).gsub(/[^\w-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
119
|
+
base = "file" if base.empty?
|
|
120
|
+
[prefix, Time.now.utc.strftime("%Y/%m"), SecureRandom.uuid, "#{base[0, 100]}#{extension_for(content_type)}"]
|
|
121
|
+
.reject(&:empty?).join("/")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
EXTENSIONS = { "image/jpeg" => ".jpg", "image/png" => ".png", "image/gif" => ".gif", "image/webp" => ".webp",
|
|
125
|
+
"image/avif" => ".avif", "application/pdf" => ".pdf", "text/plain" => ".txt",
|
|
126
|
+
"text/csv" => ".csv" }.freeze
|
|
127
|
+
|
|
128
|
+
def extension_for(content_type) = EXTENSIONS[content_type] || Rack::Mime::MIME_TYPES.key(content_type) || ".bin"
|
|
129
|
+
|
|
130
|
+
def content_type_for(key) = Rack::Mime.mime_type(File.extname(key.to_s), "application/octet-stream")
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def build_service(service)
|
|
135
|
+
case service
|
|
136
|
+
when :disk, "disk" then Services::Disk.new(root: config.root, path: config.path)
|
|
137
|
+
when :s3, "s3"
|
|
138
|
+
require_relative "storage/services/s3"
|
|
139
|
+
Services::S3.new(bucket: config.bucket, region: config.region, endpoint: config.endpoint,
|
|
140
|
+
**config.s3_options)
|
|
141
|
+
else
|
|
142
|
+
unless service.respond_to?(:presigned_upload)
|
|
143
|
+
raise ConfigurationError, "unknown config.storage.service #{service.inspect} (use :disk, :s3 or an object)"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
service
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_upload!(content_type, byte_size)
|
|
151
|
+
errors = {}
|
|
152
|
+
errors[:content_type] = ["#{content_type.inspect} is not allowed"] unless allowed_content_type?(content_type)
|
|
153
|
+
max = config.max_upload_size
|
|
154
|
+
if byte_size.nil? || byte_size <= 0
|
|
155
|
+
errors[:byte_size] = ["must be a positive number of bytes"]
|
|
156
|
+
elsif byte_size > max
|
|
157
|
+
errors[:byte_size] = ["is too large (maximum #{max / 1024 / 1024} MB)"]
|
|
158
|
+
end
|
|
159
|
+
raise InvalidUpload.new("Upload refused", errors: errors) unless errors.empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Browsers show images and PDFs inline; anything else downloads.
|
|
163
|
+
def disposition_for(key) = INLINE_TYPES.include?(content_type_for(key)) ? "inline" : "attachment"
|
|
164
|
+
|
|
165
|
+
def digest(data) = OpenSSL::HMAC.hexdigest("SHA256", GemStack.key_for("storage"), data)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
require_relative "storage/services/disk"
|
|
171
|
+
require_relative "storage/endpoint"
|
|
172
|
+
|
|
173
|
+
GemStack::Config.namespace(:storage, GemStack::Storage::Config)
|
|
174
|
+
|
|
175
|
+
GemStack::Plugins.register(:storage) do |app|
|
|
176
|
+
next unless app.respond_to?(:config)
|
|
177
|
+
|
|
178
|
+
stack = app.config.http.middleware
|
|
179
|
+
# Before BodyLimit: signed uploads carry their own size limit.
|
|
180
|
+
unless stack.include?(GemStack::Storage::Endpoint)
|
|
181
|
+
stack.insert_before(GemStack::HTTP::Middleware::BodyLimit, GemStack::Storage::Endpoint)
|
|
182
|
+
end
|
|
183
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gemstack-storage
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shoaib Malik
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: gemstack-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: gemstack-http
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - '='
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - '='
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
40
|
+
email:
|
|
41
|
+
- gemstack26@gmail.com
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- CHANGELOG.md
|
|
47
|
+
- LICENSE.txt
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/gemstack/storage.rb
|
|
50
|
+
- lib/gemstack/storage/endpoint.rb
|
|
51
|
+
- lib/gemstack/storage/services/disk.rb
|
|
52
|
+
- lib/gemstack/storage/services/s3.rb
|
|
53
|
+
- lib/gemstack/storage/testing.rb
|
|
54
|
+
homepage: https://github.com/gemstack-rb/gemstack
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
rubygems_mfa_required: 'true'
|
|
59
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-storage
|
|
60
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-storage/CHANGELOG.md
|
|
61
|
+
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
62
|
+
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '4.0'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 4.0.20
|
|
78
|
+
specification_version: 4
|
|
79
|
+
summary: 'GemStack storage: disk and S3 services, signed URLs, direct uploads'
|
|
80
|
+
test_files: []
|