diff --git a/composer.json b/composer.json index 79824d4f..ddbe4437 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,9 @@ "hautelook/templated-uri-bundle": "~1.0 | ~2.0", "doctrine/dbal": "~2.5@rc", "doctrine/doctrine-bundle": "~1.3@beta", - "liip/imagine-bundle": "~1.0" + "liip/imagine-bundle": "~1.0", + "symfony/expression-language": "~2.4", + "sensio/framework-extra-bundle": "~3.0" }, "require-dev": { "behat/behat": "3.0.*", diff --git a/doc/varnish/varnish.md b/doc/varnish/varnish.md new file mode 100644 index 00000000..7e1f42a0 --- /dev/null +++ b/doc/varnish/varnish.md @@ -0,0 +1,14 @@ +# eZ Publish Varnish configuration + +## Prerequisites +* A working Varnish 3 or Varnish 4 setup. + +## Recommended VCL base files +For Varnish to work properly with eZ, you'll need to use one of the provided files as a basis: + +* [eZ 5.4+ / 2014.09+ with Varnish 3](vcl/varnish3.vcl) +* [eZ 5.4+ / 2014.09+ with Varnish 4](vcl/varnish4.vcl) + +> **Note:** Http cache management is done with the help of [FOSHttpCacheBundle](http://foshttpcachebundle.readthedocs.org/). + One may need to tweak their VCL further on according to [FOSHttpCache documentation](http://foshttpcache.readthedocs.org/en/latest/varnish-configuration.html) + in order to use features supported by it. diff --git a/doc/varnish/vcl/varnish3.vcl b/doc/varnish/vcl/varnish3.vcl new file mode 100644 index 00000000..9ac0e06f --- /dev/null +++ b/doc/varnish/vcl/varnish3.vcl @@ -0,0 +1,209 @@ +// Varnish 3 style - eZ 5.4+ / 2014.09+ +// Complete VCL example + +// Our Backend - Assuming that web server is listening on port 80 +// Replace the host to fit your setup +backend ezpublish { + .host = "127.0.0.1"; + .port = "80"; +} + +// ACL for invalidators IP +acl invalidators { + "127.0.0.1"; + "192.168.0.0"/16; +} + +// ACL for debuggers IP +acl debuggers { + "127.0.0.1"; + "192.168.0.0"/16; +} + +// Called at the beginning of a request, after the complete request has been received +sub vcl_recv { + + // Set the backend + set req.backend = ezpublish; + + // Advertise Symfony for ESI support + set req.http.Surrogate-Capability = "abc=ESI/1.0"; + + // Add a unique header containing the client address (only for master request) + // Please note that /_fragment URI can change in Symfony configuration + if (!req.url ~ "^/_fragment") { + if (req.http.x-forwarded-for) { + set req.http.X-Forwarded-For = req.http.X-Forwarded-For + ", " + client.ip; + } else { + set req.http.X-Forwarded-For = client.ip; + } + } + + // Trigger cache purge if needed + call ez_purge; + + // Don't cache requests other than GET and HEAD. + if (req.request != "GET" && req.request != "HEAD") { + return (pass); + } + + // Normalize the Accept-Encoding headers + if (req.http.Accept-Encoding) { + if (req.http.Accept-Encoding ~ "gzip") { + set req.http.Accept-Encoding = "gzip"; + } elsif (req.http.Accept-Encoding ~ "deflate") { + set req.http.Accept-Encoding = "deflate"; + } else { + unset req.http.Accept-Encoding; + } + } + + // Don't cache Authenticate & Authorization + // You may remove this when using REST API with basic auth. + if (req.http.Authenticate || req.http.Authorization) { + if (client.ip ~ debuggers) { + set req.http.X-Debug = "Not Cached according to configuration (Authorization)"; + } + return(pass); + } + + // Do a standard lookup on assets + // Note that file extension list below is not extensive, so consider completing it to fit your needs. + if (req.url ~ "\.(css|js|gif|jpe?g|bmp|png|tiff?|ico|img|tga|wmf|svg|swf|ico|mp3|mp4|m4a|ogg|mov|avi|wmv|zip|gz|pdf|ttf|eot|wof)$") { + return (lookup); + } + + // Retrieve client user hash and add it to the forwarded request. + call ez_user_hash; + + // If it passes all these tests, do a lookup anyway. + return (lookup); +} + +// Called when the requested object has been retrieved from the backend +sub vcl_fetch { + + if (req.restarts == 0 + && req.http.accept ~ "application/vnd.fos.user-context-hash" + && beresp.status >= 500 + ) { + error 503 "Hash error"; + } + + // Optimize to only parse the Response contents from Symfony + if (beresp.http.Surrogate-Control ~ "ESI/1.0") { + unset beresp.http.Surrogate-Control; + set beresp.do_esi = true; + } + + // Respect the Cache-Control=private header from the backend + if (beresp.http.Cache-Control ~ "no-cache|no-store|private") { + set beresp.ttl = 120s; + return (hit_for_pass); + } + + return (deliver); +} + +// Handle purge +// You may add FOSHttpCacheBundle tagging rules +// See http://foshttpcache.readthedocs.org/en/latest/varnish-configuration.html#id4 +sub ez_purge { + + if (req.request == "BAN") { + if (!client.ip ~ invalidators) { + error 405 "Method not allowed"; + } + + if (req.http.X-Location-Id) { + ban( "obj.http.X-Location-Id ~ " + req.http.X-Location-Id); + if (client.ip ~ debuggers) { + set req.http.X-Debug = "Ban done for content connected to LocationId " + req.http.X-Location-Id; + } + error 200 "Banned"; + } + } +} + +// Sub-routine to get client user hash, for context-aware HTTP cache. +sub ez_user_hash { + + // Prevent tampering attacks on the hash mechanism + if (req.restarts == 0 + && (req.http.accept ~ "application/vnd.fos.user-context-hash" + || req.http.x-user-hash + ) + ) { + error 400; + } + + if (req.restarts == 0 && (req.request == "GET" || req.request == "HEAD")) { + // Anonymous user => Set a hardcoded anonymous hash + if (req.http.Cookie !~ "eZSESSID" && !req.http.authorization) { + set req.http.X-User-Hash = "38015b703d82206ebc01d17a39c727e5"; + } + // Pre-authenticate request to get shared cache, even when authenticated + else { + set req.http.x-fos-original-url = req.url; + set req.http.x-fos-original-accept = req.http.accept; + set req.http.x-fos-original-cookie = req.http.cookie; + // Clean up cookie for the hash request to only keep session cookie, as hash cache will vary on cookie. + set req.http.cookie = ";" + req.http.cookie; + set req.http.cookie = regsuball(req.http.cookie, "; +", ";"); + set req.http.cookie = regsuball(req.http.cookie, ";(eZSESSID[^=]*)=", "; \1="); + set req.http.cookie = regsuball(req.http.cookie, ";[^ ][^;]*", ""); + set req.http.cookie = regsuball(req.http.cookie, "^[; ]+|[; ]+$", ""); + + set req.http.accept = "application/vnd.fos.user-context-hash"; + set req.url = "/_fos_user_context_hash"; + + // Force the lookup, the backend must tell how to cache/vary response containing the user hash + + return (lookup); + } + } + + // Rebuild the original request which now has the hash. + if (req.restarts > 0 + && req.http.accept == "application/vnd.fos.user-context-hash" + ) { + set req.url = req.http.x-fos-original-url; + set req.http.accept = req.http.x-fos-original-accept; + set req.http.cookie = req.http.x-fos-original-cookie; + + unset req.http.x-fos-original-url; + unset req.http.x-fos-original-accept; + unset req.http.x-fos-original-cookie; + + // Force the lookup, the backend must tell not to cache or vary on the + // user hash to properly separate cached data. + + return (lookup); + } +} + +sub vcl_deliver { + // On receiving the hash response, copy the hash header to the original + // request and restart. + if (req.restarts == 0 + && resp.http.content-type ~ "application/vnd.fos.user-context-hash" + && resp.status == 200 + ) { + set req.http.x-user-hash = resp.http.x-user-hash; + + return (restart); + } + + // If we get here, this is a real response that gets sent to the client. + + // Remove the vary on context user hash, this is nothing public. Keep all + // other vary headers. + set resp.http.Vary = regsub(resp.http.Vary, "(?i),? *x-user-hash *", ""); + set resp.http.Vary = regsub(resp.http.Vary, "^, *", ""); + if (resp.http.Vary == "") { + unset resp.http.Vary; + } + + // Sanity check to prevent ever exposing the hash to a client. + unset resp.http.x-user-hash; +} diff --git a/doc/varnish/vcl/varnish4.vcl b/doc/varnish/vcl/varnish4.vcl new file mode 100644 index 00000000..733290b4 --- /dev/null +++ b/doc/varnish/vcl/varnish4.vcl @@ -0,0 +1,205 @@ +// Varnish 4 style - eZ 5.4+ / 2014.09+ +// Complete VCL example + +vcl 4.0; + +// Our Backend - Assuming that web server is listening on port 80 +// Replace the host to fit your setup +backend ezpublish { + .host = "127.0.0.1"; + .port = "80"; +} + +// ACL for invalidators IP +acl invalidators { + "127.0.0.1"; + "192.168.0.0"/16; +} + +// ACL for debuggers IP +acl debuggers { + "127.0.0.1"; + "192.168.0.0"/16; +} + +// Called at the beginning of a request, after the complete request has been received +sub vcl_recv { + + // Set the backend + set req.backend_hint = ezpublish; + + // Advertise Symfony for ESI support + set req.http.Surrogate-Capability = "abc=ESI/1.0"; + + // Add a unique header containing the client address (only for master request) + // Please note that /_fragment URI can change in Symfony configuration + if (!req.url ~ "^/_fragment") { + if (req.http.x-forwarded-for) { + set req.http.X-Forwarded-For = req.http.X-Forwarded-For + ", " + client.ip; + } else { + set req.http.X-Forwarded-For = client.ip; + } + } + + // Trigger cache purge if needed + call ez_purge; + + // Don't cache requests other than GET and HEAD. + if (req.method != "GET" && req.method != "HEAD") { + return (pass); + } + + // Normalize the Accept-Encoding headers + if (req.http.Accept-Encoding) { + if (req.http.Accept-Encoding ~ "gzip") { + set req.http.Accept-Encoding = "gzip"; + } elsif (req.http.Accept-Encoding ~ "deflate") { + set req.http.Accept-Encoding = "deflate"; + } else { + unset req.http.Accept-Encoding; + } + } + + // Don't cache Authenticate & Authorization + // You may remove this when using REST API with basic auth. + if (req.http.Authenticate || req.http.Authorization) { + if (client.ip ~ debuggers) { + set req.http.X-Debug = "Not Cached according to configuration (Authorization)"; + } + return (hash); + } + + // Do a standard lookup on assets + // Note that file extension list below is not extensive, so consider completing it to fit your needs. + if (req.url ~ "\.(css|js|gif|jpe?g|bmp|png|tiff?|ico|img|tga|wmf|svg|swf|ico|mp3|mp4|m4a|ogg|mov|avi|wmv|zip|gz|pdf|ttf|eot|wof)$") { + return (hash); + } + + // Retrieve client user hash and add it to the forwarded request. + call ez_user_hash; + + // If it passes all these tests, do a lookup anyway. + return (hash); +} + +// Called when the requested object has been retrieved from the backend +sub vcl_backend_response { + + if (bereq.http.accept ~ "application/vnd.fos.user-context-hash" + && beresp.status >= 500 + ) { + return (abandon); + } + + // Optimize to only parse the Response contents from Symfony + if (beresp.http.Surrogate-Control ~ "ESI/1.0") { + unset beresp.http.Surrogate-Control; + set beresp.do_esi = true; + } + + // Allow stale content, in case the backend goes down or cache is not fresh any more + // make Varnish keep all objects for 1 hours beyond their TTL + set beresp.grace = 1h; +} + +// Handle purge +// You may add FOSHttpCacheBundle tagging rules +// See http://foshttpcache.readthedocs.org/en/latest/varnish-configuration.html#id4 +sub ez_purge { + + if (req.method == "BAN") { + if (!client.ip ~ invalidators) { + return (synth(405, "Method not allowed")); + } + + if (req.http.X-Location-Id) { + ban("obj.http.X-Location-Id ~ " + req.http.X-Location-Id); + if (client.ip ~ debuggers) { + set req.http.X-Debug = "Ban done for content connected to LocationId " + req.http.X-Location-Id; + } + return (synth(200, "Banned")); + } + } +} + +// Sub-routine to get client user hash, for context-aware HTTP cache. +sub ez_user_hash { + + // Prevent tampering attacks on the hash mechanism + if (req.restarts == 0 + && (req.http.accept ~ "application/vnd.fos.user-context-hash" + || req.http.x-user-hash + ) + ) { + return (synth(400)); + } + + if (req.restarts == 0 && (req.method == "GET" || req.method == "HEAD")) { + // Anonymous user => Set a hardcoded anonymous hash + if (req.http.Cookie !~ "eZSESSID" && !req.http.authorization) { + set req.http.X-User-Hash = "38015b703d82206ebc01d17a39c727e5"; + } + // Pre-authenticate request to get shared cache, even when authenticated + else { + set req.http.x-fos-original-url = req.url; + set req.http.x-fos-original-accept = req.http.accept; + set req.http.x-fos-original-cookie = req.http.cookie; + // Clean up cookie for the hash request to only keep session cookie, as hash cache will vary on cookie. + set req.http.cookie = ";" + req.http.cookie; + set req.http.cookie = regsuball(req.http.cookie, "; +", ";"); + set req.http.cookie = regsuball(req.http.cookie, ";(eZSESSID[^=]*)=", "; \1="); + set req.http.cookie = regsuball(req.http.cookie, ";[^ ][^;]*", ""); + set req.http.cookie = regsuball(req.http.cookie, "^[; ]+|[; ]+$", ""); + + set req.http.accept = "application/vnd.fos.user-context-hash"; + set req.url = "/_fos_user_context_hash"; + + // Force the lookup, the backend must tell how to cache/vary response containing the user hash + + return (hash); + } + } + + // Rebuild the original request which now has the hash. + if (req.restarts > 0 + && req.http.accept == "application/vnd.fos.user-context-hash" + ) { + set req.url = req.http.x-fos-original-url; + set req.http.accept = req.http.x-fos-original-accept; + set req.http.cookie = req.http.x-fos-original-cookie; + + unset req.http.x-fos-original-url; + unset req.http.x-fos-original-accept; + unset req.http.x-fos-original-cookie; + + // Force the lookup, the backend must tell not to cache or vary on the + // user hash to properly separate cached data. + + return (hash); + } +} + +sub vcl_deliver { + // On receiving the hash response, copy the hash header to the original + // request and restart. + if (req.restarts == 0 + && resp.http.content-type ~ "application/vnd.fos.user-context-hash" + ) { + set req.http.x-user-hash = resp.http.x-user-hash; + + return (restart); + } + + // If we get here, this is a real response that gets sent to the client. + + // Remove the vary on context user hash, this is nothing public. Keep all + // other vary headers. + set resp.http.Vary = regsub(resp.http.Vary, "(?i),? *x-user-hash *", ""); + set resp.http.Vary = regsub(resp.http.Vary, "^, *", ""); + if (resp.http.Vary == "") { + unset resp.http.Vary; + } + + // Sanity check to prevent ever exposing the hash to a client. + unset resp.http.x-user-hash; +} diff --git a/ezpublish/EzPublishKernel.php b/ezpublish/EzPublishKernel.php index 70bcdf15..5345cd03 100644 --- a/ezpublish/EzPublishKernel.php +++ b/ezpublish/EzPublishKernel.php @@ -18,6 +18,7 @@ use EzSystems\BehatBundle\EzSystemsBehatBundle; use eZ\Bundle\EzPublishCoreBundle\Kernel; use EzSystems\NgsymfonytoolsBundle\EzSystemsNgsymfonytoolsBundle; +use FOS\HttpCacheBundle\FOSHttpCacheBundle; use Liip\ImagineBundle\LiipImagineBundle; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -37,6 +38,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Knp\Bundle\MenuBundle\KnpMenuBundle; use Oneup\FlysystemBundle\OneupFlysystemBundle; +use Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle; class EzPublishKernel extends Kernel { @@ -57,9 +59,11 @@ public function registerBundles() new SwiftmailerBundle(), new AsseticBundle(), new DoctrineBundle(), + new SensioFrameworkExtraBundle(), new TedivmStashBundle(), new HautelookTemplatedUriBundle(), new LiipImagineBundle(), + new FOSHttpCacheBundle(), new EzPublishCoreBundle(), new EzPublishLegacyBundle( $this ), new EzPublishIOBundle(),