ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

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缩略图引擎选型与安全边界为什么选择 libvipsZulip 使用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.pyDEFAULT_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.pystore_single_avatar_image分别写入file_path .original原始图供日后重新缩略、get_avatar_path(file_path, mediumFalse)100x100 PNG与get_avatar_path(file_path, mediumTrue)500x500 PNG。不可枚举的文件名AVATAR_SALT 用户 ID 版本号文件名由服务器通过哈希生成核心实现在 zerver/lib/avatar_hash.pydef 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_stringn-1加载全部帧若源图非方形则对每一帧用透明背景extendBACKGROUND、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.pyhash_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与图标IconsRealm logos统一转换为 PNG并缩小到800x100 的边界框内保持纵横比不添加留白。文档示例1000x10 的图会变为 800x810x20 的图保持 10x20。实现见 resize_logopyvips.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_eventsTrue跳过立即派发避免与消息渲染产生竞态。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(urlNone, ...)并重新入队非空时通过get_default_thumbnail_url选择默认缩略图。HTML 重写则通过rewrite_thumbnailed_images/process_inline_images_to_thumbnailszerver/lib/thumbnail.py完成占位图会被替换为真正的缩略图 URL并写入data-original-dimensions、data-original-content-type、data-animated、data-transcoded-image等属性。无论哪种情况img标签都会编码原始尺寸与是否动图让客户端在视口中预留相应空间。第 3 步thumbnail worker 生成缩略图并静默更新消息thumbnail worker 消费队列事件核心流程ensure_thumbnailszerver/worker/thumbnail.py以select_for_update锁定ImageAttachment行防止与按需渲染竞态同时因为失败时可能删除该行需要FOR UPDATE全锁missing_thumbnails对比当前服务器配置的输出格式与行内已有的thumbnail_metadata得出缺失集合从 S3/磁盘读取原始字节save_attachment_contents对每个缺失格式调用pyvips.Image.thumbnail_buffer只缩不放大sizepyvips.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{帧数}静态输出格式则传n1只取第一帧。当前默认输出格式与转码格式zerver/lib/thumbnail.py 定义了服务器当前生成的格式集THUMBNAIL_OUTPUT_FORMATS ( ThumbnailFormat(webp, 840, 560, animatedTrue), ThumbnailFormat(webp, 840, 560, animatedFalse), ) TRANSCODED_IMAGE_FORMAT ThumbnailFormat(webp, 4032, 3024, animatedFalse)默认缩略图特意做得较大840x560这样不理解缩略图协议的老客户端如移动端拿到的图不会显得像素化也便于 Web 端 lightbox 在加载原图前临时放大显示转码格式TRANSCODED_IMAGE_FORMAT4032x3024仅对可缩略但浏览器不能直接内联渲染的类型即不在INLINE_MIME_TYPES中的类型如 TIFF额外生成竖图时宽高会互换见missing_thumbnails中 L317-L332 的 portrait 处理。服务器可解析的类型白名单为THUMBNAIL_ACCEPT_IMAGE_TYPESzerver/lib/thumbnail.pyavif、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),仅供参考
返回列表