monkrb 0.22.2 → 0.23.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 +46 -0
- data/README.md +3 -2
- data/lib/monk/base.rb +5 -0
- data/lib/monk/generators/base.rb +1 -0
- data/lib/monk/generators/storage.rb +28 -0
- data/lib/monk/generators.rb +1 -1
- data/lib/monk/storage/backends/file.rb +242 -0
- data/lib/monk/storage/backends/s3.rb +197 -0
- data/lib/monk/storage/errors.rb +34 -0
- data/lib/monk/storage/input.rb +83 -0
- data/lib/monk/storage/key.rb +40 -0
- data/lib/monk/storage/object.rb +7 -0
- data/lib/monk/storage/routes.rb +110 -0
- data/lib/monk/storage/sig_v4.rb +124 -0
- data/lib/monk/storage/sniff.rb +81 -0
- data/lib/monk/storage/token.rb +45 -0
- data/lib/monk/storage/upload.rb +10 -0
- data/lib/monk/storage/url.rb +126 -0
- data/lib/monk/storage.rb +184 -0
- data/lib/monk/templates/base/Dockerfile +5 -0
- data/lib/monk/templates/base/Gemfile +1 -1
- data/lib/monk/templates/base/bin/server +20 -0
- data/lib/monk/templates/base/setup.md +10 -0
- data/lib/monk/templates/storage/agents.md +35 -0
- data/lib/monk/templates/storage/app/routes/storage.rb +67 -0
- data/lib/monk/templates/storage/config/storage.rb +21 -0
- data/lib/monk/templates/storage/setup.md +43 -0
- data/lib/monk/templates/storage/test/storage_test.rb +33 -0
- data/lib/monk/version.rb +1 -1
- metadata +20 -1
data/lib/monk/storage.rb
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
require_relative "freeze_hooks"
|
|
2
|
+
require_relative "environment"
|
|
3
|
+
require_relative "log"
|
|
4
|
+
require_relative "storage/errors"
|
|
5
|
+
require_relative "storage/key"
|
|
6
|
+
require_relative "storage/input"
|
|
7
|
+
require_relative "storage/object"
|
|
8
|
+
require_relative "storage/upload"
|
|
9
|
+
require_relative "storage/token"
|
|
10
|
+
require_relative "storage/routes"
|
|
11
|
+
require_relative "storage/sig_v4"
|
|
12
|
+
require_relative "storage/backends/file"
|
|
13
|
+
require_relative "storage/backends/s3"
|
|
14
|
+
require_relative "storage/url"
|
|
15
|
+
require_relative "storage/sniff"
|
|
16
|
+
|
|
17
|
+
module Monk
|
|
18
|
+
# Storing the files users send. Opt-in: require "monk/storage"
|
|
19
|
+
# explicitly -- `require "monk"` alone does not load this. One
|
|
20
|
+
# S3-compatible backend for every provider, local files in development
|
|
21
|
+
# and test, and uploads that go from the browser straight to the bucket
|
|
22
|
+
# (docs/adr/0018-storage-s3-compatible-direct-upload.md).
|
|
23
|
+
module Storage
|
|
24
|
+
# Signs the file backend's links in development and test when
|
|
25
|
+
# STORAGE_SECRET is unset. Fixed, so a link built by bin/jobs also
|
|
26
|
+
# works in the web process.
|
|
27
|
+
DEVELOPMENT_SECRET = "monk-storage-development-secret".freeze
|
|
28
|
+
# url's defaults: links valid for at least an hour, the same URL for
|
|
29
|
+
# a whole hour (docs/history/plan-storage.md decision 9).
|
|
30
|
+
CACHE_WINDOW = 3600
|
|
31
|
+
EXPIRES_IN = 3600
|
|
32
|
+
# presigned_put's default: an upload starts within minutes of asking.
|
|
33
|
+
UPLOAD_EXPIRES_IN = 600
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Typically, in config/storage.rb:
|
|
37
|
+
#
|
|
38
|
+
# Monk::Storage.configure(url: ENV["STORAGE_URL"], secret: ENV["STORAGE_SECRET"])
|
|
39
|
+
#
|
|
40
|
+
# url: picks the backend (file:// or s3://) and is parsed here, so a
|
|
41
|
+
# bad one fails the boot. Unset or empty means file://storage/<env>
|
|
42
|
+
# in development and test, and raises in staging and production: on
|
|
43
|
+
# hosts whose disk is replaced on deploy, a silent fallback to local
|
|
44
|
+
# files would lose every upload. secret: signs the file backend's
|
|
45
|
+
# links, and is required with file:// outside development and test.
|
|
46
|
+
# An S3 backend doesn't use it. expires_in: and cache_window: are
|
|
47
|
+
# url's defaults, and together stay within the 7 days a signed S3
|
|
48
|
+
# link allows.
|
|
49
|
+
def configure(url:, secret: nil, expires_in: EXPIRES_IN, cache_window: CACHE_WINDOW)
|
|
50
|
+
Input.link_times!(expires_in: expires_in, cache_window: cache_window)
|
|
51
|
+
backend = backend_for(url)
|
|
52
|
+
backend = backend.with(secret: secret_for(secret)) if backend.is_a?(Backends::File)
|
|
53
|
+
|
|
54
|
+
@config = { backend: backend, expires_in: expires_in, cache_window: cache_window }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :config
|
|
58
|
+
|
|
59
|
+
# "tmp/1f0c...": a random key under prefix. Uploads go under tmp/
|
|
60
|
+
# until the app confirms them.
|
|
61
|
+
def generate_key(prefix:)
|
|
62
|
+
Key.generate(prefix: prefix)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The API, the same on every backend (docs/history/plan-storage.md decision 3),
|
|
66
|
+
# each call logged as one line without URLs or secrets.
|
|
67
|
+
|
|
68
|
+
# Returns the key. body is a String, or an IO that responds to size,
|
|
69
|
+
# read and rewind.
|
|
70
|
+
def put(key, body, content_type: Input::DEFAULT_CONTENT_TYPE)
|
|
71
|
+
logged("put", key) { [backend.put(key, body, content_type: content_type), "bytes=#{body.size}"] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The bytes, as a frozen binary String; range: 0..15 reads only the
|
|
75
|
+
# first 16. A missing key raises NotFoundError.
|
|
76
|
+
def get(key, range: nil)
|
|
77
|
+
logged("get", key) do
|
|
78
|
+
bytes = backend.get(key, range: range)
|
|
79
|
+
[bytes, "bytes=#{bytes.bytesize}"]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A Storage::Object, or nil when the key is missing.
|
|
84
|
+
def head(key)
|
|
85
|
+
logged("head", key) do
|
|
86
|
+
object = backend.head(key)
|
|
87
|
+
[object, object ? "bytes=#{object.byte_size}" : "missing"]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Idempotent: deleting a missing key is not an error.
|
|
92
|
+
def delete(key)
|
|
93
|
+
logged("delete", key) { [backend.delete(key), nil] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns to. A missing from raises NotFoundError.
|
|
97
|
+
def copy(from, to)
|
|
98
|
+
logged("copy", from) { [backend.copy(from, to), "to=#{to}"] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A signed download link for key, the String to redirect to (302)
|
|
102
|
+
# from the app's own route, after checking the viewer may see it.
|
|
103
|
+
# The same URL for every call within one cache_window, so the
|
|
104
|
+
# browser's cache works; valid at least expires_in seconds.
|
|
105
|
+
def url(key, expires_in: config_value(:expires_in), cache_window: config_value(:cache_window))
|
|
106
|
+
logged("url", key) { [backend.url(key, expires_in: expires_in, cache_window: cache_window), nil] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A signed upload link for a browser: an Upload with the url to PUT
|
|
110
|
+
# the file to and the headers to send. content_type: and byte_size:
|
|
111
|
+
# are inside the signature, so the bucket refuses any other. key
|
|
112
|
+
# goes under tmp/ until the app confirms it.
|
|
113
|
+
def presigned_put(key, content_type:, byte_size:, expires_in: UPLOAD_EXPIRES_IN)
|
|
114
|
+
logged("presigned_put", key) do
|
|
115
|
+
[backend.presigned_put(key, content_type: content_type, byte_size: byte_size, expires_in: expires_in),
|
|
116
|
+
"bytes=#{byte_size}",]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Called from Base#dispatch, after Monk::Assets.response: the file
|
|
121
|
+
# backend's /_monk/storage routes, or nil for any other request
|
|
122
|
+
# (and always nil with an S3 backend, or before configure).
|
|
123
|
+
def response(env)
|
|
124
|
+
Routes.response(env, config[:backend]) if config
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Freezes
|
|
128
|
+
# the value, not the module, same as Monk::Mail.freeze_registry! --
|
|
129
|
+
# an unfrozen config Hash can't be read from a worker Ractor at all.
|
|
130
|
+
def freeze_registry!
|
|
131
|
+
@config = Ractor.make_shareable(@config)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Test-only: drops the current config. Not part of the app-facing API.
|
|
135
|
+
def reset!
|
|
136
|
+
@config = nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def backend
|
|
142
|
+
raise NotConfiguredError, "call Monk::Storage.configure(url:) before using Monk::Storage" unless config
|
|
143
|
+
|
|
144
|
+
config[:backend]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def config_value(name)
|
|
148
|
+
backend
|
|
149
|
+
config[name]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The block returns [result, detail for the log line].
|
|
153
|
+
def logged(operation, key)
|
|
154
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
|
+
result, detail = yield
|
|
156
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
157
|
+
Monk::Log.info("[storage] #{operation} key=#{key}#{" #{detail}" if detail} #{elapsed}ms")
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def backend_for(url)
|
|
162
|
+
return URL.parse(url) unless url.nil? || url.empty?
|
|
163
|
+
return URL.parse("file://storage/#{Monk.env}") if Monk.env.development? || Monk.env.test?
|
|
164
|
+
|
|
165
|
+
raise MissingStorageUrlError,
|
|
166
|
+
"Monk::Storage.configure(url:) is empty and this isn't development or test (MONK_ENV=#{Monk.env}) -- " \
|
|
167
|
+
"uploads would go to local files a deploy can replace. Set STORAGE_URL, e.g. " \
|
|
168
|
+
"#{URL::S3_EXAMPLE}, or file:///path/on/a/volume if one server with a persistent volume is truly intended."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def secret_for(secret)
|
|
172
|
+
return secret unless secret.nil? || secret.empty?
|
|
173
|
+
return DEVELOPMENT_SECRET if Monk.env.development? || Monk.env.test?
|
|
174
|
+
|
|
175
|
+
raise MissingStorageSecretError,
|
|
176
|
+
"STORAGE_URL is file:// and Monk::Storage.configure(secret:) is empty (MONK_ENV=#{Monk.env}) -- " \
|
|
177
|
+
"the file backend's links would be signed with a public value. Set STORAGE_SECRET to a random " \
|
|
178
|
+
"value of its own (e.g. `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`), not the auth secret."
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
Monk.freeze_hooks << self
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -29,5 +29,10 @@ COPY . .
|
|
|
29
29
|
# `bin/websocket_server` as the command instead of publishing another
|
|
30
30
|
# port from this Dockerfile -- see docs/guides/deploying.md. Every other
|
|
31
31
|
# process in bin/ (bin/jobs, say) is the same: this image, that command.
|
|
32
|
+
# Production unless the platform says otherwise (MONK_ENV=staging in its
|
|
33
|
+
# env, say). Unset, Monk runs as development: mail printed instead of
|
|
34
|
+
# sent, magic links logged, demo routes on, a public storage secret.
|
|
35
|
+
ENV MONK_ENV=production
|
|
36
|
+
|
|
32
37
|
EXPOSE 9292
|
|
33
38
|
CMD ["bin/server", "--bind", "0.0.0.0"]
|
|
@@ -2,4 +2,24 @@
|
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
cd "$(dirname "$0")/.."
|
|
4
4
|
|
|
5
|
+
# Bound beyond this machine (the Dockerfile's --bind 0.0.0.0) with MONK_ENV
|
|
6
|
+
# unset: almost certainly deployed, and running as development. A warning,
|
|
7
|
+
# not an error, so a container run locally on purpose still starts.
|
|
8
|
+
bind="" previous=""
|
|
9
|
+
for arg in "$@"; do
|
|
10
|
+
case "$previous" in -b | --bind) bind="$arg" ;; esac
|
|
11
|
+
case "$arg" in --bind=*) bind="${arg#--bind=}" ;; esac
|
|
12
|
+
previous="$arg"
|
|
13
|
+
done
|
|
14
|
+
case "$bind" in
|
|
15
|
+
"" | 127.* | localhost | ::1) ;;
|
|
16
|
+
*)
|
|
17
|
+
if [[ -z "${MONK_ENV:-}" ]] && ! grep -qs '^MONK_ENV=' .env; then
|
|
18
|
+
echo "bin/server: warning: listening on $bind with MONK_ENV unset, so this runs as development:" \
|
|
19
|
+
"mail is printed instead of sent, magic links are logged, auth cookies aren't Secure, demo routes" \
|
|
20
|
+
"are on. Set MONK_ENV=production (or staging) where the app is deployed." >&2
|
|
21
|
+
fi
|
|
22
|
+
;;
|
|
23
|
+
esac
|
|
24
|
+
|
|
5
25
|
exec bundle exec kino -p "${PORT:-9292}" "$@" config.ru
|
|
@@ -7,6 +7,16 @@ bundle install
|
|
|
7
7
|
bin/server # the app on http://localhost:9292
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
+
## Before production
|
|
11
|
+
|
|
12
|
+
Set `MONK_ENV=production` (or `staging`) wherever the app runs deployed.
|
|
13
|
+
Unset, it means `development`, and the app runs that way without an
|
|
14
|
+
error: mail printed instead of sent, magic links logged, demo routes on.
|
|
15
|
+
The `Dockerfile` already sets it; without the Dockerfile, set it in the
|
|
16
|
+
platform's env. `bin/server` warns when it listens beyond this machine
|
|
17
|
+
(`--bind 0.0.0.0`) with `MONK_ENV` unset. Platform examples:
|
|
18
|
+
`docs/guides/deploying.md` in the monk repo.
|
|
19
|
+
|
|
10
20
|
## Check it works
|
|
11
21
|
|
|
12
22
|
1. `bundle exec rake test` runs the tests.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
## storage
|
|
2
|
+
|
|
3
|
+
**Where:** `config/storage.rb` (from `STORAGE_URL`, `STORAGE_SECRET`);
|
|
4
|
+
routes that upload and serve files in `app/routes/storage.rb`. Files live
|
|
5
|
+
under `storage/<env>` until `STORAGE_URL` names a bucket.
|
|
6
|
+
|
|
7
|
+
**Main calls:** `Monk::Storage.generate_key(prefix: "tmp")` for every new
|
|
8
|
+
file; `put(key, body, content_type:)`, `get(key, range: 0..99)`,
|
|
9
|
+
`head(key)` (an object with `byte_size`, `content_type`, `etag`, or nil),
|
|
10
|
+
`delete(key)`, `copy(from, to)`. Downloads: `redirect
|
|
11
|
+
Monk::Storage.url(key)`. Browser uploads:
|
|
12
|
+
`presigned_put(key, content_type:, byte_size:)` gives the `url` and
|
|
13
|
+
`headers` to PUT the file with. At confirm,
|
|
14
|
+
`Monk::Storage.get(key, range: Monk::Storage::Sniff::RANGE)` and
|
|
15
|
+
`Monk::Storage::Sniff.content_type(bytes)` check the file is what it
|
|
16
|
+
claims; `Sniff.refuse_svg!(bytes)` raises on SVG.
|
|
17
|
+
|
|
18
|
+
**Test:** `test/storage_test.rb`. Tests use `storage/test` (an unset
|
|
19
|
+
`STORAGE_URL`); call a signed link through `APP.call` with
|
|
20
|
+
`Rack::MockRequest.env_for(Monk::Storage.url(key))`.
|
|
21
|
+
|
|
22
|
+
**Pitfalls:**
|
|
23
|
+
- Keys come from `generate_key`, never from a user's filename: keep the
|
|
24
|
+
filename in the app's own table.
|
|
25
|
+
- An upload stays under `tmp/` until the app checks it; then `copy` to
|
|
26
|
+
its final key, and `delete` the `tmp/` one after the copy, never before.
|
|
27
|
+
- Check the viewer may see a file before redirecting to its `url`: a
|
|
28
|
+
signed link works for anyone who has it, until it expires.
|
|
29
|
+
- Large files go from the browser straight to the bucket with
|
|
30
|
+
`presigned_put`. Don't stream them through a route: it holds a worker
|
|
31
|
+
Ractor for the whole upload.
|
|
32
|
+
- Never accept SVG or HTML as an upload: both can carry scripts.
|
|
33
|
+
|
|
34
|
+
**Examples:** `storage-upload`, `storage-confirm` and `storage-download`
|
|
35
|
+
in `app/routes/storage.rb`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Routes that store files users send (monk add storage). An upload goes
|
|
2
|
+
# from the browser straight to the bucket (or, in development, to
|
|
3
|
+
# /_monk/storage): bin/server only signs the link, then checks the file.
|
|
4
|
+
#
|
|
5
|
+
# monk:example storage-upload -- a signed link to upload one file to
|
|
6
|
+
# # Only the types and sizes the app accepts get a link, and both are
|
|
7
|
+
# # inside its signature. The file goes under tmp/ until it's confirmed.
|
|
8
|
+
# # Try it (in development the link is a path on this app):
|
|
9
|
+
# # curl -X POST http://localhost:9292/uploads -H "content-type: application/json" \
|
|
10
|
+
# # -d "{\"content_type\": \"image/png\", \"byte_size\": $(wc -c < photo.png)}"
|
|
11
|
+
# # curl -X PUT "http://localhost:9292<url>" -H "content-type: image/png" --data-binary @photo.png
|
|
12
|
+
# class App
|
|
13
|
+
# post("/uploads") do
|
|
14
|
+
# content_type = params[:content_type].to_s
|
|
15
|
+
# byte_size = params[:byte_size].to_i
|
|
16
|
+
# halt 422, "type not accepted" unless %w[image/png image/jpeg image/webp application/pdf].include?(content_type)
|
|
17
|
+
# halt 422, "size not accepted" unless byte_size.between?(1, 10 * 1024 * 1024)
|
|
18
|
+
#
|
|
19
|
+
# key = Monk::Storage.generate_key(prefix: "tmp")
|
|
20
|
+
# upload = Monk::Storage.presigned_put(key, content_type: content_type, byte_size: byte_size)
|
|
21
|
+
# json(key: key, url: upload.url, headers: upload.headers)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# monk:end
|
|
25
|
+
#
|
|
26
|
+
# monk:example storage-confirm -- check an upload, then move it out of tmp/
|
|
27
|
+
# # head, check the first bytes, copy, then delete: if a step fails, the
|
|
28
|
+
# # tmp/ file stays and the bucket's lifecycle rule expires it. A real app
|
|
29
|
+
# # also checks who uploaded it, and saves a record (key, filename, type,
|
|
30
|
+
# # size) in its own table: docs/guides/storage.md in the monk repo.
|
|
31
|
+
# # curl -X POST http://localhost:9292/uploads/confirm -H "content-type: application/json" \
|
|
32
|
+
# # -d '{"key": "tmp/<key>"}'
|
|
33
|
+
# class App
|
|
34
|
+
# post("/uploads/confirm") do
|
|
35
|
+
# key = params[:key].to_s
|
|
36
|
+
# halt 422, "not an upload" unless key.match?(%r{\Atmp/[0-9a-f]{32}\z})
|
|
37
|
+
# object = Monk::Storage.head(key)
|
|
38
|
+
# halt 404, "no such upload" unless object
|
|
39
|
+
#
|
|
40
|
+
# # Also refuses SVG, which can carry scripts: Sniff never names it.
|
|
41
|
+
# first_bytes = Monk::Storage.get(key, range: Monk::Storage::Sniff::RANGE)
|
|
42
|
+
# unless Monk::Storage::Sniff.content_type(first_bytes) == object.content_type
|
|
43
|
+
# Monk::Storage.delete(key)
|
|
44
|
+
# halt 422, "the file isn't a #{object.content_type}"
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# media_key = key.sub("tmp/", "media/")
|
|
48
|
+
# Monk::Storage.copy(key, media_key)
|
|
49
|
+
# Monk::Storage.delete(key)
|
|
50
|
+
# json(key: media_key, content_type: object.content_type, byte_size: object.byte_size)
|
|
51
|
+
# end
|
|
52
|
+
# end
|
|
53
|
+
# monk:end
|
|
54
|
+
#
|
|
55
|
+
# monk:example storage-download -- a redirect to a file's signed link
|
|
56
|
+
# # NO ACCESS CHECK: anyone who knows a key gets the file. In a real app
|
|
57
|
+
# # this becomes GET /media/:id, which looks the record up, checks the
|
|
58
|
+
# # viewer may see it, then redirects to its key's link.
|
|
59
|
+
# # curl -i http://localhost:9292/media/<key without media/>
|
|
60
|
+
# class App
|
|
61
|
+
# get("/media/*") do
|
|
62
|
+
# halt 404, "" unless params[:splat].match?(/\A[0-9a-f]{32}\z/)
|
|
63
|
+
#
|
|
64
|
+
# redirect Monk::Storage.url("media/#{params[:splat]}")
|
|
65
|
+
# end
|
|
66
|
+
# end
|
|
67
|
+
# monk:end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "monk"
|
|
2
|
+
require "monk/storage"
|
|
3
|
+
|
|
4
|
+
# Stores the files users send with Monk::Storage: put, get, head, delete
|
|
5
|
+
# and copy by key, signed download links (url) and uploads that go from
|
|
6
|
+
# the browser straight to the bucket (presigned_put). See
|
|
7
|
+
# docs/guides/storage.md in the monk repo. Loaded by config/load.rb.
|
|
8
|
+
Monk::Settings.configure do
|
|
9
|
+
# Where files go. Unset in development and test means
|
|
10
|
+
# file://storage/<env>, files under this app's storage/ directory, which
|
|
11
|
+
# Monk serves at /_monk/storage; anywhere else an unset one fails the
|
|
12
|
+
# boot. In production an S3 bucket, e.g.
|
|
13
|
+
# s3://KEY:SECRET@bucket?endpoint=fsn1.your-objectstorage.com®ion=fsn1
|
|
14
|
+
optional :storage_url, default: ""
|
|
15
|
+
# Signs the file backend's links. Only needed with a file:// URL outside
|
|
16
|
+
# development and test (one server with a persistent volume); a bucket
|
|
17
|
+
# signs its own. A random value of its own, not AUTH_SECRET.
|
|
18
|
+
optional :storage_secret, default: ""
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Monk::Storage.configure(url: Monk::Settings[:storage_url], secret: Monk::Settings[:storage_secret])
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
## storage
|
|
2
|
+
|
|
3
|
+
Files users send, stored by key. In development and test `STORAGE_URL` is
|
|
4
|
+
unset, so files go under this app's `storage/development` and
|
|
5
|
+
`storage/test` directories (ignored by git), and Monk serves their signed
|
|
6
|
+
links at `/_monk/storage`, the way a bucket would. Nothing to start.
|
|
7
|
+
|
|
8
|
+
### Before production
|
|
9
|
+
|
|
10
|
+
Create an S3-compatible bucket (Hetzner Object Storage, AWS S3, Cloudflare
|
|
11
|
+
R2, ...) and set `STORAGE_URL`, e.g.
|
|
12
|
+
`s3://KEY:SECRET@bucket?endpoint=fsn1.your-objectstorage.com®ion=fsn1`.
|
|
13
|
+
An unset `STORAGE_URL` fails the boot outside development and test. The
|
|
14
|
+
bucket needs a CORS rule allowing `PUT` from `PUBLIC_URL`, so browsers can
|
|
15
|
+
upload to it, and a lifecycle rule expiring `tmp/` after a day, so
|
|
16
|
+
unconfirmed uploads don't pile up: both are in docs/guides/storage.md in
|
|
17
|
+
the monk repo. A `file://` URL works only on one server with a persistent
|
|
18
|
+
volume, and then needs `STORAGE_SECRET` set to a random value of its own.
|
|
19
|
+
|
|
20
|
+
### Check it works
|
|
21
|
+
|
|
22
|
+
1. `bundle exec rake test` runs `test/storage_test.rb`: a file stored by
|
|
23
|
+
key reads back, and its signed link answers.
|
|
24
|
+
2. By hand:
|
|
25
|
+
`bundle exec ruby -r ./config/load -e 'Monk::Storage.put("notes/hello", "Hello"); p Monk::Storage.get("notes/hello")'`
|
|
26
|
+
prints `"Hello"`, and the file is under `storage/development/objects/`.
|
|
27
|
+
3. The examples: uncomment `storage-upload`, `storage-confirm` and
|
|
28
|
+
`storage-download` in `app/routes/storage.rb`, restart `bin/server`,
|
|
29
|
+
and upload a PNG:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
curl -X POST http://localhost:9292/uploads -H "content-type: application/json" \
|
|
33
|
+
-d "{\"content_type\": \"image/png\", \"byte_size\": $(wc -c < photo.png)}"
|
|
34
|
+
# PUT the file to the "url" it answers, with the content-type it signed:
|
|
35
|
+
curl -X PUT "http://localhost:9292<url>" -H "content-type: image/png" --data-binary @photo.png
|
|
36
|
+
curl -X POST http://localhost:9292/uploads/confirm -H "content-type: application/json" \
|
|
37
|
+
-d '{"key": "<key>"}'
|
|
38
|
+
# The confirmed key is media/<hex>; open its link:
|
|
39
|
+
curl -iL http://localhost:9292/media/<hex>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The last command follows a redirect to a signed `/_monk/storage` link
|
|
43
|
+
and gets the PNG back.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
require "rack/mock"
|
|
3
|
+
|
|
4
|
+
# Monk::Storage is configured, and with STORAGE_URL unset tests use
|
|
5
|
+
# file://storage/test, apart from development's files: a file stored by
|
|
6
|
+
# key reads back, and its signed link answers. SETUP.md, storage.
|
|
7
|
+
class StorageTest < Minitest::Test
|
|
8
|
+
def setup
|
|
9
|
+
@key = Monk::Storage.generate_key(prefix: "check")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def teardown
|
|
13
|
+
Monk::Storage.delete(@key)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_a_file_stored_by_key_reads_back
|
|
17
|
+
Monk::Storage.put(@key, "hello", content_type: "text/plain")
|
|
18
|
+
|
|
19
|
+
object = Monk::Storage.head(@key)
|
|
20
|
+
assert_equal ["text/plain", 5], [object.content_type, object.byte_size]
|
|
21
|
+
assert_equal "hello", Monk::Storage.get(@key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_its_signed_link_answers
|
|
25
|
+
Monk::Storage.put(@key, "hello", content_type: "text/plain")
|
|
26
|
+
|
|
27
|
+
status, headers, body = APP.call(Rack::MockRequest.env_for(Monk::Storage.url(@key)))
|
|
28
|
+
|
|
29
|
+
assert_equal 200, status
|
|
30
|
+
assert_equal "text/plain", headers["content-type"]
|
|
31
|
+
assert_equal "hello", body.to_enum(:each).to_a.join
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/monk/version.rb
CHANGED
|
@@ -5,5 +5,5 @@ module Monk
|
|
|
5
5
|
# class of bug .freeze! guards against for routes/error handlers/models,
|
|
6
6
|
# just on a top-level constant that every worker Ractor reads on boot
|
|
7
7
|
# (found live under kino: GET /hello 500'd until this was frozen).
|
|
8
|
-
VERSION = "0.
|
|
8
|
+
VERSION = "0.23.0".freeze
|
|
9
9
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: monkrb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.23.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matteo Folin
|
|
@@ -206,6 +206,7 @@ files:
|
|
|
206
206
|
- lib/monk/generators/mail.rb
|
|
207
207
|
- lib/monk/generators/postgres.rb
|
|
208
208
|
- lib/monk/generators/redis.rb
|
|
209
|
+
- lib/monk/generators/storage.rb
|
|
209
210
|
- lib/monk/generators/websocket.rb
|
|
210
211
|
- lib/monk/jobs.rb
|
|
211
212
|
- lib/monk/jobs/adapters/pg.rb
|
|
@@ -251,6 +252,19 @@ files:
|
|
|
251
252
|
- lib/monk/persistence/pool.rb
|
|
252
253
|
- lib/monk/settings.rb
|
|
253
254
|
- lib/monk/state_ractor.rb
|
|
255
|
+
- lib/monk/storage.rb
|
|
256
|
+
- lib/monk/storage/backends/file.rb
|
|
257
|
+
- lib/monk/storage/backends/s3.rb
|
|
258
|
+
- lib/monk/storage/errors.rb
|
|
259
|
+
- lib/monk/storage/input.rb
|
|
260
|
+
- lib/monk/storage/key.rb
|
|
261
|
+
- lib/monk/storage/object.rb
|
|
262
|
+
- lib/monk/storage/routes.rb
|
|
263
|
+
- lib/monk/storage/sig_v4.rb
|
|
264
|
+
- lib/monk/storage/sniff.rb
|
|
265
|
+
- lib/monk/storage/token.rb
|
|
266
|
+
- lib/monk/storage/upload.rb
|
|
267
|
+
- lib/monk/storage/url.rb
|
|
254
268
|
- lib/monk/templates/auth/agents.md
|
|
255
269
|
- lib/monk/templates/auth/app/mailers/auth_mailer.rb
|
|
256
270
|
- lib/monk/templates/auth/app/routes/auth.rb
|
|
@@ -334,6 +348,11 @@ files:
|
|
|
334
348
|
- lib/monk/templates/redis/config/redis.rb
|
|
335
349
|
- lib/monk/templates/redis/setup.md
|
|
336
350
|
- lib/monk/templates/redis/test/redis_test.rb
|
|
351
|
+
- lib/monk/templates/storage/agents.md
|
|
352
|
+
- lib/monk/templates/storage/app/routes/storage.rb
|
|
353
|
+
- lib/monk/templates/storage/config/storage.rb
|
|
354
|
+
- lib/monk/templates/storage/setup.md
|
|
355
|
+
- lib/monk/templates/storage/test/storage_test.rb
|
|
337
356
|
- lib/monk/templates/websocket/agents.md
|
|
338
357
|
- lib/monk/templates/websocket/app/sockets/chat.rb
|
|
339
358
|
- lib/monk/templates/websocket/bin/websocket_server
|