gemstack-storage 0.2.4 → 0.3.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 +4 -4
- data/CHANGELOG.md +2 -22
- data/README.md +6 -20
- data/lib/gemstack-storage.rb +5 -0
- metadata +21 -27
- data/lib/gemstack/storage/endpoint.rb +0 -112
- data/lib/gemstack/storage/services/disk.rb +0 -60
- data/lib/gemstack/storage/services/s3.rb +0 -65
- data/lib/gemstack/storage/testing.rb +0 -30
- data/lib/gemstack/storage.rb +0 -183
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4107a2a933e26f36e14bc12c7b833b07316a0624858314548be2adf1dd35c513
|
|
4
|
+
data.tar.gz: bec13b73b203d0b6ded767d349318a7456d7da3ddf7962d31e2d7291ad4d07d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7af9ff8cb691d50b7a5f67128dd0627006efa271d3a261bfe5f7b3a986b1b370c9035a4945902e39ca9798ea9d6e464eab686e91804d50503888f28ac47abd03
|
|
7
|
+
data.tar.gz: c0064bcd68fbdcae44594961c78a3b13ddf2825a933bd770e7cbb2a666e932b3ee8a4b0a4ebde8322b6aa92c0987db0fee0ebdd9c3626defade34392a1c08a02
|
data/CHANGELOG.md
CHANGED
|
@@ -1,25 +1,5 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.3.0
|
|
4
4
|
|
|
5
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
6
|
-
|
|
7
|
-
## 0.2.3
|
|
8
|
-
|
|
9
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
10
|
-
|
|
11
|
-
## 0.2.2
|
|
12
|
-
|
|
13
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
14
|
-
|
|
15
|
-
## 0.2.1
|
|
16
|
-
|
|
17
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
18
|
-
|
|
19
|
-
## 0.2.0
|
|
20
|
-
|
|
21
|
-
See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
22
|
-
|
|
23
|
-
## 0.1.0
|
|
24
|
-
|
|
25
|
-
First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
|
5
|
+
Merged into the gemstack gem; this version is a transition shim. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
|
data/README.md
CHANGED
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
# gemstack-storage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Merged into [`gemstack`](https://rubygems.org/gems/gemstack) in GemStack 0.3.0.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
This version is a transition shim: it depends on `gemstack` and loads `gemstack/storage`, so
|
|
6
|
+
Gemfiles that still list `gemstack-storage` keep working. To finish upgrading, remove `gem "gemstack-storage"`
|
|
7
|
+
from your Gemfile and make sure `config/app.rb` has `require "gemstack/storage"` (apps created with 0.3.0 do).
|
|
8
8
|
|
|
9
|
-
|
|
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`).
|
|
9
|
+
GemStack is a modular Ruby API framework for Next.js applications by
|
|
10
|
+
[Adware Technologies](https://www.adwaretech.com) — [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack).
|
|
25
11
|
|
|
26
12
|
## License
|
|
27
13
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemstack-storage
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adware Technologies
|
|
@@ -11,33 +11,28 @@ cert_chain: []
|
|
|
11
11
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name: gemstack
|
|
14
|
+
name: gemstack
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- -
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.
|
|
20
|
-
|
|
21
|
-
prerelease: false
|
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
-
requirements:
|
|
24
|
-
- - '='
|
|
19
|
+
version: 0.3.0
|
|
20
|
+
- - "<"
|
|
25
21
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: gemstack-http
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - '='
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: 0.2.4
|
|
22
|
+
version: '1.0'
|
|
34
23
|
type: :runtime
|
|
35
24
|
prerelease: false
|
|
36
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
26
|
requirements:
|
|
38
|
-
- -
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: 0.3.0
|
|
30
|
+
- - "<"
|
|
39
31
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: 0
|
|
32
|
+
version: '1.0'
|
|
33
|
+
description: Since GemStack 0.3.0, gemstack-storage is part of the gemstack gem. This
|
|
34
|
+
version only depends on gemstack and loads gemstack/storage, so Gemfiles that still
|
|
35
|
+
list it keep working.
|
|
41
36
|
email:
|
|
42
37
|
- gemstack26@gmail.com
|
|
43
38
|
executables: []
|
|
@@ -47,20 +42,19 @@ files:
|
|
|
47
42
|
- CHANGELOG.md
|
|
48
43
|
- LICENSE.txt
|
|
49
44
|
- README.md
|
|
50
|
-
- lib/gemstack
|
|
51
|
-
- lib/gemstack/storage/endpoint.rb
|
|
52
|
-
- lib/gemstack/storage/services/disk.rb
|
|
53
|
-
- lib/gemstack/storage/services/s3.rb
|
|
54
|
-
- lib/gemstack/storage/testing.rb
|
|
45
|
+
- lib/gemstack-storage.rb
|
|
55
46
|
homepage: https://github.com/gemstack-rb/gemstack
|
|
56
47
|
licenses:
|
|
57
48
|
- MIT
|
|
58
49
|
metadata:
|
|
59
50
|
rubygems_mfa_required: 'true'
|
|
60
|
-
source_code_uri: https://github.com/gemstack-rb/gemstack
|
|
61
|
-
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/
|
|
51
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack
|
|
52
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md
|
|
62
53
|
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
63
54
|
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
55
|
+
post_install_message: gemstack-storage is now part of the gemstack gem. Remove `gem
|
|
56
|
+
"gemstack-storage"` from your Gemfile and make sure `config/app.rb` has `require
|
|
57
|
+
"gemstack/storage"` (apps created with 0.3.0 do).
|
|
64
58
|
rdoc_options: []
|
|
65
59
|
require_paths:
|
|
66
60
|
- lib
|
|
@@ -77,5 +71,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
77
71
|
requirements: []
|
|
78
72
|
rubygems_version: 4.0.20
|
|
79
73
|
specification_version: 4
|
|
80
|
-
summary:
|
|
74
|
+
summary: Merged into the gemstack gem — remove gemstack-storage from your Gemfile
|
|
81
75
|
test_files: []
|
|
@@ -1,112 +0,0 @@
|
|
|
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
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
data/lib/gemstack/storage.rb
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
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
|