sunnysideup /
silverstripe-simple-template-caching
| 1 | <?php |
||
| 2 | |||
| 3 | namespace Sunnysideup\SimpleTemplateCaching\Extensions; |
||
| 4 | |||
| 5 | use Exception; |
||
| 6 | use Page; |
||
|
0 ignored issues
–
show
|
|||
| 7 | use SilverStripe\CMS\Model\SiteTree; |
||
| 8 | use SilverStripe\Control\Controller; |
||
| 9 | use SilverStripe\Control\Director; |
||
| 10 | use SilverStripe\Core\Injector\Injector; |
||
| 11 | use SilverStripe\Forms\CheckboxField; |
||
| 12 | use SilverStripe\Forms\FieldList; |
||
| 13 | use SilverStripe\Forms\GridField\GridField; |
||
| 14 | use SilverStripe\Forms\GridField\GridFieldConfig_RecordViewer; |
||
| 15 | use SilverStripe\Forms\NumericField; |
||
| 16 | use SilverStripe\Forms\ReadonlyField; |
||
| 17 | use SilverStripe\Core\Extension; |
||
| 18 | use SilverStripe\Forms\HeaderField; |
||
| 19 | use SilverStripe\ORM\DB; |
||
| 20 | use SilverStripe\ORM\FieldType\DBDatetime; |
||
| 21 | use SilverStripe\SiteConfig\SiteConfig; |
||
| 22 | use Sunnysideup\SimpleTemplateCaching\Model\ObjectsUpdated; |
||
| 23 | |||
| 24 | /** |
||
| 25 | * Class \Sunnysideup\SimpleTemplateCaching\Extensions\SimpleTemplateCachingSiteConfigExtension. |
||
| 26 | * |
||
| 27 | * @property SiteConfig|SimpleTemplateCachingSiteConfigExtension $owner |
||
| 28 | * @property bool $HasCaching |
||
| 29 | * @property bool $HasPartialCaching |
||
| 30 | * @property bool $HasResourceCaching |
||
| 31 | * @property int $PublicCacheDurationInSeconds |
||
| 32 | * @property bool $RecordCacheUpdates |
||
| 33 | * @property string $CacheKeyLastEdited |
||
| 34 | * @property string $ClassNameLastEdited |
||
| 35 | * @property int $ResourceCachingTimeInSeconds |
||
| 36 | */ |
||
| 37 | class SimpleTemplateCachingSiteConfigExtension extends Extension |
||
| 38 | { |
||
| 39 | private const MAX_OBJECTS_UPDATED = 1000; |
||
| 40 | |||
| 41 | /** |
||
| 42 | * 864000 = ten days |
||
| 43 | * |
||
| 44 | * @var string |
||
| 45 | */ |
||
| 46 | private static string $image_cache_directive = ' |
||
| 47 | <IfModule mod_headers.c> |
||
| 48 | <FilesMatch "^(?:_resources/themes|assets)/.*\.(jpg|jpeg|png|gif|webp|svg|avif)$"> |
||
| 49 | Header set Cache-Control "public, max-age=864000" |
||
| 50 | </FilesMatch> |
||
| 51 | </IfModule> |
||
| 52 | '; |
||
| 53 | |||
| 54 | private static string $css_and_js_cache_directive = ' |
||
| 55 | <IfModule mod_headers.c> |
||
| 56 | <FilesMatch "^_resources/themes/.*\.(js|css)$"> |
||
| 57 | Header set Cache-Control "public, max-age=864000" |
||
| 58 | </FilesMatch> |
||
| 59 | </IfModule> |
||
| 60 | '; |
||
| 61 | |||
| 62 | private static string $font_cache_directive = ' |
||
| 63 | <IfModule mod_headers.c> |
||
| 64 | <FilesMatch "^_resources/themes/.*\.(woff|woff2|ttf|otf|eot)$"> |
||
| 65 | Header set Cache-Control "public, max-age=864000" |
||
| 66 | </FilesMatch> |
||
| 67 | </IfModule> |
||
| 68 | '; |
||
| 69 | |||
| 70 | private static $db = [ |
||
| 71 | 'HasCaching' => 'Boolean(1)', |
||
| 72 | 'HasPartialCaching' => 'Boolean(1)', |
||
| 73 | 'HasResourceCaching' => 'Boolean(1)', |
||
| 74 | 'PublicCacheDurationInSeconds' => 'Int', |
||
| 75 | 'RecordCacheUpdates' => 'Boolean(0)', |
||
| 76 | 'CacheKeyLastEdited' => 'DBDatetime', |
||
| 77 | 'ClassNameLastEdited' => 'Varchar(200)', |
||
| 78 | 'ResourceCachingTimeInSeconds' => 'Int', |
||
| 79 | ]; |
||
| 80 | |||
| 81 | public function updateCMSFields(FieldList $fields) |
||
| 82 | { |
||
| 83 | $owner = $this->getOwner(); |
||
| 84 | $name = '[none]'; |
||
| 85 | if (class_exists((string) $owner->ClassNameLastEdited)) { |
||
| 86 | $name = Injector::inst()->get($owner->ClassNameLastEdited)->i18n_singular_name(); |
||
| 87 | } |
||
| 88 | |||
| 89 | // page caching |
||
| 90 | $fields->addFieldsToTab( |
||
| 91 | 'Root.Caching', |
||
| 92 | [ |
||
| 93 | HeaderField::create('FullPageCachingHeader', 'Full Page Caching'), |
||
| 94 | CheckboxField::create('HasCaching', 'Allow caching of entire pages?') |
||
| 95 | ->setDescription( |
||
| 96 | 'You will also need to set up the cache time below for it to be enabled. |
||
| 97 | You can set a default time below, but you can also set the time for individual pages.' |
||
| 98 | ), |
||
| 99 | ] |
||
| 100 | ); |
||
| 101 | if ($owner->HasCaching) { |
||
| 102 | $fields->addFieldsToTab( |
||
| 103 | 'Root.Caching', |
||
| 104 | [ |
||
| 105 | NumericField::create('PublicCacheDurationInSeconds', 'Cache time for ALL pages') |
||
| 106 | ->setDescription( |
||
| 107 | 'USE WITH CARE - This will apply caching to ALL pages on the site. |
||
| 108 | Time is in seconds (e.g. 600 = 10 minutes). |
||
| 109 | Cache time on individual pages will override this value set here. |
||
| 110 | The total number of pages on the site with an individual caching time is: ' . Page::get()->filter('PublicCacheDurationInSeconds:GreaterThan', 0)->count() |
||
| 111 | ), |
||
| 112 | ] |
||
| 113 | ); |
||
| 114 | } |
||
| 115 | |||
| 116 | //partial caching |
||
| 117 | $fields->addFieldsToTab( |
||
| 118 | 'Root.Caching', |
||
| 119 | [ |
||
| 120 | HeaderField::create('PartialCachingHeader', 'Partial Caching'), |
||
| 121 | CheckboxField::create('HasPartialCaching', 'Allow partial template caching?') |
||
| 122 | ->setDescription( |
||
| 123 | 'This should usually be turned on unless you want to make sure no templates are cached in any part at all.' |
||
| 124 | ), |
||
| 125 | ] |
||
| 126 | ); |
||
| 127 | if ($owner->HasPartialCaching) { |
||
| 128 | $fields->addFieldsToTab( |
||
| 129 | 'Root.Caching', |
||
| 130 | [ |
||
| 131 | CheckboxField::create('RecordCacheUpdates', 'Keep a record of what is being changed?') |
||
| 132 | ->setDescription( |
||
| 133 | 'To work out when the cache is being cleared, |
||
| 134 | you can keep a record of the last ' . self::MAX_OBJECTS_UPDATED . ' records changed. |
||
| 135 | Only turn this on temporarily for tuning purposes.' |
||
| 136 | ), |
||
| 137 | ] |
||
| 138 | ); |
||
| 139 | if ($this->getOwner()->RecordCacheUpdates) { |
||
| 140 | $fields->addFieldsToTab( |
||
| 141 | 'Root.Caching', |
||
| 142 | [ |
||
| 143 | |||
| 144 | ReadonlyField::create('CacheKeyLastEditedNice', 'Last database change', $owner->dbObject('CacheKeyLastEdited')->ago()) |
||
| 145 | ->setDescription( |
||
| 146 | 'The frontend template cache will be invalidated every time this value changes. |
||
| 147 | The value changes every time anything is changed in the database.' |
||
| 148 | ), |
||
| 149 | ReadonlyField::create('ClassNameLastEditedNice', 'Last record updated', $name) |
||
| 150 | ->setDescription('The last record to invalidate the cache.'), |
||
| 151 | |||
| 152 | GridField::create( |
||
| 153 | 'ObjectsUpdated', |
||
| 154 | 'Last ' . self::MAX_OBJECTS_UPDATED . ' records updated', |
||
| 155 | ObjectsUpdated::get()->limit(self::MAX_OBJECTS_UPDATED), |
||
| 156 | GridFieldConfig_RecordViewer::create() |
||
| 157 | ) |
||
| 158 | ->setDescription( |
||
| 159 | ' |
||
| 160 | This is a list of the last ' . self::MAX_OBJECTS_UPDATED . ' records updated. |
||
| 161 | It is used to track changes to the database. |
||
| 162 | It includes: ' . ObjectsUpdated::classes_edited() |
||
| 163 | ), |
||
| 164 | ] |
||
| 165 | ); |
||
| 166 | } |
||
| 167 | } |
||
| 168 | // resource caching |
||
| 169 | $fields->addFieldsToTab( |
||
| 170 | 'Root.Caching', |
||
| 171 | [ |
||
| 172 | HeaderField::create('ResourceCachingHeader', 'Resource Caching'), |
||
| 173 | CheckboxField::create('HasResourceCaching', 'Allow caching of resources (e.g. images, styles, etc.). ') |
||
| 174 | ->setDescription( |
||
| 175 | 'This will add cache control headers to your .htaccess file for images, styles, and scripts. |
||
| 176 | This will help with performance, but once cached, a cache can not be cleared without changing the file name.' |
||
| 177 | ), |
||
| 178 | NumericField::create('ResourceCachingTimeInSeconds', 'Cache time for resources') |
||
| 179 | ->setDescription( |
||
| 180 | 'Time is in seconds (e.g. 600 = 10 minutes, 86400 = 1 day). |
||
| 181 | This will be used for all resources on the site (fonts, images, styles, and scripts).' |
||
| 182 | ), |
||
| 183 | ] |
||
| 184 | ); |
||
| 185 | } |
||
| 186 | |||
| 187 | public static function site_cache_key(): string |
||
| 188 | { |
||
| 189 | $obj = SiteConfig::current_site_config(); |
||
| 190 | if ($obj->HasPartialCaching) { |
||
| 191 | return 'ts_' . strtotime((string) $obj->CacheKeyLastEdited); |
||
| 192 | } |
||
| 193 | |||
| 194 | return 'ts_' . time(); |
||
| 195 | } |
||
| 196 | |||
| 197 | public static function update_cache_key(?string $className = '') |
||
| 198 | { |
||
| 199 | // important - avoid endless loop! |
||
| 200 | if (SiteConfig::get()->exists()) { |
||
| 201 | $howOldIsIt = DB::query('SELECT Created FROM SiteConfig LIMIT 1')->value(); |
||
| 202 | if ($howOldIsIt && strtotime((string) $howOldIsIt) > strtotime('-5 minutes')) { |
||
| 203 | return; |
||
| 204 | } |
||
| 205 | } else { |
||
| 206 | return; |
||
| 207 | } |
||
| 208 | $obj = null; |
||
|
0 ignored issues
–
show
|
|||
| 209 | try { |
||
| 210 | $obj = SiteConfig::current_site_config(); |
||
| 211 | if ($obj->HasPartialCaching) { |
||
| 212 | DB::query(' |
||
| 213 | UPDATE "SiteConfig" |
||
| 214 | SET |
||
| 215 | "CacheKeyLastEdited" = \'' . DBDatetime::now()->Rfc2822() . '\', |
||
| 216 | "ClassNameLastEdited" = \'' . addslashes((string) $className) . '\' |
||
| 217 | WHERE ID = ' . $obj->ID . ' |
||
| 218 | LIMIT 1 |
||
| 219 | ;'); |
||
| 220 | if ($obj->RecordCacheUpdates) { |
||
| 221 | $recordId = Injector::inst() |
||
| 222 | ->create(ObjectsUpdated::class, ['ClassNameLastEdited' => $className]) |
||
| 223 | ->write(); |
||
| 224 | DB::query('DELETE FROM "ObjectsUpdated" WHERE "ID" < ' . (int) ($recordId - self::MAX_OBJECTS_UPDATED)); |
||
| 225 | } |
||
| 226 | } else { |
||
| 227 | DB::query('TRUNCATE "ObjectsUpdated";'); |
||
| 228 | } |
||
| 229 | } catch (Exception $e) { |
||
| 230 | if (isset($obj) && $obj && $obj->ID) { |
||
| 231 | DB::query(' |
||
| 232 | UPDATE "SiteConfig" |
||
| 233 | SET |
||
| 234 | "CacheKeyLastEdited" = \'' . DBDatetime::now()->Rfc2822() . '\', |
||
| 235 | "ClassNameLastEdited" = \'ERROR\' |
||
| 236 | WHERE ID = ' . $obj->ID . ' |
||
| 237 | LIMIT 1 |
||
| 238 | ;'); |
||
| 239 | } |
||
| 240 | } |
||
| 241 | } |
||
| 242 | |||
| 243 | public function onAfterWrite() |
||
| 244 | { |
||
| 245 | $this->updateHtaccess(); |
||
| 246 | } |
||
| 247 | |||
| 248 | public function requireDefaultRecords() |
||
| 249 | { |
||
| 250 | $this->updateHtaccess(true); |
||
| 251 | } |
||
| 252 | |||
| 253 | protected function updateHtaccess(?bool $verbose = false) |
||
| 254 | { |
||
| 255 | $owner = $this->getOwner(); |
||
| 256 | $currentSiteConfig = SiteConfig::current_site_config(); |
||
| 257 | if ((int) SiteConfig::get()->count() > 100) { |
||
| 258 | if ($currentSiteConfig) { |
||
|
0 ignored issues
–
show
|
|||
| 259 | if ($verbose) { |
||
| 260 | DB::alteration_message('Deleting all SiteConfig records except for the current one.', 'deleted'); |
||
| 261 | } |
||
| 262 | DB::query('DELETE FROM "SiteConfig" WHERE ID <> ' . $currentSiteConfig->ID); |
||
| 263 | } |
||
| 264 | } |
||
| 265 | foreach ( |
||
| 266 | [ |
||
| 267 | 'IMAGE_CACHE_DIRECTIVE' => $currentSiteConfig->config()->get('image_cache_directive'), |
||
| 268 | 'CSS_JS_CACHE_DIRECTIVE' => $currentSiteConfig->config()->get('css_and_js_cache_directive'), |
||
| 269 | 'FONT_CACHE_DIRECTIVE' => $currentSiteConfig->config()->get('font_cache_directive'), |
||
| 270 | ] as $key => $value |
||
| 271 | ) { |
||
| 272 | if (! $currentSiteConfig->HasResourceCaching) { |
||
| 273 | $value = ''; |
||
| 274 | } |
||
| 275 | if ($owner->ResourceCachingTimeInSeconds) { |
||
| 276 | $value = str_replace('max-age=600', 'max-age=' . $owner->ResourceCachingTimeInSeconds, $value); |
||
| 277 | } |
||
| 278 | |||
| 279 | $this->updateHtaccessForOne($key, $value, $verbose); |
||
| 280 | } |
||
| 281 | } |
||
| 282 | |||
| 283 | public function DoesNotHaveCaching(): bool |
||
| 284 | { |
||
| 285 | $owner = $this->getOwner(); |
||
| 286 | return ! $owner->HasCaching; |
||
| 287 | } |
||
| 288 | |||
| 289 | protected function updateHtaccessForOne(string $code, string $toAdd, ?bool $verbose = false) |
||
| 290 | { |
||
| 291 | $htaccessPath = Controller::join_links(Director::publicFolder(), '.htaccess'); |
||
| 292 | $htaccessContent = file_get_contents($htaccessPath); |
||
| 293 | $originalContent = $htaccessContent; |
||
| 294 | |||
| 295 | // Define start and end comments |
||
| 296 | $startComment = PHP_EOL . "# auto add start " . $code . PHP_EOL; |
||
| 297 | $endComment = PHP_EOL . "# auto add end " . $code . PHP_EOL; |
||
| 298 | |||
| 299 | // Full content to replace or add |
||
| 300 | $toAddFull = $startComment . $toAdd . $endComment; |
||
| 301 | |||
| 302 | // Check if the section already exists |
||
| 303 | $pattern = "/" . preg_quote($startComment, '/') . ".*?" . preg_quote($endComment, '/') . "/s"; |
||
| 304 | if (preg_match($pattern, $htaccessContent)) { |
||
| 305 | // Replace existing content between the start and end comments |
||
| 306 | $htaccessContent = preg_replace($pattern, $toAddFull, $htaccessContent); |
||
| 307 | } else { |
||
| 308 | // Prepend the new content if not found |
||
| 309 | $htaccessContent = $toAddFull . $htaccessContent; |
||
| 310 | } |
||
| 311 | if ($originalContent !== $htaccessContent) { |
||
| 312 | // Save the updated .htaccess file |
||
| 313 | if ($verbose) { |
||
| 314 | DB::alteration_message('Updating .htaccess file with ' . $code . ' cache directive', 'created'); |
||
| 315 | } |
||
| 316 | if (!is_writable($htaccessPath)) { |
||
| 317 | if ($verbose) { |
||
| 318 | DB::alteration_message('The .htaccess file is not writable: ' . $htaccessPath, 'deleted'); |
||
| 319 | } |
||
| 320 | } |
||
| 321 | file_put_contents($htaccessPath, $htaccessContent); |
||
| 322 | } |
||
| 323 | } |
||
| 324 | } |
||
| 325 |
The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g.
excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths