Skip to content

Commit bc21608

Browse files
committed
Merge branch 'release/2.0.22'
2 parents b06ece4 + 1e8fc07 commit bc21608

37 files changed

Lines changed: 2170 additions & 194 deletions

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,46 @@
1+
# v2.0.22
2+
## 08/31/2026
3+
4+
1. [](#new)
5+
* The `url()` Twig function now takes a language, so a link to a route that isn't a page - a search page, a form action - can carry the site's language prefix: `{{ url('/search', lang=true) }}`
6+
* Blueprints can use a `media` field type, which saves a picked file as its path and keeps a list of them when the field allows more than one
7+
* Page collections can now exclude one or more template types with `notOfType()`, the counterpart to the existing `ofType()` [#3910](https://github.com/getgrav/grav/issues/3910)
8+
* The scheduler can now run the jobs that have missed their scheduled time, rather than only the ones due this very minute. Run it with `bin/grav scheduler --catch-up`, which is what you want on a site that has no cron entry set up
9+
1. [](#improved)
10+
* Twig in page content can no longer read the site's configuration through the `print_r`, `vardump`, `json_encode`, `yaml_encode` and `string` filters. The check that was meant to stop it had been asking whether the whole site was in sandbox mode, which Grav never does — it decides per template — so it had been letting everything through. Thanks to @Vectrain51
11+
* The `|map`, `|filter` and `|reduce` Twig filters now check for themselves whether the template calling them is sandboxed, rather than relying on Twig to work it out. Thanks to @DhiyaneshGeek
12+
* The scheduler no longer rebuilds a queued job from an unsigned queue file, so a file written into the queue folder by something other than Grav can at most re-run a job the site was already set up to run. The page index also refuses to build objects while reading its cache. Thanks to @elite0529
13+
* Plugin and theme descriptions are now rendered in Parsedown's safe mode before admin displays them, so a description carrying raw HTML shows as text rather than being rendered. Thanks to @alham-rizvi
14+
* The debugger's Clockwork data endpoint now answers only requests coming from the server itself, or requests presenting the secret set in the new `debugger.token` option. Cookies and API tokens are no longer recorded in profiler data whatever the `censored` option is set to
15+
* Building a URL is now faster, which adds up over the hundreds of asset and link URLs a single page render produces
16+
* The scheduler now records whether a run was started by cron or by hand, and a run you started yourself no longer counts as evidence that cron is set up
17+
* `bin/grav scheduler -r` now records the run against each job, the same as a scheduled run, so the next run knows what has already happened
18+
* A field that is rejected only for being too long or too short now says so, and gives both the length submitted and the limit, instead of the same "Invalid input" any other bad value gets
19+
* Multiline fields no longer carry a length limit low enough to affect real writing. Set `max: 0` on a field to remove the limit altogether
20+
1. [](#bugfix)
21+
* The `|reduce` Twig filter now actually reduces. It was running the `|map` code by mistake and throwing away the starting value, so `[1,2,3]|reduce((c, v) => c + v, 0)` gave back a list instead of `6`. Thanks to @DhiyaneshGeek
22+
* A form field's `minlength` and `maxlength` are now checked when the form is submitted, not only by the browser. They were being written into the page as HTML attributes but ignored on the server, so anything that skipped the browser's own check went straight through [#642](https://github.com/getgrav/grav-plugin-form/issues/642)
23+
* A field with a `step` set now accepts the lengths and counts that land on a step, and rejects the ones that do not. The check was the wrong way round, so it rejected exactly the values it was meant to allow
24+
* A long page can be saved from the admin again. Page content was capped at 65,536 characters, so anything longer than roughly twenty pages of text was refused, and the only way to edit it was to write the file directly [#3643](https://github.com/getgrav/grav/issues/3643)
25+
* A site installed in a subfolder no longer mangles URLs whose path repeats the install folder's name, such as an image at `/images/subdir/photo.jpg` on a site installed at `/subdir`
26+
* A link to a page that carries a query string or an anchor, such as `/blog?page=2`, now resolves to the page and keeps its language prefix, instead of being passed through as a plain path
27+
* On a site installed in a subfolder, links written with the full path now resolve to the page, so they pick up the site's language and page extension
28+
* A cache folder that the web server cannot write to no longer takes the whole site down. Grav now logs a warning naming the folder and serves the request without the cache, so the front end and the admin both stay reachable and the Problems plugin can report what is wrong [#4260](https://github.com/getgrav/grav/issues/4260)
29+
* The same failure writing `user/config/versions.yaml` no longer stops the site either [#3688](https://github.com/getgrav/grav/issues/3688)
30+
* Errors about a file that cannot be written now name the folder and say whether it is missing or not writable, instead of only reporting the file
31+
* A session cookie name starting with `__Secure-` or `__Host-` now keeps that prefix and is sent with the settings browsers require for it, so the extra protection those prefixes give actually applies. Thanks to @wakqasahmed for the fix [#3773](https://github.com/getgrav/grav/issues/3773)
32+
Note: sites whose `system.session.name` contains capitals, underscores or a leading or trailing dash will get a slightly different cookie name after this update, which signs their users out once.
33+
* The scheduler no longer reports that cron is not set up when the crontab entry is written in a valid but slightly different style, such as one using `&&` or an absolute path to `bin/grav`
34+
* On a site with a custom scheduler job of its own, looking up a job by name no longer misses every job the system and its plugins register, so the backup and cache jobs can be found and run individually
35+
* A scheduler job that finished its work but could not then write its output file, send its notification email or run its callback no longer aborts the whole run. The remaining jobs run, every result is still recorded, and the problem is written to the log
36+
* A scheduler job registered without a schedule of its own no longer causes an error when its next run time is worked out
37+
* `bin/grav scheduler -j` no longer fails on a site with jobs registered by a plugin, and `-d` no longer fails on a job that has never run
38+
* A scheduler job that runs one of Grav's own command line scripts now works when the scheduler is triggered from the web rather than from cron. Those jobs used to fail with "env: php: No such file or directory", because the web server does not have php on its path
39+
* A scheduler job registered as a whole command line, such as `bin/plugin myplugin sync`, now runs. Only the executable and its arguments given separately used to work, so a job written the other way looked for a file whose name contained spaces and failed every time it ran
40+
* A site served from a subpath by a proxy no longer loses that subpath when a trailing slash is redirected, which previously sent visitors outside the site. The homepage of such a site also no longer redirects to the bare domain. Thanks to @wakqasahmed for the fix [#3822](https://github.com/getgrav/grav/issues/3822)
41+
* With `force_ssl` turned on, a page that does not exist now redirects to HTTPS like every other page, instead of serving the 404 over plain HTTP. Thanks to @wakqasahmed for the fix [#3703](https://github.com/getgrav/grav/issues/3703)
42+
* Image settings are no longer applied to audio, video, SVG or document media. An embedded MP3 kept its player instead of being turned into a linked thumbnail, and media URLs no longer pick up stray `loading`, `decoding` and `fetchpriority` values, which happened on every site whether or not those settings had been changed. Thanks to @wakqasahmed for the fix [#4264](https://github.com/getgrav/grav/issues/4264)
43+
144
# v2.0.21
245
## 08/22/2026
346

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Every published advisory is a signal to operators that they need to act today. W
2626
* **Anything an admin or super-admin can do within their granted capabilities.** Editing config, running CLI or scheduler tasks, installing plugins and themes, writing Twig into templates, using the file manager, reaching the filesystem through admin-only tooling.
2727
* **Publisher-authored content executing in the publisher's own scope.** Twig or markdown that a page editor writes and that runs as that page editor is the feature.
2828
* **`Security::detectXss()` bypasses.** `detectXss()` is a heuristic denylist used to flag suspicious content for humans. It is explicitly **not** a security boundary, it never was complete, and it cannot be. Grav's actual XSS defense is escaping at output. A new string that slips past the pattern list is not a vulnerability and we will not issue advisories for it. If you have found content that renders unescaped at output, that *is* in scope, so report that instead and show the rendered sink.
29+
* **`Utils::isDangerousFunction()` bypasses.** Like `detectXss()`, this is a denylist: a list of PHP function names the Twig callback filters refuse. It exists to make the common mistakes loud, not to enumerate every harmful function in PHP, and it cannot. The Twig content sandbox is the actual boundary, and inside sandboxed content the callback filters reject string callables outright, closure or nothing. A report that a function absent from the denylist can be called is not a vulnerability. A report that a **string callable of any kind** executes inside sandboxed content is, and we want that one, so show the sandboxed template and the rendered output.
2930
* **Account or email enumeration** through registration, password reset, or login messaging. This is documented, intentional behavior in the default configuration.
3031
* **Self-XSS**, or any issue requiring the victim to paste a payload into their own browser or admin form.
3132
* **Reports with no working proof of concept.** Static analyzer output, LLM-generated code readings, and "this pattern looks unsafe" reports without a demonstrated exploit path are closed. We do not have the capacity to build the PoC for you.

system/blueprints/config/system.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,13 @@ form:
13461346
validate:
13471347
type: bool
13481348

1349+
debugger.token:
1350+
type: text
1351+
size: medium
1352+
label: PLUGIN_ADMIN.DEBUGGER_TOKEN
1353+
help: PLUGIN_ADMIN.DEBUGGER_TOKEN_HELP
1354+
autocomplete: 'off'
1355+
13491356
debugger.shutdown.close_connection:
13501357
type: toggle
13511358
label: PLUGIN_ADMIN.SHUTDOWN_CLOSE_CONNECTION

system/blueprints/pages/default.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ form:
3434
type: markdown
3535
validate:
3636
type: textarea
37+
# Page body has no length ceiling: `0` opts out of the default
38+
# multiline guard, which used to reject long pages (#3643).
39+
max: 0
3740

3841
header.media_order:
3942
type: pagemedia

system/config/system.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ debugger:
179179
enabled: false # Enable Grav debugger and following settings
180180
provider: clockwork # Debugger provider: debugbar | clockwork
181181
censored: false # Censor potentially sensitive information (POST parameters, cookies, files, configuration and most array/object data in log messages)
182+
token: '' # Shared secret a remote client must send as `X-Clockwork-Auth` to read `/__clockwork/` profiler data. Empty means loopback-only
182183
shutdown:
183184
close_connection: true # Close the connection before calling onShutdown(). false for debugging
184185

@@ -218,7 +219,7 @@ session:
218219
initialize: true # Initialize session from Grav (if false, plugin needs to start the session)
219220
read_and_close: false # Release the session lock right after the initial read (read-only start); the first write re-acquires it. Lets requests sharing a session id run concurrently instead of serializing on PHP's per-session lock. Opt-in: read-modify-write across a request is no longer atomic.
220221
timeout: 1800 # Timeout in seconds
221-
name: grav-site # Name prefix of the session cookie. Use alphanumeric, dashes or underscores only. Do not use dots in the session name
222+
name: grav-site # Name prefix of the session cookie. Use alphanumeric, dashes or underscores only. Do not use dots in the session name. A `__Secure-` prefix requires HTTPS; a `__Host-` prefix additionally forces no cookie domain and a site-wide path
222223
uniqueness: path # Should sessions be `path` based or `security.salt` based
223224
secure: false # Set session secure. If true, indicates that communication for this cookie must be over an encrypted transmission. Enable this only on sites that run exclusively on HTTPS
224225
secure_https: true # Set session secure on HTTPS but not on HTTP. Has no effect if you have `session.secure: true`. Set to false if your site jumps between HTTP and HTTPS.

system/defines.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
// Some standard defines
1111
define("GRAV", true);
12-
define("GRAV_VERSION", "2.0.21");
12+
define("GRAV_VERSION", "2.0.22");
1313
define("GRAV_SCHEMA", "1.8.0_2026-06-09_0");
1414
define("GRAV_TESTING", false);
1515

system/languages/en.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ GRAV:
9797
VALIDATION_FAIL: '<b>Validation failed:</b>'
9898
INVALID_INPUT: 'Invalid input in'
9999
UNEXPECTED_VALUES: '(unexpected value(s): %s)'
100+
LENGTH_TOO_LONG: '(%s characters, maximum is %s)'
101+
LENGTH_TOO_SHORT: '(%s characters, minimum is %s)'
100102
MISSING_REQUIRED_FIELD: 'Missing required field:'
101103
XSS_ISSUES: "Potential XSS issues detected in '%s' field"
102104
MONTHS_OF_THE_YEAR: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']

system/src/Grav/Common/Config/CompiledBase.php

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
use BadMethodCallException;
1313
use Exception;
14+
use Grav\Common\Grav;
1415
use RocketTheme\Toolbox\File\PhpFile;
1516
use RuntimeException;
17+
use Throwable;
1618
use function filter_var;
1719
use function function_exists;
1820
use function get_class;
@@ -228,7 +230,6 @@ protected function loadCompiledFile($filename)
228230
*
229231
* @param string $filename
230232
* @return void
231-
* @throws RuntimeException
232233
* @internal
233234
*/
234235
protected function saveCompiledFile($filename)
@@ -255,14 +256,73 @@ protected function saveCompiledFile($filename)
255256
'data' => $this->getState()
256257
];
257258

258-
$file->save($cache);
259-
$file->unlock();
259+
// The compiled file is a cache and can always be rebuilt from the source
260+
// YAML. If it cannot be written we serve the request from the freshly
261+
// parsed files instead of taking the whole site down: this runs during
262+
// config init, before the logger, the error handler and the Problems
263+
// plugin exist, so an exception here 500s every route including /admin
264+
// and leaves no in-browser way back. (#4260)
265+
try {
266+
$file->save($cache);
267+
$file->unlock();
268+
269+
$this->preloadOpcodeCache($file);
270+
271+
$file->free();
260272

261-
$this->preloadOpcodeCache($file);
273+
$this->modified();
274+
} catch (Throwable $e) {
275+
static::logCacheWriteFailure($filename, $e->getMessage());
276+
277+
$file->unlock();
278+
$file->free();
279+
}
280+
}
262281

263-
$file->free();
282+
/**
283+
* Record that a compiled cache file could not be written and that the request
284+
* is being served uncached.
285+
*
286+
* Degrading is the right behaviour, but doing it silently hides what is
287+
* almost always a directory permission problem, so name the directory and say
288+
* what is wrong with it. The logger is resolved defensively and the whole
289+
* call is guarded, so reporting a degraded cache can never itself become the
290+
* fatal we are recovering from.
291+
*
292+
* @param string $filename Cache file that could not be written.
293+
* @param string $reason Failure reported by the writer.
294+
* @return void
295+
*/
296+
public static function logCacheWriteFailure(string $filename, string $reason): void
297+
{
298+
$dir = dirname($filename);
299+
if (!is_dir($dir)) {
300+
$hint = sprintf('the directory %s does not exist', $dir);
301+
} elseif (!is_writable($dir)) {
302+
$hint = sprintf('the directory %s is not writable by the web server user', $dir);
303+
} else {
304+
$hint = sprintf('the directory %s is writable, so the file itself may be owned by another user', $dir);
305+
}
306+
307+
$message = sprintf(
308+
'Could not write compiled cache %s (%s) - %s. Serving this request uncached.',
309+
$filename,
310+
$reason,
311+
$hint
312+
);
313+
314+
try {
315+
$log = Grav::instance()['log'] ?? null;
316+
if ($log) {
317+
$log->warning($message);
318+
319+
return;
320+
}
321+
} catch (Throwable) {
322+
// Logging is best-effort: never let it mask the recovery it reports.
323+
}
264324

265-
$this->modified();
325+
error_log('Grav: ' . $message);
266326
}
267327

268328
/**

0 commit comments

Comments
 (0)