Thumbnailing
libvips
Zulip uses the libvips
image processing toolkit
for thumbnailing, as a low-memory and high-performance image processing
library. Some smaller images are thumbnailed synchronously inside the Django
process, but the majority of the work is offloaded to one or more thumbnail
worker processes.
Thumbnailing is a notoriously high-risk surface from a security standpoint,
since it parses arbitrary binary user input with often complex grammars. On
versions of libvips
which support it (>= 8.13, on or after Ubuntu 24.04 or
Debian 12), Zulip limits libvips
to only the image parsers and libraries whose
image formats we expect to parse, all of which are fuzz-tested by
oss-fuzz
.
Avatars
Avatar images are served at two of potential resolutions (100x100 and 500x500, the latter of which is called “medium”), and always as PNGs. These are served from a “dumb” endpoint – that is, if S3 is used, we provide a direct link to the content in the S3 bucket (or a Cloudfront distribution in front of it), and the request does not pass through the Zulip server. This is because avatars are referenced in emails, and thus their URLs need to be permanent and publicly-accessible. This also means that any choice of resolution and file format needs to be entirely done by the client.
Avatars are thumbnailed synchronously upon upload into 100x100 and 500x500 PNGs; the originals are not preserved. The smallest dimension is scaled to fit, and the largest dimension is cropped centered; the image may be scaled up to fit the 100x100 or 500x500 dimensions. To generate the filename, the server hashes the avatar salt (a server-side secret), the user-id, and a per-user sequence (the “version”) to produce a filename which is not enumerable, and can only be determined by the server. Hashing the version means that avatars can be served with long-lasting caching headers.
The original avatar image is stored adjacent to the thumbnailed versions, enabling later re-thumbnailing to other dimensions or formats without requiring users to re-upload it.
Emoji
Emoji URLs are hard-coded into emails, and as such their URLs need to be permanent and publicly-accessible. They are served at a consistent 1:1 aspect ratio, and while they may be rendered at different scales based on the line-height of the client, we only need to store them at one resolution.
Emoji are thumbnailed synchronously into 64x64 images, and they are saved in the same file format that they were uploaded in. Transparent pixels are added to the smaller dimension to make the image square after resizing. The filename of the emoji is based on a hash of the avatar salt (a server-side secret) and the emoji’s id – but because the filename is stored in the database, it can be anything with sufficient entropy to not be enumerable or have collisions.
For animated emoji, a separate “still” version of the emoji is generated from the first frame, as a 64x64 PNG image. This is not currently used, but is intended to be part of a user preference to disable emoji animations (see #13434).
The original emoji is stored adjacent to the thumbnailed version, enabling later re-thumbnailing to other dimensions or formats without requiring users to re-upload it.
There is no technical reason that we preserve the uploader’s choice of file format, or that we use PNGs as the file format for the “still” version. Both of these would plausibly benefit from being WebP images.
Realm logos
Realm logos are converted to PNGs, thumbnailed down to fit within 800x100; a 1000x10 pixel image will end up as 800x8, and a 10x20 will end up 10x20. The original is stored adjacent to the converted thumbnail.
Realm icons
Realm icons are converted to PNGs, and treated identical to avatars, albeit only producing the 100x100 size.
File uploads
Images
When an image file (as determined by the browser-supplied content-type) is
uploaded, we immediately upload the original content into S3 or onto disk. Its
headers are then examined, and used to create an ImageAttachment row, with
properties determined from the image; thumbnailed_metadata
is left empty. A
task is dispatched to the thumbnail
worker to generate thumbnails in all of
the format/size combinations that the server currently has configured.
Because we parse the image headers enough to extract size information at upload time, this also serves as a check that the upload is indeed a valid image. If the image is determined to be invalid at this stage, the file upload returns 200, but the message content is left with a link to the uploaded content, not an inline image.
When a message is sent, it checks the ImageAttachment rows for each referenced
image; if they have a non-empty thumbnailed_metadata
, then it writes out an
img
tag pointing to one of them (see below); otherwise, it writes out a
specially-tagged “spinner” image, which indicates the server is still processing
the upload. The image tag encodes the original dimensions and if the image is
animated into the rendered content so clients can reserve the appropriate space
in the viewport.
If a message is rendered with a spinner, it also inserts the image into the
thumbnail
worker’s queue. This is generally redundant – the image was
inserted into the queue when the image was uploaded. The exception is if the
image was uploaded prior to the existence of thumbnailing support, in which case
the additional is required to have the spinner ever resolve. Since the worker
takes no action if all necessary thumbnails already exist, this has little cost
in general.
The thumbnail
worker generates the thumbnails, uploads them to S3 or disk, and
then updates the thumbnailed_metadata
of the ImageAttachment row to contain a
list of formats/sizes which thumbnails were generated in. At the time of commit,
if there are already messages which reference the attachment row, then we do a
“silent” update of all of them to remove the “spinner” and insert an image.
In either case, the image which is inserted into the message body is at a “reasonable” scale and format, as decided by the server. The paths to all the generated thumbnails are not specified in the message content – instead, the client is told at registration time the set of formats/sizes which the server supports, and knows how to transform any single thumbnailed path into any of the other supported thumbnail variants. The client is responsible for choosing the most appropriate format/size based on viewport size and format support, and rewriting the URL accordingly.
All requests for images go through /user_uploads
, which is processed by
Django. Any request for an ImageAttachment URL is first determined to be a valid
format/size for the server’s current configuration; if is not valid, the server
may return any other thumbnail of its choosing (preferring similar sizes, and
accepted formats based on the client’s Accepts
header).
If the request is for a thumbnail format/size which is supported by the server,
but not in the ImageAttachment’s thumbnailed_metadata
(as would happen if the
server’s supported set is added to over time) then the server should generate,
store, and return the requested format/size on-demand.
Migrations
Historical image uploads have ImageAttachment rows generated for them, but not thumbnails. If the message content is re-rendered (for instance, due to being edited) then it will trigger the image to be thumbnailed.
Videos and PDFs
The thumbnailing system only processes images; it does not transcode videos or produce image renderings of documents (e.g., PDFs), though those are natural potential extensions.