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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b284b12a8bb2491dd6d50c14d0d1db8cb0f2bf2d62a19dc525fc6ecbec81fb30
|
|
4
|
+
data.tar.gz: 6251d1fe25530d954df4a65f6d22a2b339cb55b87b97a8587259f8b16dbbbb24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 586f9d0355b353a5e18f638059e0636f447c4aa104ceff395efa7dc81b05529019c9fdabbeb10c489eb360ecbc6d9d5d864ef4b5b48323769c3796c7757f3b4c
|
|
7
|
+
data.tar.gz: b03b5885c324e698b2b10946ea9289e61bbf89253fa225ab96c89d8039a8553e991c3f3158ad7f2caacc8c072c518bd752cb75e33fe6c45e645a9eecd0c2fda3
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ All notable changes to this project are documented here. Format is loosely
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); versions are as released
|
|
5
5
|
in `lib/monk/version.rb`.
|
|
6
6
|
|
|
7
|
+
## 0.23.0 - 2026-10-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **File storage, `Monk::Storage`** (`require "monk/storage"`, ADR 0018,
|
|
12
|
+
`docs/guides/storage.md`). Files are stored by key in any S3-compatible
|
|
13
|
+
bucket (Hetzner, Cloudflare R2, Backblaze B2, AWS, MinIO), chosen by
|
|
14
|
+
one `STORAGE_URL`, and on the local disk in development and test.
|
|
15
|
+
`put`, `get` (with `range:`), `head`, `delete`, `copy`, signed download
|
|
16
|
+
links (`url`, the same URL within an hour so browsers cache it) and
|
|
17
|
+
browser uploads (`presigned_put`, with the type and length inside the
|
|
18
|
+
signature), all from any worker Ractor. SigV4 is Monk's own, with no
|
|
19
|
+
gem. Uploads go from the browser straight to the bucket and downloads
|
|
20
|
+
redirect to it, so no worker Ractor waits on the bytes. The file
|
|
21
|
+
backend behaves like a bucket, signed links and refusals included, at
|
|
22
|
+
`/_monk/storage`. `Monk::Storage::Sniff` tells PNG, JPEG, GIF, WebP,
|
|
23
|
+
Ogg/Opus and PDF from a file's first 64 bytes, and refuses SVG.
|
|
24
|
+
Checked on Hetzner Object Storage: the signed upload limits, CORS for
|
|
25
|
+
browser uploads, the lifecycle rule that expires `tmp/`, and `copy`
|
|
26
|
+
with both addressing styles (`bin/storage_check`).
|
|
27
|
+
- **`monk add storage`**: `config/storage.rb`, the upload, confirm and
|
|
28
|
+
download flow as commented examples in `app/routes/storage.rb` (driven
|
|
29
|
+
with `curl`), and a test. No migration and no JavaScript: the guide
|
|
30
|
+
has an uploads table and the browser code to copy. An existing app
|
|
31
|
+
gets it the same way (`docs/guides/upgrading.md`, 0.23).
|
|
32
|
+
- **`bin/storage_check`**, in the monk repository: checks a real bucket's
|
|
33
|
+
CORS rule for browser uploads and its `tmp/` lifecycle rule, then
|
|
34
|
+
removes both.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **A new app runs as production in its Docker image.** The scaffolded
|
|
39
|
+
`Dockerfile` sets `MONK_ENV=production`, which a platform's env can
|
|
40
|
+
override (`staging`). Unset, `MONK_ENV` means development, and a deploy
|
|
41
|
+
that forgot it ran that way without an error: mail printed instead of
|
|
42
|
+
sent, magic links logged, auth cookies without `Secure`, demo routes on,
|
|
43
|
+
storage links signed with a public value. `bin/server` now warns when it
|
|
44
|
+
listens beyond the machine (`--bind 0.0.0.0`) with `MONK_ENV` unset,
|
|
45
|
+
`monk new` lists `MONK_ENV` among the values to set before production,
|
|
46
|
+
and `docs/guides/deploying.md` sets it in every platform's example
|
|
47
|
+
(Render's start command is now `bin/server`, so the warning applies
|
|
48
|
+
there too). The deploy guide also gains storage's row.
|
|
49
|
+
- **A new app's `Gemfile` asks for `monkrb ~> 0.23`.**
|
|
50
|
+
- `docs/framework-comparison.md` is recomputed for 0.23.0, and
|
|
51
|
+
`docs/roadmap.md` moves storage up to 0.23 (it was planned as 0.26).
|
|
52
|
+
|
|
7
53
|
## 0.22.2 - 2026-10-22
|
|
8
54
|
|
|
9
55
|
### Fixed
|
data/README.md
CHANGED
|
@@ -21,7 +21,7 @@ bin/server # -> http://localhost:9292/hello
|
|
|
21
21
|
monk add --list # the modules you can add: postgres, auth, jobs, live...
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
`monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", "~> 0.
|
|
24
|
+
`monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", "~> 0.23", require: "monk"`.
|
|
25
25
|
|
|
26
26
|
`monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `public/`, its tests, a `SETUP.md` and an `AGENTS.md` for coding agents), with the app's own code under `app/`: `app/app.rb` and `app/routes/` for routes, `app/views/`, and one directory per role (`models/`, `presenters/`, `helpers/`, `mailers/`, `broadcasts/`, `jobs/`). `monk add` adds Postgres, Redis, email, auth, background jobs, WebSocket and live updates, to a new app or an old one, each with its wiring, commented examples of how to use it, and a test (`monk new my_app --with auth,jobs` does both at once): see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md), which also says where each kind of code goes.
|
|
27
27
|
|
|
@@ -65,6 +65,7 @@ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
|
|
|
65
65
|
| Background jobs | `Monk::Jobs`: a queue in Postgres, run by `bin/jobs` on worker Ractors; retries with backoff, scheduled jobs, recurring jobs on a cron schedule (UTC), enqueue inside the app's own transaction, `drain!` for tests | `require "monk/jobs"` (+ `require "monk/jobs/runtime"` in the job process); needs the `pg` gem and a registered Postgres connection | [`jobs.md`](docs/guides/jobs.md) |
|
|
66
66
|
| WebSocket | `Monk::WebSocket`: RFC 6455 server as its own process, Redis or Postgres fan-out | `require "monk/websocket"`; fan-out: `require "monk/websocket/redis_fanout"` (+ the `redis` gem) or `require "monk/websocket/pg_fanout"` (+ the `pg` gem) | [`websocket.md`](docs/guides/websocket.md) |
|
|
67
67
|
| Live updates | `Monk::Live`: server-rendered HTML patches pushed to open tabs | `require "monk/live"`; needs the WebSocket server and, across processes, Redis or Postgres | [`live.md`](docs/guides/live.md) |
|
|
68
|
+
| File storage | `Monk::Storage`: files by key in any S3-compatible bucket (Hetzner, R2, B2, AWS), local files in development and test; browser uploads and downloads through signed links, never through a worker Ractor | `require "monk/storage"`; no gem needed | [`storage.md`](docs/guides/storage.md) |
|
|
68
69
|
| Scaffolding | `monk new` and `monk add`: one generator per module, for a new app or an existing one; text or `--json` output | — (the `monk` command: `monk new NAME --with ...`, `monk add MODULE`, `monk add --list`) | [`scaffolding.md`](docs/guides/scaffolding.md) |
|
|
69
70
|
|
|
70
71
|
## More documentation
|
|
@@ -88,6 +89,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
|
|
|
88
89
|
|
|
89
90
|
## Status
|
|
90
91
|
|
|
91
|
-
Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). Features to add are listed in [`docs/roadmap.md`](docs/roadmap.md). Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`). In 0.21 (2026-10-09): scaffolding by module. `monk new` makes the core app, and `monk add` adds a module (Postgres, Redis, mail, auth, jobs, WebSocket, Live) to any app, new or existing, with its wiring, commented examples, a test, and its sections of `SETUP.md` and an `AGENTS.md` for coding agents; `--json` for agents and scripts. Redis and WebSocket become modules of their own, the WebSocket transport is chosen once per app, and `monk new`'s module flags are gone (ADR 0017, `docs/guides/scaffolding.md`, `docs/history/plan-scaffold.md`). In 0.22 (2026-10-13): recurring jobs on a cron schedule, `Monk::Jobs.recurring` in `config/recurring.rb`, enqueued once per occurrence however many `bin/jobs` run (ADR 0016, `docs/guides/jobs.md`); auth housekeeping, with a login-email rate limit in the database, an index on `sessions (subject)` and a nightly cleanup job (`docs/guides/auth.md`); and an upgrade guide for apps made with an earlier Monk (`docs/guides/upgrading.md`). In 0.22.1 (2026-10-20): the WebSocket server queues a connect burst instead of collapsing under it, refuses a handshake it can't check with a `503`, and takes more connects a second with thousands of sockets open: fewer allocations a connect, GC settings in `bin/websocket_server`, and `WS_PROCESSES` for several server processes on one port (`docs/guides/websocket.md`, `docs/plan-ws-connect-ceiling.md`). In 0.22.2 (2026-10-22): a fix for a handshake that could die when two connections were the first after boot (`docs/plan-digest-preload.md`).
|
|
92
|
+
Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). Features to add are listed in [`docs/roadmap.md`](docs/roadmap.md). Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`). In 0.21 (2026-10-09): scaffolding by module. `monk new` makes the core app, and `monk add` adds a module (Postgres, Redis, mail, auth, jobs, WebSocket, Live) to any app, new or existing, with its wiring, commented examples, a test, and its sections of `SETUP.md` and an `AGENTS.md` for coding agents; `--json` for agents and scripts. Redis and WebSocket become modules of their own, the WebSocket transport is chosen once per app, and `monk new`'s module flags are gone (ADR 0017, `docs/guides/scaffolding.md`, `docs/history/plan-scaffold.md`). In 0.22 (2026-10-13): recurring jobs on a cron schedule, `Monk::Jobs.recurring` in `config/recurring.rb`, enqueued once per occurrence however many `bin/jobs` run (ADR 0016, `docs/guides/jobs.md`); auth housekeeping, with a login-email rate limit in the database, an index on `sessions (subject)` and a nightly cleanup job (`docs/guides/auth.md`); and an upgrade guide for apps made with an earlier Monk (`docs/guides/upgrading.md`). In 0.22.1 (2026-10-20): the WebSocket server queues a connect burst instead of collapsing under it, refuses a handshake it can't check with a `503`, and takes more connects a second with thousands of sockets open: fewer allocations a connect, GC settings in `bin/websocket_server`, and `WS_PROCESSES` for several server processes on one port (`docs/guides/websocket.md`, `docs/plan-ws-connect-ceiling.md`). In 0.22.2 (2026-10-22): a fix for a handshake that could die when two connections were the first after boot (`docs/plan-digest-preload.md`). In 0.23 (2026-10-26): file storage, `Monk::Storage`, in any S3-compatible bucket (Hetzner, R2, B2, AWS) through Monk's own SigV4 signer, with local files in development and test that behave like a bucket. Browsers upload straight to the bucket and download through a redirect to a signed link, so no worker Ractor waits on the bytes, and `monk add storage` scaffolds the flow (ADR 0018, `docs/guides/storage.md`, `docs/history/plan-storage.md`).
|
|
92
93
|
|
|
93
94
|
[Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.md)
|
data/lib/monk/base.rb
CHANGED
|
@@ -156,6 +156,11 @@ module Monk
|
|
|
156
156
|
asset = Monk::Assets.response(env)
|
|
157
157
|
return asset if asset
|
|
158
158
|
|
|
159
|
+
# The file storage backend's signed links (docs/history/plan-storage.md
|
|
160
|
+
# decision 7), only when the app requires monk/storage.
|
|
161
|
+
stored = Monk::Storage.response(env) if defined?(Monk::Storage)
|
|
162
|
+
return stored if stored
|
|
163
|
+
|
|
159
164
|
route, path_params = find_route(env["REQUEST_METHOD"], env["PATH_INFO"])
|
|
160
165
|
return not_found_response(env) unless route
|
|
161
166
|
|
data/lib/monk/generators/base.rb
CHANGED
|
@@ -15,5 +15,6 @@ Monk::Generator.define(:base) do
|
|
|
15
15
|
|
|
16
16
|
setup_section "base/setup.md"
|
|
17
17
|
agents_section "base/agents.md"
|
|
18
|
+
set_before_production "MONK_ENV", "production or staging; unset = development (the Dockerfile sets production)"
|
|
18
19
|
next_step "bin/server", note: "http://localhost:9292"
|
|
19
20
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Files users send (Monk::Storage): local files in development and test,
|
|
2
|
+
# an S3-compatible bucket in production. No service to start, no migration
|
|
3
|
+
# and no JS: the upload flow comes as commented routes driven with curl,
|
|
4
|
+
# and the guide carries the uploads table and browser code
|
|
5
|
+
# (docs/history/plan-storage.md Phase 8).
|
|
6
|
+
Monk::Generator.define(:storage) do
|
|
7
|
+
summary "File storage (local files in development, an S3 bucket in production)"
|
|
8
|
+
installed_if "config/storage.rb"
|
|
9
|
+
|
|
10
|
+
copy "config/storage.rb", role: :wiring
|
|
11
|
+
copy "app/routes/storage.rb", role: :example
|
|
12
|
+
copy "test/storage_test.rb", role: :test
|
|
13
|
+
|
|
14
|
+
# Unset STORAGE_URL is file://storage/<env> in development and test, so
|
|
15
|
+
# neither .env file needs a line, and tests never touch development's
|
|
16
|
+
# files.
|
|
17
|
+
env :example, { "STORAGE_URL" => "s3://KEY:SECRET@bucket?endpoint=fsn1.your-objectstorage.com®ion=fsn1",
|
|
18
|
+
"STORAGE_SECRET" => "only-with-file-urls-a-random-hex-value", }, commented: true
|
|
19
|
+
ignore "/storage/"
|
|
20
|
+
|
|
21
|
+
setup_section "storage/setup.md"
|
|
22
|
+
agents_section "storage/agents.md"
|
|
23
|
+
set_before_production "STORAGE_URL", "an S3 bucket; file:// only on one server with a persistent volume"
|
|
24
|
+
set_before_production "STORAGE_SECRET", "only with file://: signs its links"
|
|
25
|
+
example "storage-upload", todo: "uncomment, and set the types and sizes this app accepts"
|
|
26
|
+
example "storage-confirm", todo: "uncomment, and save a record of each file in the app's own table"
|
|
27
|
+
example "storage-download", todo: "uncomment, then replace with a record lookup and an access check"
|
|
28
|
+
end
|
data/lib/monk/generators.rb
CHANGED
|
@@ -2,6 +2,6 @@ require_relative "generator"
|
|
|
2
2
|
|
|
3
3
|
# Monk's own modules, one generator each (docs/adr/0017), in dependency
|
|
4
4
|
# order only for readability: the registry resolves dependencies itself.
|
|
5
|
-
%w[base postgres redis mail auth jobs websocket live].each do |name|
|
|
5
|
+
%w[base postgres redis mail auth jobs websocket live storage].each do |name|
|
|
6
6
|
require_relative "generators/#{name}"
|
|
7
7
|
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
# Loaded here, at boot: left to Digest.const_missing it loads on the first
|
|
3
|
+
# put, and a worker Ractor storing another in parallel can catch it
|
|
4
|
+
# half-defined (test/digest_preload_test.rb).
|
|
5
|
+
require "digest/md5"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "json"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
|
|
10
|
+
module Monk
|
|
11
|
+
module Storage
|
|
12
|
+
module Backends
|
|
13
|
+
# Local disk, the development and test default (file://storage/<env>)
|
|
14
|
+
# and an explicit choice for one server with a persistent volume.
|
|
15
|
+
# root is absolute, expanded at configure, so a worker Ractor never
|
|
16
|
+
# depends on the current directory. secret signs the links of the
|
|
17
|
+
# /_monk/storage routes (docs/adr/0018-storage-s3-compatible-direct-upload.md).
|
|
18
|
+
#
|
|
19
|
+
# It behaves like a bucket: an object's bytes are in
|
|
20
|
+
# <root>/objects/<key>~ and its metadata (content type, size, MD5) in
|
|
21
|
+
# <root>/meta/<key>~.json. The "~", which no key contains, keeps a
|
|
22
|
+
# key's file apart from the directory of a longer key ("a" and
|
|
23
|
+
# "a/b" both exist in a bucket). Writes go to a temporary file then
|
|
24
|
+
# File.rename, so a reader never sees half an object. An object
|
|
25
|
+
# exists once its metadata does.
|
|
26
|
+
#
|
|
27
|
+
# Inside this block a bare File is this class: the disk is ::File.
|
|
28
|
+
File = Data.define(:root, :secret) do
|
|
29
|
+
# Returns the key. body is a String, or an IO that responds to size,
|
|
30
|
+
# read and rewind (File, Tempfile, StringIO).
|
|
31
|
+
def put(key, body, content_type: Input::DEFAULT_CONTENT_TYPE)
|
|
32
|
+
Input.content_type!(content_type)
|
|
33
|
+
Input.body!(body)
|
|
34
|
+
object = path_for("objects", key, "~")
|
|
35
|
+
meta = path_for("meta", key, "~.json")
|
|
36
|
+
|
|
37
|
+
md5, size = write_atomically(object) { |out| copy_body(body, out) }
|
|
38
|
+
write_atomically(meta) do |out|
|
|
39
|
+
out.write(JSON.generate(content_type: content_type, byte_size: size, etag: md5))
|
|
40
|
+
end
|
|
41
|
+
key
|
|
42
|
+
rescue SystemCallError, IOError => e
|
|
43
|
+
raise RequestError, "file storage put #{key} failed: #{e.class}: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The bytes, as a frozen binary String. range: is a Range of byte
|
|
47
|
+
# offsets (0..15 for the first 16 bytes); a range past the end gives
|
|
48
|
+
# what's left, possibly "". A missing key raises NotFoundError.
|
|
49
|
+
def get(key, range: nil)
|
|
50
|
+
span = Input.byte_span(range) if range
|
|
51
|
+
path = path_for("objects", key, "~")
|
|
52
|
+
meta = read_meta(key) or raise NotFoundError, "no stored object #{key.inspect}"
|
|
53
|
+
bytes = span ? read_span(path, span, meta[:byte_size]) : ::File.binread(path)
|
|
54
|
+
bytes.freeze
|
|
55
|
+
rescue Errno::ENOENT
|
|
56
|
+
raise NotFoundError, "no stored object #{key.inspect}"
|
|
57
|
+
rescue SystemCallError, IOError => e
|
|
58
|
+
raise RequestError, "file storage get #{key} failed: #{e.class}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A Storage::Object, or nil when the key is missing.
|
|
62
|
+
def head(key)
|
|
63
|
+
meta = read_meta(key) or return nil
|
|
64
|
+
Storage::Object.new(key: key, byte_size: meta[:byte_size], content_type: meta[:content_type],
|
|
65
|
+
etag: meta[:etag],)
|
|
66
|
+
rescue SystemCallError, IOError => e
|
|
67
|
+
raise RequestError, "file storage head #{key} failed: #{e.class}: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Idempotent, like S3: deleting a missing key is not an error.
|
|
71
|
+
def delete(key)
|
|
72
|
+
meta = path_for("meta", key, "~.json")
|
|
73
|
+
object = path_for("objects", key, "~")
|
|
74
|
+
::FileUtils.rm_f(meta)
|
|
75
|
+
::FileUtils.rm_f(object)
|
|
76
|
+
nil
|
|
77
|
+
rescue SystemCallError, IOError => e
|
|
78
|
+
raise RequestError, "file storage delete #{key} failed: #{e.class}: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns to. A missing from raises NotFoundError.
|
|
82
|
+
def copy(from, to)
|
|
83
|
+
source = path_for("objects", from, "~")
|
|
84
|
+
target = path_for("objects", to, "~")
|
|
85
|
+
target_meta = path_for("meta", to, "~.json")
|
|
86
|
+
meta = read_meta(from) or raise NotFoundError, "no stored object #{from.inspect}"
|
|
87
|
+
return to if source == target
|
|
88
|
+
|
|
89
|
+
write_atomically(target) { |out| ::File.open(source, "rb") { |input| ::IO.copy_stream(input, out) } }
|
|
90
|
+
write_atomically(target_meta) { |out| out.write(JSON.generate(meta)) }
|
|
91
|
+
to
|
|
92
|
+
rescue Errno::ENOENT
|
|
93
|
+
raise NotFoundError, "no stored object #{from.inspect}"
|
|
94
|
+
rescue SystemCallError, IOError => e
|
|
95
|
+
raise RequestError, "file storage copy #{from} -> #{to} failed: #{e.class}: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A signed download link, the same for every call within one
|
|
99
|
+
# cache_window, so the browser's cache works: valid until the end
|
|
100
|
+
# of the window plus expires_in (docs/history/plan-storage.md decision 9).
|
|
101
|
+
def url(key, expires_in:, cache_window:, now: Time.now.to_i)
|
|
102
|
+
Key.validate!(key)
|
|
103
|
+
Input.link_times!(expires_in: expires_in, cache_window: cache_window)
|
|
104
|
+
window_start = now - (now % cache_window)
|
|
105
|
+
link({ m: "GET", k: key, e: window_start + cache_window + expires_in })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A signed upload link for a browser, with the length and content
|
|
109
|
+
# type inside it.
|
|
110
|
+
def presigned_put(key, content_type:, byte_size:, expires_in:, now: Time.now.to_i)
|
|
111
|
+
Key.validate!(key)
|
|
112
|
+
Input.content_type!(content_type)
|
|
113
|
+
Input.byte_size!(byte_size)
|
|
114
|
+
Input.link_times!(expires_in: expires_in, cache_window: 1)
|
|
115
|
+
url = link({ m: "PUT", k: key, e: now + expires_in, l: byte_size, t: content_type })
|
|
116
|
+
Upload.new(url: url, headers: { "Content-Type" => content_type, "Content-Length" => byte_size.to_s }.freeze)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The /_monk/storage PUT: stores input (rack.input, read once) as
|
|
120
|
+
# key, if it holds exactly byte_size bytes. Returns the MD5, or nil
|
|
121
|
+
# when the length is wrong, and then stores nothing.
|
|
122
|
+
def receive(key, input, content_type:, byte_size:)
|
|
123
|
+
object = path_for("objects", key, "~")
|
|
124
|
+
meta = path_for("meta", key, "~.json")
|
|
125
|
+
|
|
126
|
+
md5 = write_atomically(object) do |out|
|
|
127
|
+
digest, size = copy_at_most(input, out, byte_size)
|
|
128
|
+
return nil unless size == byte_size
|
|
129
|
+
|
|
130
|
+
digest
|
|
131
|
+
end
|
|
132
|
+
write_atomically(meta) do |out|
|
|
133
|
+
out.write(JSON.generate(content_type: content_type, byte_size: byte_size, etag: md5))
|
|
134
|
+
end
|
|
135
|
+
md5
|
|
136
|
+
rescue SystemCallError, IOError => e
|
|
137
|
+
raise RequestError, "file storage upload #{key} failed: #{e.class}: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Never print the secret -- a backend ends up in logs and exception
|
|
141
|
+
# messages more easily than anyone intends (same as Mail's SMTP).
|
|
142
|
+
def inspect
|
|
143
|
+
"#<#{self.class.name} file://#{root}>"
|
|
144
|
+
end
|
|
145
|
+
alias_method :to_s, :inspect
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# The key is validated first, then the path is checked to stay under
|
|
150
|
+
# root -- the Assets#disk_entry containment check -- and to go
|
|
151
|
+
# through no symbolic link below root, which could point anywhere.
|
|
152
|
+
def path_for(area, key, suffix)
|
|
153
|
+
Key.validate!(key)
|
|
154
|
+
base = ::File.join(root, area)
|
|
155
|
+
full = ::File.expand_path("#{key}#{suffix}", base)
|
|
156
|
+
unless full.start_with?("#{base}/")
|
|
157
|
+
raise InvalidKeyError,
|
|
158
|
+
"storage key #{key.inspect} resolves outside #{base}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
refuse_symlinks!(key, base, full)
|
|
162
|
+
full
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def refuse_symlinks!(key, base, full)
|
|
166
|
+
path = base
|
|
167
|
+
[nil, *full.delete_prefix("#{base}/").split("/")].each do |segment|
|
|
168
|
+
path = ::File.join(path, segment) if segment
|
|
169
|
+
next unless ::File.symlink?(path)
|
|
170
|
+
|
|
171
|
+
raise InvalidKeyError, "storage key #{key.inspect} goes through a symbolic link (#{path})"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def read_meta(key)
|
|
176
|
+
JSON.parse(::File.read(path_for("meta", key, "~.json")), symbolize_names: true)
|
|
177
|
+
rescue Errno::ENOENT
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def read_span(path, span, size)
|
|
182
|
+
first, last = span
|
|
183
|
+
last = size - 1 if last.nil? || last >= size
|
|
184
|
+
return "".b if first >= size
|
|
185
|
+
|
|
186
|
+
::File.open(path, "rb") do |file|
|
|
187
|
+
file.seek(first)
|
|
188
|
+
file.read(last - first + 1) || "".b
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Yields a binary IO on a temporary file next to path, then renames
|
|
193
|
+
# it into place. Returns the block's result.
|
|
194
|
+
def write_atomically(path, &)
|
|
195
|
+
::FileUtils.mkdir_p(::File.dirname(path))
|
|
196
|
+
temporary = "#{path}#{SecureRandom.hex(8)}.tmp"
|
|
197
|
+
result = ::File.open(temporary, "wb", &)
|
|
198
|
+
::File.rename(temporary, path)
|
|
199
|
+
result
|
|
200
|
+
ensure
|
|
201
|
+
::FileUtils.rm_f(temporary) if temporary && ::File.exist?(temporary)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def link(payload)
|
|
205
|
+
"#{ROUTE_PREFIX}#{Token.sign(payload, secret)}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Reads at most limit + 1 bytes, enough to tell a body that's too
|
|
209
|
+
# long. Returns [md5 hex, bytes read].
|
|
210
|
+
def copy_at_most(input, out, limit)
|
|
211
|
+
md5 = Digest::MD5.new
|
|
212
|
+
size = 0
|
|
213
|
+
while size <= limit && (chunk = input.read([Input::CHUNK_BYTES, limit + 1 - size].min))
|
|
214
|
+
out.write(chunk)
|
|
215
|
+
md5 << chunk
|
|
216
|
+
size += chunk.bytesize
|
|
217
|
+
end
|
|
218
|
+
[md5.hexdigest, size]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Returns [md5 hex, bytes written].
|
|
222
|
+
def copy_body(body, out)
|
|
223
|
+
md5 = Digest::MD5.new
|
|
224
|
+
if body.is_a?(String)
|
|
225
|
+
out.write(body)
|
|
226
|
+
md5 << body
|
|
227
|
+
return [md5.hexdigest, body.bytesize]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
body.rewind
|
|
231
|
+
size = 0
|
|
232
|
+
while (chunk = body.read(Input::CHUNK_BYTES))
|
|
233
|
+
out.write(chunk)
|
|
234
|
+
md5 << chunk
|
|
235
|
+
size += chunk.bytesize
|
|
236
|
+
end
|
|
237
|
+
[md5.hexdigest, size]
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module Storage
|
|
5
|
+
module Backends
|
|
6
|
+
# Any S3-compatible service (Hetzner, R2, B2, AWS, MinIO): Monk names
|
|
7
|
+
# no provider, only an endpoint and a region. A frozen value holding
|
|
8
|
+
# settings only; the HTTP client is built for each call, inside the
|
|
9
|
+
# Ractor making it, like Mail's SMTP and PG::Connection
|
|
10
|
+
# (docs/adr/0018-storage-s3-compatible-direct-upload.md).
|
|
11
|
+
#
|
|
12
|
+
# endpoint is host[:port]. tls: false only for a local server
|
|
13
|
+
# (endpoint=http://..., refused in staging and production).
|
|
14
|
+
# path_style: true puts the bucket in the path instead of the host
|
|
15
|
+
# name, which local servers need.
|
|
16
|
+
S3 = Data.define(:bucket, :endpoint, :region, :access_key, :secret_key, :path_style, :tls,
|
|
17
|
+
:open_timeout, :read_timeout,) do
|
|
18
|
+
def initialize(bucket:, endpoint:, region:, access_key:, secret_key:, path_style: false, tls: true,
|
|
19
|
+
open_timeout: 5, read_timeout: 30)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the key. body is a String, or an IO that responds to size,
|
|
24
|
+
# read and rewind: its SHA-256 is computed first and signed, then it
|
|
25
|
+
# is rewound and streamed, so it's read twice but never held in
|
|
26
|
+
# memory (docs/history/plan-storage.md decision 11).
|
|
27
|
+
def put(key, body, content_type: Input::DEFAULT_CONTENT_TYPE)
|
|
28
|
+
Key.validate!(key)
|
|
29
|
+
Input.content_type!(content_type)
|
|
30
|
+
Input.body!(body)
|
|
31
|
+
|
|
32
|
+
response = request("PUT", key, body: body, headers: { "content-type" => content_type })
|
|
33
|
+
return key if response.is_a?(Net::HTTPSuccess)
|
|
34
|
+
|
|
35
|
+
fail!("put", key, response)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The bytes, as a frozen binary String. range: is a Range of byte
|
|
39
|
+
# offsets; one that starts past the end gives "" (S3's 416).
|
|
40
|
+
def get(key, range: nil)
|
|
41
|
+
Key.validate!(key)
|
|
42
|
+
headers = {}
|
|
43
|
+
if range
|
|
44
|
+
first, last = Input.byte_span(range)
|
|
45
|
+
headers["range"] = "bytes=#{first}-#{last}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
response = request("GET", key, headers: headers)
|
|
49
|
+
case response
|
|
50
|
+
when Net::HTTPSuccess then response.body.to_s.b.freeze
|
|
51
|
+
when Net::HTTPNotFound then raise NotFoundError, "no stored object #{key.inspect}"
|
|
52
|
+
when Net::HTTPRangeNotSatisfiable then "".b.freeze
|
|
53
|
+
else fail!("get", key, response)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A Storage::Object, or nil when the key is missing. A bucket that
|
|
58
|
+
# doesn't grant ListBucket answers 403, not 404, for a missing key:
|
|
59
|
+
# that's a RequestError here, as the credentials need fixing.
|
|
60
|
+
def head(key)
|
|
61
|
+
Key.validate!(key)
|
|
62
|
+
response = request("HEAD", key)
|
|
63
|
+
return nil if response.is_a?(Net::HTTPNotFound)
|
|
64
|
+
|
|
65
|
+
fail!("head", key, response) unless response.is_a?(Net::HTTPSuccess)
|
|
66
|
+
|
|
67
|
+
Storage::Object.new(key: key, byte_size: response["content-length"].to_i,
|
|
68
|
+
content_type: response["content-type"], etag: response["etag"].to_s.delete('"'),)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Idempotent: S3 answers 204 for a missing key too.
|
|
72
|
+
def delete(key)
|
|
73
|
+
Key.validate!(key)
|
|
74
|
+
response = request("DELETE", key)
|
|
75
|
+
return nil if response.is_a?(Net::HTTPSuccess)
|
|
76
|
+
|
|
77
|
+
fail!("delete", key, response)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns to. The content type is copied with the bytes. S3 can
|
|
81
|
+
# answer 200 to a copy that failed, with an <Error> in the body, so
|
|
82
|
+
# the body is checked too.
|
|
83
|
+
def copy(from, to)
|
|
84
|
+
Key.validate!(from)
|
|
85
|
+
Key.validate!(to)
|
|
86
|
+
source = SigV4.encode_path("/#{bucket}/#{from}")
|
|
87
|
+
|
|
88
|
+
response = request("PUT", to, headers: { "x-amz-copy-source" => source })
|
|
89
|
+
raise NotFoundError, "no stored object #{from.inspect}" if response.is_a?(Net::HTTPNotFound)
|
|
90
|
+
return to if response.is_a?(Net::HTTPSuccess) && !response.body.to_s.include?("<Error>")
|
|
91
|
+
|
|
92
|
+
fail!("copy #{from} ->", to, response)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A signed GET link, the same for every call within one
|
|
96
|
+
# cache_window: signed at the window's start, valid for the window
|
|
97
|
+
# plus expires_in (docs/history/plan-storage.md decision 9).
|
|
98
|
+
def url(key, expires_in:, cache_window:, now: Time.now.to_i)
|
|
99
|
+
Key.validate!(key)
|
|
100
|
+
Input.link_times!(expires_in: expires_in, cache_window: cache_window)
|
|
101
|
+
query = SigV4.presign(method: "GET", path: path_for(key), headers: { "host" => host },
|
|
102
|
+
expires_in: cache_window + expires_in, credentials: credentials, now: now - (now % cache_window),)
|
|
103
|
+
"#{base_url}#{SigV4.encode_path(path_for(key))}?#{query}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A signed PUT link for a browser, with Content-Type and
|
|
107
|
+
# Content-Length among its signed headers: the bucket refuses a
|
|
108
|
+
# body of another length or type.
|
|
109
|
+
def presigned_put(key, content_type:, byte_size:, expires_in:, now: Time.now.to_i)
|
|
110
|
+
Key.validate!(key)
|
|
111
|
+
Input.content_type!(content_type)
|
|
112
|
+
Input.byte_size!(byte_size)
|
|
113
|
+
headers = { "host" => host, "content-type" => content_type, "content-length" => byte_size.to_s }
|
|
114
|
+
query = SigV4.presign(method: "PUT", path: path_for(key), headers: headers, expires_in: expires_in,
|
|
115
|
+
credentials: credentials, now: now,)
|
|
116
|
+
|
|
117
|
+
Upload.new(url: "#{base_url}#{SigV4.encode_path(path_for(key))}?#{query}",
|
|
118
|
+
headers: { "Content-Type" => content_type, "Content-Length" => byte_size.to_s }.freeze,)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Never print the secret key -- a backend ends up in logs and
|
|
122
|
+
# exception messages more easily than anyone intends.
|
|
123
|
+
def inspect
|
|
124
|
+
"#<#{self.class.name} s3://#{access_key}:***@#{bucket} #{tls ? "https" : "http"}://#{endpoint} " \
|
|
125
|
+
"region=#{region}#{" path_style" if path_style}>"
|
|
126
|
+
end
|
|
127
|
+
alias_method :to_s, :inspect
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def credentials
|
|
132
|
+
{ access_key: access_key, secret_key: secret_key, region: region }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Virtual-hosted (bucket.endpoint/key) unless path_style
|
|
136
|
+
# (endpoint/bucket/key).
|
|
137
|
+
def host
|
|
138
|
+
path_style ? endpoint : "#{bucket}.#{endpoint}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def path_for(key)
|
|
142
|
+
path_style ? "/#{bucket}/#{key}" : "/#{key}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def base_url
|
|
146
|
+
"#{tls ? "https" : "http"}://#{host}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One signed request, on a connection opened for it in the calling
|
|
150
|
+
# Ractor. Network and TLS failures become a RequestError, the
|
|
151
|
+
# original as its #cause.
|
|
152
|
+
def request(method, key, body: nil, headers: {})
|
|
153
|
+
payload_hash = body ? Input.sha256(body) : SigV4::EMPTY_PAYLOAD
|
|
154
|
+
signed = headers.merge("host" => host, "x-amz-content-sha256" => payload_hash)
|
|
155
|
+
signed["content-length"] = body.size.to_s if body
|
|
156
|
+
path = path_for(key)
|
|
157
|
+
authorization = SigV4.sign_headers(method: method, path: path, headers: signed,
|
|
158
|
+
payload_hash: payload_hash, credentials: credentials, now: Time.now.to_i,)
|
|
159
|
+
|
|
160
|
+
http_request = Net::HTTPGenericRequest.new(method, !body.nil?, method != "HEAD", SigV4.encode_path(path),
|
|
161
|
+
signed.merge(authorization).merge("accept-encoding" => "identity"),)
|
|
162
|
+
attach(http_request, body) if body
|
|
163
|
+
connection.start { |http| http.request(http_request) }
|
|
164
|
+
rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse,
|
|
165
|
+
Net::ProtocolError => e
|
|
166
|
+
raise RequestError, "S3 #{method} #{key} via #{host} failed: #{e.class}: #{e.message}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def attach(http_request, body)
|
|
170
|
+
if body.is_a?(String)
|
|
171
|
+
http_request.body = body
|
|
172
|
+
else
|
|
173
|
+
body.rewind
|
|
174
|
+
http_request.body_stream = body
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def connection
|
|
179
|
+
http = Net::HTTP.new(host.sub(/:\d+\z/, ""), endpoint[/:(\d+)\z/, 1]&.to_i || (tls ? 443 : 80))
|
|
180
|
+
http.use_ssl = tls
|
|
181
|
+
http.open_timeout = open_timeout
|
|
182
|
+
http.read_timeout = read_timeout
|
|
183
|
+
http.write_timeout = read_timeout
|
|
184
|
+
http
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def fail!(operation, key, response)
|
|
188
|
+
code = response.body.to_s[%r{<Code>([^<]*)</Code>}, 1]
|
|
189
|
+
message = response.body.to_s[%r{<Message>([^<]*)</Message>}, 1]
|
|
190
|
+
detail = [code, message].compact.join(": ")
|
|
191
|
+
raise RequestError,
|
|
192
|
+
"S3 #{operation} #{key} via #{host} failed: HTTP #{response.code}#{" #{detail}" unless detail.empty?}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Monk
|
|
2
|
+
module Storage
|
|
3
|
+
class NotConfiguredError < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
class InvalidStorageUrlError < StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class MissingStorageUrlError < StandardError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class MissingStorageSecretError < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# An ArgumentError: a key is always the caller's input.
|
|
16
|
+
class InvalidKeyError < ArgumentError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# get or copy of a key that isn't there.
|
|
20
|
+
class NotFoundError < StandardError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A call that failed on the way to or from the backend -- network, TLS,
|
|
24
|
+
# the disk, or the bucket refusing it. The original exception is the
|
|
25
|
+
# #cause, the way Monk::Mail::DeliveryError works.
|
|
26
|
+
class RequestError < StandardError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Bytes that must never be served as uploaded (SVG, which can carry
|
|
30
|
+
# scripts).
|
|
31
|
+
class UnsafeContentError < StandardError
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|