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.
@@ -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"]
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "monkrb", "~> 0.22", require: "monk"
3
+ gem "monkrb", "~> 0.23", require: "monk"
4
4
  gem "rackup"
5
5
  gem "webrick"
6
6
  gem "kino"
@@ -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&region=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&region=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.22.2".freeze
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.22.2
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