Skip to content

Commit e3707c8

Browse files
committed
Add pages.media_route_urls to serve page media through the page route
Medium::url() returns the file's path on disk with GRAV_ROOT stripped, so page media is linked as /user/pages/02.my-page/report.pdf. The web server answers that path itself and Grav never starts, so onPageFallBackUrl listeners only ever see media requested through the page route. That is why the Login plugin's protect_protected_page_media has never been able to cover a direct request, and why it keeps being reported as a bypass. When enabled, each page medium is stamped with a url override pointing at <page route>/<filename>. ImageMedium::url() honours the override only for unmodified originals, so resized and cropped derivatives keep serving from the image cache. Applied in media() rather than where the collection is built because the collection is cached and the route depends on the active language and base route. Off by default; nothing changes for an existing site until it is turned on. Every web server config gains a commented user/pages deny rule, which is what actually closes the direct path. It carries a warning because it must not be enabled on its own: with media_route_urls off, denying user/pages turns every media URL on the site into a 403.
1 parent 0b004fd commit e3707c8

13 files changed

Lines changed: 160 additions & 2 deletions

File tree

.htaccess

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ RewriteRule ^(user)/accounts/(.*) error [F,NC]
7171
# served per project policy despite the same risk on this user-writable folder.
7272
RewriteCond %{REQUEST_URI} !\.(jpe?g|png|gif|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|pdf|woff2|woff|ttf|otf|eot|css|js)$ [NC]
7373
RewriteRule ^(user)/data/(.*) error [F,NC]
74+
# Serve page media only through the page route, so plugins can apply the page's
75+
# `access` rules to it (the Login plugin's "Protect a login-protected page media"
76+
# setting, for one). Page media is normally linked by its path on disk, which the
77+
# web server answers without ever starting Grav.
78+
# DO NOT enable this without first setting `pages.media_route_urls: true` in
79+
# user/config/system.yaml, or every media URL on the site becomes a 403.
80+
# RewriteRule ^(user)/pages/(.*) error [F,NC]
7481
# Block access to specific file types for these system folders
7582
RewriteRule ^(system|vendor)/(.*)\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F,NC]
7683
# Block access to specific file types for these user folders

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# v2.0.25
2+
## 09/04/2026
3+
4+
1. [](#new)
5+
* A new `pages.media_route_urls` setting, off by default, links a page's media by its page route instead of its path on disk, so plugins can apply the page's `access` rules to media requests. Resized images keep serving from the image cache
6+
* Every web server config now carries a commented rule for denying direct access to `user/pages`, which only becomes safe to enable once `pages.media_route_urls` is on
7+
18
# v2.0.24
29
## 09/03/2026
310

system/blueprints/config/system.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,17 @@ form:
177177
validate:
178178
type: bool
179179

180+
pages.media_route_urls:
181+
type: toggle
182+
label: PLUGIN_ADMIN.PAGE_MEDIA_ROUTE_URLS
183+
help: PLUGIN_ADMIN.PAGE_MEDIA_ROUTE_URLS_HELP
184+
highlight: 0
185+
options:
186+
1: PLUGIN_ADMIN.YES
187+
0: PLUGIN_ADMIN.NO
188+
validate:
189+
type: bool
190+
180191
pages.events:
181192
type: checkboxes
182193
label: PLUGIN_ADMIN.EVENTS

system/config/system.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ pages:
5353
short: 'jS M Y' # Short date format
5454
long: 'F jS \a\t g:ia' # Long date format
5555
publish_dates: true # automatically publish/unpublish based on dates
56+
media_route_urls: false # Link page media through the page route instead of its path on disk, so plugins can apply the page's `access` rules. Required before the `user/pages` deny rule in .htaccess (or your webserver-configs equivalent) can be enabled
5657
process:
5758
markdown: true # Process Markdown
5859
# NOTE: If you override this `process:` block in user/config/system.yaml,

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.24");
12+
define("GRAV_VERSION", "2.0.25");
1313
define("GRAV_SCHEMA", "1.8.0_2026-06-09_0");
1414
define("GRAV_TESTING", false);
1515

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
/**
4+
* @package Grav\Common\Media
5+
*
6+
* @copyright Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
7+
* @license MIT License; see LICENSE file for details.
8+
*/
9+
10+
namespace Grav\Common\Media;
11+
12+
use Grav\Common\Grav;
13+
use Grav\Common\Media\Interfaces\MediaCollectionInterface;
14+
use Grav\Common\Page\Interfaces\PageInterface;
15+
use function is_string;
16+
use function rawurlencode;
17+
use function rtrim;
18+
19+
/**
20+
* Rewrites page media URLs to go through the page route.
21+
*
22+
* By default `Medium::url()` returns the file's path on disk with GRAV_ROOT
23+
* stripped, so a page's media is linked as `/user/pages/02.my-page/report.pdf`.
24+
* The web server answers that path itself and Grav is never started, which is
25+
* why `onPageFallBackUrl` listeners (the Login plugin's `access` check, for one)
26+
* only ever see media requested through the page route.
27+
*
28+
* When `system.pages.media_route_urls` is enabled, every page medium is stamped
29+
* with a `url` override pointing at `<page route>/<filename>`, so links emitted
30+
* by templates and markdown go through `Grav::fallbackUrl()` and those listeners
31+
* run. `ImageMedium::url()` honours the override only for unmodified originals,
32+
* so resized and cropped derivatives keep serving straight from `images/`.
33+
*
34+
* This on its own hides the on-disk path; it does not block it. Denying
35+
* `user/pages` at the web server (see the commented rule in `.htaccess` and the
36+
* files under `webserver-configs/`) is what closes it, and that rule must not be
37+
* enabled unless this setting is on, or every media URL on the site becomes a
38+
* 403.
39+
*
40+
* @package Grav\Common\Media
41+
*/
42+
final class MediaRouteUrls
43+
{
44+
/**
45+
* Stamp a route-based `url` override on each of a page's media items.
46+
*
47+
* No-op unless `system.pages.media_route_urls` is enabled, so the default
48+
* install pays nothing for this.
49+
*
50+
* @param PageInterface $page
51+
* @param MediaCollectionInterface|null $media
52+
* @return void
53+
*/
54+
public static function apply(PageInterface $page, $media): void
55+
{
56+
if (!$media instanceof MediaCollectionInterface) {
57+
return;
58+
}
59+
60+
$grav = Grav::instance();
61+
if (!$grav['config']->get('system.pages.media_route_urls', false)) {
62+
return;
63+
}
64+
65+
// Modules are not routable on their own, but `Pages::find()` resolves
66+
// them when asked for every page, which is how `Grav::fallbackUrl()`
67+
// reaches media stored in a `_module` folder. Their route is still the
68+
// right address for that media.
69+
$route = $page->url();
70+
if (!is_string($route) || $route === '') {
71+
return;
72+
}
73+
74+
$base = rtrim($route, '/');
75+
foreach ($media->all() as $filename => $medium) {
76+
// The filename is decoded again by `Grav::fallbackUrl()`, which
77+
// reads it back through `rawurldecode()`.
78+
$medium->set('url', $base . '/' . rawurlencode((string)$filename));
79+
}
80+
}
81+
}

system/src/Grav/Common/Page/Page.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Grav\Common\Markdown\ParsedownExtra;
2323
use Grav\Common\Page\Interfaces\PageCollectionInterface;
2424
use Grav\Common\Page\Interfaces\PageInterface;
25+
use Grav\Common\Media\MediaRouteUrls;
2526
use Grav\Common\Media\Traits\MediaTrait;
2627
use Grav\Common\Page\Markdown\Excerpts;
2728
use Grav\Common\Page\Traits\PageFormTrait;
@@ -1603,6 +1604,10 @@ public function media($var = null)
16031604
/** @var Media $media */
16041605
$media = $this->getMedia();
16051606

1607+
// Applied here rather than in getMedia() because the media collection is
1608+
// cached, and the route depends on the active language and base route.
1609+
MediaRouteUrls::apply($this, $media);
1610+
16061611
return $media;
16071612
}
16081613

