From 9594d4fe3a3b7cc1ac7f3941d80a62cd456a83fb Mon Sep 17 00:00:00 2001 From: Jules Cournut Date: Fri, 9 Oct 2026 15:57:10 +0200 Subject: [PATCH 1/5] docs(nginx): let image derivatives not generated yet reach PHP --- docs/getting-started/nginx-configuration.md | 1 + .../version-3.0/getting-started/nginx-configuration.md | 1 + .../version-3.2/getting-started/nginx-configuration.md | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/getting-started/nginx-configuration.md b/docs/getting-started/nginx-configuration.md index 38bc5952b..899950ce9 100644 --- a/docs/getting-started/nginx-configuration.md +++ b/docs/getting-started/nginx-configuration.md @@ -71,6 +71,7 @@ server { # Static files caching location ~* \.(jpg|jpeg|gif|css|png|js|ico|pdf|zip|woff|woff2|ttf|svg)$ { + try_files $uri @rewriteapp; expires 30d; access_log off; log_not_found off; diff --git a/versioned_docs/version-3.0/getting-started/nginx-configuration.md b/versioned_docs/version-3.0/getting-started/nginx-configuration.md index 16818199b..6996b16f3 100644 --- a/versioned_docs/version-3.0/getting-started/nginx-configuration.md +++ b/versioned_docs/version-3.0/getting-started/nginx-configuration.md @@ -61,6 +61,7 @@ server { # Static files caching location ~* \.(jpg|jpeg|gif|css|png|js|ico|pdf|zip|woff|woff2|ttf|svg)$ { + try_files $uri @rewriteapp; expires 30d; access_log off; log_not_found off; diff --git a/versioned_docs/version-3.2/getting-started/nginx-configuration.md b/versioned_docs/version-3.2/getting-started/nginx-configuration.md index 38bc5952b..899950ce9 100644 --- a/versioned_docs/version-3.2/getting-started/nginx-configuration.md +++ b/versioned_docs/version-3.2/getting-started/nginx-configuration.md @@ -71,6 +71,7 @@ server { # Static files caching location ~* \.(jpg|jpeg|gif|css|png|js|ico|pdf|zip|woff|woff2|ttf|svg)$ { + try_files $uri @rewriteapp; expires 30d; access_log off; log_not_found off; From 65a5d053c989d657ceea4a4a452939187a59ef68 Mon Sep 17 00:00:00 2001 From: Jules Cournut Date: Fri, 9 Oct 2026 15:57:21 +0200 Subject: [PATCH 2/5] docs: explain how to serve theme files and catalogue images from a CDN --- docs/getting-started/cdn.md | 385 ++++++++++++++++++ docs/getting-started/nginx-configuration.md | 4 + .../version-3.2/getting-started/cdn.md | 385 ++++++++++++++++++ .../getting-started/nginx-configuration.md | 4 + 4 files changed, 778 insertions(+) create mode 100644 docs/getting-started/cdn.md create mode 100644 versioned_docs/version-3.2/getting-started/cdn.md diff --git a/docs/getting-started/cdn.md b/docs/getting-started/cdn.md new file mode 100644 index 000000000..3229fcefd --- /dev/null +++ b/docs/getting-started/cdn.md @@ -0,0 +1,385 @@ +--- +title: Serving files from a CDN +sidebar_position: 4.6 +--- + +# Serving files from a CDN + +A shop serves three families of files, and each one is configured in a different place: + +| Files | Where they come from | What to configure | +| --- | --- | --- | +| Theme files (JavaScript, CSS, fonts, icons) | Compiled by AssetMapper into `public/assets/` | Cache headers on the origin; `ASSETS_CDN_URL` only for a separate domain | +| Catalogue images | Generated on demand by LiipImagine into `public/media/cache/` | Cache headers on the origin; `MEDIA_CDN_URL` only for a separate domain | +| Protected files (virtual products, invoices, order PDFs) | Served by the shop after checking the visitor's rights | Nothing: they must not be cached by a CDN | + +HTML pages and protected files are sent as `private`, so a CDN that honours the origin headers +does not cache them. The rest of this page explains each family, then the settings that keep +absolute URLs on the shop's own domain. + +The setups below were checked on Thelia 3.2 and on the development version of the core, with +the Flexy theme and the default back office theme, behind a caching reverse proxy configured +like a pull CDN that honours the origin's `Cache-Control` headers. Rules specific to a +commercial CDN (edge TTL overrides, "cache everything" options) are not covered. + +## Two ways to set it up + +The reference setup puts a pull CDN in front of the shop's own domain: the CDN answers for +`shop.example.com`, sends the requests it cannot serve from its cache to the shop, and caches +what the origin marks as cacheable. Thelia needs no setting for it, theme files and images keep +their usual URLs, and the back office works as before. What it needs is the cache headers of +[Headers on the origin](#headers-on-the-origin) and the host and proxy settings of +[Absolute URLs and trusted hosts](#absolute-urls-and-trusted-hosts). + +The other setup serves the files from a separate domain, such as `cdn.example.com`, while +pages stay on the shop's domain. Thelia then has to write that domain in the URLs it builds: +`ASSETS_CDN_URL` for theme files, `MEDIA_CDN_URL` for catalogue images. A separate domain never +receives the shop's cookies, but it needs the CORS header below, and the default back office +theme does not support `ASSETS_CDN_URL` yet (see the warning in +[Pointing asset URLs to a separate domain](#pointing-asset-urls-to-a-separate-domain)). + +## Theme files + +The front theme's files are served under `/assets/frontOffice//`, for Flexy +`/assets/frontOffice/flexy/`. The back office theme's files are compiled under the same prefix +(`/assets/frontOffice/flexy/backoffice/...`), because the whole application has a single asset +map. The prefix follows the active theme, so changing theme changes every asset URL. + +Each file name carries a digest of its content, for example `app-e2rXCYD.js`. A file whose +content changes gets a new name, which is what makes long caching safe. + +With a CDN in front of the shop's domain, theme files need nothing more than the +[headers on the origin](#headers-on-the-origin). The next section only applies to a separate +domain. + +### Pointing asset URLs to a separate domain + +Create `config/packages/assets_cdn.yaml` in the project: + +```yaml +parameters: + env(ASSETS_CDN_URL): '' + +framework: + assets: + base_urls: '%env(ASSETS_CDN_URL)%' +``` + +Then set the variable in the environment of the server: + +```bash +# .env.local, or the environment of the hosting +ASSETS_CDN_URL=https://cdn.example.com +``` + +With the variable empty, nothing changes and asset URLs stay relative, so the file can be +committed and the CDN switched on per environment. With the variable set, every asset URL is +rewritten: `asset()` calls (favicons), the importmap +(`"app": "https://cdn.example.com/assets/frontOffice/flexy/app-e2rXCYD.js"`), the +`` tags and the HTTP `Link: <...css>; rel="preload"` header. + +There is a single default asset package and no option per area, so the back office asset URLs +are rewritten too. + +:::warning The default back office theme breaks with `ASSETS_CDN_URL` +The default back office theme (`thelia/backoffice-default-twig-template`, up to 1.2.2 and in its +development version) loads its entry script through `asset()`, so from the CDN domain, but +writes its importmap with paths on the shop's domain. The relative imports of the entry script +then resolve on the CDN domain, match no importmap entry, and the browser requests files that do +not exist, such as `/assets/frontOffice/flexy/backoffice/bootstrap.js` (404). The back office +loses its scripts: tabs, pickers and every form that relies on JavaScript stop working. The front +office is not affected. + +Until the back office theme handles it, do not set `ASSETS_CDN_URL` on a shop that uses this +theme: put the CDN in front of the shop's domain instead. +::: + +### Headers on the origin + +By default nginx sends no `Cache-Control` header for `/assets/`, and a CDN that honours origin +headers then caches nothing. Add these blocks to the shop's server block, next to the +configuration of the [nginx page](./nginx-configuration.md): + +```nginx +# Theme files: content-hashed names, never change once published. +location ^~ /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header Access-Control-Allow-Origin "*" always; + try_files $uri =404; +} + +# Catalogue derivatives: generated on demand, same name when regenerated. +location ^~ /media/cache/ { + try_files $uri @rewriteapp; + add_header Cache-Control "public, max-age=86400" always; +} +``` + +The `/media/cache/` block is explained in [Catalogue images](#catalogue-images). With another +web server, or with rules set on the CDN instead of the origin, apply the same principle: +a long TTL and `immutable` on `/assets/`, and the CORS header below. + +### The CORS header is required + +The importmap loads the theme's scripts as ES modules. Once they come from another domain, the +browser only runs them if the response carries `Access-Control-Allow-Origin`. + +:::warning Without CORS the front office stops working +Without the header, the browser console fills with errors such as +`... has been blocked by CORS policy`: `app.js`, Turbo and the Stimulus controllers are all +blocked, and the pages lose every interactive behaviour. With +`Access-Control-Allow-Origin: *` on `/assets/`, the same pages load with no error. +::: + +Fonts are referenced by relative `url()` in the compiled CSS, so they load from the CDN as well. + +### Deploying + +A deploy needs no purge. A changed file gets a new name, the page references the new name, +and the CDN fetches it from the origin on the first request. The old URL stays in the CDN +cache, where it does no harm. + +What the deploy must do is rebuild the compiled files that the web server serves: + +```bash +php Thelia asset-map:compile +``` + +- `bin/install` runs `importmap:install`, `tailwind:build` and `sass:build`, but not + `asset-map:compile`. +- `php Thelia thelia:cache:clear` deletes `public/assets` unless you pass + `--without-assets`. Compile again after it. +- The Composer package of the theme does not ship `assets/vendor/`: run + `php Thelia importmap:install` after updating it. +- Changing the active theme changes the prefix of every asset URL, so the CDN fetches the + new files on first request. + +## Catalogue images + +The original images and documents live outside the web root, in `local/media/images` and +`local/media/documents`. The resized versions shown on the shop (derivatives) are generated on +demand into `public/media/cache//...`. + +A derivative that does not exist yet is linked as `/media/cache/resolve//`. PHP +generates it and answers a `302` redirect to the final file, with +`cache-control: max-age=0, must-revalidate, private` and a session cookie. A CDN never caches +that redirect, and it should not. Once the file exists, pages link it directly. + +Derivative URLs are absolute and built from the host of the request, for example +`src="https://shop.example.com/media/cache/product_card/product/PROD002-1.jpg"`. Their names +are not versioned by content (`-.`): a regenerated image keeps its +name. Give them a moderate TTL, such as the one day of the nginx block above, and never +`immutable`. + +The `try_files $uri @rewriteapp;` line of the `/media/cache/` block is what lets a +`/media/cache/resolve/` request reach PHP. Without it, a derivative that has not been generated +yet is never generated and the image stays broken. + +There are two ways to put a CDN in front of these images. + +### CDN in front of the shop domain + +A pull CDN placed in front of the whole shop domain caches the derivatives under their usual +URLs. No Thelia setting is needed: the CDN honours the `Cache-Control` headers of the origin, +caches `/media/cache//...` files and lets the `private` responses through. The same +applies if you keep images on the shop domain and only move theme files to the CDN. + +### Dedicated media domain + +To serve derivatives from a separate domain, create `config/packages/liip_imagine_cdn.yaml`: + +```yaml +parameters: + env(MEDIA_CDN_URL): '' + +services: + app.imagine.cdn_resolver: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\ProxyResolver + arguments: + - '@liip_imagine.cache.resolver.default' + - ['%env(MEDIA_CDN_URL)%'] + tags: + - { name: liip_imagine.cache.resolver, resolver: cdn } + +liip_imagine: + cache: cdn +``` + +```bash +# .env.local, or the environment of the hosting +MEDIA_CDN_URL=https://media.example.com +``` + +The CDN pulls from the shop domain. Product images and their WebP `` variants +are then linked on the media domain. A derivative not generated yet keeps its +`/media/cache/resolve/` URL on the shop domain, the shop generates it and redirects to the +media domain, and the CDN fetches it from the origin. A new image therefore shows without +purging the CDN. + +The file can be committed and kept in every environment. With `MEDIA_CDN_URL` empty, the +resolver removes the scheme and host from derivative URLs, which become relative +(`src="/media/cache/product_card/..."`), and the redirect sent by +`/media/cache/resolve/` is relative too. Images display as before, and derivative URLs no +longer depend on the `Host` header of the request. The `/media/cache/resolve/` links +themselves stay absolute, because they are generated by the router. + +Pushing derivatives to object storage is another option. LiipImagine provides resolvers for it, +but the distribution installs no Flysystem or AWS package, and this setup is not covered here. + +### The WebP trap + +:::warning Do not turn on `liip_imagine.webp.generate` +With `liip_imagine.webp.generate: true`, LiipImagine always returns the +`/media/cache/resolve/` URL and picks the format from the `Accept: image/webp` header of the +browser. Every view of every image becomes a private `302`, and a CDN caches nothing. Flexy sets +`generate: false` since version 1.0.1. A project that sets it back to `true` loses image caching. +::: + +To check a shop, load a product page twice and count the `/media/cache/resolve/` URLs in the +`src` and `srcset` attributes of the second response. There should be none; with the setting +on, every image of the page uses one. A zoom link (`data-zoom-src`) can keep a +`/media/cache/resolve/` URL until a visitor opens the zoom, which is expected. + +```bash +curl -s https://shop.example.com/ > /dev/null +curl -s https://shop.example.com/ \ + | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l +``` + +## Protected files + +These files are served by the shop after it has checked the visitor's rights. They must never +go through CDN caching: + +| URL | Content | Check made by the shop | +| --- | --- | --- | +| `/account/order/download/{orderProductId}` | Virtual product file | Logged-in customer, order line of that customer, paid order | +| `/order/track/{token}/download/{orderProductId}` | Virtual product file, guest order | Signed guest token | +| `/account/order/pdf/{delivery,invoice,quotation}/{id}` | Order PDFs | Logged-in customer | +| `/account/return/pdf/{id}` | Return PDF | Logged-in customer | +| `/order/track/{token}/invoice` | Invoice, guest order | Signed guest token | + +Virtual product files are read from `local/media/documents/` and sent as attachments. All of +these responses carry `cache-control: private`: requested anonymously through a CDN, +`/account/order/download/1` and `/account/order/pdf/invoice/1` redirect to `/customer/login`, +and the CDN fetches them from the origin every time. + +They stay safe as long as the CDN honours the origin's `Cache-Control`. Never add a "cache +everything" rule on: + +- `/account/*` +- `/order/*` +- `/admin*` +- `/api/*` +- `/_components/*` (LiveComponent requests) +- `/media/cache/resolve/*` +- HTML pages + +Catalogue documents attached to products, categories or contents are public by design. They +are served by the API under `/api/front/{product,category,content,...}_documents/{id}/file` and +from `public/cache/documents/`. + +SVG images are accepted (sanitised at upload) and served as `image/svg+xml`. Serving media from +a separate domain limits what a malicious SVG could do on the shop's domain. + +## Absolute URLs and trusted hosts + +Several absolute URLs are built from the `Host` header of the request: derivative image URLs, +the sitemap and the guest order tracking link. The +[media resolver file](#dedicated-media-domain) takes derivative images out of that list, but +not the others. Thelia ships no list of trusted hosts, so a +request sent with a forged `Host` gets pages whose image URLs point to that host. Worse, +`/sitemap.xml?flush=1` sent with a forged `Host` rewrites the cached sitemap, and every visitor +and search engine then receives URLs on the attacker's domain until the next rebuild. + +A CDN makes this easier to exploit, because a poisoned response can be cached and served to +everyone. Declaring the shop's host is therefore mandatory. Set `url_site` to the shop URL +first (back office configuration, or the command below; it is empty on a fresh demo install): + +```bash +php Thelia thelia:config set url_site "https://shop.example.com" +``` + +Console commands that build a request, such as `php Thelia tntsearch:indexes`, take its host +from `url_site` and fall back to `localhost` when it is empty. With trusted hosts set and +`url_site` empty, they stop with `Untrusted Host "localhost"`. + +Then declare the host: + +```bash +# .env.local, or the environment of the hosting: a regular expression, several separated by commas +SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' +``` + +Symfony reads it through `trusted_hosts: '%env(default::SYMFONY_TRUSTED_HOSTS)%'`. Once it is +set, a request with any other `Host` gets a `400` response, and the shop host still answers +normally, directly and through the CDN. Keep it out of the environment of the test suites: they +send their requests with the `localhost` host and would all be refused. + +It follows that the CDN must send the shop's own host to the origin (often called "host header += origin host" in pull zone settings), never the CDN domain or the host sent by the client. + +When the CDN terminates TLS, Thelia also needs to know which proxies to trust, so that it sees +the visitor's address and the `https` scheme. Thelia reads `SYMFONY_TRUSTED_PROXIES`; the +default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and `X-Forwarded-Proto`, not +`X-Forwarded-Host`. The rules for choosing the addresses are in +[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer). + +`url_site` also serves the e-mail layout and the SEO canonical and hreflang URLs, so links in +e-mails do not depend on the request either. + +## HTML and cookies + +HTML pages are sent with `cache-control: max-age=0, must-revalidate, private` and a +`PHPSESSID` cookie on the first visit, so a CDN caches no HTML today. Caching public pages is +a separate topic. + +Files under `/assets/` and existing files under `/media/cache//` are sent without +`Set-Cookie`. A separate CDN or media domain never receives the shop's cookies, so browsers do +not send them with every image and script request. + +## Checking the setup + +Theme files, through the CDN. Run it twice: the second response should show the CDN's cache +status header as a hit. + +```bash +curl -sI https://cdn.example.com/assets/frontOffice/flexy/app-.js +# cache-control: public, max-age=31536000, immutable +# access-control-allow-origin: * +``` + +Asset URLs in a page point to the CDN domain: + +```bash +curl -s https://shop.example.com/ | grep -oE '(src|href)="[^"]*/assets/[^"]*"' | head +``` + +HTML stays private: + +```bash +curl -sI https://shop.example.com/ +# cache-control: ... private +``` + +A forged host is refused, against the origin directly: + +```bash +curl -s -o /dev/null -w '%{http_code}' -H 'Host: evil.example' https://shop.example.com/ +# 400 +``` + +In the browser, open the developer tools, filter the Network tab by domain, and check that the +console shows no CORS error. Do the same on a back office page with tabs, such as a product +edit page: the tabs must switch and the Network tab must show no `404` under `/assets/`. + +### After the first deploy + +- Theme files load through the CDN, and the back office still switches tabs and saves forms. +- After a deploy that changes a theme file, pages use the new version without any purge. +- Virtual product downloads are still served by the shop, and a logged-out request is + redirected to the login page. +- Links in e-mails and invoices use the shop domain (`url_site` is set). +- A newly added product image shows without purging the CDN. +- `sitemap.xml` and the canonical URLs of the pages use the shop domain, not the CDN domain. +- A request with a forged `Host` gets a `400`. diff --git a/docs/getting-started/nginx-configuration.md b/docs/getting-started/nginx-configuration.md index 899950ce9..4908102f9 100644 --- a/docs/getting-started/nginx-configuration.md +++ b/docs/getting-started/nginx-configuration.md @@ -112,3 +112,7 @@ Ensure Nginx (www-data) has write access to: chmod -R 755 var/cache var/log local/session local/media public/cache chown -R www-data:www-data var/ local/ public/cache ``` + +## Serving files from a CDN + +To serve theme files and catalogue images from a CDN, see [Serving files from a CDN](./cdn.md). diff --git a/versioned_docs/version-3.2/getting-started/cdn.md b/versioned_docs/version-3.2/getting-started/cdn.md new file mode 100644 index 000000000..3229fcefd --- /dev/null +++ b/versioned_docs/version-3.2/getting-started/cdn.md @@ -0,0 +1,385 @@ +--- +title: Serving files from a CDN +sidebar_position: 4.6 +--- + +# Serving files from a CDN + +A shop serves three families of files, and each one is configured in a different place: + +| Files | Where they come from | What to configure | +| --- | --- | --- | +| Theme files (JavaScript, CSS, fonts, icons) | Compiled by AssetMapper into `public/assets/` | Cache headers on the origin; `ASSETS_CDN_URL` only for a separate domain | +| Catalogue images | Generated on demand by LiipImagine into `public/media/cache/` | Cache headers on the origin; `MEDIA_CDN_URL` only for a separate domain | +| Protected files (virtual products, invoices, order PDFs) | Served by the shop after checking the visitor's rights | Nothing: they must not be cached by a CDN | + +HTML pages and protected files are sent as `private`, so a CDN that honours the origin headers +does not cache them. The rest of this page explains each family, then the settings that keep +absolute URLs on the shop's own domain. + +The setups below were checked on Thelia 3.2 and on the development version of the core, with +the Flexy theme and the default back office theme, behind a caching reverse proxy configured +like a pull CDN that honours the origin's `Cache-Control` headers. Rules specific to a +commercial CDN (edge TTL overrides, "cache everything" options) are not covered. + +## Two ways to set it up + +The reference setup puts a pull CDN in front of the shop's own domain: the CDN answers for +`shop.example.com`, sends the requests it cannot serve from its cache to the shop, and caches +what the origin marks as cacheable. Thelia needs no setting for it, theme files and images keep +their usual URLs, and the back office works as before. What it needs is the cache headers of +[Headers on the origin](#headers-on-the-origin) and the host and proxy settings of +[Absolute URLs and trusted hosts](#absolute-urls-and-trusted-hosts). + +The other setup serves the files from a separate domain, such as `cdn.example.com`, while +pages stay on the shop's domain. Thelia then has to write that domain in the URLs it builds: +`ASSETS_CDN_URL` for theme files, `MEDIA_CDN_URL` for catalogue images. A separate domain never +receives the shop's cookies, but it needs the CORS header below, and the default back office +theme does not support `ASSETS_CDN_URL` yet (see the warning in +[Pointing asset URLs to a separate domain](#pointing-asset-urls-to-a-separate-domain)). + +## Theme files + +The front theme's files are served under `/assets/frontOffice//`, for Flexy +`/assets/frontOffice/flexy/`. The back office theme's files are compiled under the same prefix +(`/assets/frontOffice/flexy/backoffice/...`), because the whole application has a single asset +map. The prefix follows the active theme, so changing theme changes every asset URL. + +Each file name carries a digest of its content, for example `app-e2rXCYD.js`. A file whose +content changes gets a new name, which is what makes long caching safe. + +With a CDN in front of the shop's domain, theme files need nothing more than the +[headers on the origin](#headers-on-the-origin). The next section only applies to a separate +domain. + +### Pointing asset URLs to a separate domain + +Create `config/packages/assets_cdn.yaml` in the project: + +```yaml +parameters: + env(ASSETS_CDN_URL): '' + +framework: + assets: + base_urls: '%env(ASSETS_CDN_URL)%' +``` + +Then set the variable in the environment of the server: + +```bash +# .env.local, or the environment of the hosting +ASSETS_CDN_URL=https://cdn.example.com +``` + +With the variable empty, nothing changes and asset URLs stay relative, so the file can be +committed and the CDN switched on per environment. With the variable set, every asset URL is +rewritten: `asset()` calls (favicons), the importmap +(`"app": "https://cdn.example.com/assets/frontOffice/flexy/app-e2rXCYD.js"`), the +`` tags and the HTTP `Link: <...css>; rel="preload"` header. + +There is a single default asset package and no option per area, so the back office asset URLs +are rewritten too. + +:::warning The default back office theme breaks with `ASSETS_CDN_URL` +The default back office theme (`thelia/backoffice-default-twig-template`, up to 1.2.2 and in its +development version) loads its entry script through `asset()`, so from the CDN domain, but +writes its importmap with paths on the shop's domain. The relative imports of the entry script +then resolve on the CDN domain, match no importmap entry, and the browser requests files that do +not exist, such as `/assets/frontOffice/flexy/backoffice/bootstrap.js` (404). The back office +loses its scripts: tabs, pickers and every form that relies on JavaScript stop working. The front +office is not affected. + +Until the back office theme handles it, do not set `ASSETS_CDN_URL` on a shop that uses this +theme: put the CDN in front of the shop's domain instead. +::: + +### Headers on the origin + +By default nginx sends no `Cache-Control` header for `/assets/`, and a CDN that honours origin +headers then caches nothing. Add these blocks to the shop's server block, next to the +configuration of the [nginx page](./nginx-configuration.md): + +```nginx +# Theme files: content-hashed names, never change once published. +location ^~ /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header Access-Control-Allow-Origin "*" always; + try_files $uri =404; +} + +# Catalogue derivatives: generated on demand, same name when regenerated. +location ^~ /media/cache/ { + try_files $uri @rewriteapp; + add_header Cache-Control "public, max-age=86400" always; +} +``` + +The `/media/cache/` block is explained in [Catalogue images](#catalogue-images). With another +web server, or with rules set on the CDN instead of the origin, apply the same principle: +a long TTL and `immutable` on `/assets/`, and the CORS header below. + +### The CORS header is required + +The importmap loads the theme's scripts as ES modules. Once they come from another domain, the +browser only runs them if the response carries `Access-Control-Allow-Origin`. + +:::warning Without CORS the front office stops working +Without the header, the browser console fills with errors such as +`... has been blocked by CORS policy`: `app.js`, Turbo and the Stimulus controllers are all +blocked, and the pages lose every interactive behaviour. With +`Access-Control-Allow-Origin: *` on `/assets/`, the same pages load with no error. +::: + +Fonts are referenced by relative `url()` in the compiled CSS, so they load from the CDN as well. + +### Deploying + +A deploy needs no purge. A changed file gets a new name, the page references the new name, +and the CDN fetches it from the origin on the first request. The old URL stays in the CDN +cache, where it does no harm. + +What the deploy must do is rebuild the compiled files that the web server serves: + +```bash +php Thelia asset-map:compile +``` + +- `bin/install` runs `importmap:install`, `tailwind:build` and `sass:build`, but not + `asset-map:compile`. +- `php Thelia thelia:cache:clear` deletes `public/assets` unless you pass + `--without-assets`. Compile again after it. +- The Composer package of the theme does not ship `assets/vendor/`: run + `php Thelia importmap:install` after updating it. +- Changing the active theme changes the prefix of every asset URL, so the CDN fetches the + new files on first request. + +## Catalogue images + +The original images and documents live outside the web root, in `local/media/images` and +`local/media/documents`. The resized versions shown on the shop (derivatives) are generated on +demand into `public/media/cache//...`. + +A derivative that does not exist yet is linked as `/media/cache/resolve//`. PHP +generates it and answers a `302` redirect to the final file, with +`cache-control: max-age=0, must-revalidate, private` and a session cookie. A CDN never caches +that redirect, and it should not. Once the file exists, pages link it directly. + +Derivative URLs are absolute and built from the host of the request, for example +`src="https://shop.example.com/media/cache/product_card/product/PROD002-1.jpg"`. Their names +are not versioned by content (`-.`): a regenerated image keeps its +name. Give them a moderate TTL, such as the one day of the nginx block above, and never +`immutable`. + +The `try_files $uri @rewriteapp;` line of the `/media/cache/` block is what lets a +`/media/cache/resolve/` request reach PHP. Without it, a derivative that has not been generated +yet is never generated and the image stays broken. + +There are two ways to put a CDN in front of these images. + +### CDN in front of the shop domain + +A pull CDN placed in front of the whole shop domain caches the derivatives under their usual +URLs. No Thelia setting is needed: the CDN honours the `Cache-Control` headers of the origin, +caches `/media/cache//...` files and lets the `private` responses through. The same +applies if you keep images on the shop domain and only move theme files to the CDN. + +### Dedicated media domain + +To serve derivatives from a separate domain, create `config/packages/liip_imagine_cdn.yaml`: + +```yaml +parameters: + env(MEDIA_CDN_URL): '' + +services: + app.imagine.cdn_resolver: + class: Liip\ImagineBundle\Imagine\Cache\Resolver\ProxyResolver + arguments: + - '@liip_imagine.cache.resolver.default' + - ['%env(MEDIA_CDN_URL)%'] + tags: + - { name: liip_imagine.cache.resolver, resolver: cdn } + +liip_imagine: + cache: cdn +``` + +```bash +# .env.local, or the environment of the hosting +MEDIA_CDN_URL=https://media.example.com +``` + +The CDN pulls from the shop domain. Product images and their WebP `` variants +are then linked on the media domain. A derivative not generated yet keeps its +`/media/cache/resolve/` URL on the shop domain, the shop generates it and redirects to the +media domain, and the CDN fetches it from the origin. A new image therefore shows without +purging the CDN. + +The file can be committed and kept in every environment. With `MEDIA_CDN_URL` empty, the +resolver removes the scheme and host from derivative URLs, which become relative +(`src="/media/cache/product_card/..."`), and the redirect sent by +`/media/cache/resolve/` is relative too. Images display as before, and derivative URLs no +longer depend on the `Host` header of the request. The `/media/cache/resolve/` links +themselves stay absolute, because they are generated by the router. + +Pushing derivatives to object storage is another option. LiipImagine provides resolvers for it, +but the distribution installs no Flysystem or AWS package, and this setup is not covered here. + +### The WebP trap + +:::warning Do not turn on `liip_imagine.webp.generate` +With `liip_imagine.webp.generate: true`, LiipImagine always returns the +`/media/cache/resolve/` URL and picks the format from the `Accept: image/webp` header of the +browser. Every view of every image becomes a private `302`, and a CDN caches nothing. Flexy sets +`generate: false` since version 1.0.1. A project that sets it back to `true` loses image caching. +::: + +To check a shop, load a product page twice and count the `/media/cache/resolve/` URLs in the +`src` and `srcset` attributes of the second response. There should be none; with the setting +on, every image of the page uses one. A zoom link (`data-zoom-src`) can keep a +`/media/cache/resolve/` URL until a visitor opens the zoom, which is expected. + +```bash +curl -s https://shop.example.com/ > /dev/null +curl -s https://shop.example.com/ \ + | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l +``` + +## Protected files + +These files are served by the shop after it has checked the visitor's rights. They must never +go through CDN caching: + +| URL | Content | Check made by the shop | +| --- | --- | --- | +| `/account/order/download/{orderProductId}` | Virtual product file | Logged-in customer, order line of that customer, paid order | +| `/order/track/{token}/download/{orderProductId}` | Virtual product file, guest order | Signed guest token | +| `/account/order/pdf/{delivery,invoice,quotation}/{id}` | Order PDFs | Logged-in customer | +| `/account/return/pdf/{id}` | Return PDF | Logged-in customer | +| `/order/track/{token}/invoice` | Invoice, guest order | Signed guest token | + +Virtual product files are read from `local/media/documents/` and sent as attachments. All of +these responses carry `cache-control: private`: requested anonymously through a CDN, +`/account/order/download/1` and `/account/order/pdf/invoice/1` redirect to `/customer/login`, +and the CDN fetches them from the origin every time. + +They stay safe as long as the CDN honours the origin's `Cache-Control`. Never add a "cache +everything" rule on: + +- `/account/*` +- `/order/*` +- `/admin*` +- `/api/*` +- `/_components/*` (LiveComponent requests) +- `/media/cache/resolve/*` +- HTML pages + +Catalogue documents attached to products, categories or contents are public by design. They +are served by the API under `/api/front/{product,category,content,...}_documents/{id}/file` and +from `public/cache/documents/`. + +SVG images are accepted (sanitised at upload) and served as `image/svg+xml`. Serving media from +a separate domain limits what a malicious SVG could do on the shop's domain. + +## Absolute URLs and trusted hosts + +Several absolute URLs are built from the `Host` header of the request: derivative image URLs, +the sitemap and the guest order tracking link. The +[media resolver file](#dedicated-media-domain) takes derivative images out of that list, but +not the others. Thelia ships no list of trusted hosts, so a +request sent with a forged `Host` gets pages whose image URLs point to that host. Worse, +`/sitemap.xml?flush=1` sent with a forged `Host` rewrites the cached sitemap, and every visitor +and search engine then receives URLs on the attacker's domain until the next rebuild. + +A CDN makes this easier to exploit, because a poisoned response can be cached and served to +everyone. Declaring the shop's host is therefore mandatory. Set `url_site` to the shop URL +first (back office configuration, or the command below; it is empty on a fresh demo install): + +```bash +php Thelia thelia:config set url_site "https://shop.example.com" +``` + +Console commands that build a request, such as `php Thelia tntsearch:indexes`, take its host +from `url_site` and fall back to `localhost` when it is empty. With trusted hosts set and +`url_site` empty, they stop with `Untrusted Host "localhost"`. + +Then declare the host: + +```bash +# .env.local, or the environment of the hosting: a regular expression, several separated by commas +SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' +``` + +Symfony reads it through `trusted_hosts: '%env(default::SYMFONY_TRUSTED_HOSTS)%'`. Once it is +set, a request with any other `Host` gets a `400` response, and the shop host still answers +normally, directly and through the CDN. Keep it out of the environment of the test suites: they +send their requests with the `localhost` host and would all be refused. + +It follows that the CDN must send the shop's own host to the origin (often called "host header += origin host" in pull zone settings), never the CDN domain or the host sent by the client. + +When the CDN terminates TLS, Thelia also needs to know which proxies to trust, so that it sees +the visitor's address and the `https` scheme. Thelia reads `SYMFONY_TRUSTED_PROXIES`; the +default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and `X-Forwarded-Proto`, not +`X-Forwarded-Host`. The rules for choosing the addresses are in +[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer). + +`url_site` also serves the e-mail layout and the SEO canonical and hreflang URLs, so links in +e-mails do not depend on the request either. + +## HTML and cookies + +HTML pages are sent with `cache-control: max-age=0, must-revalidate, private` and a +`PHPSESSID` cookie on the first visit, so a CDN caches no HTML today. Caching public pages is +a separate topic. + +Files under `/assets/` and existing files under `/media/cache//` are sent without +`Set-Cookie`. A separate CDN or media domain never receives the shop's cookies, so browsers do +not send them with every image and script request. + +## Checking the setup + +Theme files, through the CDN. Run it twice: the second response should show the CDN's cache +status header as a hit. + +```bash +curl -sI https://cdn.example.com/assets/frontOffice/flexy/app-.js +# cache-control: public, max-age=31536000, immutable +# access-control-allow-origin: * +``` + +Asset URLs in a page point to the CDN domain: + +```bash +curl -s https://shop.example.com/ | grep -oE '(src|href)="[^"]*/assets/[^"]*"' | head +``` + +HTML stays private: + +```bash +curl -sI https://shop.example.com/ +# cache-control: ... private +``` + +A forged host is refused, against the origin directly: + +```bash +curl -s -o /dev/null -w '%{http_code}' -H 'Host: evil.example' https://shop.example.com/ +# 400 +``` + +In the browser, open the developer tools, filter the Network tab by domain, and check that the +console shows no CORS error. Do the same on a back office page with tabs, such as a product +edit page: the tabs must switch and the Network tab must show no `404` under `/assets/`. + +### After the first deploy + +- Theme files load through the CDN, and the back office still switches tabs and saves forms. +- After a deploy that changes a theme file, pages use the new version without any purge. +- Virtual product downloads are still served by the shop, and a logged-out request is + redirected to the login page. +- Links in e-mails and invoices use the shop domain (`url_site` is set). +- A newly added product image shows without purging the CDN. +- `sitemap.xml` and the canonical URLs of the pages use the shop domain, not the CDN domain. +- A request with a forged `Host` gets a `400`. diff --git a/versioned_docs/version-3.2/getting-started/nginx-configuration.md b/versioned_docs/version-3.2/getting-started/nginx-configuration.md index 899950ce9..4908102f9 100644 --- a/versioned_docs/version-3.2/getting-started/nginx-configuration.md +++ b/versioned_docs/version-3.2/getting-started/nginx-configuration.md @@ -112,3 +112,7 @@ Ensure Nginx (www-data) has write access to: chmod -R 755 var/cache var/log local/session local/media public/cache chown -R www-data:www-data var/ local/ public/cache ``` + +## Serving files from a CDN + +To serve theme files and catalogue images from a CDN, see [Serving files from a CDN](./cdn.md). From 5c54ba9e0b7b8cd90e4fa93e6d00f702560cf603 Mon Sep 17 00:00:00 2001 From: Jules Cournut Date: Mon, 19 Oct 2026 11:39:36 +0200 Subject: [PATCH 3/5] docs: place the CDN page after the reverse proxy cache guide and align the trusted proxies wording --- docs/getting-started/cdn.md | 23 +++++++++++-------- .../version-3.2/getting-started/cdn.md | 11 +++++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/getting-started/cdn.md b/docs/getting-started/cdn.md index 3229fcefd..41f737b8d 100644 --- a/docs/getting-started/cdn.md +++ b/docs/getting-started/cdn.md @@ -1,6 +1,6 @@ --- title: Serving files from a CDN -sidebar_position: 4.6 +sidebar_position: 4.7 --- # Serving files from a CDN @@ -13,8 +13,8 @@ A shop serves three families of files, and each one is configured in a different | Catalogue images | Generated on demand by LiipImagine into `public/media/cache/` | Cache headers on the origin; `MEDIA_CDN_URL` only for a separate domain | | Protected files (virtual products, invoices, order PDFs) | Served by the shop after checking the visitor's rights | Nothing: they must not be cached by a CDN | -HTML pages and protected files are sent as `private`, so a CDN that honours the origin headers -does not cache them. The rest of this page explains each family, then the settings that keep +By default, HTML pages and protected files are sent as `private`, so a CDN that honours the +origin headers does not cache them. The rest of this page explains each family, then the settings that keep absolute URLs on the shop's own domain. The setups below were checked on Thelia 3.2 and on the development version of the core, with @@ -320,10 +320,13 @@ It follows that the CDN must send the shop's own host to the origin (often calle = origin host" in pull zone settings), never the CDN domain or the host sent by the client. When the CDN terminates TLS, Thelia also needs to know which proxies to trust, so that it sees -the visitor's address and the `https` scheme. Thelia reads `SYMFONY_TRUSTED_PROXIES`; the -default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and `X-Forwarded-Proto`, not -`X-Forwarded-Host`. The rules for choosing the addresses are in -[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer). +the visitor's address and the `https` scheme. Without project configuration, Symfony reads +them from `SYMFONY_TRUSTED_PROXIES`; a project that wires its own `TRUSTED_PROXIES` variable +into `framework.trusted_proxies`, as +[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer) +shows, sets that one instead. The same section gives the rules for choosing the addresses. +Thelia's default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and +`X-Forwarded-Proto`, not `X-Forwarded-Host`. `url_site` also serves the e-mail layout and the SEO canonical and hreflang URLs, so links in e-mails do not depend on the request either. @@ -331,8 +334,10 @@ e-mails do not depend on the request either. ## HTML and cookies HTML pages are sent with `cache-control: max-age=0, must-revalidate, private` and a -`PHPSESSID` cookie on the first visit, so a CDN caches no HTML today. Caching public pages is -a separate topic. +`PHPSESSID` cookie on the first visit, so a CDN caches no HTML by default. From Thelia 3.3, +public catalogue pages can be made shareable for anonymous visitors: see +[Reverse proxy cache](./reverse-proxy-cache.md) before letting a CDN keep HTML, in particular +its warning on the forwarded headers a visitor can send. Files under `/assets/` and existing files under `/media/cache//` are sent without `Set-Cookie`. A separate CDN or media domain never receives the shop's cookies, so browsers do diff --git a/versioned_docs/version-3.2/getting-started/cdn.md b/versioned_docs/version-3.2/getting-started/cdn.md index 3229fcefd..3a0f7d96a 100644 --- a/versioned_docs/version-3.2/getting-started/cdn.md +++ b/versioned_docs/version-3.2/getting-started/cdn.md @@ -320,10 +320,13 @@ It follows that the CDN must send the shop's own host to the origin (often calle = origin host" in pull zone settings), never the CDN domain or the host sent by the client. When the CDN terminates TLS, Thelia also needs to know which proxies to trust, so that it sees -the visitor's address and the `https` scheme. Thelia reads `SYMFONY_TRUSTED_PROXIES`; the -default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and `X-Forwarded-Proto`, not -`X-Forwarded-Host`. The rules for choosing the addresses are in -[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer). +the visitor's address and the `https` scheme. Without project configuration, Symfony reads +them from `SYMFONY_TRUSTED_PROXIES`; a project that wires its own `TRUSTED_PROXIES` variable +into `framework.trusted_proxies`, as +[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer) +shows, sets that one instead. The same section gives the rules for choosing the addresses. +Thelia's default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and +`X-Forwarded-Proto`, not `X-Forwarded-Host`. `url_site` also serves the e-mail layout and the SEO canonical and hreflang URLs, so links in e-mails do not depend on the request either. From 7b437c59c038c37031628ffaa2d98571c368c851 Mon Sep 17 00:00:00 2001 From: Jules Cournut Date: Mon, 19 Oct 2026 12:19:56 +0200 Subject: [PATCH 4/5] docs: shorten the CDN page around the recommended setup --- docs/getting-started/cdn.md | 411 ++++-------------- .../version-3.2/getting-started/cdn.md | 400 ++++------------- 2 files changed, 177 insertions(+), 634 deletions(-) diff --git a/docs/getting-started/cdn.md b/docs/getting-started/cdn.md index 41f737b8d..ea3778b8a 100644 --- a/docs/getting-started/cdn.md +++ b/docs/getting-started/cdn.md @@ -5,188 +5,100 @@ sidebar_position: 4.7 # Serving files from a CDN -A shop serves three families of files, and each one is configured in a different place: - -| Files | Where they come from | What to configure | +| Files | Served from | CDN cache | | --- | --- | --- | -| Theme files (JavaScript, CSS, fonts, icons) | Compiled by AssetMapper into `public/assets/` | Cache headers on the origin; `ASSETS_CDN_URL` only for a separate domain | -| Catalogue images | Generated on demand by LiipImagine into `public/media/cache/` | Cache headers on the origin; `MEDIA_CDN_URL` only for a separate domain | -| Protected files (virtual products, invoices, order PDFs) | Served by the shop after checking the visitor's rights | Nothing: they must not be cached by a CDN | - -By default, HTML pages and protected files are sent as `private`, so a CDN that honours the -origin headers does not cache them. The rest of this page explains each family, then the settings that keep -absolute URLs on the shop's own domain. - -The setups below were checked on Thelia 3.2 and on the development version of the core, with -the Flexy theme and the default back office theme, behind a caching reverse proxy configured -like a pull CDN that honours the origin's `Cache-Control` headers. Rules specific to a -commercial CDN (edge TTL overrides, "cache everything" options) are not covered. - -## Two ways to set it up - -The reference setup puts a pull CDN in front of the shop's own domain: the CDN answers for -`shop.example.com`, sends the requests it cannot serve from its cache to the shop, and caches -what the origin marks as cacheable. Thelia needs no setting for it, theme files and images keep -their usual URLs, and the back office works as before. What it needs is the cache headers of -[Headers on the origin](#headers-on-the-origin) and the host and proxy settings of -[Absolute URLs and trusted hosts](#absolute-urls-and-trusted-hosts). - -The other setup serves the files from a separate domain, such as `cdn.example.com`, while -pages stay on the shop's domain. Thelia then has to write that domain in the URLs it builds: -`ASSETS_CDN_URL` for theme files, `MEDIA_CDN_URL` for catalogue images. A separate domain never -receives the shop's cookies, but it needs the CORS header below, and the default back office -theme does not support `ASSETS_CDN_URL` yet (see the warning in -[Pointing asset URLs to a separate domain](#pointing-asset-urls-to-a-separate-domain)). - -## Theme files - -The front theme's files are served under `/assets/frontOffice//`, for Flexy -`/assets/frontOffice/flexy/`. The back office theme's files are compiled under the same prefix -(`/assets/frontOffice/flexy/backoffice/...`), because the whole application has a single asset -map. The prefix follows the active theme, so changing theme changes every asset URL. - -Each file name carries a digest of its content, for example `app-e2rXCYD.js`. A file whose -content changes gets a new name, which is what makes long caching safe. - -With a CDN in front of the shop's domain, theme files need nothing more than the -[headers on the origin](#headers-on-the-origin). The next section only applies to a separate -domain. - -### Pointing asset URLs to a separate domain +| Theme files (JS, CSS, fonts, icons) | `public/assets/`, compiled by AssetMapper | Yes, long TTL | +| Catalogue images | `public/media/cache/`, generated on demand by LiipImagine | Yes, moderate TTL | +| Protected files (virtual products, order PDFs) | The shop, after checking the visitor's rights | Never | -Create `config/packages/assets_cdn.yaml` in the project: +Checked on Thelia 3.2 and the development version of the core, with Flexy and the default back +office theme, behind a caching proxy that honours the origin's `Cache-Control`. Options specific +to a commercial CDN are not covered. -```yaml -parameters: - env(ASSETS_CDN_URL): '' +## Recommended setup: a CDN in front of the shop domain -framework: - assets: - base_urls: '%env(ASSETS_CDN_URL)%' -``` +The CDN answers for `shop.example.com` and forwards to the shop what it does not have in cache. +URLs do not change, the back office works as before, and Thelia needs three settings. -Then set the variable in the environment of the server: +### 1. Cache headers on the origin -```bash -# .env.local, or the environment of the hosting -ASSETS_CDN_URL=https://cdn.example.com -``` - -With the variable empty, nothing changes and asset URLs stay relative, so the file can be -committed and the CDN switched on per environment. With the variable set, every asset URL is -rewritten: `asset()` calls (favicons), the importmap -(`"app": "https://cdn.example.com/assets/frontOffice/flexy/app-e2rXCYD.js"`), the -`` tags and the HTTP `Link: <...css>; rel="preload"` header. - -There is a single default asset package and no option per area, so the back office asset URLs -are rewritten too. - -:::warning The default back office theme breaks with `ASSETS_CDN_URL` -The default back office theme (`thelia/backoffice-default-twig-template`, up to 1.2.2 and in its -development version) loads its entry script through `asset()`, so from the CDN domain, but -writes its importmap with paths on the shop's domain. The relative imports of the entry script -then resolve on the CDN domain, match no importmap entry, and the browser requests files that do -not exist, such as `/assets/frontOffice/flexy/backoffice/bootstrap.js` (404). The back office -loses its scripts: tabs, pickers and every form that relies on JavaScript stop working. The front -office is not affected. - -Until the back office theme handles it, do not set `ASSETS_CDN_URL` on a shop that uses this -theme: put the CDN in front of the shop's domain instead. -::: - -### Headers on the origin - -By default nginx sends no `Cache-Control` header for `/assets/`, and a CDN that honours origin -headers then caches nothing. Add these blocks to the shop's server block, next to the -configuration of the [nginx page](./nginx-configuration.md): +Add to the shop's server block (see the [nginx page](./nginx-configuration.md)): ```nginx -# Theme files: content-hashed names, never change once published. +# Theme files: the name carries a digest of the content. location ^~ /assets/ { add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Access-Control-Allow-Origin "*" always; try_files $uri =404; } -# Catalogue derivatives: generated on demand, same name when regenerated. +# Image derivatives: same name when regenerated, so no immutable. location ^~ /media/cache/ { try_files $uri @rewriteapp; add_header Cache-Control "public, max-age=86400" always; } ``` -The `/media/cache/` block is explained in [Catalogue images](#catalogue-images). With another -web server, or with rules set on the CDN instead of the origin, apply the same principle: -a long TTL and `immutable` on `/assets/`, and the CORS header below. - -### The CORS header is required - -The importmap loads the theme's scripts as ES modules. Once they come from another domain, the -browser only runs them if the response carries `Access-Control-Allow-Origin`. - -:::warning Without CORS the front office stops working -Without the header, the browser console fills with errors such as -`... has been blocked by CORS policy`: `app.js`, Turbo and the Stimulus controllers are all -blocked, and the pages lose every interactive behaviour. With -`Access-Control-Allow-Origin: *` on `/assets/`, the same pages load with no error. -::: +`try_files ... @rewriteapp` is required: a derivative not generated yet is linked as +`/media/cache/resolve/...`, which PHP generates before answering a `302` to the file. -Fonts are referenced by relative `url()` in the compiled CSS, so they load from the CDN as well. +### 2. The shop URL and its trusted host -### Deploying +Absolute URLs (images, sitemap, guest order link) are built from the `Host` header of the +request. Without a trusted host list, a forged `Host` ends up in the links, and +`/sitemap.xml?flush=1` sent with it rewrites the cached sitemap for every visitor. -A deploy needs no purge. A changed file gets a new name, the page references the new name, -and the CDN fetches it from the origin on the first request. The old URL stays in the CDN -cache, where it does no harm. - -What the deploy must do is rebuild the compiled files that the web server serves: +```bash +php Thelia thelia:config set url_site "https://shop.example.com" +``` ```bash -php Thelia asset-map:compile +# .env.local or the hosting environment: regular expressions, comma separated +SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' ``` -- `bin/install` runs `importmap:install`, `tailwind:build` and `sass:build`, but not - `asset-map:compile`. -- `php Thelia thelia:cache:clear` deletes `public/assets` unless you pass - `--without-assets`. Compile again after it. -- The Composer package of the theme does not ship `assets/vendor/`: run - `php Thelia importmap:install` after updating it. -- Changing the active theme changes the prefix of every asset URL, so the CDN fetches the - new files on first request. +Set `url_site` first: console commands that build a request (`tntsearch:indexes`, for one) take +its host, and fail with `Untrusted Host "localhost"` while it is empty. Any other host now gets a +`400`. Keep the variable out of the test suites, which use `localhost`. -## Catalogue images +The CDN must send the shop's own host to the origin ("host header = origin host"). -The original images and documents live outside the web root, in `local/media/images` and -`local/media/documents`. The resized versions shown on the shop (derivatives) are generated on -demand into `public/media/cache//...`. +### 3. The trusted proxies -A derivative that does not exist yet is linked as `/media/cache/resolve//`. PHP -generates it and answers a `302` redirect to the final file, with -`cache-control: max-age=0, must-revalidate, private` and a session cookie. A CDN never caches -that redirect, and it should not. Once the file exists, pages link it directly. +When the CDN ends TLS, Thelia must trust it to see the visitor's address and `https`: set +`SYMFONY_TRUSTED_PROXIES`, or the `TRUSTED_PROXIES` variable a project wires as shown in +[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer). +`X-Forwarded-Host` is not trusted by default. -Derivative URLs are absolute and built from the host of the request, for example -`src="https://shop.example.com/media/cache/product_card/product/PROD002-1.jpg"`. Their names -are not versioned by content (`-.`): a regenerated image keeps its -name. Give them a moderate TTL, such as the one day of the nginx block above, and never -`immutable`. +## Files on a separate domain -The `try_files $uri @rewriteapp;` line of the `/media/cache/` block is what lets a -`/media/cache/resolve/` request reach PHP. Without it, a derivative that has not been generated -yet is never generated and the image stays broken. +To serve files from `cdn.example.com` while pages stay on the shop domain, Thelia must write +that domain in the URLs. Both files below can be committed: with the variable empty, nothing +changes. -There are two ways to put a CDN in front of these images. +**Theme files**, `config/packages/assets_cdn.yaml`: -### CDN in front of the shop domain +```yaml +parameters: + env(ASSETS_CDN_URL): '' + +framework: + assets: + base_urls: '%env(ASSETS_CDN_URL)%' +``` -A pull CDN placed in front of the whole shop domain caches the derivatives under their usual -URLs. No Thelia setting is needed: the CDN honours the `Cache-Control` headers of the origin, -caches `/media/cache//...` files and lets the `private` responses through. The same -applies if you keep images on the shop domain and only move theme files to the CDN. +This rewrites `asset()`, the importmap, the `modulepreload` tags and the `Link` header. The +`Access-Control-Allow-Origin` header of step 1 is then mandatory: without it the browser blocks +every theme script (`... has been blocked by CORS policy`). -### Dedicated media domain +:::warning The default back office theme breaks +Up to 1.2.2 and in its development version, `default-twig` loads its entry script from the CDN +domain but keeps its importmap on the shop domain: the browser requests +`/assets/frontOffice/flexy/backoffice/bootstrap.js` (404) and the back office loses its +JavaScript. Do not set `ASSETS_CDN_URL` with this theme; use the recommended setup. +::: -To serve derivatives from a separate domain, create `config/packages/liip_imagine_cdn.yaml`: +**Catalogue images**, `config/packages/liip_imagine_cdn.yaml`: ```yaml parameters: @@ -205,186 +117,45 @@ liip_imagine: cache: cdn ``` -```bash -# .env.local, or the environment of the hosting -MEDIA_CDN_URL=https://media.example.com -``` - -The CDN pulls from the shop domain. Product images and their WebP `` variants -are then linked on the media domain. A derivative not generated yet keeps its -`/media/cache/resolve/` URL on the shop domain, the shop generates it and redirects to the -media domain, and the CDN fetches it from the origin. A new image therefore shows without -purging the CDN. - -The file can be committed and kept in every environment. With `MEDIA_CDN_URL` empty, the -resolver removes the scheme and host from derivative URLs, which become relative -(`src="/media/cache/product_card/..."`), and the redirect sent by -`/media/cache/resolve/` is relative too. Images display as before, and derivative URLs no -longer depend on the `Host` header of the request. The `/media/cache/resolve/` links -themselves stay absolute, because they are generated by the router. - -Pushing derivatives to object storage is another option. LiipImagine provides resolvers for it, -but the distribution installs no Flysystem or AWS package, and this setup is not covered here. - -### The WebP trap - -:::warning Do not turn on `liip_imagine.webp.generate` -With `liip_imagine.webp.generate: true`, LiipImagine always returns the -`/media/cache/resolve/` URL and picks the format from the `Accept: image/webp` header of the -browser. Every view of every image becomes a private `302`, and a CDN caches nothing. Flexy sets -`generate: false` since version 1.0.1. A project that sets it back to `true` loses image caching. -::: - -To check a shop, load a product page twice and count the `/media/cache/resolve/` URLs in the -`src` and `srcset` attributes of the second response. There should be none; with the setting -on, every image of the page uses one. A zoom link (`data-zoom-src`) can keep a -`/media/cache/resolve/` URL until a visitor opens the zoom, which is expected. - -```bash -curl -s https://shop.example.com/ > /dev/null -curl -s https://shop.example.com/ \ - | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l -``` - -## Protected files - -These files are served by the shop after it has checked the visitor's rights. They must never -go through CDN caching: - -| URL | Content | Check made by the shop | -| --- | --- | --- | -| `/account/order/download/{orderProductId}` | Virtual product file | Logged-in customer, order line of that customer, paid order | -| `/order/track/{token}/download/{orderProductId}` | Virtual product file, guest order | Signed guest token | -| `/account/order/pdf/{delivery,invoice,quotation}/{id}` | Order PDFs | Logged-in customer | -| `/account/return/pdf/{id}` | Return PDF | Logged-in customer | -| `/order/track/{token}/invoice` | Invoice, guest order | Signed guest token | - -Virtual product files are read from `local/media/documents/` and sent as attachments. All of -these responses carry `cache-control: private`: requested anonymously through a CDN, -`/account/order/download/1` and `/account/order/pdf/invoice/1` redirect to `/customer/login`, -and the CDN fetches them from the origin every time. - -They stay safe as long as the CDN honours the origin's `Cache-Control`. Never add a "cache -everything" rule on: - -- `/account/*` -- `/order/*` -- `/admin*` -- `/api/*` -- `/_components/*` (LiveComponent requests) -- `/media/cache/resolve/*` -- HTML pages - -Catalogue documents attached to products, categories or contents are public by design. They -are served by the API under `/api/front/{product,category,content,...}_documents/{id}/file` and -from `public/cache/documents/`. - -SVG images are accepted (sanitised at upload) and served as `image/svg+xml`. Serving media from -a separate domain limits what a malicious SVG could do on the shop's domain. - -## Absolute URLs and trusted hosts - -Several absolute URLs are built from the `Host` header of the request: derivative image URLs, -the sitemap and the guest order tracking link. The -[media resolver file](#dedicated-media-domain) takes derivative images out of that list, but -not the others. Thelia ships no list of trusted hosts, so a -request sent with a forged `Host` gets pages whose image URLs point to that host. Worse, -`/sitemap.xml?flush=1` sent with a forged `Host` rewrites the cached sitemap, and every visitor -and search engine then receives URLs on the attacker's domain until the next rebuild. - -A CDN makes this easier to exploit, because a poisoned response can be cached and served to -everyone. Declaring the shop's host is therefore mandatory. Set `url_site` to the shop URL -first (back office configuration, or the command below; it is empty on a fresh demo install): - -```bash -php Thelia thelia:config set url_site "https://shop.example.com" -``` - -Console commands that build a request, such as `php Thelia tntsearch:indexes`, take its host -from `url_site` and fall back to `localhost` when it is empty. With trusted hosts set and -`url_site` empty, they stop with `Untrusted Host "localhost"`. - -Then declare the host: - -```bash -# .env.local, or the environment of the hosting: a regular expression, several separated by commas -SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' -``` - -Symfony reads it through `trusted_hosts: '%env(default::SYMFONY_TRUSTED_HOSTS)%'`. Once it is -set, a request with any other `Host` gets a `400` response, and the shop host still answers -normally, directly and through the CDN. Keep it out of the environment of the test suites: they -send their requests with the `localhost` host and would all be refused. - -It follows that the CDN must send the shop's own host to the origin (often called "host header -= origin host" in pull zone settings), never the CDN domain or the host sent by the client. - -When the CDN terminates TLS, Thelia also needs to know which proxies to trust, so that it sees -the visitor's address and the `https` scheme. Without project configuration, Symfony reads -them from `SYMFONY_TRUSTED_PROXIES`; a project that wires its own `TRUSTED_PROXIES` variable -into `framework.trusted_proxies`, as -[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer) -shows, sets that one instead. The same section gives the rules for choosing the addresses. -Thelia's default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and -`X-Forwarded-Proto`, not `X-Forwarded-Host`. - -`url_site` also serves the e-mail layout and the SEO canonical and hreflang URLs, so links in -e-mails do not depend on the request either. - -## HTML and cookies - -HTML pages are sent with `cache-control: max-age=0, must-revalidate, private` and a -`PHPSESSID` cookie on the first visit, so a CDN caches no HTML by default. From Thelia 3.3, -public catalogue pages can be made shareable for anonymous visitors: see -[Reverse proxy cache](./reverse-proxy-cache.md) before letting a CDN keep HTML, in particular -its warning on the forwarded headers a visitor can send. - -Files under `/assets/` and existing files under `/media/cache//` are sent without -`Set-Cookie`. A separate CDN or media domain never receives the shop's cookies, so browsers do -not send them with every image and script request. +Images and their WebP variants are then linked on `MEDIA_CDN_URL`; a new image shows without +purge. With the variable empty, image URLs become relative and no longer depend on the `Host` +of the request. A separate domain never receives the shop's cookies, and keeps uploaded SVG off +the shop domain. + +## Pitfalls + +- A deploy needs no purge: a changed theme file gets a new name. Run + `php Thelia asset-map:compile` on each deploy (`bin/install` does not), again after + `thelia:cache:clear` (it empties `public/assets` unless `--without-assets`), and + `importmap:install` after updating the theme package. +- Never set `liip_imagine.webp.generate: true`: every image then goes through a + private `302` that no CDN caches. To check, the second load of a product page must show no + `/media/cache/resolve/` in `src` or `srcset` (the zoom link `data-zoom-src` may keep one): + + ```bash + curl -s https://shop.example.com/ > /dev/null + curl -s https://shop.example.com/ | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l + ``` + +- Protected files are sent `private` and must stay so: no "cache everything" rule on + `/account/*`, `/order/*`, `/admin*`, `/api/*`, `/_components/*`, `/media/cache/resolve/*` or HTML. + Virtual product downloads (`/account/order/download/{id}`, `/order/track/{token}/download/{id}`) + require the customer's paid order or the guest token. +- HTML is `private` with a session cookie, so no CDN caches it by default. From Thelia 3.3, + read [Reverse proxy cache](./reverse-proxy-cache.md) before letting a CDN keep pages. ## Checking the setup -Theme files, through the CDN. Run it twice: the second response should show the CDN's cache -status header as a hit. - ```bash +# Theme file: long TTL and CORS, then a cache hit on the second call curl -sI https://cdn.example.com/assets/frontOffice/flexy/app-.js -# cache-control: public, max-age=31536000, immutable -# access-control-allow-origin: * -``` - -Asset URLs in a page point to the CDN domain: - -```bash -curl -s https://shop.example.com/ | grep -oE '(src|href)="[^"]*/assets/[^"]*"' | head -``` - -HTML stays private: - -```bash -curl -sI https://shop.example.com/ -# cache-control: ... private -``` - -A forged host is refused, against the origin directly: - -```bash +# HTML stays private +curl -sI https://shop.example.com/ | grep -i cache-control +# A forged host is refused (against the origin): 400 curl -s -o /dev/null -w '%{http_code}' -H 'Host: evil.example' https://shop.example.com/ -# 400 ``` -In the browser, open the developer tools, filter the Network tab by domain, and check that the -console shows no CORS error. Do the same on a back office page with tabs, such as a product -edit page: the tabs must switch and the Network tab must show no `404` under `/assets/`. - -### After the first deploy - -- Theme files load through the CDN, and the back office still switches tabs and saves forms. -- After a deploy that changes a theme file, pages use the new version without any purge. -- Virtual product downloads are still served by the shop, and a logged-out request is - redirected to the login page. -- Links in e-mails and invoices use the shop domain (`url_site` is set). -- A newly added product image shows without purging the CDN. -- `sitemap.xml` and the canonical URLs of the pages use the shop domain, not the CDN domain. -- A request with a forged `Host` gets a `400`. +In the browser, the console shows no CORS error, and on a back office product page the tabs +switch with no `404` under `/assets/`. After the first deploy, check that a changed theme file +and a new product image show without purge, that a logged-out download redirects to the login +page, and that e-mails, the sitemap and canonical URLs use the shop domain. diff --git a/versioned_docs/version-3.2/getting-started/cdn.md b/versioned_docs/version-3.2/getting-started/cdn.md index 3a0f7d96a..5da3adb18 100644 --- a/versioned_docs/version-3.2/getting-started/cdn.md +++ b/versioned_docs/version-3.2/getting-started/cdn.md @@ -5,188 +5,100 @@ sidebar_position: 4.6 # Serving files from a CDN -A shop serves three families of files, and each one is configured in a different place: - -| Files | Where they come from | What to configure | +| Files | Served from | CDN cache | | --- | --- | --- | -| Theme files (JavaScript, CSS, fonts, icons) | Compiled by AssetMapper into `public/assets/` | Cache headers on the origin; `ASSETS_CDN_URL` only for a separate domain | -| Catalogue images | Generated on demand by LiipImagine into `public/media/cache/` | Cache headers on the origin; `MEDIA_CDN_URL` only for a separate domain | -| Protected files (virtual products, invoices, order PDFs) | Served by the shop after checking the visitor's rights | Nothing: they must not be cached by a CDN | - -HTML pages and protected files are sent as `private`, so a CDN that honours the origin headers -does not cache them. The rest of this page explains each family, then the settings that keep -absolute URLs on the shop's own domain. - -The setups below were checked on Thelia 3.2 and on the development version of the core, with -the Flexy theme and the default back office theme, behind a caching reverse proxy configured -like a pull CDN that honours the origin's `Cache-Control` headers. Rules specific to a -commercial CDN (edge TTL overrides, "cache everything" options) are not covered. - -## Two ways to set it up - -The reference setup puts a pull CDN in front of the shop's own domain: the CDN answers for -`shop.example.com`, sends the requests it cannot serve from its cache to the shop, and caches -what the origin marks as cacheable. Thelia needs no setting for it, theme files and images keep -their usual URLs, and the back office works as before. What it needs is the cache headers of -[Headers on the origin](#headers-on-the-origin) and the host and proxy settings of -[Absolute URLs and trusted hosts](#absolute-urls-and-trusted-hosts). - -The other setup serves the files from a separate domain, such as `cdn.example.com`, while -pages stay on the shop's domain. Thelia then has to write that domain in the URLs it builds: -`ASSETS_CDN_URL` for theme files, `MEDIA_CDN_URL` for catalogue images. A separate domain never -receives the shop's cookies, but it needs the CORS header below, and the default back office -theme does not support `ASSETS_CDN_URL` yet (see the warning in -[Pointing asset URLs to a separate domain](#pointing-asset-urls-to-a-separate-domain)). +| Theme files (JS, CSS, fonts, icons) | `public/assets/`, compiled by AssetMapper | Yes, long TTL | +| Catalogue images | `public/media/cache/`, generated on demand by LiipImagine | Yes, moderate TTL | +| Protected files (virtual products, order PDFs) | The shop, after checking the visitor's rights | Never | -## Theme files +Checked on Thelia 3.2 and the development version of the core, with Flexy and the default back +office theme, behind a caching proxy that honours the origin's `Cache-Control`. Options specific +to a commercial CDN are not covered. -The front theme's files are served under `/assets/frontOffice//`, for Flexy -`/assets/frontOffice/flexy/`. The back office theme's files are compiled under the same prefix -(`/assets/frontOffice/flexy/backoffice/...`), because the whole application has a single asset -map. The prefix follows the active theme, so changing theme changes every asset URL. +## Recommended setup: a CDN in front of the shop domain -Each file name carries a digest of its content, for example `app-e2rXCYD.js`. A file whose -content changes gets a new name, which is what makes long caching safe. +The CDN answers for `shop.example.com` and forwards to the shop what it does not have in cache. +URLs do not change, the back office works as before, and Thelia needs three settings. -With a CDN in front of the shop's domain, theme files need nothing more than the -[headers on the origin](#headers-on-the-origin). The next section only applies to a separate -domain. +### 1. Cache headers on the origin -### Pointing asset URLs to a separate domain - -Create `config/packages/assets_cdn.yaml` in the project: - -```yaml -parameters: - env(ASSETS_CDN_URL): '' - -framework: - assets: - base_urls: '%env(ASSETS_CDN_URL)%' -``` - -Then set the variable in the environment of the server: - -```bash -# .env.local, or the environment of the hosting -ASSETS_CDN_URL=https://cdn.example.com -``` - -With the variable empty, nothing changes and asset URLs stay relative, so the file can be -committed and the CDN switched on per environment. With the variable set, every asset URL is -rewritten: `asset()` calls (favicons), the importmap -(`"app": "https://cdn.example.com/assets/frontOffice/flexy/app-e2rXCYD.js"`), the -`` tags and the HTTP `Link: <...css>; rel="preload"` header. - -There is a single default asset package and no option per area, so the back office asset URLs -are rewritten too. - -:::warning The default back office theme breaks with `ASSETS_CDN_URL` -The default back office theme (`thelia/backoffice-default-twig-template`, up to 1.2.2 and in its -development version) loads its entry script through `asset()`, so from the CDN domain, but -writes its importmap with paths on the shop's domain. The relative imports of the entry script -then resolve on the CDN domain, match no importmap entry, and the browser requests files that do -not exist, such as `/assets/frontOffice/flexy/backoffice/bootstrap.js` (404). The back office -loses its scripts: tabs, pickers and every form that relies on JavaScript stop working. The front -office is not affected. - -Until the back office theme handles it, do not set `ASSETS_CDN_URL` on a shop that uses this -theme: put the CDN in front of the shop's domain instead. -::: - -### Headers on the origin - -By default nginx sends no `Cache-Control` header for `/assets/`, and a CDN that honours origin -headers then caches nothing. Add these blocks to the shop's server block, next to the -configuration of the [nginx page](./nginx-configuration.md): +Add to the shop's server block (see the [nginx page](./nginx-configuration.md)): ```nginx -# Theme files: content-hashed names, never change once published. +# Theme files: the name carries a digest of the content. location ^~ /assets/ { add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Access-Control-Allow-Origin "*" always; try_files $uri =404; } -# Catalogue derivatives: generated on demand, same name when regenerated. +# Image derivatives: same name when regenerated, so no immutable. location ^~ /media/cache/ { try_files $uri @rewriteapp; add_header Cache-Control "public, max-age=86400" always; } ``` -The `/media/cache/` block is explained in [Catalogue images](#catalogue-images). With another -web server, or with rules set on the CDN instead of the origin, apply the same principle: -a long TTL and `immutable` on `/assets/`, and the CORS header below. - -### The CORS header is required - -The importmap loads the theme's scripts as ES modules. Once they come from another domain, the -browser only runs them if the response carries `Access-Control-Allow-Origin`. +`try_files ... @rewriteapp` is required: a derivative not generated yet is linked as +`/media/cache/resolve/...`, which PHP generates before answering a `302` to the file. -:::warning Without CORS the front office stops working -Without the header, the browser console fills with errors such as -`... has been blocked by CORS policy`: `app.js`, Turbo and the Stimulus controllers are all -blocked, and the pages lose every interactive behaviour. With -`Access-Control-Allow-Origin: *` on `/assets/`, the same pages load with no error. -::: - -Fonts are referenced by relative `url()` in the compiled CSS, so they load from the CDN as well. - -### Deploying +### 2. The shop URL and its trusted host -A deploy needs no purge. A changed file gets a new name, the page references the new name, -and the CDN fetches it from the origin on the first request. The old URL stays in the CDN -cache, where it does no harm. +Absolute URLs (images, sitemap, guest order link) are built from the `Host` header of the +request. Without a trusted host list, a forged `Host` ends up in the links, and +`/sitemap.xml?flush=1` sent with it rewrites the cached sitemap for every visitor. -What the deploy must do is rebuild the compiled files that the web server serves: +```bash +php Thelia thelia:config set url_site "https://shop.example.com" +``` ```bash -php Thelia asset-map:compile +# .env.local or the hosting environment: regular expressions, comma separated +SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' ``` -- `bin/install` runs `importmap:install`, `tailwind:build` and `sass:build`, but not - `asset-map:compile`. -- `php Thelia thelia:cache:clear` deletes `public/assets` unless you pass - `--without-assets`. Compile again after it. -- The Composer package of the theme does not ship `assets/vendor/`: run - `php Thelia importmap:install` after updating it. -- Changing the active theme changes the prefix of every asset URL, so the CDN fetches the - new files on first request. +Set `url_site` first: console commands that build a request (`tntsearch:indexes`, for one) take +its host, and fail with `Untrusted Host "localhost"` while it is empty. Any other host now gets a +`400`. Keep the variable out of the test suites, which use `localhost`. + +The CDN must send the shop's own host to the origin ("host header = origin host"). -## Catalogue images +### 3. The trusted proxies -The original images and documents live outside the web root, in `local/media/images` and -`local/media/documents`. The resized versions shown on the shop (derivatives) are generated on -demand into `public/media/cache//...`. +When the CDN ends TLS, Thelia must trust it to see the visitor's address and `https`: set +`SYMFONY_TRUSTED_PROXIES`, or the `TRUSTED_PROXIES` variable a project wires as shown in +[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer). +`X-Forwarded-Host` is not trusted by default. -A derivative that does not exist yet is linked as `/media/cache/resolve//`. PHP -generates it and answers a `302` redirect to the final file, with -`cache-control: max-age=0, must-revalidate, private` and a session cookie. A CDN never caches -that redirect, and it should not. Once the file exists, pages link it directly. +## Files on a separate domain -Derivative URLs are absolute and built from the host of the request, for example -`src="https://shop.example.com/media/cache/product_card/product/PROD002-1.jpg"`. Their names -are not versioned by content (`-.`): a regenerated image keeps its -name. Give them a moderate TTL, such as the one day of the nginx block above, and never -`immutable`. +To serve files from `cdn.example.com` while pages stay on the shop domain, Thelia must write +that domain in the URLs. Both files below can be committed: with the variable empty, nothing +changes. -The `try_files $uri @rewriteapp;` line of the `/media/cache/` block is what lets a -`/media/cache/resolve/` request reach PHP. Without it, a derivative that has not been generated -yet is never generated and the image stays broken. +**Theme files**, `config/packages/assets_cdn.yaml`: -There are two ways to put a CDN in front of these images. +```yaml +parameters: + env(ASSETS_CDN_URL): '' -### CDN in front of the shop domain +framework: + assets: + base_urls: '%env(ASSETS_CDN_URL)%' +``` -A pull CDN placed in front of the whole shop domain caches the derivatives under their usual -URLs. No Thelia setting is needed: the CDN honours the `Cache-Control` headers of the origin, -caches `/media/cache//...` files and lets the `private` responses through. The same -applies if you keep images on the shop domain and only move theme files to the CDN. +This rewrites `asset()`, the importmap, the `modulepreload` tags and the `Link` header. The +`Access-Control-Allow-Origin` header of step 1 is then mandatory: without it the browser blocks +every theme script (`... has been blocked by CORS policy`). -### Dedicated media domain +:::warning The default back office theme breaks +Up to 1.2.2 and in its development version, `default-twig` loads its entry script from the CDN +domain but keeps its importmap on the shop domain: the browser requests +`/assets/frontOffice/flexy/backoffice/bootstrap.js` (404) and the back office loses its +JavaScript. Do not set `ASSETS_CDN_URL` with this theme; use the recommended setup. +::: -To serve derivatives from a separate domain, create `config/packages/liip_imagine_cdn.yaml`: +**Catalogue images**, `config/packages/liip_imagine_cdn.yaml`: ```yaml parameters: @@ -205,184 +117,44 @@ liip_imagine: cache: cdn ``` -```bash -# .env.local, or the environment of the hosting -MEDIA_CDN_URL=https://media.example.com -``` - -The CDN pulls from the shop domain. Product images and their WebP `` variants -are then linked on the media domain. A derivative not generated yet keeps its -`/media/cache/resolve/` URL on the shop domain, the shop generates it and redirects to the -media domain, and the CDN fetches it from the origin. A new image therefore shows without -purging the CDN. - -The file can be committed and kept in every environment. With `MEDIA_CDN_URL` empty, the -resolver removes the scheme and host from derivative URLs, which become relative -(`src="/media/cache/product_card/..."`), and the redirect sent by -`/media/cache/resolve/` is relative too. Images display as before, and derivative URLs no -longer depend on the `Host` header of the request. The `/media/cache/resolve/` links -themselves stay absolute, because they are generated by the router. - -Pushing derivatives to object storage is another option. LiipImagine provides resolvers for it, -but the distribution installs no Flysystem or AWS package, and this setup is not covered here. - -### The WebP trap - -:::warning Do not turn on `liip_imagine.webp.generate` -With `liip_imagine.webp.generate: true`, LiipImagine always returns the -`/media/cache/resolve/` URL and picks the format from the `Accept: image/webp` header of the -browser. Every view of every image becomes a private `302`, and a CDN caches nothing. Flexy sets -`generate: false` since version 1.0.1. A project that sets it back to `true` loses image caching. -::: +Images and their WebP variants are then linked on `MEDIA_CDN_URL`; a new image shows without +purge. With the variable empty, image URLs become relative and no longer depend on the `Host` +of the request. A separate domain never receives the shop's cookies, and keeps uploaded SVG off +the shop domain. -To check a shop, load a product page twice and count the `/media/cache/resolve/` URLs in the -`src` and `srcset` attributes of the second response. There should be none; with the setting -on, every image of the page uses one. A zoom link (`data-zoom-src`) can keep a -`/media/cache/resolve/` URL until a visitor opens the zoom, which is expected. +## Pitfalls -```bash -curl -s https://shop.example.com/ > /dev/null -curl -s https://shop.example.com/ \ - | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l -``` - -## Protected files - -These files are served by the shop after it has checked the visitor's rights. They must never -go through CDN caching: - -| URL | Content | Check made by the shop | -| --- | --- | --- | -| `/account/order/download/{orderProductId}` | Virtual product file | Logged-in customer, order line of that customer, paid order | -| `/order/track/{token}/download/{orderProductId}` | Virtual product file, guest order | Signed guest token | -| `/account/order/pdf/{delivery,invoice,quotation}/{id}` | Order PDFs | Logged-in customer | -| `/account/return/pdf/{id}` | Return PDF | Logged-in customer | -| `/order/track/{token}/invoice` | Invoice, guest order | Signed guest token | - -Virtual product files are read from `local/media/documents/` and sent as attachments. All of -these responses carry `cache-control: private`: requested anonymously through a CDN, -`/account/order/download/1` and `/account/order/pdf/invoice/1` redirect to `/customer/login`, -and the CDN fetches them from the origin every time. - -They stay safe as long as the CDN honours the origin's `Cache-Control`. Never add a "cache -everything" rule on: - -- `/account/*` -- `/order/*` -- `/admin*` -- `/api/*` -- `/_components/*` (LiveComponent requests) -- `/media/cache/resolve/*` -- HTML pages - -Catalogue documents attached to products, categories or contents are public by design. They -are served by the API under `/api/front/{product,category,content,...}_documents/{id}/file` and -from `public/cache/documents/`. - -SVG images are accepted (sanitised at upload) and served as `image/svg+xml`. Serving media from -a separate domain limits what a malicious SVG could do on the shop's domain. - -## Absolute URLs and trusted hosts - -Several absolute URLs are built from the `Host` header of the request: derivative image URLs, -the sitemap and the guest order tracking link. The -[media resolver file](#dedicated-media-domain) takes derivative images out of that list, but -not the others. Thelia ships no list of trusted hosts, so a -request sent with a forged `Host` gets pages whose image URLs point to that host. Worse, -`/sitemap.xml?flush=1` sent with a forged `Host` rewrites the cached sitemap, and every visitor -and search engine then receives URLs on the attacker's domain until the next rebuild. - -A CDN makes this easier to exploit, because a poisoned response can be cached and served to -everyone. Declaring the shop's host is therefore mandatory. Set `url_site` to the shop URL -first (back office configuration, or the command below; it is empty on a fresh demo install): - -```bash -php Thelia thelia:config set url_site "https://shop.example.com" -``` - -Console commands that build a request, such as `php Thelia tntsearch:indexes`, take its host -from `url_site` and fall back to `localhost` when it is empty. With trusted hosts set and -`url_site` empty, they stop with `Untrusted Host "localhost"`. - -Then declare the host: - -```bash -# .env.local, or the environment of the hosting: a regular expression, several separated by commas -SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' -``` +- A deploy needs no purge: a changed theme file gets a new name. Run + `php Thelia asset-map:compile` on each deploy (`bin/install` does not), again after + `thelia:cache:clear` (it empties `public/assets` unless `--without-assets`), and + `importmap:install` after updating the theme package. +- Never set `liip_imagine.webp.generate: true`: every image then goes through a + private `302` that no CDN caches. To check, the second load of a product page must show no + `/media/cache/resolve/` in `src` or `srcset` (the zoom link `data-zoom-src` may keep one): -Symfony reads it through `trusted_hosts: '%env(default::SYMFONY_TRUSTED_HOSTS)%'`. Once it is -set, a request with any other `Host` gets a `400` response, and the shop host still answers -normally, directly and through the CDN. Keep it out of the environment of the test suites: they -send their requests with the `localhost` host and would all be refused. + ```bash + curl -s https://shop.example.com/ > /dev/null + curl -s https://shop.example.com/ | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l + ``` -It follows that the CDN must send the shop's own host to the origin (often called "host header -= origin host" in pull zone settings), never the CDN domain or the host sent by the client. - -When the CDN terminates TLS, Thelia also needs to know which proxies to trust, so that it sees -the visitor's address and the `https` scheme. Without project configuration, Symfony reads -them from `SYMFONY_TRUSTED_PROXIES`; a project that wires its own `TRUSTED_PROXIES` variable -into `framework.trusted_proxies`, as -[Behind a proxy or a load balancer](../api/rate-limiting.md#behind-a-proxy-or-a-load-balancer) -shows, sets that one instead. The same section gives the rules for choosing the addresses. -Thelia's default trusted headers are `X-Forwarded-For`, `X-Forwarded-Port` and -`X-Forwarded-Proto`, not `X-Forwarded-Host`. - -`url_site` also serves the e-mail layout and the SEO canonical and hreflang URLs, so links in -e-mails do not depend on the request either. - -## HTML and cookies - -HTML pages are sent with `cache-control: max-age=0, must-revalidate, private` and a -`PHPSESSID` cookie on the first visit, so a CDN caches no HTML today. Caching public pages is -a separate topic. - -Files under `/assets/` and existing files under `/media/cache//` are sent without -`Set-Cookie`. A separate CDN or media domain never receives the shop's cookies, so browsers do -not send them with every image and script request. +- Protected files are sent `private` and must stay so: no "cache everything" rule on + `/account/*`, `/order/*`, `/admin*`, `/api/*`, `/_components/*`, `/media/cache/resolve/*` or HTML. + Virtual product downloads (`/account/order/download/{id}`, `/order/track/{token}/download/{id}`) + require the customer's paid order or the guest token. +- HTML is `private` with a session cookie, so no CDN caches it. ## Checking the setup -Theme files, through the CDN. Run it twice: the second response should show the CDN's cache -status header as a hit. - ```bash +# Theme file: long TTL and CORS, then a cache hit on the second call curl -sI https://cdn.example.com/assets/frontOffice/flexy/app-.js -# cache-control: public, max-age=31536000, immutable -# access-control-allow-origin: * -``` - -Asset URLs in a page point to the CDN domain: - -```bash -curl -s https://shop.example.com/ | grep -oE '(src|href)="[^"]*/assets/[^"]*"' | head -``` - -HTML stays private: - -```bash -curl -sI https://shop.example.com/ -# cache-control: ... private -``` - -A forged host is refused, against the origin directly: - -```bash +# HTML stays private +curl -sI https://shop.example.com/ | grep -i cache-control +# A forged host is refused (against the origin): 400 curl -s -o /dev/null -w '%{http_code}' -H 'Host: evil.example' https://shop.example.com/ -# 400 ``` -In the browser, open the developer tools, filter the Network tab by domain, and check that the -console shows no CORS error. Do the same on a back office page with tabs, such as a product -edit page: the tabs must switch and the Network tab must show no `404` under `/assets/`. - -### After the first deploy - -- Theme files load through the CDN, and the back office still switches tabs and saves forms. -- After a deploy that changes a theme file, pages use the new version without any purge. -- Virtual product downloads are still served by the shop, and a logged-out request is - redirected to the login page. -- Links in e-mails and invoices use the shop domain (`url_site` is set). -- A newly added product image shows without purging the CDN. -- `sitemap.xml` and the canonical URLs of the pages use the shop domain, not the CDN domain. -- A request with a forged `Host` gets a `400`. +In the browser, the console shows no CORS error, and on a back office product page the tabs +switch with no `404` under `/assets/`. After the first deploy, check that a changed theme file +and a new product image show without purge, that a logged-out download redirects to the login +page, and that e-mails, the sitemap and canonical URLs use the shop domain. From 0cbb6d3edd08554d15bcd3e9019ce7abdab41f07 Mon Sep 17 00:00:00 2001 From: Jules Cournut Date: Tue, 20 Oct 2026 09:20:31 +0200 Subject: [PATCH 5/5] docs: serve image derivatives from cache/images, warn about subdirectory shops and explain why downloads are private --- docs/getting-started/cdn.md | 22 ++++++++++++------- .../version-3.2/getting-started/cdn.md | 22 ++++++++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/docs/getting-started/cdn.md b/docs/getting-started/cdn.md index ea3778b8a..96ae41b45 100644 --- a/docs/getting-started/cdn.md +++ b/docs/getting-started/cdn.md @@ -8,7 +8,7 @@ sidebar_position: 4.7 | Files | Served from | CDN cache | | --- | --- | --- | | Theme files (JS, CSS, fonts, icons) | `public/assets/`, compiled by AssetMapper | Yes, long TTL | -| Catalogue images | `public/media/cache/`, generated on demand by LiipImagine | Yes, moderate TTL | +| Catalogue images | `public/cache/images/`, generated on demand by LiipImagine | Yes, moderate TTL | | Protected files (virtual products, order PDFs) | The shop, after checking the visitor's rights | Never | Checked on Thelia 3.2 and the development version of the core, with Flexy and the default back @@ -33,14 +33,16 @@ location ^~ /assets/ { } # Image derivatives: same name when regenerated, so no immutable. -location ^~ /media/cache/ { - try_files $uri @rewriteapp; +location ^~ /cache/images/ { add_header Cache-Control "public, max-age=86400" always; + try_files $uri =404; } ``` -`try_files ... @rewriteapp` is required: a derivative not generated yet is linked as -`/media/cache/resolve/...`, which PHP generates before answering a `302` to the file. +Derivatives are written under `public/cache/images/`, the `cache_prefix` TheliaLibrary sets in +`config/packages/liip_imagine_thelia.yaml`. One not generated yet is linked as +`/media/cache/resolve/...`, which PHP generates before answering a `302` to the file: that path +must reach PHP, as the [nginx page](./nginx-configuration.md) configures it. ### 2. The shop URL and its trusted host @@ -73,8 +75,10 @@ When the CDN ends TLS, Thelia must trust it to see the visitor's address and `ht ## Files on a separate domain To serve files from `cdn.example.com` while pages stay on the shop domain, Thelia must write -that domain in the URLs. Both files below can be committed: with the variable empty, nothing -changes. +that domain in the URLs. With the variable empty, the files below change nothing for a shop at +the root of its domain, so they can be committed. A shop installed in a subdirectory loses its +base path as soon as `assets_cdn.yaml` exists, the variable empty or not: there, add that file +only where `ASSETS_CDN_URL` is set. **Theme files**, `config/packages/assets_cdn.yaml`: @@ -137,7 +141,9 @@ the shop domain. curl -s https://shop.example.com/ | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l ``` -- Protected files are sent `private` and must stay so: no "cache everything" rule on +- Protected files are sent `private`, the guest download route included: Thelia starts a session + on every front request, and Symfony then marks the response `private`, even a + `BinaryFileResponse`, which is public by default. They must stay so: no "cache everything" rule on `/account/*`, `/order/*`, `/admin*`, `/api/*`, `/_components/*`, `/media/cache/resolve/*` or HTML. Virtual product downloads (`/account/order/download/{id}`, `/order/track/{token}/download/{id}`) require the customer's paid order or the guest token. diff --git a/versioned_docs/version-3.2/getting-started/cdn.md b/versioned_docs/version-3.2/getting-started/cdn.md index 5da3adb18..b887f52e9 100644 --- a/versioned_docs/version-3.2/getting-started/cdn.md +++ b/versioned_docs/version-3.2/getting-started/cdn.md @@ -8,7 +8,7 @@ sidebar_position: 4.6 | Files | Served from | CDN cache | | --- | --- | --- | | Theme files (JS, CSS, fonts, icons) | `public/assets/`, compiled by AssetMapper | Yes, long TTL | -| Catalogue images | `public/media/cache/`, generated on demand by LiipImagine | Yes, moderate TTL | +| Catalogue images | `public/cache/images/`, generated on demand by LiipImagine | Yes, moderate TTL | | Protected files (virtual products, order PDFs) | The shop, after checking the visitor's rights | Never | Checked on Thelia 3.2 and the development version of the core, with Flexy and the default back @@ -33,14 +33,16 @@ location ^~ /assets/ { } # Image derivatives: same name when regenerated, so no immutable. -location ^~ /media/cache/ { - try_files $uri @rewriteapp; +location ^~ /cache/images/ { add_header Cache-Control "public, max-age=86400" always; + try_files $uri =404; } ``` -`try_files ... @rewriteapp` is required: a derivative not generated yet is linked as -`/media/cache/resolve/...`, which PHP generates before answering a `302` to the file. +Derivatives are written under `public/cache/images/`, the `cache_prefix` TheliaLibrary sets in +`config/packages/liip_imagine_thelia.yaml`. One not generated yet is linked as +`/media/cache/resolve/...`, which PHP generates before answering a `302` to the file: that path +must reach PHP, as the [nginx page](./nginx-configuration.md) configures it. ### 2. The shop URL and its trusted host @@ -73,8 +75,10 @@ When the CDN ends TLS, Thelia must trust it to see the visitor's address and `ht ## Files on a separate domain To serve files from `cdn.example.com` while pages stay on the shop domain, Thelia must write -that domain in the URLs. Both files below can be committed: with the variable empty, nothing -changes. +that domain in the URLs. With the variable empty, the files below change nothing for a shop at +the root of its domain, so they can be committed. A shop installed in a subdirectory loses its +base path as soon as `assets_cdn.yaml` exists, the variable empty or not: there, add that file +only where `ASSETS_CDN_URL` is set. **Theme files**, `config/packages/assets_cdn.yaml`: @@ -137,7 +141,9 @@ the shop domain. curl -s https://shop.example.com/ | grep -oE '[[:space:]](src|srcset)="[^"]*/media/cache/resolve/' | wc -l ``` -- Protected files are sent `private` and must stay so: no "cache everything" rule on +- Protected files are sent `private`, the guest download route included: Thelia starts a session + on every front request, and Symfony then marks the response `private`, even a + `BinaryFileResponse`, which is public by default. They must stay so: no "cache everything" rule on `/account/*`, `/order/*`, `/admin*`, `/api/*`, `/_components/*`, `/media/cache/resolve/*` or HTML. Virtual product downloads (`/account/order/download/{id}`, `/order/track/{token}/download/{id}`) require the customer's paid order or the guest token.