ファイルアップロード#
multipart/form-data で来たファイルは bodyFile() から取ります。
Data files = context.request().bodyFile();
落とし穴
ファイルは bodyAll() にも bodyForm() にも載りません。bodyFile() だけです。
同じ本文に入っているテキスト項目のほうは、いつもどおり bodyAll() から取れます。
受け取る#
for (Object value : context.request().bodyFile().values()) {
if (value instanceof List<?> list) {
for (Object item : list) {
UploadFile uploadFile = (UploadFile) item;
received.add("%s:%s:%d".formatted(
uploadFile.name, uploadFile.fileName, uploadFile.fileSize));
received.add("content=" + read(uploadFile.file.toPath()));
tempFiles.add(uploadFile.file.toPath());
}
}
}
取り出すのは UploadFile です。メソッドを持たない入れ物で、中身はこれだけです。
| フィールド | 中身 |
|---|---|
name |
フォームの name 属性 |
fileName |
クライアントが名乗ったファイル名(無ければ "") |
contentType |
クライアントが名乗った Content-Type(無ければ "") |
fileSize |
実際に書き出したバイト数 |
file |
一時ファイル(java.io.File) |
注意
ファイルが1件でも List にくるまれます。
bodyFile() の値を UploadFile に直接キャストすると落ちます。
同じ name で複数送れる以上、常にリストとして扱うのが正しい形です。
一時ファイル#
| 置き場所 | upload.temp_dir(既定は java.io.tmpdir) |
| 名前 | jimble-upload-<連番>.tmp。元のファイル名は使いません |
| 消えるとき | リクエストが終わるとき(コンテキストのクローズ) |
落とし穴
リクエストが終わったあとに uploadFile.file は触れません。
非同期に処理を投げたり、パスだけ DB に入れて後で読んだりすると、
もう無いファイルを掴みます。残したいものはハンドラの中でコピーしてください。
一時ファイルには deleteOnExit() を付けていません。
プロセスが落ちれば残るので、そこは OS の一時ディレクトリの掃除に任せています。
上限#
設定を書かなくても上限は掛かります(要件 NF-S-05)。
upload {
max_file_size = 10485760 # 1ファイル。既定 10MB
max_total_size = 52428800 # 1リクエスト合計。既定 50MB
max_files = 20 # 件数
temp_dir = "" # 空なら java.io.tmpdir
}
超えたときはその場で例外になり、書きかけの一時ファイルも消します。
| 条件 | 返り |
|---|---|
| 件数超過 | 413「アップロードできるファイル数は N 件までです」 |
| 1ファイル/合計の超過 | 413「アップロードのサイズが上限(N バイト)を超えています」 |
| multipart として読めない | 400「multipart を読めませんでした」 |
サイズは読みながら見ています。全部受け取ってから測ると、 上限を超えた分までディスクに書いてしまうからです。
注意
server.max_request_size(既定 10MB)のほうが先に効きます。
upload.max_total_size の既定は 50MB なので、そのままでは 50MB は届きません。
大きいものを許すなら、両方を上げてください。
保存する#
保存のヘルパはありません。uploadFile.file を JDK の Files で写します。
private static String saveImage (WebContext context) throws Exception {
UploadFile uploadFile = firstFile(context);
if (uploadFile == null || uploadFile.fileSize <= 0) {
return null;
}
String extension = extensionOf(uploadFile.fileName);
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new io.jimble.web.http.HttpException(
400, "受け付けられない形式です: %s(%s のみ)"
.formatted(uploadFile.fileName, String.join(" ", ALLOWED_EXTENSIONS)));
}
Path dir = Path.of(UPLOAD_DIR);
Files.createDirectories(dir);
String saved = UUID.randomUUID() + extension;
/*
* 一時ファイルはリクエストが終わると消える(要件 F-W-06)。
* 残したいものはここで移す。
*/
Files.copy(uploadFile.file.toPath(), dir.resolve(saved), StandardCopyOption.REPLACE_EXISTING);
return saved;
}
要点は3つです。
| 名前はこちらで付ける | UUID.randomUUID() + extension |
| 拡張子は許可リストで見る | 落ちたら 400 で断る |
Files.copy |
元は消えるので、コピーでも移動でもよい |
落とし穴
uploadFile.fileName をそのまま保存先に使わないでください。
jimble はこの名前を一切検査しません(クライアントが名乗ったままです)。
../../etc/passwd のような名前が来ると、置き場所の外に書けてしまいます。
注意
**contentType も検査していません。**クライアントの自己申告です。
image/png と名乗った実行ファイルが来ます。
拡張子の許可リストと、必要なら中身の検査を自分で書いてください。
テキスト項目との混在#
同じ本文に入っているので、1回で解析して持っておきます(本文は1回しか読めません)。
ファイル名の無いパートはテキスト項目として扱われ、bodyAll() に載ります。
補足
テキスト項目は UTF-8 固定で読み、全量をメモリに載せます(サイズ上限はありません)。
ここを守っているのは server.max_request_size だけです。
返す(ダウンロード)#
context.response().download(file); // 実ファイル名で添付
context.response().download(file, "報告書.xlsx"); // 名前を指定して添付
context.response().file(file); // インライン(種類は中身から判定)
context.response().file(file, "image/png"); // インライン(種類を指定)
context.response().stream(in, "text/csv"); // ストリーム
context.response().stream(in, "text/csv", length); // 長さが分かっているストリーム
Content-Disposition は RFC 5987 の形(filename*=UTF-8''...)で付くので、
日本語のファイル名がそのまま通ります。
補足
互換用の filename="..."(ASCII 版)は併記していません。
また、名前に半角スペースが入ると + になります(%20 ではありません)。
気になる場合はスペースを _ に置き換えてから渡してください。