system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Grav\Common\Grav;
1515
use Grav\Common\Markdown\Parsedown;
1616
use Grav\Common\Markdown\ParsedownExtra;
17+
use Grav\Common\Media\MediaRouteUrls;
1718
use Grav\Common\Page\Header;
1819
use Grav\Common\Page\Interfaces\PageInterface;
1920
use Grav\Common\Page\Markdown\Excerpts;
@@ -192,7 +193,15 @@ public function media($var = null): Media
192193
$this->setProperty('media', $var);
193194
}
194195

195-
return $this->getProperty('media');
196+
$media = $this->getProperty('media');
197+
198+
// Applied here rather than where the collection is built because the
199+
// route depends on the active language and base route. See Page::media().
200+
if ($this instanceof PageInterface) {
201+
MediaRouteUrls::apply($this, $media);
202+
}
203+
204+
return $media;
196205
}
197206

198207
/**

webserver-configs/Caddyfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ root * .
4747
not path_regexp (?i)\.(jpe?g|png|gif|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|pdf)$
4848
}
4949

50+
# Serve page media only through the page route, so plugins can apply the page's
51+
# `access` rules to it. DO NOT uncomment this matcher and its `respond` line
52+
# below without first setting `pages.media_route_urls: true` in
53+
# user/config/system.yaml, or every media URL on the site becomes a 403.
54+
# @denied_user_pages {
55+
# path_regexp (?i)^/user/pages/
56+
# }
57+
5058
# deny running scripts inside core system folders
5159
@denied_system path_regexp (?i)^/(system|vendor)/.*\.(txt|xml|md|html|htm|shtml|shtm|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$
5260

@@ -65,6 +73,7 @@ route {
6573
respond @denied_user_config 403
6674
respond @denied_user_accounts 403
6775
respond @denied_user_data 403
76+
# respond @denied_user_pages 403
6877
respond @denied_system 403
6978
respond @denied_user_scripts 403
7079
respond @denied_root_files 403

webserver-configs/htaccess.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ RewriteRule ^(user)/accounts/(.*) error [F,NC]
7171
# served per project policy despite the same risk on this user-writable folder.
7272
RewriteCond %{REQUEST_URI} !\.(jpe?g|png|gif|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|pdf|woff2|woff|ttf|otf|eot|css|js)$ [NC]
7373
RewriteRule ^(user)/data/(.*) error [F,NC]
74+
# Serve page media only through the page route, so plugins can apply the page's
75+
# `access` rules to it (the Login plugin's "Protect a login-protected page media"
76+
# setting, for one). Page media is normally linked by its path on disk, which the
77+
# web server answers without ever starting Grav.
78+
# DO NOT enable this without first setting `pages.media_route_urls: true` in
79+
# user/config/system.yaml, or every media URL on the site becomes a 403.
80+
# RewriteRule ^(user)/pages/(.*) error [F,NC]
7481
# Block access to specific file types for these system folders
7582
RewriteRule ^(system|vendor)/(.*)\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F,NC]
7683
# Block access to specific file types for these user folders

0 commit comments

Comments
 (0)