diff --git a/docs/getting-started/cdn.md b/docs/getting-started/cdn.md new file mode 100644 index 000000000..96ae41b45 --- /dev/null +++ b/docs/getting-started/cdn.md @@ -0,0 +1,167 @@ +--- +title: Serving files from a CDN +sidebar_position: 4.7 +--- + +# Serving files from a CDN + +| Files | Served from | CDN cache | +| --- | --- | --- | +| Theme files (JS, CSS, fonts, icons) | `public/assets/`, compiled by AssetMapper | Yes, long 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 +office theme, behind a caching proxy that honours the origin's `Cache-Control`. Options specific +to a commercial CDN are not covered. + +## Recommended setup: a CDN in front of the shop domain + +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. + +### 1. Cache headers on the origin + +Add to the shop's server block (see the [nginx page](./nginx-configuration.md)): + +```nginx +# 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; +} + +# Image derivatives: same name when regenerated, so no immutable. +location ^~ /cache/images/ { + add_header Cache-Control "public, max-age=86400" always; + try_files $uri =404; +} +``` + +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 + +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. + +```bash +php Thelia thelia:config set url_site "https://shop.example.com" +``` + +```bash +# .env.local or the hosting environment: regular expressions, comma separated +SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' +``` + +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"). + +### 3. The trusted proxies + +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. + +## 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. 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`: + +```yaml +parameters: + env(ASSETS_CDN_URL): '' + +framework: + assets: + base_urls: '%env(ASSETS_CDN_URL)%' +``` + +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`). + +:::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. +::: + +**Catalogue images**, `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 +``` + +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`, 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. +- 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 + +```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 +# 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/ +``` + +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/docs/getting-started/nginx-configuration.md b/docs/getting-started/nginx-configuration.md index 38bc5952b..4908102f9 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; @@ -111,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.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/cdn.md b/versioned_docs/version-3.2/getting-started/cdn.md new file mode 100644 index 000000000..b887f52e9 --- /dev/null +++ b/versioned_docs/version-3.2/getting-started/cdn.md @@ -0,0 +1,166 @@ +--- +title: Serving files from a CDN +sidebar_position: 4.6 +--- + +# Serving files from a CDN + +| Files | Served from | CDN cache | +| --- | --- | --- | +| Theme files (JS, CSS, fonts, icons) | `public/assets/`, compiled by AssetMapper | Yes, long 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 +office theme, behind a caching proxy that honours the origin's `Cache-Control`. Options specific +to a commercial CDN are not covered. + +## Recommended setup: a CDN in front of the shop domain + +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. + +### 1. Cache headers on the origin + +Add to the shop's server block (see the [nginx page](./nginx-configuration.md)): + +```nginx +# 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; +} + +# Image derivatives: same name when regenerated, so no immutable. +location ^~ /cache/images/ { + add_header Cache-Control "public, max-age=86400" always; + try_files $uri =404; +} +``` + +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 + +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. + +```bash +php Thelia thelia:config set url_site "https://shop.example.com" +``` + +```bash +# .env.local or the hosting environment: regular expressions, comma separated +SYMFONY_TRUSTED_HOSTS='^shop\.example\.com$' +``` + +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"). + +### 3. The trusted proxies + +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. + +## 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. 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`: + +```yaml +parameters: + env(ASSETS_CDN_URL): '' + +framework: + assets: + base_urls: '%env(ASSETS_CDN_URL)%' +``` + +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`). + +:::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. +::: + +**Catalogue images**, `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 +``` + +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`, 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. +- HTML is `private` with a session cookie, so no CDN caches it. + +## Checking the setup + +```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 +# 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/ +``` + +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/nginx-configuration.md b/versioned_docs/version-3.2/getting-started/nginx-configuration.md index 38bc5952b..4908102f9 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; @@ -111,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).