Skip to content

fix(thumbnails): serve saved thumbnails from disk again - #3829

Open
Vondry wants to merge 5 commits into
bolt:6.1from
Vondry:fix/thumbnails-serve-from-disk
Open

Vondry wants to merge 5 commits into
bolt:6.1from
Vondry:fix/thumbnails-serve-from-disk

Conversation

@Vondry

@Vondry Vondry commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Since 6.0.1 (5161020) save_files writes thumbnails to thumbs/<w>_<h>_<q>_<fit>_<location>/, a path no thumbnail URL points to. The web server never serves them, so every /thumbs/… request is handled by PHP.

This supersedes #3828, which grew well beyond that fix. Its other changes will follow as separate PRs.

Changes

  • Save thumbnails under the requested parameter string again, so Apache / Nginx serve the next request from disk. Thumbnail URLs don't change, and neither do public/.htaccess or the Nginx config. Nothing is saved for:

    • a dot segment (., .., .htaccess)
    • a path that changes when joined
    • a double-encoded filename (Glide decodes it again, so foo/%252e%252e/kitten.jpg would save a copy for every prefix)
    • a ?path= query (it changes the image, not the URL path)
    • parameters that aren't understood (e.g. …×crop×files×<anything>, which would fill the disk)
    • an image that doesn't exist (Glide's cache still has deleted images, and would save them again)

    These are still generated by PHP, as before.

  • Serve thumbnails with the same headers the web server sends for any image, e.g. in files/. That's Last-Modified and Content-Type by extension, with no lifetime (Cache-Control: public), and 304 for conditional requests.

    • There's no ETag. Apache and Nginx build theirs from the file's size and mtime in their own format, so one sent by PHP never matches: a browser revalidating the copy it got from PHP got the whole file again. Last-Modified matches, as both use the file's mtime.
    • This applies to the request that generates the thumbnail, and to requests for a saved one that still reach PHP. Those no longer generate and write the thumbnail again.
    • Before, the response had Glide's one-year Cache-Control, which the firewall's use of the session turned into private, max-age=0. Responses for a started session stay private, as they may set the session cookie.
  • Deleting a file or folder in the file manager also removes its saved thumbnails. Since 6.0.1, PHP returned the error image for the thumbnail of a deleted image. A saved thumbnail served from disk would stay public instead.

  • Deleting a file resolved its path against the working directory, which only worked because PHP runs from public/. It is now resolved against its location.

Not changed

  • Thumbnails that aren't saved, and every thumbnail with save_files: false, are served by Glide as before.
  • Which locations images are read from, the size of generated thumbnails, and long-lived caching stay as on 6.1. This PR doesn't widen any of them. They'll follow as separate PRs.

Tests

  • ImageControllerTest (56 cases) covers:
    • saving at the URL path, including every URL form ThumbnailHelper generates
    • the cases that aren't saved
    • traversal through the filename and ?path=
    • serving saved files, with the headers, 304s and a started session
  • ThumbnailCacheClearerTest and FileDeleteThumbnailsTest cover removing the thumbnails of deleted files and folders, and that they aren't saved again afterwards.

Each fix's tests fail without it.

I also checked four 6.1 sites under Apache 2.4 with PHP-FPM. The first request for a thumbnail is generated by PHP, and later ones are served from disk with the same headers as images in files/. Revalidating either response gives a 304. Deleting an image in the file manager removes the file and its thumbnails, and its thumbnail URL then gets the error image.

Upgrade: the thumbs/<w>_<h>_…_files/ folders saved since 6.0.1 are no longer used. bin/console cache:thumbs removes them, along with thumbnails of images deleted outside the file manager (FTP, deploys). bolt/project needs no changes.

Refs #3714. That PR saves under normalised parameters (c → crop), so URLs using shorthand or the default fit still miss the saved file.

Since 6.0.1 `save_files` writes thumbnails to
`thumbs/<w>_<h>_<q>_<fit>_<location>/`, a path that no thumbnail URL points
to, so the web server never serves them and every request is handled by
PHP. Save them under the requested parameter string again, so the web
server serves the next request for a thumbnail straight from disk.

The parameter string and the filename come from the URL, so a thumbnail is
not saved for:
- a dot segment (`.`, `..`, `.htaccess`), or a path that changes when
  it is joined
- a double-encoded filename, which Glide decodes again
- a `?path=` query, which changes the image but not the URL path
- parameters that are not understood, which would save a copy of the same
  thumbnail for every variant of the URL

Those are still generated by PHP, as before.
A saved thumbnail is served by the web server with the same headers as any
other image, e.g. in files/: Last-Modified and an ETag, but no lifetime.
The request that generates it got Glide's one-year Cache-Control instead,
which the firewall's use of the session turned into `private, max-age=0`.

Serve the saved file from PHP as well, with the same headers, also when a
request for an already saved thumbnail still reaches PHP, instead of
generating and writing it again. The response stays private if the
session was started, as it may set the session cookie.
The web server now serves saved thumbnails straight from disk, so deleting
an image no longer took it off its thumbnail URLs: until 6.0.1 the
thumbnails of a deleted image stayed public, and since 6.0.1 PHP replaced
them with the error image only because no thumbnail was ever served from
disk. Deleting a file or folder in the file manager now also removes its
saved thumbnails.

Deleting a file resolved its path relative to the working directory, which
only worked because PHP runs from the web root. It is now resolved against
the path of its location.
…exists

Glide serves a generated image from its cache, also after the image itself
was deleted. The next request for a thumbnail of a deleted image saved it
again from that cache, so the web server served the deleted image once
more. Only save a thumbnail if its image exists; otherwise the error image
is served, as before.
PHP serves a thumbnail only until it is saved: from then on the web server
serves the file, with its own ETag. Apache and Nginx each make it in their
own format, so a browser that revalidated the response from PHP with its
ETag got the whole thumbnail again instead of a 304.

Send only Last-Modified, so the browser revalidates with If-Modified-Since,
which any web server answers for the saved file.
@Vondry
Vondry marked this pull request as ready for review October 12, 2026 12:31

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant