Passed
Push — master ( 685068...b74428 )
by Nicolaas
04:45
created

onBeforeWrite()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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