news 2026/9/12 15:24:13

Zulip 图片缩略图子系统深度解析:基于 libvips 的头像、Emoji 与消息图片处理全链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Zulip 图片缩略图子系统深度解析:基于 libvips 的头像、Emoji 与消息图片处理全链路

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):同步缩略 + 不可枚举的哈希文件名

两种分辨率与"哑端点"

头像只有两种规格:100x100500x500(后者称为 "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=BACKGROUNDbackground=[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 会:

  1. 立即把原始内容上传到 S3 或本地磁盘;
  2. 解析图片头部(header),创建 ImageAttachment 数据行,记录path_idcontent_typeoriginal_width_pxoriginal_height_pxframes,而thumbnail_metadata保持为空列表;
  3. 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-dimensionsdata-original-content-typedata-animateddata-transcoded-image等属性。

无论哪种情况,img标签都会编码原始尺寸是否动图,让客户端在视口中预留相应空间。

第 3 步:thumbnail worker 生成缩略图并静默更新消息

thumbnail worker 消费队列事件,核心流程(ensure_thumbnails,zerver/worker/thumbnail.py):

  1. select_for_update锁定ImageAttachment行(防止与按需渲染竞态;同时因为失败时可能删除该行,需要FOR UPDATE全锁);
  2. missing_thumbnails对比当前服务器配置的输出格式与行内已有的thumbnail_metadata,得出缺失集合;
  3. 从 S3/磁盘读取原始字节(save_attachment_contents);
  4. 对每个缺失格式调用pyvips.Image.thumbnail_buffer(只缩不放大,size=pyvips.Size.DOWN)生成缩略图;
  5. 上传缩略图到 S3/磁盘(store_message_attachment),路径规则为thumbnail/{path_id}/{format}(见 get_image_thumbnail_path);
  6. StoredThumbnailFormat追加进thumbnail_metadata并保存;
  7. 调用update_message_rendered_content:若已有消息引用该附件,则对所有消息做**"静默"更新**(silent update)——通过do_update_embedded_data推送给客户端,把 spinner 替换成图片。

动图的帧数截断也发生在这里:为到达IMAGE_MAX_ANIMATED_PIXELS预算,计算每帧像素与所需帧数,通过option_stringn=-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):

  1. 先验证请求的thumbnail_format(形如840x560.webp840x560-anim.webp)是当前配置下的合法格式(解析规则见 BaseThumbnailFormat.from_string);
  2. 若不合法,服务器可返回任意其他缩略图closest_thumbnail_format会优先匹配"动画属性一致 → 扩展名一致 → 客户端Accept头接受且质量分高 → 尺寸最接近 → 字节数最小"的候选项(zerver/views/upload.py);
  3. 若请求的是受支持但尚未生成的格式(例如服务器日后新增了支持的格式集合,而历史图片的thumbnail_metadata中没有它),则服务器同步、按需地生成并存储该格式后再返回——期间对行加锁,避免与后台 worker 重复劳动(ensure_thumbnails在极端失败场景可能删除ImageAttachment行,因此使用完整FOR UPDATE锁);
  4. 最终按本地磁盘或 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 15:22:38

AI如何用多模态技术重构学术PPT制作

1. 项目概述&#xff1a;AI如何重构学术汇报体验虎贲等考AI PPT功能正在颠覆传统学术汇报的制作模式。这个工具的核心价值在于将复杂的学术内容结构化、可视化&#xff0c;同时大幅降低制作门槛。想象一下&#xff0c;以往需要花费数小时调整格式、设计排版的PPT&#xff0c;现…

作者头像 李华
网站建设 2026/9/12 15:21:44

前端UMD模块方案:实现跨环境兼容的JavaScript库

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 15:19:56

预训练模型微调:知识迁移与高效实践

1. 预训练与微调的技术演进脉络预训练语言模型的发展经历了从静态词向量到动态上下文表示的重大跨越。早期的Word2Vec、GloVe等模型只能生成静态词向量&#xff0c;无法处理一词多义现象。2018年诞生的BERT首次展示了大规模预训练的威力&#xff0c;通过掩码语言建模&#xff0…

作者头像 李华
网站建设 2026/9/12 15:18:21

H3502高压降压芯片:100V输入高频小体积电源设计指南

1. 项目概述&#xff1a;为什么H3502在高压降压场景中成了“小体积高频方案”的代名词 我第一次在工业现场看到H3502&#xff0c;是在一台户外光伏汇流箱的辅助电源板上——输入电压标称96V&#xff08;实测波动范围85–105V&#xff09;&#xff0c;要稳定输出12V/2A给PLC和无…

作者头像 李华
网站建设 2026/9/12 15:17:08

PHP闭包、生成器与属性三大特性深度解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华