「パソコンの画面を撮ったスクリーンショットを問い合わせに付けられるようにしたい。でも、変なファイルを置かれたり、他の人の添付が見えたりしないか心配…」
「社内ツールに添付ファイルを付けたい。作るのは簡単そうだけど、何に気をつければいいの?」
先に結論です。添付ファイルの機能は、「本体は R2(ファイル置き場)、情報は D1(データベース)に分ける」「種類・大きさ・件数を入口で制限する」「ダウンロードは必ず権限チェックを通す」の3点で、小さく堅く作れます。この回では、この3点を Hono と Cloudflare Workers で実装し、テストで確かめるところまでを扱います。
前回は、ログインと権限(社員・担当者・管理者)を付けました。詳しくは次の記事で解説しています。


この回で作るのは次の4つです。
- 添付ファイルの「目録」テーブル(D1)と、保存場所 R2 の設定
- アップロード API(種類・大きさ・件数の制限つき)
- 添付の一覧 API
- 権限を確かめてから返すダウンロード API
(2026年10月31日 追記:記事どおりに再現できるよう、必要なコードと手順を追加しました)
※ 今回のコードは、Node.js 22系・Hono 4・Wrangler 4・TypeScript 6 で、型チェックとテスト(Cloudflare Workers の実行環境 workerd 上。R2 もローカルの模擬で動作)が通ることを確認しています。Cloudflare の実環境(本物の R2 バケット)へのデプロイは、この回でも未確認です。
仕組み:ファイルの「本体」と「目録」を分ける
R2 とは何か
R2 は、Cloudflare が提供するファイル置き場(オブジェクトストレージ)です。画像や PDF など、データベースに入れるには大きいファイルを置くのに向いています。AWS の S3 と同じ使い方ができる仕組みで、Workers からは「バインディング」という設定ひとつで直接読み書きできます。
📰 出典:Cloudflare Docs「R2」
なぜ D1 と R2 を分けるのか
ファイルの中身を D1 に入れることもできますが、容量や速度の面で不向きです。そこで次のように役割を分けます。
| 置き場所 | 持つもの | 理由 |
|---|---|---|
| R2 | ファイル本体 | 大きなデータの保存に向く |
| D1 | 目録(どのチケットの、元のファイル名、種類、大きさ、R2 のキー) | 「このチケットの添付一覧」を素早く引ける。権限の判断にも使う |
今回決める制限
添付は、受け付ける範囲を先に決めておくのが安全です。発注側で最初に決めておくと、手戻りが減る部分です。
| 項目 | この連載での値 | 考え方 |
|---|---|---|
| 種類 | PNG・JPEG・PDF | スクリーンショットと資料を想定。実行形式(.exe など)は受け付けない |
| 1ファイルの大きさ | 5MB まで | 画面キャプチャには十分。大きな動画などは別の手段にする |
| 1チケットの件数 | 5 件まで | 置き場の使いすぎを防ぐ |
| ダウンロード | ログイン必須+チケットを見られる人だけ | R2 の URL は公開しない |
ファイルのアップロードで気をつける点は、OWASP(Webセキュリティの国際的な非営利団体)もまとめています。
📰 出典:OWASP「File Upload Cheat Sheet」
この資料では、許可する拡張子・種類を絞ること、ファイルサイズに上限を設けること、利用者が付けたファイル名をそのまま保存先に使わないことなどが、基本の対策として挙げられています。今回の実装は、この考え方に沿っています。
実装
第3回までのプロジェクトに続けて作業します。新しい部品のインストールはありません(R2 は Workers に組み込まれています)。
1. R2 バケットの設定
wrangler.jsonc に、R2 の設定を足します(d1_databases の前に追加)。次の変更を加えます(+ が追加、- が削除)。
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -5,6 +5,12 @@
"compatibility_date": "2026-08-01",
"observability": { "enabled": true },
"vars": { "APP_NAME": "社内ヘルプデスク" },
+ "r2_buckets": [
+ {
+ "binding": "ATTACHMENTS",
+ "bucket_name": "helpdesk-attachments"
+ }
+ ],
"d1_databases": [
{
"binding": "DB",
binding はコードから使うときの名前、bucket_name は実際のバケット名です。ローカルの開発・テストでは、バケットを作らなくても模擬の R2 が使われます。本番では npx wrangler r2 bucket create helpdesk-attachments で作成します(この回では未実行)。
型定義は npx wrangler types で自動生成され、env.ATTACHMENTS の型が使えるようになります。
2. 目録テーブル
migrations/0004_create_attachments.sql を次の内容で作ります。
-- migrations/0004_create_attachments.sql
-- 第4回: 添付ファイルの「目録」。ファイル本体は R2 に置き、D1 には名前・種類・大きさ・R2 のキーだけを持つ
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES tickets (id),
r2_key TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_attachments_ticket ON attachments (ticket_id);
r2_key は R2 の中でファイルを特定するキー(保存場所を表す名前)です。元のファイル名は表示用に filename へ別に持ちます。
3. 制限のルールを1か所にまとめる
src/attachments.ts を次の内容で作ります。制限を変えたいときは、このファイルだけを直せばよい形にしています。
// src/attachments.ts
// 添付ファイルのルール。ここを変えれば、受け付ける種類や大きさを変えられる
export const MAX_BYTES = 5 * 1024 * 1024 // 1ファイル 5MB まで
export const MAX_FILES_PER_TICKET = 5
// 受け付ける種類(スクリーンショットと資料を想定)。先頭バイトの特徴で中身を確かめる
const signatures: Record<string, (b: Uint8Array) => boolean> = {
'image/png': (b) => startsWith(b, [0x89, 0x50, 0x4e, 0x47]),
'image/jpeg': (b) => startsWith(b, [0xff, 0xd8, 0xff]),
'application/pdf': (b) => startsWith(b, [0x25, 0x50, 0x44, 0x46]), // "%PDF"
}
export const allowedTypes = Object.keys(signatures)
const startsWith = (b: Uint8Array, head: number[]) => head.every((v, i) => b[i] === v)
// 申告された種類が許可リストにあり、かつ中身がその種類の形をしているか
export const looksLike = (type: string, head: Uint8Array) => signatures[type]?.(head) ?? false
// 表示・ダウンロード名に使える形にする。パスの区切りや制御文字を除き、長すぎるものは切る
export const safeFilename = (name: string) => {
const base = name.split(/[\\/]/).pop() ?? ''
const cleaned = base.replace(/[\u0000-\u001f\u007f"]/g, '').trim().slice(0, 100)
return cleaned || 'file'
}
ポイントは、「申告された種類」を信用しないことです。アップロードする側は、ファイルの種類を自由に名乗れます。そこで、ファイルの先頭の数バイト(形式ごとに決まった目印)を見て、名乗った種類と中身が合うかを確かめます。
4. アップロード API
src/routes/attachments.ts を作ります。説明のために3つのブロックに分けていますが、3つを順につなげるとファイルの全文になります。まず、「そのチケットを見てよい人か」を確かめる部品と、アップロードの処理の前半です。
// src/routes/attachments.ts
import { Hono } from 'hono'
import { requireLogin, type AppEnv } from '../auth'
import { allowedTypes, looksLike, MAX_BYTES, MAX_FILES_PER_TICKET, safeFilename } from '../attachments'
export const attachments = new Hono<AppEnv>()
attachments.use('*', requireLogin)
type TicketRow = { id: number; requester: string }
// チケットが存在し、見てよい人(本人または担当者・管理者)なら返す。それ以外は null(呼び出し側で 404)
const visibleTicket = async (c: { env: Env; get: (k: 'user') => AppEnv['Variables']['user'] }, id: number) => {
const user = c.get('user')
const ticket = await c.env.DB.prepare('SELECT id, requester FROM tickets WHERE id = ?').bind(id).first<TicketRow>()
if (!ticket) return null
if (user.role === 'employee' && ticket.requester !== user.email) return null
return ticket
}
// アップロード。multipart/form-data の "file" 項目を1つ受け取り、R2 に保存して目録を D1 に残す
attachments.post('/tickets/:id{[0-9]+}/attachments', async (c) => {
const ticket = await visibleTicket(c, Number(c.req.param('id')))
if (!ticket) return c.json({ error: 'not_found' }, 404)
// 本体を読む前に、申告された全体サイズで門前払いにする(余白は multipart の枠分)
const declared = Number(c.req.header('content-length') ?? 0)
if (declared > MAX_BYTES + 64 * 1024) return c.json({ error: 'too_large', maxBytes: MAX_BYTES }, 413)
const form = await c.req.formData().catch(() => null)
const file = form?.get('file')
if (!(file instanceof File)) return c.json({ error: 'invalid_request', message: 'file が必要です' }, 400)
if (file.size === 0) return c.json({ error: 'invalid_request', message: '空のファイルです' }, 400)
if (file.size > MAX_BYTES) return c.json({ error: 'too_large', maxBytes: MAX_BYTES }, 413)
// 申告された種類を信用せず、許可リストと中身の先頭バイトの両方で確かめる
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer())
if (!allowedTypes.includes(file.type) || !looksLike(file.type, head)) {
return c.json({ error: 'unsupported_type', allowed: allowedTypes }, 415)
}
チェックの順番にも意味があります。「そもそも見てよいチケットか」→「大きさ」→「種類」の順で、安い確認から先に行い、不正なものを早く返します。他人のチケットは、第3回と同じく「存在しない」と同じ 404 にします。
続いて、件数の確認、R2 への保存、目録への記録です(前のブロックの続き)。
const count = await c.env.DB.prepare('SELECT COUNT(*) AS n FROM attachments WHERE ticket_id = ?')
.bind(ticket.id)
.first<{ n: number }>()
if ((count?.n ?? 0) >= MAX_FILES_PER_TICKET) return c.json({ error: 'too_many_files', max: MAX_FILES_PER_TICKET }, 409)
// R2 のキーは利用者が付けた名前を使わず、こちらで決める(名前の衝突・パス操作を避ける)
const key = `tickets/${ticket.id}/${crypto.randomUUID()}`
await c.env.ATTACHMENTS.put(key, file.stream(), { httpMetadata: { contentType: file.type } })
try {
const row = await c.env.DB.prepare(
`INSERT INTO attachments (ticket_id, r2_key, filename, content_type, size, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, ticket_id, filename, content_type, size, created_at`,
)
.bind(ticket.id, key, safeFilename(file.name), file.type, file.size, c.get('user').email)
.first()
return c.json(row, 201)
} catch (e) {
// 目録に書けなかったら、置いたファイルも消す(持ち主のいないファイルを残さない)
await c.env.ATTACHMENTS.delete(key)
throw e
}
})
R2 と D1 は別々の仕組みなので、片方だけ成功する場面が起こりえます。そのため、目録への記録に失敗したら、置いたファイルを消しています。
5. 一覧とダウンロード
ファイルの残り(3つ目のブロック)です。前のブロックの後ろに1行空けて続けます。
attachments.get('/tickets/:id{[0-9]+}/attachments', async (c) => {
const ticket = await visibleTicket(c, Number(c.req.param('id')))
if (!ticket) return c.json({ error: 'not_found' }, 404)
const { results } = await c.env.DB.prepare(
`SELECT id, filename, content_type, size, uploaded_by, created_at
FROM attachments WHERE ticket_id = ? ORDER BY id`,
)
.bind(ticket.id)
.all()
return c.json({ items: results })
})
// ダウンロード。R2 のURLを直接公開せず、必ずこのAPIを通して権限を確かめてから返す
attachments.get('/attachments/:id{[0-9]+}', async (c) => {
const row = await c.env.DB.prepare(
'SELECT ticket_id, r2_key, filename, content_type FROM attachments WHERE id = ?',
)
.bind(Number(c.req.param('id')))
.first<{ ticket_id: number; r2_key: string; filename: string; content_type: string }>()
if (!row || !(await visibleTicket(c, row.ticket_id))) return c.json({ error: 'not_found' }, 404)
const object = await c.env.ATTACHMENTS.get(row.r2_key)
if (!object) return c.json({ error: 'not_found' }, 404)
return new Response(object.body, {
headers: {
'content-type': row.content_type,
'content-length': String(object.size),
// ブラウザで開かず保存させ、種類の推測もさせない(なりすましたファイルが画面内で動くのを防ぐ)
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(row.filename)}`,
'x-content-type-options': 'nosniff',
'cache-control': 'private, no-store',
},
})
})
ダウンロードで付けている3つのヘッダーには、それぞれ役割があります。
| ヘッダー | 役割 |
|---|---|
content-disposition: attachment | ブラウザの画面内で開かず、保存させる |
x-content-type-options: nosniff | ブラウザが中身を見て種類を勝手に推測するのを止める |
cache-control: private, no-store | 共有のキャッシュに残さない(他の人に配られないようにする) |
最後に、src/index.ts に部品をつなぎます。次の変更を加えます(+ が追加、- が削除)。
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,6 +3,7 @@ import type { AppEnv } from './auth'
import { auth } from './routes/auth'
import { users } from './routes/users'
import { tickets } from './routes/tickets'
+import { attachments } from './routes/attachments'
const app = new Hono<AppEnv>()
@@ -19,5 +20,6 @@ app.get('/api/health', async (c) => {
app.route('/api/auth', auth)
app.route('/api/users', users)
app.route('/api/tickets', tickets)
+app.route('/api', attachments)
export default app
動作確認:テストで確かめる
test/attachments.test.ts に、次のケースを書いています。npm test で全 28 件(今回の追加は 7 件)が通ることを確認しました。
| 確かめること | 期待する結果 |
|---|---|
| PNG をアップロードしてダウンロード | 201、R2 にファイルがあり、ダウンロードした中身が元と同じ |
| 実行ファイル、または PNG と名乗る別の中身 | 415 で拒否 |
| 5MB を超えるファイル | 413 で拒否 |
| 他の社員のチケットへの添付・ダウンロード | 404(担当者は 200) |
| 6 件目のアップロード | 409 で拒否 |
../../etc/passwd.png というファイル名 | passwd.png に直る |
| ログインなしのダウンロード | 401 |
テストの全文です。
test/attachments.test.ts の全文(84行)
// test/attachments.test.ts
import { SELF, env } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import { loginAs } from './helpers'
let staff = ''
let other = ''
let agent = ''
beforeAll(async () => {
staff = await loginAs('taro@example.com', 'employee')
other = await loginAs('hanako@example.com', 'employee')
agent = await loginAs('agent@example.com', 'agent')
})
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]
const newTicket = async (cookie = staff) => {
const res = await SELF.fetch('http://example.com/api/tickets', {
method: 'POST',
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({ title: '添付テスト', body: '本文', category: 'pc' }),
})
return (await res.json<{ id: number }>()).id
}
const upload = (id: number, bytes: number[] | Uint8Array, type: string, name = 'shot.png', cookie = staff) => {
const form = new FormData()
form.set('file', new File([new Uint8Array(bytes)], name, { type }))
return SELF.fetch(`http://example.com/api/tickets/${id}/attachments`, { method: 'POST', headers: { cookie }, body: form })
}
describe('添付ファイル', () => {
it('PNG をアップロードでき、R2 に保存され、本人がダウンロードできる', async () => {
const id = await newTicket()
const res = await upload(id, PNG, 'image/png')
expect(res.status).toBe(201)
const meta = await res.json<{ id: number; size: number }>()
expect(meta.size).toBe(PNG.length)
expect((await env.ATTACHMENTS.list({ prefix: `tickets/${id}/` })).objects).toHaveLength(1)
const dl = await SELF.fetch(`http://example.com/api/attachments/${meta.id}`, { headers: { cookie: staff } })
expect(dl.status).toBe(200)
expect(dl.headers.get('content-type')).toBe('image/png')
expect(dl.headers.get('content-disposition')).toContain('attachment')
expect(dl.headers.get('x-content-type-options')).toBe('nosniff')
expect(Array.from(new Uint8Array(await dl.arrayBuffer()))).toEqual(PNG)
})
it('許可していない種類は 415(申告が image/png でも中身が違えば拒否)', async () => {
const id = await newTicket()
expect((await upload(id, [0x4d, 0x5a, 0x90, 0], 'application/x-msdownload', 'a.exe')).status).toBe(415)
expect((await upload(id, [0x4d, 0x5a, 0x90, 0], 'image/png', 'fake.png')).status).toBe(415)
})
it('大きすぎるファイルは 413', async () => {
const id = await newTicket()
const big = new Uint8Array(5 * 1024 * 1024 + 1)
big.set(PNG)
expect((await upload(id, big, 'image/png')).status).toBe(413)
})
it('他人のチケットには添付も取得もできず 404', async () => {
const id = await newTicket()
const meta = await (await upload(id, PNG, 'image/png')).json<{ id: number }>()
expect((await upload(id, PNG, 'image/png', 'x.png', other)).status).toBe(404)
expect((await SELF.fetch(`http://example.com/api/attachments/${meta.id}`, { headers: { cookie: other } })).status).toBe(404)
expect((await SELF.fetch(`http://example.com/api/attachments/${meta.id}`, { headers: { cookie: agent } })).status).toBe(200)
})
it('1チケット 5 件まで。6件目は 409', async () => {
const id = await newTicket()
for (let i = 0; i < 5; i++) expect((await upload(id, PNG, 'image/png')).status).toBe(201)
expect((await upload(id, PNG, 'image/png')).status).toBe(409)
})
it('ファイル名のパス部分は落とされる', async () => {
const id = await newTicket()
const meta = await (await upload(id, PNG, 'image/png', '../../etc/passwd.png')).json<{ filename: string }>()
expect(meta.filename).toBe('passwd.png')
})
it('ログインしていなければ 401', async () => {
const res = await SELF.fetch('http://example.com/api/attachments/1')
expect(res.status).toBe(401)
})
})
npx wrangler types
npm run typecheck
npm test
実際のブラウザからの送信は、curl では次のようになります(ログイン後の Cookie を session.txt に保存した場合の例)。
curl -b session.txt -F "file=@screenshot.png;type=image/png" \
https://<デプロイ先>/api/tickets/1/attachments
※ 本物の R2 バケットを作っての確認は、この回では行っていません。
つまずきやすい点・セキュリティ上の注意
- 種類の確認は「名乗り」だけでは足りません。 ファイルの種類は送る側が自由に書き換えられます。許可リストと先頭バイトの両方で見ます。ただし先頭バイトの確認は万能ではありません。本格的に運用するなら、ウイルス検査の仕組みを足すのが一般的です。
- PDF や画像の中身までは検査していません。 今回の確認は入口の簡易な検査です。外部に公開する機能では、検査サービスの導入を検討してください。
- 大きさの制限は二段構えです。
content-lengthで本体を読む前に弾き、読んだあとで実際の大きさも確認しています。申告は偽れるためです。なお、Workers が受け付けられるリクエストの大きさの上限はプランによって決まっています。大きなファイルを扱うときは、最新の公式の制限を確認してください。 - R2 のバケットは公開設定にしません。 今回は、バケットを公開せず、必ず API を通して返します。権限の確認を飛ばして取得できる道を作らないためです。
- 削除機能は未実装です。 チケットや添付を消す機能(と、R2 側のファイルの後始末)は、運用に入る前に決める必要があります。連載では省略しています。
発注者向けメモ:添付ファイル機能を頼むときの確認点
添付ファイルは「付けるだけ」に見えて、決めることが多い機能です。開発会社に頼むとき、次の点を確認しておくと、後から追加費用になりにくくなります。
| 確認すること | 質問の例 |
|---|---|
| 受け付ける種類・大きさ・件数 | 「どの種類のファイルを、最大何MBまで、何件まで受け付けますか。動画や Excel を付けたい場合は別の対応になりますか」 |
| 見られる人の範囲 | 「添付は、チケットを見られる人だけが見られる仕組みになっていますか。URLを知っていれば誰でも開ける作りではありませんか」 |
| ウイルス・不正ファイル対策 | 「アップロードされたファイルの検査はどこまで含まれますか。含まれない場合、後から足すのは別の費用ですか」 |
| 保存期間と削除 | 「添付はいつまで残しますか。チケットを完了したら消す、古いものを自動で消す、といった運用は仕様に入りますか」 |
| 保存先の費用 | 「保存先の利用料は、誰の契約で、毎月どのくらい増える見込みですか。件数が増えたときの目安を教えてください」 |
| 個人情報 | 「画面のスクリーンショットに個人情報や社外秘が写り込むことがあります。保存場所・アクセス記録・消し方のルールは決まっていますか」 |
「添付ファイルをつけたい」の一言には、実は上のような判断が含まれています。見積もりの段階で、種類・大きさ・見られる人の範囲を文書にしておくと、「ここまでは含まれる」を双方で確認できます。
まとめと次回予告
この回では、添付ファイルを R2 に保存する機能を作りました。
- ファイル本体は R2、目録は D1 に分けた
- 種類(許可リスト+先頭バイト)・大きさ・件数を入口で制限した
- R2 を公開せず、ダウンロードは権限を確かめて返した(他人のチケットは 404)
次回の第5回では、担当者への通知メールを Cloudflare Queues で非同期に送る仕組み(キュー、リトライ、失敗時の扱い)を作ります。
この連載の記事一覧
この記事は連載「Honoで作る社内ヘルプデスク」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Honoで作る社内ヘルプデスク 第0回】連載の全体像と、Hono+Cloudflare Workersの雛形を動かす
- 【Honoで作る社内ヘルプデスク 第1回】チケット登録APIを作る:D1のマイグレーションとZodの入力チェック
- 【Honoで作る社内ヘルプデスク 第2回】チケットの一覧と状態遷移を作る:ページングと「戻せない状態」のルール
- 【Honoで作る社内ヘルプデスク 第3回】ログインと権限を付ける:パスワードの保存・Cookie・「社員/担当者/管理者」の線引き
- 【Honoで作る社内ヘルプデスク 第4回】添付ファイルを R2 に保存する:種類・大きさの制限とダウンロード権限(この記事)









コメント
コメント一覧 (2件)
[…] 【Honoで作る社内ヘルプデスク 第4回】添付ファイルを R2 に保存する:種類… […]
[…] 【Honoで作る社内ヘルプデスク 第4回】添付ファイルを R2 に保存する:種類… […]