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。
一、先选上传模式 常见有两种模式。
第一种,后端中转上传:
优点是简单,权限控制集中,后端可以做校验。
缺点是文件流量经过 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 }>()
后面就能通过:
访问 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) => { })
文件访问权限不要只靠“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 业务文件:必须保存数据库记录
上传不是“存文件”这么简单。
它是一个权限、存储、网络、缓存、清理和安全问题的集合。
先把边界设计好,后面才不会越写越乱。
参考资料