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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4e809c375ae56d6ec8e0831623b9fb588550bc329e68a3c722cc81910e6bcc8
4
- data.tar.gz: 542338ca1dd6750276d91fedce501c02e06d68506f6bbf6ffaafea64a9485611
3
+ metadata.gz: b284b12a8bb2491dd6d50c14d0d1db8cb0f2bf2d62a19dc525fc6ecbec81fb30
4
+ data.tar.gz: 6251d1fe25530d954df4a65f6d22a2b339cb55b87b97a8587259f8b16dbbbb24
5
5
  SHA512:
6
- metadata.gz: aa576c261a4aaa57a117b163202a9c2b0b4865eb20fec828bebf39c9ef8e76bb868e809913c216773843cc69264b04471457b54c0124005db85cfc87d49b585b
7
- data.tar.gz: 3844bc6db9d0b2edf4cc4cdeed5ac5dbe8e3035ac6634c7f30e525cbf6705e140ca0dcc2e30993e92ece55cb8aecb259d0dfdd5cca73232886875f8ab01e859b
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.22", require: "monk"`.
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
 
@@ -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&region=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
@@ -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