Hono 生产实战 02:文件上传、R2 和预签名 URL
wxk1991 Lv5

Hono 生产实战 02:文件上传、R2 和预签名 URL

文件上传看起来简单。

前端一个 <input type="file">,后端接一下,存起来。

但生产环境里,文件上传是最容易把 API 拖垮的功能之一。

你要考虑:

1
2
3
4
5
6
7
8
9
文件大小限制
文件类型校验
文件名安全
存储位置
访问权限
上传失败重试
病毒和内容风险
图片处理
CDN 缓存

Hono 很适合写上传接口,Cloudflare R2 很适合存对象文件。两者组合起来,可以做一套轻量、低运维的文件系统。

这篇文章讲 Hono + Cloudflare R2 的文件上传实践,以及什么时候应该使用预签名 URL。


一、先选上传模式

常见有两种模式。

第一种,后端中转上传:

1
浏览器 -> Hono API -> R2

优点是简单,权限控制集中,后端可以做校验。

缺点是文件流量经过 Worker,适合中小文件。

第二种,前端直传:

1
2
浏览器 -> R2
浏览器 -> Hono API 保存文件记录

优点是后端压力小,适合大文件。

缺点是签名、权限、回调和状态管理更复杂。

我的建议是:

1
2
头像、附件、小图片:先用后端中转
大视频、大文件、多文件上传:考虑预签名 URL 直传

不要一上来就做很复杂的上传体系。


二、配置 R2 binding

wrangler.jsonc

1
2
3
4
5
6
7
8
9
10
11
{
"name": "hono-upload-api",
"main": "src/index.ts",
"compatibility_date": "2026-06-23",
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "my-upload-bucket"
}
]
}

Hono 类型:

1
2
3
4
5
type Bindings = {
BUCKET: R2Bucket
}

const app = new Hono<{ Bindings: Bindings }>()

后面就能通过:

1
c.env.BUCKET

访问 R2。


三、后端中转上传

前端表单:

1
2
3
4
<form id="form">
<input name="file" type="file" />
<button>上传</button>
</form>

前端请求:

1
2
3
4
5
6
7
const formData = new FormData()
formData.append('file', file)

await fetch('/api/upload', {
method: 'POST',
body: formData,
})

Hono route:

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
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'

type Bindings = {
BUCKET: R2Bucket
}

const upload = new Hono<{ Bindings: Bindings }>()

upload.use('*', bodyLimit({
maxSize: 10 * 1024 * 1024,
onError: (c) => {
return c.json({
error: {
code: 'FILE_TOO_LARGE',
message: '文件不能超过 10MB',
},
}, 413)
},
}))

upload.post('/', async (c) => {
const body = await c.req.parseBody()
const file = body.file

if (!(file instanceof File)) {
return c.json({
error: {
code: 'FILE_REQUIRED',
message: '请选择文件',
},
}, 400)
}

if (!file.type.startsWith('image/')) {
return c.json({
error: {
code: 'INVALID_FILE_TYPE',
message: '只允许上传图片',
},
}, 400)
}

const key = `uploads/${crypto.randomUUID()}-${safeName(file.name)}`

await c.env.BUCKET.put(key, file.stream(), {
httpMetadata: {
contentType: file.type,
},
})

return c.json({
data: {
key,
size: file.size,
contentType: file.type,
},
}, 201)
})

function safeName(name: string) {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.slice(0, 80)
}

export default upload

这里有几个重点:

1
2
3
4
5
限制大小
校验类型
不要信任原始文件名
用随机 key 防止覆盖
保存 contentType

不要直接把用户文件名当 R2 key。


四、文件访问接口

如果 bucket 不是公开的,可以用 Hono 做读取代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
app.get('/files/*', async (c) => {
const key = c.req.path.replace('/files/', '')
const object = await c.env.BUCKET.get(key)

if (!object) {
return c.json({
error: {
code: 'FILE_NOT_FOUND',
message: '文件不存在',
},
}, 404)
}

const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('etag', object.httpEtag)
headers.set('cache-control', 'public, max-age=31536000, immutable')

return new Response(object.body, {
headers,
})
})

私有文件则要先鉴权:

1
2
3
app.get('/api/private/files/:key', auth(), async (c) => {
// 校验当前用户是否有权限访问这个 key
})

文件访问权限不要只靠“URL 很难猜”。


五、什么时候用预签名 URL

预签名 URL 适合:

1
2
3
4
5
6
大文件
多文件
移动端弱网
上传进度
减少 Worker 流量
前端直传对象存储

基本流程:

1
2
3
4
5
前端请求 Hono:我要上传一个文件
Hono 校验用户和文件信息
Hono 生成短期有效的上传 URL
前端直接 PUT 到 R2 S3 API
上传成功后通知 Hono 保存文件记录

注意,生成预签名 URL 时,签名密钥必须在后端。

不要把 R2 Access Key Secret 放到浏览器。

Hono route 可以设计成:

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
app.post('/api/uploads/presign', auth(), async (c) => {
const { filename, contentType, size } = await c.req.json()

if (size > 100 * 1024 * 1024) {
return c.json({
error: {
code: 'FILE_TOO_LARGE',
message: '文件不能超过 100MB',
},
}, 413)
}

const key = `uploads/${c.get('user').id}/${crypto.randomUUID()}-${safeName(filename)}`

const uploadUrl = await createR2PresignedPutUrl({
key,
contentType,
expiresIn: 300,
})

return c.json({
data: {
key,
uploadUrl,
expiresIn: 300,
},
})
})

createR2PresignedPutUrl 可以用 S3 兼容签名工具实现。重点不是工具名,而是边界:

1
2
3
4
后端生成短期 URL
前端只拿临时 URL
密钥不出后端
上传完成后再保存业务记录

六、保存文件记录

不要只把文件丢到 R2。

业务系统应该保存一张文件表:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE files (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
object_key TEXT NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
);

状态可以是:

1
2
3
4
pending
uploaded
failed
deleted

预签名上传尤其需要状态。

因为用户可能拿到 URL 后没有上传,或者上传一半失败。


七、常见安全坑

第一,只校验文件扩展名。

不要只看 .jpg,至少校验 contentType,更严格场景还要检查文件内容。

第二,文件名直接入库和展示。

原始文件名可能包含奇怪字符,展示时要转义。

第三,公开 bucket 存私有文件。

头像可以公开,合同、证件、订单附件不应该公开。

第四,没有大小限制。

上传接口必须有 body limit。

第五,上传后不清理孤儿文件。

预签名上传尤其容易产生 pending 记录和无业务归属文件,要做定时清理。


八、最后的建议

Hono + R2 做上传,建议从简单开始:

1
2
3
4
5
小文件:Hono 中转上传到 R2
大文件:预签名 URL 直传
私有文件:Hono 鉴权后代理读取
公开静态文件:R2 + 自定义域名/CDN
业务文件:必须保存数据库记录

上传不是“存文件”这么简单。

它是一个权限、存储、网络、缓存、清理和安全问题的集合。

先把边界设计好,后面才不会越写越乱。


参考资料