私人文件保险箱(二):网页上传 200 MB,Flask 和 Nginx 之间要配些什么
上一篇写的是这个小站的权限部分:用 HMAC 无状态 Token 分出管理员和临时账户两种角色。这一篇写上传本身。需求很朴素——在自己小站的私密目录里,浏览器点一下就能把文件传上去,200 MB 不断线,而且每个文件能写一句「这是干什么用的」。听起来简单,真正要踩的是链路上每一环的默认行为。
一、整条链路只有两个进程
浏览器到 Nginx(TLS 终结在这里),Nginx 再转发到回环地址上的 Flask。后端不听公网端口,复用已经开着的 443,不额外在防火墙上开洞:
app.run(host='127.0.0.1', port=8791)
这条约束比参数本身重要:对外只有一个入口,上传、下载、登录全走同一条反代路径,机器上少一个监听端口,就少一个被扫到的面。
二、大小限制在两个地方,只配一个会得到很难看的失败
Flask 侧:
MAX_BYTES = int(cfg.get('max_upload_mb', 200)) * 1024 * 1024
app.config['MAX_CONTENT_LENGTH'] = MAX_BYTES
Nginx 侧:
client_max_body_size 200m;
两处必须对齐。只配 Flask 的话,超限请求根本到不了 Flask,Nginx 直接挡回一个 HTML 格式的 413 页面;前端 fetch 拿到的是整页 HTML 而不是 JSON,弹出来的错误让人完全猜不到原因。所以 Flask 这边还要把 413 也回成 JSON:
@app.errorhandler(413)
def too_large(e):
return jsonify({'error': '文件超过大小限制'}), 413
前后端都是自己写的,错误一律回 JSON,才能把人话直接显示给用户。
三、别直接 f.save(path):先写 .part,再原子改名
把上传的文件直接落到最终路径,中途断网、关页面、进程被杀都会留下一个半成品,而且这个名字还被它占着。改成先写临时文件,再在同目录 rename:
tmp = VAULT / ('u-%d-%s.part' % (os.getpid(), secrets.token_hex(4)))
try:
f.save(tmp)
if tmp.stat().st_size > MAX_BYTES:
abort(413, description='超过大小限制')
os.chmod(tmp, 0o640)
tmp.replace(path) # 同一文件系统内的 rename 是原子的
finally:
tmp.unlink(missing_ok=True)
两个细节:chmod 要在 rename 之前,否则文件会短暂以默认权限存在;rename 在同一文件系统内是原子操作,目录项要么没有、要么是完整文件,列表页永远读不到半个文件。以点开头的临时名也会被列表接口过滤掉,因为约定不列隐藏条目。
四、同名不覆盖,加时间戳
name = '%s-%s%s' % (Path(name).stem, time.strftime('%H%M%S'), Path(name).suffix)
私人文件库最常见的事故不是传不上去,而是把上一个同名文件静默盖掉了。用时间戳后缀而不是覆盖,可读性比 hash 前缀好,列表里一眼能看出先后。
五、文件名校验:黑名单不够,最后用目录归属兜底
guard_name 做三件事。第一,长度不超过 120、不以点开头、不以点或空格结尾。第二,把在 Windows、SMB、URL 里会二次出事的字符一次列全:尖括号、冒号、双引号、竖线、问号、星号、反斜杠,加上 0x00 到 0x1f 的控制字符,再单独拒绝斜杠、反斜杠和两个点。第三,也是最关键的:
path = (VAULT / name).resolve()
if path.parent != VAULT.resolve():
abort(400, description='文件名不合法')
前面所有字符规则都可能被某种新奇编码绕过,但归一化之后断言父目录就是存储目录,这一条是兜底。目录穿越在写入那一刻已经被堵死,而不是靠文件名碰运气。
六、大文件不超时:反代三个参数缺一不可
proxy_request_buffering off;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
不关 request buffering 时,Nginx 会把完整的 200 MB 先写进自己的临时目录再往后端送,等于双份磁盘占用加一倍等待;开了边收边转发,后端就能在接收过程中直接落盘。代价是 Nginx 没法再换 upstream 重试,所以上传必须幂等——刚好就是第三条那个 .part 加 rename。
七、每个文件配一句「这是干什么用的」
文件一多,光靠文件名真的想不起来它是什么。做法是把说明和文件名绑定,存在一个 notes.json 里,列表接口一起带出去。
def save_notes(notes):
tmp = VAULT / '.notes.tmp'
tmp.write_text(json.dumps(notes, ensure_ascii=False, indent=2))
os.chmod(tmp, 0o640)
tmp.replace(NOTES)
两个坑值得单独说。一,notes.json 本身躺在存储目录里,必须把它放进一个 BLOCKED 集合,否则它会被当成一个普通文件列出来,甚至能被下载接口拉走。二,删除文件时要同步把对应说明 pop 掉,不然这个 JSON 只会越长越脏,存的全是指向不存在文件的僵尸条目。
八、鉴权:服务端不存 session,只验签名
登录成功下发一个 HMAC 签名的 Token,带 30 天过期,字段里直接写用户名和角色;服务端不存会话,重启也不影响已登录的浏览器:
resp.set_cookie('vault_token', token, httponly=True, secure=True, samesite='Lax')
写操作(上传、改说明、删文件)额外检查一个自定义请求头,前端固定带上。它挡不住有心人,但能拦掉绝大多数表单伪造式的盲打,成本几乎为零。登录失败按来源 IP 计数,连续 5 次错锁 5 分钟;计数放在进程内存里,重启清零——单实例小站可以接受,但别把它当成防暴力破解的正解。
九、上线前照着点一遍的自查清单
- DevTools 里传一个 300 MB 的文件,拿到的应该是 JSON 的 413,而不是 Nginx 的 HTML 错误页。
- 传一个名字里带两个点的文件,应该返回 400,且服务器目录里真的没多出东西。
- 传输中途直接关掉页面,稍后目录里不应残留 .part 文件。
- 退出登录后直接访问文件列表接口,应该 401。
- 用临时账户调删除接口,应该 403。
- systemctl restart 一次,已上传的文件和它们的说明都还在。
这套东西没有任何高级的地方,全部是把会失败的姿势一个个堵掉。个人小站不需要对象存储,一台小机器、一个 Flask 文件、几行 Nginx 配置就够用,前提是你把边界——谁能传、传到哪、多大、叫什么名字——写成代码,而不是写在文档里。
- 点赞
- 收藏
- 关注作者
评论(0)