Skip to content

Commit ddc8740

Browse files
committed
[#235] Updates to styles.php
Implemented dynamic loading and caching. (cherry picked from commit 57a7b1d)
1 parent 9dc0455 commit ddc8740

File tree

3 files changed

+174
-4
lines changed

3 files changed

+174
-4
lines changed

classes/local/hooks/output/before_http_headers.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,38 @@ class before_http_headers {
3131
* @param \core\hook\output\before_http_headers $unused
3232
*/
3333
public static function callback(\core\hook\output\before_http_headers $unused): void {
34-
global $PAGE;
35-
$PAGE->requires->css('/course/format/onetopic/styles.php');
34+
global $PAGE, $COURSE;
35+
36+
// Don't require styles script if the course format isn't 'onetopic'.
37+
if ($PAGE->course && isset($COURSE->id) && $COURSE->format == 'onetopic') {
38+
39+
// Check if site-wide tab styles are configured, if not, do nothing.
40+
if (!get_config('format_onetopic', 'tabstyles')) {
41+
return;
42+
}
43+
44+
$revision = self::get_tabstyles_revision();
45+
$PAGE->requires->css(new \moodle_url('/course/format/onetopic/styles.php', [
46+
'revision' => $revision,
47+
]));
48+
}
49+
}
50+
51+
/**
52+
* Generates an 8-character hash from the tab styles configuration.
53+
* When styles change, the hash changes, creating a new URL that
54+
* busts the cache.
55+
*
56+
* @return string
57+
*/
58+
public static function get_tabstyles_revision(): string {
59+
$tabstyles = get_config('format_onetopic', 'tabstyles');
60+
61+
if (empty($tabstyles)) {
62+
return '0';
63+
}
64+
65+
// Use first 8 chars of md5 hash as revision.
66+
return substr(md5($tabstyles), 0, 8);
3667
}
3768
}

styles.php

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@
2828
// phpcs:disable moodle.Files.RequireLogin.Missing
2929
require_once('../../../config.php');
3030

31-
@header('Content-Disposition: inline; filename="styles.php"');
32-
@header('Content-Type: text/css; charset=utf-8');
31+
$revision = optional_param('revision', 0, PARAM_ALPHANUM);
3332

3433
$withunits = ['font-size', 'line-height', 'margin', 'padding', 'border-width', 'border-radius'];
3534
$csscontent = '';
@@ -137,4 +136,37 @@
137136
}
138137
}
139138

139+
$csstabstyles = trim($csstabstyles);
140+
$etag = md5($csstabstyles . $revision);
141+
142+
// ETag validation: Return 304 if client has the current version. This will
143+
// preserve the existing cache for $cache_lifetime.
144+
if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
145+
$clientetag = trim($_SERVER['HTTP_IF_NONE_MATCH'], '"');
146+
if ($clientetag === $etag) {
147+
header('HTTP/1.1 304 Not Modified');
148+
header('ETag: "' . $etag . '"');
149+
exit;
150+
}
151+
}
152+
153+
// Return empty response for edge cases (no styles configured & direct URL access).
154+
if (empty($csstabstyles)) {
155+
header('HTTP/1.1 304 Not Modified');
156+
header('Content-Length: 0');
157+
exit;
158+
}
159+
160+
// Cache for 1 year, this is safe due to cache busting via revision param.
161+
$cache_lifetime = 31536000;
162+
header('Cache-Control: public, max-age=' . $cache_lifetime . ', immutable');
163+
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $cache_lifetime) . ' GMT');
164+
header('ETag: "' . $etag . '"');
165+
166+
// Content headers.
167+
header('Content-Length: ' . strlen($csstabstyles));
168+
header('Content-Disposition: inline; filename="styles.php"');
169+
header('Content-Type: text/css; charset=utf-8');
170+
header('X-Content-Type-Options: nosniff');
171+
140172
echo $csstabstyles;

tests/styles_caching_test.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
namespace format_onetopic;
18+
19+
use format_onetopic\local\hooks\output\before_http_headers;
20+
21+
/**
22+
* Tests for styles.php caching functionality.
23+
*
24+
* @package format_onetopic
25+
* @author Jonathan Archer <jonathanarcher@catalyst-au.net>
26+
* @copyright Catalyst IT
27+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28+
* @covers \format_onetopic\local\hooks\output\before_http_headers::get_tabstyles_revision
29+
*/
30+
class styles_caching_test extends \advanced_testcase {
31+
32+
/**
33+
* Test revision is '0' when no tab styles configured.
34+
*/
35+
public function test_revision_empty_when_no_styles(): void {
36+
$this->resetAfterTest(true);
37+
38+
set_config('tabstyles', '', 'format_onetopic');
39+
40+
$revision = before_http_headers::get_tabstyles_revision();
41+
$this->assertEquals('0', $revision);
42+
}
43+
44+
/**
45+
* Test revision is valid hash when tab styles exist.
46+
*/
47+
public function test_revision_is_hash_when_styles_exist(): void {
48+
$this->resetAfterTest(true);
49+
50+
$tabstyles = json_encode(['default' => ['color' => 'red']]);
51+
set_config('tabstyles', $tabstyles, 'format_onetopic');
52+
53+
$revision = before_http_headers::get_tabstyles_revision();
54+
55+
$this->assertIsString($revision);
56+
$this->assertEquals(8, strlen($revision));
57+
$this->assertMatchesRegularExpression('/^[a-f0-9]{8}$/', $revision);
58+
}
59+
60+
/**
61+
* Test revision changes when tab styles change.
62+
*/
63+
public function test_revision_changes_when_styles_change(): void {
64+
$this->resetAfterTest(true);
65+
66+
$tabstyles1 = json_encode(['default' => ['color' => 'red']]);
67+
set_config('tabstyles', $tabstyles1, 'format_onetopic');
68+
$revision1 = before_http_headers::get_tabstyles_revision();
69+
70+
$tabstyles2 = json_encode(['default' => ['color' => 'blue']]);
71+
set_config('tabstyles', $tabstyles2, 'format_onetopic');
72+
$revision2 = before_http_headers::get_tabstyles_revision();
73+
74+
$this->assertNotEquals($revision1, $revision2);
75+
}
76+
77+
/**
78+
* Test revision stays same for identical styles.
79+
*/
80+
public function test_revision_consistent_for_same_styles(): void {
81+
$this->resetAfterTest(true);
82+
83+
$tabstyles = json_encode(['default' => ['color' => 'red']]);
84+
set_config('tabstyles', $tabstyles, 'format_onetopic');
85+
86+
$revision1 = before_http_headers::get_tabstyles_revision();
87+
$revision2 = before_http_headers::get_tabstyles_revision();
88+
89+
$this->assertEquals($revision1, $revision2);
90+
}
91+
92+
/**
93+
* Test revision is deterministic (same input = same hash).
94+
*/
95+
public function test_revision_is_deterministic(): void {
96+
$this->resetAfterTest(true);
97+
98+
$tabstyles = json_encode(['default' => ['color' => 'red']]);
99+
set_config('tabstyles', $tabstyles, 'format_onetopic');
100+
101+
$expected = substr(md5($tabstyles), 0, 8);
102+
$actual = before_http_headers::get_tabstyles_revision();
103+
104+
$this->assertEquals($expected, $actual);
105+
}
106+
107+
}

0 commit comments

Comments
 (0)