Zulip 图片缩略图子系统深度解析:基于 libvips 的头像、Emoji 与消息图片处理全链路
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
导读
本文以 Zulip 的 缩略图子系统设计文档 为主线,结合仓库源码,系统讲解 Zulip 如何基于 libvips 这一低内存、高性能的图像处理库,完成头像(Avatar)、Emoji、Realm 徽标/图标以及消息中上传图片的缩略、转码与分发。读者将理解 Zulip 缩略图体系的架构分层、异步 worker 处理流程、按需(on-demand)生成机制、客户端协议以及安全加固策略,并掌握关键常量与配置参数的取值范围与含义。
libvips:缩略图引擎选型与安全边界
为什么选择 libvips
Zulip 使用libvips图像处理工具包来完成缩略图工作,选择它的核心原因是"低内存 + 高性能":libvips 采用流式、分块(tile-based)的内存管理方式,处理大图时内存占用远低于同类工具,并支持 shrink-on-load 优化(加载时即缩小,避免完整解码)。在 Zulip 中,小尺寸图像(头像、Emoji、Realm 图标/徽标)会在 Django 进程内同步完成缩略;而消息中的图片上传则大部分交给一个或多个thumbnailworker 进程异步处理。
缩略图是高危攻击面
缩略化处理需要解析任意二进制的用户上传内容,而这些格式的语法往往非常复杂,因此它是公认的高风险攻击面。Zulip 的应对策略体现在 zerver/lib/thumbnail.py 中:
# This is what enforces security limitations on which formats are # parsed; we disable all loaders, then re-enable the ones we support # -- then explicitly disable any "untrusted" ones, in case libvips for # some reason marks one of the above formats as such (because they are # no longer fuzzed, for instance). pyvips.operation_block_set("VipsForeignLoad", True) pyvips.operation_block_set("VipsForeignLoadHeif", False) # image/avif, image/heic pyvips.operation_block_set("VipsForeignLoadNsgif", False) # image/gif pyvips.operation_block_set("VipsForeignLoadJpeg", False) # image/jpeg pyvips.operation_block_set("VipsForeignLoadPng", False) # image/png pyvips.operation_block_set("VipsForeignLoadTiff", False) # image/tiff pyvips.operation_block_set("VipsForeignLoadWebp", False) # image/webp pyvips.block_untrusted_set(True)这段代码的逻辑是:先屏蔽 libvips 的全部加载器,再按白名单逐一放行 Zulip 支持解析的格式,最后通过block_untrusted_set(True)显式禁用任何被 libvips 标记为 "untrusted"(如不再接受 oss-fuzz 模糊测试)的加载器。需要特别注意的是,该能力依赖 libvips >= 8.13(对应 Ubuntu 24.04 及以后、Debian 12 及以后);在更早的 libvips 版本上,这些调用是空操作(no-op),无法提供同样级别的防护。所有放行的格式(AVIF/HEIC、GIF、JPEG、PNG、TIFF、WebP)均由 oss-fuzz 持续模糊测试。
代码还做了两点额外加固:
- 禁用操作缓存:
pyvips.voperation.cache_set_max(0),因为系统唯一的使用场景是thumbnail_buffer,它并不利用该缓存; - 针对"图片炸弹"(image bomb,超大像素图导致的解压内存耗尽)设置了硬性上限(见下节)。
图片大小限制与校验
zerver/lib/thumbnail.py 定义了三个关键常量:
IMAGE_BOMB_TOTAL_PIXELS = 90000000 # 约 9000 万像素,24-bit 图约 1/4 GB IMAGE_MAX_ANIMATED_PIXELS = IMAGE_BOMB_TOTAL_PIXELS / 3 # 动图预算为总额的 1/3 MAX_EMOJI_GIF_FILE_SIZE_BYTES = 128 * 1024 # Emoji GIF 处理后的 still 帧上限 128KB校验逻辑位于libvips_check_image上下文管理器(zerver/lib/thumbnail.py):
- 静态图:
width * height > IMAGE_BOMB_TOTAL_PIXELS则拒绝; - 动图(不截断动画,如 Emoji,因为原始文件永远不会直接发给客户端,必须保留完整动画):按所有帧累计像素
width * height * get_n_pages()计算; - 动图(截断动画,如图片上传的缩略图):要求能至少渲染 3 帧,且开销不超过
IMAGE_MAX_ANIMATED_PIXELS,即width * height * min(3, pages) <= IMAGE_MAX_ANIMATED_PIXELS; - 校验失败统一抛出
BadImageError(错误码BAD_IMAGE),并返回可读的用户提示。
头像(Avatars):同步缩略 + 不可枚举的哈希文件名
两种分辨率与"哑端点"
头像只有两种规格:100x100与500x500(后者称为 "medium"),且永远输出为PNG。源码常量见 zerver/lib/thumbnail.py:
DEFAULT_AVATAR_SIZE = 100 MEDIUM_AVATAR_SIZE = 500头像通过"哑端点"(dumb endpoint)对外提供:如果启用了 S3,则直接给出 S3 bucket(或前置的 Cloudfront 分发)中的内容链接,请求不经过 Zulip 服务器。这样设计的原因在于:头像 URL 会写进邮件中,因此必须永久有效且公开可访问;相应地,选择何种分辨率与文件格式就完全由客户端负责。
上传时的同步缩略
头像在上传时同步缩略为 100x100 与 500x500 的 PNG,原始文件不保留(保留的是转换后的原图,见下文)。缩略策略(resize_avatar):
- 使用
pyvips.Interesting.CENTRE中心裁剪:最短边缩放至目标尺寸,最长边居中裁剪,因此 1x1000 的图会把中间像素放大填满整个方块; - 允许放大(scale up)以填满 100x100 或 500x500;
- 该尺寸策略必须与客户端代码 web/upload_widget.ts 保持同步(源码注释明确要求)。
写入路径见 zerver/lib/upload/init.py:store_single_avatar_image分别写入file_path + ".original"(原始图,供日后重新缩略)、get_avatar_path(file_path, medium=False)(100x100 PNG)与get_avatar_path(file_path, medium=True)(500x500 PNG)。
不可枚举的文件名:AVATAR_SALT + 用户 ID + 版本号
文件名由服务器通过哈希生成,核心实现在 zerver/lib/avatar_hash.py:
def user_avatar_hash(uid: str, version: str) -> str: # The salt prevents unauthenticated clients from enumerating the # avatars of all users. user_key = uid + ":" + version + ":" + settings.AVATAR_SALT return hashlib.sha256(user_key.encode()).hexdigest()[:40]settings.AVATAR_SALT是服务端密钥,使文件名不可枚举(无法遍历全部用户头像),只能由服务器确定;- 哈希中包含
avatar_version(每用户递增的版本号),意味着用户更换头像后文件名随之变化,从而可以放心地对头像 URL 使用长缓存头而不会让客户端拿到陈旧内容; - 路径格式为
{realm_id}/{user_id_hash}(user_avatar_base_path_from_ids),历史迁移见 zerver/migrations/0544_copy_avatar_images.py。
保留原始图以便重新缩略
<hash>.original原图与缩略图存储在同一位置(相邻路径),这样未来若要新增分辨率或切换格式,无需用户重新上传即可重新缩略(例如 zerver/migrations/0544_copy_avatar_images.py 就利用原始图把旧头像重新处理为多分辨率)。JDENTICON(默认头像)同样生成 100 与 500 两种规格,见 zerver/lib/avatar.py。
Emoji:同步缩略、方形化与动图 still 帧
为什么 Emoji 只需要一个分辨率
Emoji 的 URL 同样被硬编码进邮件,必须永久且公开可访问。它们以一致的1:1 宽高比提供,虽然客户端会依据行高(line-height)以不同尺寸渲染,但服务器只需存储一个分辨率即可。
缩略规则
Emoji 在上传时同步缩略为64x64(常量DEFAULT_EMOJI_SIZE = 64),并保留上传时的文件格式(GIF 仍是 GIF、PNG 仍是 PNG)。实现见 resize_emoji:
- 静态图:中心裁剪填满 64x64;
- 动图:用
option_string="n=-1"加载全部帧;若源图非方形,则对每一帧用透明背景(extend=BACKGROUND、background=[0,0,0,0])补透明像素使其变为方形,然后用pagejoin重新拼合成动画。文档中"透明像素添加到较小维度以补成方形"正指此逻辑; - 同时从第一帧生成一张 64x64 的PNG still 图(
first_still)。
still 帧的用途与现状
still 版本当前"大部分未被使用",其设计目标是服务于"禁用 Emoji 动画"的用户偏好(参见 issue #13434)。目前唯一的实际使用场景是用户状态(user status)显示:当用户用动画 Emoji 作为状态时,使用的就是 still 帧。文档同时指出:保留上传者选择的文件格式、以及 still 帧固定使用 PNG,都没有技术上的必然性,未来两者都更适合改为 WebP。
文件名:哈希 + Emoji ID
与头像类似,Emoji 文件名基于"AVATAR_SALT + emoji_id"的 SHA-256 哈希生成(zerver/lib/emoji.py):
hash_key = settings.AVATAR_SALT.encode() + b":" + str(emoji_id).encode() return "".join((hashlib.sha256(hash_key).hexdigest()[0:8], image_ext))哈希截取 8 个字符(约 32 位熵),足以使枚举和碰撞"几乎不可能";文件名会存入数据库,因此只要熵足够即可。Emoji 的原始图与缩略图相邻存储,同样支持未来无需重新上传的再缩略。
Realm 徽标(Logos)与图标(Icons)
- Realm logos:统一转换为 PNG,并缩小到800x100 的边界框内(保持纵横比,不添加留白)。文档示例:1000x10 的图会变为 800x8,10x20 的图保持 10x20。实现见 resize_logo:
pyvips.Size.DOWN表示只缩小不放大。原始图同样与转换结果相邻存储; - Realm icons:转换为 PNG 后,处理方式与头像完全一致,但只生成 100x100一个尺寸(
resize_realm_icon直接调用resize_avatar)。本地磁盘与 S3 两种存储后端均调用该逻辑(zerver/lib/upload/local.py、zerver/lib/upload/s3.py)。
消息图片上传:从上传到 spinner 再到静默更新
这是整个缩略图系统中流程最复杂、也最能体现设计思想的部分,涉及 Django 进程与thumbnailworker 的分工协作。
第 1 步:上传时创建 ImageAttachment 并派发任务
当用户上传一个图片文件(按浏览器提供的 content-type 判定)时,Zulip 会:
- 立即把原始内容上传到 S3 或本地磁盘;
- 解析图片头部(header),创建 ImageAttachment 数据行,记录
path_id、content_type、original_width_px、original_height_px、frames,而thumbnail_metadata保持为空列表; - 向
thumbnail队列派发一个事件(queue_event_on_commit("thumbnail", {"id": ..., "path_id": ...}))。
入口函数是 maybe_thumbnail。注意三点:
- "解析头部即校验":既然要读取尺寸,这一步天然成为"上传物确实是合法图片"的检查。若图片无效,上传接口仍返回 200,但消息内容里只会留下指向原始文件的链接,而不是内联图片;
- 尺寸在存储前会应用 EXIF orientation 修正(orientation 5-8 时宽高互换,见代码 L372-L379);
- 在导入(import)场景下可通过
skip_events=True跳过立即派发,避免与消息渲染产生竞态。
thumbnail_metadata是 JSONB 字段,其中存储的是StoredThumbnailFormat对象的序列化结果——它同时包含extension / max_width / max_height / animated与实际的content_type / width / height / byte_size。源码注释明确警告:该字段被序列化进数据库,字段不可删除,否则需要迁移(zerver/lib/thumbnail.py)。
第 2 步:发送消息时决定渲染 spinner 还是图片
发送消息时,系统检查每条被引用图片对应的ImageAttachment行:
thumbnail_metadata非空:在消息体中写出指向某个缩略图的img标签;thumbnail_metadata为空:写出一个带特殊标记的spinner(加载占位图),表示服务器仍在处理上传。
渲染逻辑见 manifest_and_get_user_upload_previews:空元数据时生成MarkdownImageMetadata(url=None, ...)并重新入队;非空时通过get_default_thumbnail_url选择"默认"缩略图。HTML 重写则通过rewrite_thumbnailed_images/process_inline_images_to_thumbnails(zerver/lib/thumbnail.py)完成,占位图会被替换为真正的缩略图 URL,并写入data-original-dimensions、data-original-content-type、data-animated、data-transcoded-image等属性。
无论哪种情况,img标签都会编码原始尺寸与是否动图,让客户端在视口中预留相应空间。
第 3 步:thumbnail worker 生成缩略图并静默更新消息
thumbnail worker 消费队列事件,核心流程(ensure_thumbnails,zerver/worker/thumbnail.py):
- 以
select_for_update锁定ImageAttachment行(防止与按需渲染竞态;同时因为失败时可能删除该行,需要FOR UPDATE全锁); missing_thumbnails对比当前服务器配置的输出格式与行内已有的thumbnail_metadata,得出缺失集合;- 从 S3/磁盘读取原始字节(
save_attachment_contents); - 对每个缺失格式调用
pyvips.Image.thumbnail_buffer(只缩不放大,size=pyvips.Size.DOWN)生成缩略图; - 上传缩略图到 S3/磁盘(
store_message_attachment),路径规则为thumbnail/{path_id}/{format}(见 get_image_thumbnail_path); - 把
StoredThumbnailFormat追加进thumbnail_metadata并保存; - 调用
update_message_rendered_content:若已有消息引用该附件,则对所有消息做**"静默"更新**(silent update)——通过do_update_embedded_data推送给客户端,把 spinner 替换成图片。
动图的帧数截断也发生在这里:为到达IMAGE_MAX_ANIMATED_PIXELS预算,计算每帧像素与所需帧数,通过option_string传n=-1(全部帧)或n={帧数};静态输出格式则传n=1只取第一帧。
当前默认输出格式与转码格式
zerver/lib/thumbnail.py 定义了服务器当前生成的格式集:
THUMBNAIL_OUTPUT_FORMATS = ( ThumbnailFormat("webp", 840, 560, animated=True), ThumbnailFormat("webp", 840, 560, animated=False), ) TRANSCODED_IMAGE_FORMAT = ThumbnailFormat("webp", 4032, 3024, animated=False)- 默认缩略图特意做得较大(840x560):这样不理解缩略图协议的老客户端(如移动端)拿到的图不会显得像素化,也便于 Web 端 lightbox 在加载原图前临时放大显示;
- 转码格式(
TRANSCODED_IMAGE_FORMAT,4032x3024):仅对"可缩略但浏览器不能直接内联渲染"的类型(即不在INLINE_MIME_TYPES中的类型,如 TIFF)额外生成,竖图时宽高会互换(见missing_thumbnails中 L317-L332 的 portrait 处理)。
服务器可解析的类型白名单为THUMBNAIL_ACCEPT_IMAGE_TYPES(zerver/lib/thumbnail.py):avif、gif、heic、jpeg、png、tiff、webp。源码注释特别强调:该列表不提供任何安全性(content-type 由浏览器提供,可能与实际字节不符),真正的安全边界是上面提到的 libvips 加载器白名单;且此列表必须与客户端 web/src/upload.ts 保持同步。
客户端协议:由客户端决定最终格式/尺寸
消息内容中并不写明所有缩略图路径。相反:
- 客户端在注册(registration)时被告知服务器支持的格式/尺寸集合;
- 客户端学会如何把任何一个缩略图路径变换为其他受支持的变体;
- 由客户端根据视口大小与格式支持能力自行选择最合适的格式/尺寸,并重写 URL。
这正是"客户端负责最终决策"的协议设计,使 Zulip 无需在消息体里维护冗长的路径列表。
/user_uploads 请求处理与按需生成
所有图片请求都经过/user_uploads,由 Django 处理(核心在 zerver/views/upload.py 的serve_file):
- 先验证请求的
thumbnail_format(形如840x560.webp、840x560-anim.webp)是当前配置下的合法格式(解析规则见 BaseThumbnailFormat.from_string); - 若不合法,服务器可返回任意其他缩略图:
closest_thumbnail_format会优先匹配"动画属性一致 → 扩展名一致 → 客户端Accept头接受且质量分高 → 尺寸最接近 → 字节数最小"的候选项(zerver/views/upload.py); - 若请求的是受支持但尚未生成的格式(例如服务器日后新增了支持的格式集合,而历史图片的
thumbnail_metadata中没有它),则服务器同步、按需地生成并存储该格式后再返回——期间对行加锁,避免与后台 worker 重复劳动(ensure_thumbnails在极端失败场景可能删除ImageAttachment行,因此使用完整FOR UPDATE锁); - 最终按本地磁盘或 S3 后端(
serve_local/serve_s3)返回。
此外还有两个辅助端点:
- backend_serve_thumbnail:旧式
/thumbnailURL 的向后兼容入口,不再支持对任意外部 URL 做 Camo 代理(一律 403),仅对user_uploads/...形式放行并直接 serve 原文件; - check_thumbnail_status:供客户端轮询"缩略图是否就绪",通过
missing_thumbnails判断has_thumbnail。
历史迁移(Migrations)与兼容性
历史遗留的上传文件会被补建ImageAttachment行,但没有缩略图。若消息内容被重新渲染(例如被编辑),则会触发该图片的缩略流程——这正是消息渲染时"再次入队"的意义:manifest_and_get_user_upload_previews对空元数据图片重新queue_event_on_commit,而 worker 端missing_thumbnails会先检查已存在的缩略图,若全部已生成则直接跳过,因此这个冗余入队几乎没有成本(对应文档所述"如果所有必要缩略图都已存在,worker 不采取任何动作")。相关测试覆盖了历史图片的重新缩略(zerver/tests/test_markdown_thumbnail.py 与 L585 等用例)。
边界:视频与 PDF 暂不支持
当前缩略图系统只处理图片:它不会转码视频,也不会为文档(如 PDF)生成图像渲染。文档明确指出,这两者是"自然的潜在扩展方向",但尚未实现。
测试与验证
仓库对缩略图子系统有系统性的测试覆盖,可作为理解行为契约的参考:
- zerver/tests/test_thumbnail.py:涵盖缩略重定向端点、Emoji 缩略、
missing_thumbnails的格式匹配(含缺失 content-type 的边界)、maybe_thumbnail、缩略图检索、缩略状态端点等; - zerver/tests/test_markdown_thumbnail.py:覆盖发送后缩略、内联图片缩略、转义、重复/顺序编辑、坏图、多消息、竞态(race)、历史图片、转码、卡住重新缩略等场景;
- zerver/tests/test_delete_unclaimed_attachments.py:验证删除未使用缩略图的清理逻辑。
小结
Zulip 的缩略图体系是一条清晰的三层流水线:上传时的头部校验与入队 → worker 的批量异步生成与消息静默更新 → 请求时的协议校验与按需兜底生成。其设计要点可以总结为:
- 安全第一:libvips 加载器白名单 + oss-fuzz + 图片炸弹像素上限 + 不可枚举的哈希文件名;
- 高低搭配:小图(头像/Emoji/徽标/图标)同步处理,大图(消息图片)异步 worker 处理;
- 客户端协议化:服务器只负责生成与存储,格式/尺寸的最终选择权在客户端,并通过注册时下发的能力集 + URL 变换规则实现;
- 永久公开 URL:头像与 Emoji 因会进入邮件而走"哑端点",只保留必要分辨率,同时保留原始图以备重新缩略。
理解了这条链路,无论是排查"图片一直显示 spinner"、"缩略图格式不对"还是"新格式如何加入",都可以沿着maybe_thumbnail → ThumbnailWorker/ensure_thumbnails → serve_file这条主线快速定位。
【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考