水桶只是一个说HTTP的文件夹
这个博客上的每一张图片和视频都住在MinIO里,一个S3兼容的对象存储,你可以自己运行而不是付钱给亚马逊。我多年来听过"S3"被基本上当作"云存储"的同义词在用,从来不需要知道它实际上是什么。结果是心理模型几乎是侮辱性地简单:一个水桶是一个命名的容器,一个对象是里面有个键的文件(基本上是个路径),你通过纯HTTP和一个访问密钥和密钥说话而不是,说,一个数据库连接字符串。Docker compose第一次启动时旋转起来两个水桶:
minio-init:
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} &&
mc mb --ignore-existing local/media &&
mc mb --ignore-existing local/avatars &&
mc anonymous set download local/media &&
mc anonymous set download local/avatars
"media和avatars,都设置为公开下载所以任何人都可以直接看图片URL,但没有人可以列出或写入他们没有实际的凭证。博客和auth服务是仅有的两个东西持有这些的。
什么在文件保存之前被实际检查
上传不是"取字节并把他们递给MinIO"。博客服务嗅探任何发送过来的东西的前512字节来弄清楚它的真实内容类型,而不是相信文件扩展名或浏览器碰巧声称的:
head := make([]byte, 512)
n, err := io.ReadFull(part, head)
...
head = head[:n]
contentType, ext, ok := media.DetectType(head, part.FileName())
if !ok {
writeFieldError(w, r, http.StatusUnprocessableEntity, "unsupported_media_type", "file type not allowed", "file")
return
}
maxBytes := s.MaxImageBytes
if strings.HasPrefix(contentType, "video/") {
maxBytes = s.MaxVideoBytes
}重命名.exe为photo.png而扩展名检查会高兴地相信你,但嗅探文件前面的实际字节签名不会。图片和视频得到不同的大小上限,一张图片最多10MB,一个视频得到整个1GB,这在我实际想过手机视频片段相对照片重多少后变得更有意义了。
头像得到小的、严格的对待
个人资料图片通过一个远比文章媒体更紧的上限,在auth服务上2个mebibytes,听起来刻薄直到你记起大多数手机相机在不费力情况下产生的照片是这个大小的五到十倍。而不是仅仅拒绝太大的东西并让某人去找一个图片编辑器,个人资料表格尝试为你缩小它,完全在浏览器里,在它被发送之前:
/** 重新编码的头像的最长边目标。 */
export const AVATAR_MAX_SIDE = 512;
/** 镜像auth服务的maxAvatarBytes (2 MiB)。 */
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
export function targetDimensions(
width: number,
height: number,
maxSide: number = AVATAR_MAX_SIDE
): { width: number; height: number } {
if (width <= 0 || height <= 0) return { width, height };
const longest = Math.max(width, height);
if (longest <= maxSide) return { width, height };
const scale = maxSide / longest;
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale))
};
}这是一个渐进增强,不是保证。如果canvas不可用,或浏览器完全关闭了JavaScript,原始文件就直接上去未触碰而服务器自己的上限是真正的保险丝反正一样,和以前一样。缩减纯粹是那样常见情况,某人的默认手机照片,永远不会碰到那道墙。
漏洞:上传在正好3MB时死掉
有一段时间,任何超过一定大小的上传就失败了,没有有用的错误,只是一个死的请求。一直在大约同样的大小发生,大概在3MB左右,这自己很可疑因为我在Go代码里的实际上限都没接近这么低。
在博客的上传处理器里花了一个尴尬的时间量,确信我配错了MaxImageBytes或多部分读取器什么的,添加日志从来没有一次触发过,因为请求从来没有到达博客。实际上的上限住在一个更外的层,在Caddy,它作为反向代理坐在所有东西前面:
# 之前:一个对整个admin API的大盖子,包括媒体上传
handle /api/admin/* {
request_body {
max_size 3MB
}
reverse_proxy frontend:3000
}Caddy在它到达前端容器之前,更别说博客,就限制了请求体。一个3MB的照片碰到了那道墙并直接被拒绝了,一个空的、泛用的413,没有博客否则会发送的好看JSON错误,因为博客从来没有有机会运行。我在错的服务的代码里盯着看一整个晚上。
通过给媒体上传他们自己的、远大得多的、路径匹配来固定。Caddy排序这些块按路径有多具体而不是文件顺序,所以更具体的/api/admin/media*匹配赢过一般admin块甚至尽管它在文件里坐在上面:
# 大上传路径:admin媒体和头像上传需要足够的房间给
# 合法上传(服务级上限停留在头像2MiB,媒体10MB/1GB)。
handle /api/admin/media* {
request_body {
max_size 1100MB
}
reverse_proxy frontend:3000
}
# API的其余部分(文章、评论、用户、报告等)需要
# 上面的2MB默认admin文章体的headroom(服务级
# 上限是4MB),但远低于上面的媒体块。
handle /api/admin/* {
request_body {
max_size 5MB
}
reverse_proxy frontend:3000
}责备错的服务,和真正的上限实际上住在哪
粘住的教训:这个大小的请求在任何我自己的应用代码甚至运行之前通过三个分离层,Caddy的max_size,然后SvelteKit自己的BODY_SIZE_LIMIT,然后最后博客自己的MaxImageBytes/MaxVideoBytes检查。这三个中任何一个都可以拒绝请求,而你看到的哪个错误完全取决于哪个层首先说不了。前端有一个小段代码特别是为了停止那混淆泄漏给实际上传的人:
// 一个真实的网络失败(DNS、连接拒绝)在链的任何地方没有`status`
// 这是两个如何被区分的不需要SvelteKit导出内部错误类给`instanceof`检查。
function bodyTooLargeError(thrown: unknown): { status?: unknown; message?: unknown } | undefined {
const direct = errorLike(thrown);
if (direct?.status === 413) return direct;
const cause = direct ? errorLike((direct as { cause?: unknown }).cause) : undefined;
if (cause?.status === 413) return cause;
return undefined;
}无论哪一层实际拒绝了文件,上传的人只看到"上传太大了",永远不是"服务不可用",这是一个原始丢弃的连接否则看起来会像。取了一个真的坏掉的上传路径,和一个晚上盯着错的日志,来让我关心那区别。
