1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# mon
axum の上に、サーバーの土台を敷くライブラリです。
JSON で返す共通のエラー、失敗も JSON になる extractor、HTML の組み立て、別サーバへの中継、
セッション(置き場は 4 通りから選ぶ)をまとめて持ちます。
保存先は SQLite で、sqlx から使います。
英語の説明は [README.md](./README.md) にあります。
## 使う
**mon はパスを 1 つも取りません。** どのパスに何を置くか、そもそも置くかどうかは使う側が決めます。
`route::wrap` はミドルウェアを掛けて状態を入れるだけで、ルートは置きません。
```
use axum::Router;
use axum::routing::get;
use mon::{AppState, conf, database, page, relay, route, server, session};
use std::sync::Arc;
use tokio::net::TcpListener;
let conf = Arc::new(conf::load()?); // Conf を直接組み立ててもよい
let database = database::connect(&conf.database_url).await?;
let store = session::store(&conf, &database);
session::prepare(&store).await?;
session::start_sweep(store.clone(), conf.session_max_age);
// 名前も置くかどうかも、ここで決める
let routes = my_routes()
.route("/health", get(route::health))
.nest("/api/session", session::router())
.nest("/relay", relay::router())
.nest_service("/static", route::assets(&conf))
.fallback(page::not_found);
let state = AppState {
database,
client: relay::client(&conf)?,
session: store,
conf: conf.clone(),
};
// ヘッダの受け取りに上限を掛け、TCP の相手を ClientIp に渡す
let listener = TcpListener::bind((conf.host, conf.port)).await?;
server::run(listener, route::wrap(routes, state), &conf, shutdown).await?;
```
`shutdown` は、止めるときに終わる Future です。Ctrl+C や SIGTERM を待つものを渡します。
`server::run` は、それが終わると受け付けをやめ、処理中のリクエストを終えてから戻ります。
`/health` を診療案内のページに使いたいなら、そう置けばよいだけです。mon が横取りしません。
`tests/collision.rs` で確かめています。
固定の文字列は、パス以外でも作りません。クッキー名は `SESSION_COOKIE`、
セッションの表の名前は `SESSION_TABLE`、置き場のディレクトリは `SESSION_DIRECTORY`、
リクエスト ID のヘッダ名は `REQUEST_ID_HEADER` で決めます。どれも空にすれば使いません。
表は使う側が用意します。mon が自分で作るのは、`sqlite` を選んだときの `session` 表だけで、
`session::prepare` が `create table if not exists` で用意します。
使う側のマイグレーションには混ざりません。
## 動かす
```
cargo run # 土台だけ。http://localhost:3000
cargo run --example todo # /api/todos と一覧のページを載せた例
RUST_LOG=debug cargo run # ログを詳しく
PORT=8080 cargo run # ポート変更
cargo test # テスト
cargo build --release # LTO と strip 済み
```
edition 2024 なので rustc 1.85 以上が要ります。
## Linux 向けのビルド
本番では `cargo run` ではなく、release ビルドでできた実行ファイルを動かします。
gunicorn のような、別のアプリケーションサーバーは要りません。
HTTP のサーバー(`axum::serve` の中身の hyper)は、実行ファイルの中に入っています。
tokio のマルチスレッドのランタイムで動くので、1 プロセスで全コアを使います。
ビルドは、mon を使うアプリのディレクトリで行います。
mon 自身のディレクトリで同じことをすると、mon だけを動かす `src/main.rs` がビルドされます。
ビルドしてできるのは、OS と CPU ごとの機械語です。
macOS でビルドした実行ファイルは macOS でしか動かないので、サーバーに合わせてビルドします。
Linux のマシンなら `cargo build --release` だけで済み、実行ファイルは `target/release/` にできます。
macOS からは、cargo-zigbuild でクロスコンパイルします。
同梱の SQLite と TLS のライブラリ(aws-lc-sys)には C のコードがあるので、Linux 向けの C コンパイラが要ります。
それを zig が用意します。
```
brew install zig
cargo install cargo-zigbuild
rustup target add x86_64-unknown-linux-gnu
cargo zigbuild --release --target x86_64-unknown-linux-gnu
```
実行ファイルは `target/x86_64-unknown-linux-gnu/release/` にできます。
ARM のサーバーなら、`aarch64-unknown-linux-gnu` にします。
どちらなのかは、サーバーで `uname -m` を実行するとわかります。
サーバーで `GLIBC` の版が見つからないと言われたら、`x86_64-unknown-linux-gnu.2.28` のように、target にサーバーの glibc の版を付けます。
その版は、サーバーで `ldd --version` を実行するとわかります。
release のプロファイルは fat LTO と `codegen-units = 1` なので、ビルドにメモリと CPU の時間をかなり使います。
すでにリクエストを受けている小さなサーバーの上でビルドするのは避けてください。
## サーバーで動かす
サーバーに Rust を入れる必要はありません。
SQLite も実行ファイルに入っているので、入れる必要はありません。
送るのは実行ファイルと、`route::assets` で配るなら static のディレクトリです。
systemd で動かすと、マシンの起動時に始まり、止まったときには起動し直されます。
次は例です。`app` は自分のアプリの名前に置き換えてください。
```
[Unit]
Description=app
After=network.target
[Service]
User=app
WorkingDirectory=/srv/app
ExecStart=/srv/app/app
Environment=PORT=3000
Environment=SESSION_SECURE=true
Environment=CLIENT_IP_HEADER=x-forwarded-for
UMask=0077
Restart=always
[Install]
WantedBy=multi-user.target
```
```
sudo cp app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now app
```
`WorkingDirectory` は必ず書いてください。
mon.db、`static`、`session` は、既定では起動したディレクトリからの相対パスで探されます。
`DATABASE_URL`、`STATIC_DIRECTORY`、`SESSION_DIRECTORY` で絶対パスを渡すこともできます。
`User=` のユーザーには、そのディレクトリへの書き込み権限が要ります。
mon.db と WAL のファイルがそこにできるためです。
`UMask=0077` にすると、mon.db と WAL のファイル、セッションのファイルを、そのユーザーしか読めなくなります。
ログは標準出力に出るので、`journalctl -u app` で読めます。
更新するときは、新しい実行ファイルを隣に置き、古いものの名前へ変えてから、`sudo systemctl restart app` を実行します。
動いている実行ファイルをその場で上書きすると、「Text file busy」で失敗します。
rsync は新しいファイルに書いてから名前を変えるので、そのまま使えます。
`systemctl restart` は、先に SIGTERM を送ります。
`src/main.rs` は Ctrl+C と SIGTERM を `server::run` の `shutdown` に渡しているので、処理中のリクエストを終えてから止まります。
TLS は、前に置いた nginx のようなリバースプロキシで処理します。
ドメインの server ブロックの中で、次のように mon へ渡します。
```
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
nginx が 1 段なら、`CLIENT_IP_HEADER=x-forwarded-for` と `TRUSTED_PROXY=0` で相手の IP を正しく読めます(「相手の IP」を参照)。
ブラウザは HTTPS でつなぐので、`SESSION_SECURE=true` が要ります。
mon は `HOST` のアドレスで待ち受けます。既定は 127.0.0.1 です。
そのため、プロキシを通らないリクエストは mon に届きません。
プロキシが別のマシンにあるときや、コンテナで動かすときは、`HOST=0.0.0.0` にし、プロキシのほかはそのポートへ届かないようにしてください。
`CLIENT_IP_HEADER` を設定していると、直接届いたリクエストが `X-Forwarded-For` を偽れるためです。
自分のアプリも、`axum::serve` ではなく `server::run` で起動してください。
`axum::serve` はヘッダを受け取り終えるまでの時間に上限を掛けないので、ヘッダを少しずつ送る相手や、つないだまま何も送らない相手に、接続を持たれ続けます。
`server::run` は、そうした接続を `REQUEST_TIMEOUT` の秒数で切ります。
## mon が渡す部品
置く場所は使う側が決めます。下のパスは、上の例で置いた場合のものです。
- `route::health` - `{"status": "ok"}` を返すだけ。DB は見ない
- `session::router()` - 中の `GET` で読み、`POST` で書き、`DELETE` で捨てる
- `relay::router()` - `/` とその下すべてを中継する
- `route::assets(&conf)` - 静的ファイルを配る service
- `page::not_found` - 当たらなかったときの HTML
- `server::run` - ヘッダの受け取りに上限を掛けて待ち受け、止めるときは処理中のリクエストを終えてから止まる
エラーはすべて `{"error": "..."}` の JSON です。panic も時間切れも同じ形にそろえています。
時間切れは 503 で返します。
408 は、クライアントが同じリクエストを送り直してよいという意味になるためです。
例外は 3 つです。`page::not_found` は HTML を返します。
`route::assets` の 404 と、本文が上限を超えたときの 413 は text/plain です。
どちらも tower-http がそのまま返すものです。
## 環境変数
どれも省略できます。読み取りは conf.rs の 1 箇所にまとめています。
省略すれば既定値を使いますが、**書いてあって読めないときは起動しません**。
黙って既定値へ落とすと、指定した覚えのないポートで上がり、プロセスは動いているのに
前段から見ると 502、という一番わかりにくい形になるためです。
```
PORT=800o → PORT=800o を数として読めません(invalid digit found in string)
PORT=0 → PORT=0 は 1 以上にしてください
CORS_CREDENTIALS=ture → CORS_CREDENTIALS=ture は true か false で書いてください
```
数の設定は 0 を許しません。`PORT=0` は OS が空きポートを勝手に選んでしまうためです。
0 を許すのは 2 つだけです。`CORS_MAX_AGE` はプリフライトを保存させない、
`TRUSTED_PROXY` は読み飛ばさない、という意味になります。
- `PORT` - 待ち受けポート。既定 3000
- `HOST` - 待ち受けるアドレス。既定 127.0.0.1 で、同じマシンからしか届かない
- `DATABASE_URL` - SQLite の場所。既定 `sqlite:mon.db`
- `STATIC_DIRECTORY` - 静的ファイルの置き場。既定 `static`
- `STATIC_CACHE_CONTROL` - 静的ファイルに付ける Cache-Control。既定 `no-cache`。空なら付けない
- `RELAY_ORIGIN` - 中継先の起点。既定 `http://localhost:6060`
- `CORS_ORIGIN` - 許可するオリジンをコンマ区切りで書く。空なら全部許可する
- `CORS_METHOD` - 許可するメソッドをコンマ区切りで書く。空なら既定に任せる
- `CORS_HEADER` - 許可するリクエストヘッダをコンマ区切りで書く。空なら既定に任せる
- `CORS_EXPOSE_HEADER` - JavaScript から読ませるレスポンスヘッダ。空なら既定に任せる
- `CORS_MAX_AGE` - プリフライトの結果をブラウザに保存させる秒数。既定 3600
- `CORS_CREDENTIALS` - Cookie 付きのリクエストを許すか。既定 false
- `SESSION_STORE` - セッションの置き場。`none` `memory` `file` `sqlite`。既定 sqlite
- `SESSION_MEMORY_LIMIT` - memory の置き場に入れる件数の上限。既定 100000
- `SESSION_DIRECTORY` - file のときの置き場。既定 `session`
- `SESSION_TABLE` - sqlite のときの表の名前。既定 `session`
- `SESSION_COOKIE` - クッキー名。既定 `sid`
- `SESSION_MAX_AGE` - セッションを保つ秒数。既定 2592000(30 日)
- `SESSION_SECURE` - クッキーに Secure を付けるか。既定 false。本番では立てる
- `SESSION_SAME_SITE` - `lax` `strict` `none`。既定 lax
- `REQUEST_ID_HEADER` - リクエスト ID を入れるヘッダ名。既定 `x-request-id`。空なら付けない
- `CLIENT_IP_HEADER` - 相手の IP を読むヘッダ名。既定は空。空なら TCP の相手を使う
- `TRUSTED_PROXY` - そのヘッダを右から何個読み飛ばすか。既定 0
- `CONTENT_SECURITY_POLICY` - 応答に付ける CSP。既定は空。空なら付けない
- `BODY_LIMIT` - リクエストボディの上限バイト数。既定 2 MiB
- `REQUEST_TIMEOUT` - リクエスト 1 本にかける上限秒数。`server::run` はヘッダを受け取り終えるまでの上限にも使う。既定 10
- `RELAY_TIMEOUT` - 中継先の応答を待つ上限秒数。既定 3
## CORS
`CORS_ORIGIN` が空ならどのオリジンからでも通し、書いてあればそのオリジンだけ通します。
プリフライトの OPTIONS には CorsLayer が自前で答えるので、ハンドラは書きません。
`CORS_ORIGIN` に書く値は、ブラウザが送ってくる Origin と 1 字も違ってはいけません。
`scheme://host` の形で、ポートは付けてよく、パスと末尾のスラッシュは付けません。小文字で書きます。
形が違えば起動しません。書いたのに効かない、という形で残るのが一番わかりにくいためです。
`CORS_ORIGIN=*` も止めます。全部許可するなら空にしてください。
`CORS_METHOD`、`CORS_HEADER`、`CORS_EXPOSE_HEADER` の `*` も、同じく止めます。
メソッドやヘッダ名として読めない値も、起動を止めます。
`CORS_CREDENTIALS=true` にすると Cookie 付きのリクエストが通ります。
このときオリジンにもメソッドにもヘッダにも `*` を返せない、という決まりが CORS にあります。
`CORS_ORIGIN` を空のまま credentials を立てた場合は、警告を出して credentials だけ落とします。
メソッドとヘッダは、指定がなければ次を返します。
- メソッド: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
- リクエストヘッダ: content-type, authorization
- 読ませるレスポンスヘッダ: x-request-id
credentials を使わないときは、この 3 つはどれも `*` です。
## 相手の IP
nginx のような中継の後ろでは、TCP の相手は中継そのものです。
本当の相手は `X-Forwarded-For` や `X-Real-IP` で渡ってきます。
読むヘッダは `CLIENT_IP_HEADER`、右から何個読み飛ばすかは `TRUSTED_PROXY` で決めます。
ヘッダの左端は相手が自由に書けます。
nginx の `$proxy_add_x_forwarded_for` は自分が見た IP を右へ足すので、信用できるのは右からです。
nginx が 1 段なら `CLIENT_IP_HEADER=x-forwarded-for` だけで、`TRUSTED_PROXY` は 0 のままです。
前に CDN が入ったら 1 にします。
`CLIENT_IP_HEADER` を書かないかぎり、ヘッダは一切見ません。
設定を忘れたまま詐称された値を採ることはありません。
ハンドラからはこう使います。
```
async fn where_from(ClientIp(address): ClientIp) -> String {
address.map(|value| value.to_string()).unwrap_or_default()
}
```
`server::run` は、TCP の相手を `ClientIp` に渡します。
`axum::serve` を使うなら、`into_make_service_with_connect_info::<SocketAddr>()` を渡すと同じになります。
どちらでもなければヘッダだけで決め、それも取れなければ `None` になります。
決まった値はログの span にも入ります。
## セッション
クッキーには id だけを入れ、中身は置き場に保存します。
置き場は `SESSION_STORE` で選びます。
`sqlite` は mon.db の `session` 表、`file` はセッションごとの JSON ファイル、`memory` はプロセスの中で、`none` はセッションを使いません。
memory は再起動で消え、プロセスを 2 つに増やすと片方でセッションが切れます。dev 用です。
件数の上限は `SESSION_MEMORY_LIMIT` で決めます。
上限に達すると、新しいセッションを作るリクエストには 500 を返します。
**発行は遅らせます。** 読むだけの相手には何も作らず、`Set-Cookie` も出しません。
ハンドラがセッションに書いたときに初めて id を作り、保存し、クッキーを出します。
そうしないと、初めて来た相手 1 人につき 1 件、クローラの分まで溜まっていきます。
それでも、相手が自由にセッションへ書けるルートがあれば、リクエスト 1 回につき 1 件ずつ増えます。
そうしたリクエストは、nginx の `limit_req` などで回数を抑えてください。
id は 32 バイトの乱数を 16 進で書いたものです。判定は置き場にあるかどうかなので、署名はしません。
相手が送ってきた id は、置き場に実物があるときだけ使います。覚えのない id はそのまま採らず、
新しく作り直します。
置き場には、id そのものではなく、id の SHA-256 を入れます。
表やファイル、バックアップが漏れても、そこからクッキーの値はわかりません。
file の置き場は、ディレクトリを 0700、ファイルを 0600 で作るので、同じマシンの別のユーザーからは読めません。
一時ファイルに書いてから名前を変えて置き換えるので、書いている途中のファイルを読まれることもありません。
掃除では、mon が付ける形の名前のファイルだけを消し、同じディレクトリのほかのファイルには触りません。
`SESSION_SECURE` の既定は false です。dev の `http://localhost` で動かすためで、
本番では `SESSION_SECURE=true` を立ててください。
`SESSION_SAME_SITE=none` は Secure がないとブラウザが捨てるので、その組み合わせでは起動しません。
期限切れは、読んだときと、1 時間ごとの掃除で消します。
期限の半分を過ぎたセッションは、書き換えがなくても保存し直して先へ延ばします。
`Set-Cookie` は保存し直したときだけ出します。読むだけの応答には付けません。
毎回付けると、静的ファイルの応答まで共有キャッシュや CDN に載らなくなるためです。
`Set-Cookie` を出すときは、`Cache-Control` に `private` を足し、`public` と `s-maxage` を外します。
Set-Cookie 付きの応答でも、共有キャッシュは保存してよいことになっていて(RFC 9111)、保存されると、ある人の sid が別の人に返るためです。
ログインのように相手の立場が変わったところでは、`session.renew().await` を呼んで id を替えます。
中身は残り、古い id は置き場から消えます。
呼ばないと、攻撃者が自分で取った id を相手のブラウザに置き、そのまま認証を通せます。
mon は覚えのない id を採らないので捏造は防げますが、正規に取った id を置かれる筋は塞げません。
ハンドラからはこう使います。
```
async fn login(session: Session) -> ... {
session.write().await.language = Some("ja".to_owned())
}
```
`write()` を呼んだ時点で保存する印が立ちます。`read()` は見るだけで、保存しません。
`write()` が返すのは中身の写しで、手放したときにセッションへ書き戻します。
それを持っている間も、`read()`、`renew()`、`destroy()` は待たずに戻ります。
このとき `read()` で見えるのは、書き戻す前の中身です。
`write()` の戻り値を 2 つ同時に持つと、先に手放したほうの書き換えが消えるので、panic します。
セッションに入れる項目は session.rs の `SessionData` に足します。JSON で保存するので、
足しても置き場の側は変わりません。
`session::router()` が受け取る `language` は、英数字と `-` で 35 字までです。
それ以外には 422 を返します。
返すのは `language` だけなので、`SessionData` に足した項目は相手に見えません。
ハンドラが書いた内容を保存できなかったときは、ハンドラの応答ではなく 500 を返します。
相手が、保存できたと思い込まないようにするためです。
`destroy()` のあとで置き場から消せなかったときも、同じく 500 を返します。
## ログ
`log::start(default)` を呼ぶと、標準出力へ流します。書き込みは専用のスレッドへ逃がすので、
出力先が詰まってもリクエストを捌く側は止まりません。
リクエストの span には、クエリ文字列を除いたパスだけを残します。
クエリ文字列にはトークンが入ることがあるためです。
```
// guard は終わりまで持つ。落とすと書き出しのスレッドが止まり、まだ書けていない分は消える
let _log = log::start("info,tower_http=debug");
```
レベルは `RUST_LOG` があればそちらが勝ちます。書き込みが追いつかないときは、待たずに落とします。
捌く側を止めないためです。たまり場は 128,000 行で、あふれた分は消えます。
何本消えたかは `dropped()` で聞けます。こちらから聞かないかぎり、誰も教えてくれません。
ライブラリが勝手に入れるものではないので、呼ぶかどうかは使う側が決めます。
自分で組むなら、この関数を使わずに `tracing_appender::non_blocking` を直接使ってください。
## 中継
`/relay/` より後ろのパスとクエリを `RELAY_ORIGIN` につないで転送します。
メソッドとヘッダとボディはそのまま送り、返ってきた status とヘッダとボディをそのまま返します。
接続そのものに関わるヘッダ(connection、host、transfer-encoding など)だけは送らず、返しもしません。
リダイレクトは追いません。中継先が返した 302 は、行き先ごとそのまま相手へ渡します。
追いかけると、相手には最終の応答だけが届き、元の 302 が消えるためです。
`CLIENT_IP_HEADER` を設定しているかどうかで、mon の前にプロキシがいるかどうかも決めます。
設定してあれば、`X-Forwarded-For`・`-Proto`・`-Host` は、ないときだけ足します。
あるものは前段が付けたもので、書き換えると、中継先が数える位置がずれるためです。
空なら、mon は自分が最前段だとみなします。
相手が送ってきた `X-Forwarded-*`、`Forwarded`、`X-Real-IP` はすべて捨て、`X-Forwarded-For`・`-Proto`・`-Host` を mon が書きます。
host は転送しないので、これがないと中継先は公開しているホストを知る手段がありません。
中継先の様子で返す番号を分けます。つながらなければ 502、つながったが返事がなければ 504 です。
待ち時間は「つなぐまで」と「次の一片が届くまで」の 2 つに掛けます。
全体に掛けると、大きな応答を流している途中で切れてしまうためです。
中継先へ送る `Cookie` からは、mon のセッションのクッキーを外します。
中継先が同じ名前のクッキーを書く `Set-Cookie` も通しません。
中継先に id を知られるとそのセッションを使われ、書かれると相手のセッションが差し替わるためです。
## 静的ファイル
`route::assets(&conf)` は `STATIC_DIRECTORY` を配る service です。
`STATIC_CACHE_CONTROL` の値を付けます。既定は `no-cache` で、空にすれば付けません。
何も付けないと、ブラウザは Last-Modified からの経過時間で勝手に期限を決め、その間は聞きにきません。
名前の変わらないファイルを置き換えても、古いものが出ます。
`no-cache` は保存しないという意味ではなく、使う前に必ず聞きにこさせる、という意味です。
中身が変わっていなければ 304 が返るので、往復 1 回で済みます。
ファイル名にハッシュを入れているなら、`max-age=31536000, immutable` にしてください。
## CSP
`CONTENT_SECURITY_POLICY` に書いた値を、すべての応答の Content-Security-Policy に付けます。
既定は空で、そのときは付けません。
読み込んでよい先はアプリごとに違うので、mon は既定値を持ちません。
```
CONTENT_SECURITY_POLICY="default-src 'self'; img-src 'self' data:; frame-ancestors 'none'"
```
ハンドラが自分で付けていれば、そちらを残します。
ページごとに変えたいときは、ハンドラ側で付けてください。
`frame-ancestors` は X-Frame-Options の後継です。こちらに書けば、別途 X-Frame-Options は要りません。
`html::document` は `<style>` にスタイルを直書きします。
`default-src 'self'` だけを書くと `page::not_found` のスタイルが効かなくなるので、
`style-src 'unsafe-inline'` を足すか、自分の 404 を置いてください。
## 保存
表は使う側が用意します。mon が作るのはセッションの表だけで、`session::prepare` が
`create table if not exists` で用意します。マイグレーションの仕組みは持ちません。
ライブラリがそれを持つと、使う側のマイグレーションに mon の表が混ざるためです。
表の名前も `SESSION_TABLE` で決められます。既定は `session` ですが、同じ名前の表を
すでに持っているなら変えてください。
表の `id` の列には、セッションの id の SHA-256 が入ります。
`sqlx::query!` のコンパイル時マクロは使いません。
ビルドのたびに DB への接続か `.sqlx/` のキャッシュが要るためです。
代わりに実行時の `query_as` と `FromRow` の derive を使います。
## 構成
```
src/
├── lib.rs 公開する入口
├── main.rs 土台だけを動かす bin
├── conf.rs 設定。Conf::default と、環境変数から読む load
├── state.rs 全ハンドラで共有する状態
├── database.rs SQLite のプール
├── route.rs ルーティングとミドルウェア(テストもここ)
├── server.rs 待ち受け(ヘッダの受け取りに上限を掛ける)
├── session.rs セッション(クッキーと置き場)
├── error.rs 共通エラー型
├── extract.rs Json / Path / Query
├── html.rs HTML の組み立て
├── ip.rs 相手の IP(ヘッダをどこまで信用するか)
├── log.rs ログの用意(呼ぶかどうかは使う側)
├── page.rs 当たらなかったときの HTML(置くかどうかは使う側)
└── relay.rs 別サーバへの中継
examples/
└── todo.rs /api/todos と一覧のページを載せた例
tests/
├── collision.rs mon がパスを取らないこと
├── relay.rs 中継。上流をテストの中で立てて確かめる
└── server.rs server::run。実際に TCP でつないで確かめる
```
## 試す
`cargo run --example todo` を動かしてから。
```
curl -X POST localhost:3000/api/todos -H 'content-type: application/json' -d '{"title":"買い物"}'
curl localhost:3000/api/todos
curl -X PATCH localhost:3000/api/todos/<id> -H 'content-type: application/json' -d '{"done":true}'
curl -i -X DELETE localhost:3000/api/todos/<id>
curl -i -X POST localhost:3000/api/session \
-H 'content-type: application/json' -d '{"language":"ja"}'
```
## 書き方
rustfmt はかけません。
関数どうしの間の 2 行の空行を rustfmt は 1 行に潰します。
`{key: value}` の波括弧内から空白を消す設定もありません。
どちらも表現できないので、手で揃えます。
## ライセンス
MIT と Apache-2.0 の二択です。好きなほうを選んでください。
- [LICENSE-MIT](./LICENSE-MIT)
- [LICENSE-APACHE](./LICENSE-APACHE)