Issues (23)

SimpleTemplateCachingSiteConfigExtension.php (3 issues)

1
<?php
2
3
namespace Sunnysideup\SimpleTemplateCaching\Extensions;
4
5
use Exception;
6
use Page;
0 ignored issues
show
The type Page was not found. Maybe you did not declare it correctly or list all dependencies?

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:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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
The assignment to $obj is dead and can be removed.
Loading history...
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
$currentSiteConfig is of type SilverStripe\SiteConfig\SiteConfig, thus it always evaluated to true.
Loading history...
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