From 6ef2fc42f93f8fb320987ed57a1ef7c5bcbc75da Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Thu, 25 Dec 2025 04:14:17 +0100 Subject: [PATCH 1/7] constants: Avoid manual requires We can just use `files` autoloader and have composer require it automatically. --- composer.json | 5 ++++- phpstan.dist.neon | 1 - src/common.php | 2 -- tests/bootstrap.php | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index ddd7ef488e..cde6e41ab9 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,10 @@ "spouts\\": "src/spouts/", "Selfoss\\": "src/", "Tests\\": "tests/" - } + }, + "files": [ + "src/constants.php" + ] }, "config": { "platform": { diff --git a/phpstan.dist.neon b/phpstan.dist.neon index a78908083d..1238309f14 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -9,7 +9,6 @@ parameters: - run.php bootstrapFiles: - - src/constants.php - vendor/bin/.phpunit/phpunit/vendor/autoload.php stubFiles: diff --git a/src/common.php b/src/common.php index 708917d1d5..83aa3beac3 100644 --- a/src/common.php +++ b/src/common.php @@ -20,8 +20,6 @@ use Symfony\Component\Cache\Psr16Cache; use Tracy\Debugger; -require __DIR__ . '/constants.php'; - function boot_error(string $message): never { http_response_code(500); header('Content-Type: text/plain'); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3432a0d2c8..976b8baef9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,7 +2,6 @@ declare(strict_types=1); -require __DIR__ . '/../src/constants.php'; require __DIR__ . '/../vendor/autoload.php'; date_default_timezone_set('UTC'); From 29a47a0c133dc1f6bdb9c547650bdff3a9a15857 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Mon, 6 Oct 2025 10:54:25 +0200 Subject: [PATCH 2/7] index.php: Move routes to a class Simplifies the entry point, similarly to what Nette does. --- index.php | 164 ++-------------------------------------- src/Web/Routes.php | 185 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 159 deletions(-) create mode 100644 src/Web/Routes.php diff --git a/index.php b/index.php index ae685a528d..2578cdd645 100644 --- a/index.php +++ b/index.php @@ -2,167 +2,13 @@ declare(strict_types=1); +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Jan Tojnar + use Psr\Container\ContainerInterface; -use Selfoss\controllers; require __DIR__ . '/src/common.php'; /** @var ContainerInterface $container */ -$router = $container->get(Bramus\Router\Router::class); - -// define routes - -// all users -$router->get('/', function() use ($container): void { - // json - $container->get(controllers\Index::class)->home(); -}); -$router->get('/api/about', function() use ($container): void { - // json - $container->get(controllers\About::class)->about(); -}); -$router->post('/api/private/hash-password', function() use ($container): void { - // json - $container->get(controllers\Helpers\HashPassword::class)->hash(); -}); -$router->get('/login', function() use ($container): void { - // json, deprecated - $container->get(controllers\Authentication::class)->login(); -}); -$router->post('/login', function() use ($container): void { - // json - $container->get(controllers\Authentication::class)->login(); -}); -$router->get('/logout', function() use ($container): void { - // json, deprecated - $container->get(controllers\Authentication::class)->logout(); -}); -$router->delete('/api/session/current', function() use ($container): void { - // json - $container->get(controllers\Authentication::class)->logout(); -}); -$router->get('/update', function() use ($container): void { - // text - $container->get(controllers\Sources\Update::class)->updateAll(); -}); - -// only for loggedin users or on public mode -$router->get('/rss', function() use ($container): void { - // rss - $container->get(controllers\Rss::class)->rss(); -}); -$router->get('/feed', function() use ($container): void { - // rss - $container->get(controllers\Rss::class)->rss(); -}); -$router->get('/items', function() use ($container): void { - // json - $container->get(controllers\Items::class)->listItems(); -}); -$router->get('/tags', function() use ($container): void { - // json - $container->get(controllers\Tags::class)->listTags(); -}); -$router->get('/stats', function() use ($container): void { - // json - $container->get(controllers\Items\Stats::class)->stats(); -}); -$router->get('/items/sync', function() use ($container): void { - // json - $container->get(controllers\Items\Sync::class)->sync(); -}); -$router->get('/sources/stats', function() use ($container): void { - // json - $container->get(controllers\Sources::class)->stats(); -}); - -// only loggedin users -$router->post('/mark/([0-9]+)', function(string $itemId) use ($container): void { - // json - $container->get(controllers\Items::class)->mark($itemId); -}); -$router->post('/mark', function() use ($container): void { - // json - $container->get(controllers\Items::class)->mark(); -}); -$router->post('/unmark/([0-9]+)', function(string $itemId) use ($container): void { - // json - $container->get(controllers\Items::class)->unmark($itemId); -}); -$router->post('/starr/([0-9]+)', function(string $itemId) use ($container): void { - // json - $container->get(controllers\Items::class)->starr($itemId); -}); -$router->post('/unstarr/([0-9]+)', function(string $itemId) use ($container): void { - // json - $container->get(controllers\Items::class)->unstarr($itemId); -}); -$router->post('/items/sync', function() use ($container): void { - // json - $container->get(controllers\Items\Sync::class)->updateStatuses(); -}); - -$router->get('/source/params', function() use ($container): void { - // json - $container->get(controllers\Sources::class)->params(); -}); -$router->get('/sources', function() use ($container): void { - // json - $container->get(controllers\Sources::class)->show(); -}); -$router->get('/sources/list', function() use ($container): void { - // json - $container->get(controllers\Sources::class)->listSources(); -}); -$router->post('/source/((?:new-)?[0-9]+)', function(string $id) use ($container): void { - // json - $container->get(controllers\Sources\Write::class)->write($id); -}); -$router->post('/source', function() use ($container): void { - // json - $container->get(controllers\Sources\Write::class)->write(); -}); -$router->delete('/source/([0-9]+)', function(string $id) use ($container): void { - // json - $container->get(controllers\Sources::class)->remove($id); -}); -$router->post('/source/delete/([0-9]+)', function(string $id) use ($container): void { - // json, deprecated - $container->get(controllers\Sources::class)->remove($id); -}); -$router->post('/source/([0-9]+)/update', function(string $id) use ($container): void { - // json - $container->get(controllers\Sources\Update::class)->update($id); -}); -$router->get('/sources/spouts', function() use ($container): void { - // json - $container->get(controllers\Sources::class)->spouts(); -}); - -$router->post('/tags/color', function() use ($container): void { - // json - $container->get(controllers\Tags::class)->color(); -}); - -$router->post('/opml', function() use ($container): void { - // json - $container->get(controllers\Opml\Import::class)->add(); -}); -$router->get('/opmlexport', function() use ($container): void { - // xml - $container->get(controllers\Opml\Export::class)->export(); -}); - -// Client side routes need to be directed to index.html. -$router->get('/sign/in|/opml|/password|/manage/sources(/add)?|/(newest|unread|starred)(/(all|tag-[^/]+|source-[0-9]+)(/[0-9]+)?)?', function() use ($container): void { - // html - $container->get(controllers\Index::class)->home(); -}); - -$router->set404(function(): void { - header('HTTP/1.1 404 Not Found'); - echo 'Page not found.'; -}); - -// dispatch -$router->run(); +$routes = $container->get(Selfoss\Web\Routes::class); +$routes->run(); diff --git a/src/Web/Routes.php b/src/Web/Routes.php new file mode 100644 index 0000000000..83772f1946 --- /dev/null +++ b/src/Web/Routes.php @@ -0,0 +1,185 @@ + +// SPDX-FileCopyrightText: 2025 Jan Tojnar + +namespace Selfoss\Web; + +use Bramus\Router\Router; +use Psr\Container\ContainerInterface; +use Selfoss\controllers; + +/** + * Defines API routes serving as an entry point to the web app. + */ +final class Routes { + public function __construct( + private Router $router, + private ContainerInterface $container + ) { + } + + private function setupRoutes(): void { + // all users + $this->router->get('/', function(): void { + // html/json + $this->container->get(controllers\Index::class)->home(); + }); + $this->router->get('/api/about', function(): void { + // json + $this->container->get(controllers\About::class)->about(); + }); + $this->router->post('/api/private/hash-password', function(): void { + // json + $this->container->get(controllers\Helpers\HashPassword::class)->hash(); + }); + $this->router->get('/login', function(): void { + // json, deprecated + $this->container->get(controllers\Authentication::class)->login(); + }); + $this->router->post('/login', function(): void { + // json + $this->container->get(controllers\Authentication::class)->login(); + }); + $this->router->get('/logout', function(): void { + // json, deprecated + $this->container->get(controllers\Authentication::class)->logout(); + }); + $this->router->delete('/api/session/current', function(): void { + // json + $this->container->get(controllers\Authentication::class)->logout(); + }); + $this->router->get('/update', function(): void { + // text + $this->container->get(controllers\Sources\Update::class)->updateAll(); + }); + + // only for loggedin users or on public mode + $this->router->get('/rss', function(): void { + // rss + $this->container->get(controllers\Rss::class)->rss(); + }); + $this->router->get('/feed', function(): void { + // rss + $this->container->get(controllers\Rss::class)->rss(); + }); + $this->router->get('/items', function(): void { + // json + $this->container->get(controllers\Items::class)->listItems(); + }); + $this->router->get('/tags', function(): void { + // json + $this->container->get(controllers\Tags::class)->listTags(); + }); + $this->router->get('/stats', function(): void { + // json + $this->container->get(controllers\Items\Stats::class)->stats(); + }); + $this->router->get('/items/sync', function(): void { + // json + $this->container->get(controllers\Items\Sync::class)->sync(); + }); + $this->router->get('/sources/stats', function(): void { + // json + $this->container->get(controllers\Sources::class)->stats(); + }); + + // only loggedin users + $this->router->post('/mark/([0-9]+)', function(string $itemId): void { + // json + $this->container->get(controllers\Items::class)->mark($itemId); + }); + $this->router->post('/mark', function(): void { + // json + $this->container->get(controllers\Items::class)->mark(); + }); + $this->router->post('/unmark/([0-9]+)', function(string $itemId): void { + // json + $this->container->get(controllers\Items::class)->unmark($itemId); + }); + $this->router->post('/starr/([0-9]+)', function(string $itemId): void { + // json + $this->container->get(controllers\Items::class)->starr($itemId); + }); + $this->router->post('/unstarr/([0-9]+)', function(string $itemId): void { + // json + $this->container->get(controllers\Items::class)->unstarr($itemId); + }); + $this->router->post('/items/sync', function(): void { + // json + $this->container->get(controllers\Items\Sync::class)->updateStatuses(); + }); + + $this->router->get('/source/params', function(): void { + // json + $this->container->get(controllers\Sources::class)->params(); + }); + $this->router->get('/sources', function(): void { + // json + $this->container->get(controllers\Sources::class)->show(); + }); + $this->router->get('/sources/list', function(): void { + // json + $this->container->get(controllers\Sources::class)->listSources(); + }); + $this->router->post('/source/((?:new-)?[0-9]+)', function(string $id): void { + // json + $this->container->get(controllers\Sources\Write::class)->write($id); + }); + $this->router->post('/source', function(): void { + // json + $this->container->get(controllers\Sources\Write::class)->write(); + }); + $this->router->delete('/source/([0-9]+)', function(string $id): void { + // json + $this->container->get(controllers\Sources::class)->remove($id); + }); + $this->router->post('/source/delete/([0-9]+)', function(string $id): void { + // json, deprecated + $this->container->get(controllers\Sources::class)->remove($id); + }); + $this->router->post('/source/([0-9]+)/update', function(string $id): void { + // json + $this->container->get(controllers\Sources\Update::class)->update($id); + }); + $this->router->get('/sources/spouts', function(): void { + // json + $this->container->get(controllers\Sources::class)->spouts(); + }); + + $this->router->post('/tags/color', function(): void { + // json + $this->container->get(controllers\Tags::class)->color(); + }); + + $this->router->post('/opml', function(): void { + // json + $this->container->get(controllers\Opml\Import::class)->add(); + }); + $this->router->get('/opmlexport', function(): void { + // xml + $this->container->get(controllers\Opml\Export::class)->export(); + }); + + // Client side routes need to be directed to index.html. + $this->router->get('/sign/in|/opml|/password|/manage/sources(/add)?|/(newest|unread|starred)(/(all|tag-[^/]+|source-[0-9]+)(/[0-9]+)?)?', function(): void { + // html + $this->container->get(controllers\Index::class)->home(); + }); + + $this->router->set404(function(): void { + header('HTTP/1.1 404 Not Found'); + echo 'Page not found.'; + }); + } + + public function run(): bool { + $this->setupRoutes(); + + // dispatch + return $this->router->run(); + } +} From c91290ae9d073046d30cdbedd21a22fd8d341b3f Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Fri, 3 Oct 2025 23:06:19 +0200 Subject: [PATCH 3/7] Configuration: Use `Uri` type for `base_url` More type safe and allows to introduce extra validations. --- src/controllers/Rss.php | 2 +- src/helpers/Configuration.php | 43 ++++++++++++++++++++++++++++++++++- src/helpers/Session.php | 3 +-- src/helpers/View.php | 14 ++++++------ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/controllers/Rss.php b/src/controllers/Rss.php index 9a05f6e2f1..4a2464f81f 100644 --- a/src/controllers/Rss.php +++ b/src/controllers/Rss.php @@ -39,7 +39,7 @@ public function rss(): void { $this->feedWriter->setChannelElement('description', ''); $this->feedWriter->setSelfLink($this->view->getBaseUrl() . 'feed'); - $this->feedWriter->setLink($this->view->getBaseUrl()); + $this->feedWriter->setLink((string) $this->view->getBaseUrl()); // get sources $lastSourceId = 0; diff --git a/src/helpers/Configuration.php b/src/helpers/Configuration.php index 453f0da866..608f5d3869 100644 --- a/src/helpers/Configuration.php +++ b/src/helpers/Configuration.php @@ -5,6 +5,7 @@ namespace Selfoss\helpers; use Exception; +use GuzzleHttp\Psr7\Uri; use ReflectionClass; use ReflectionNamedType; @@ -90,7 +91,14 @@ final class Configuration { public int $itemsLifetime = 30; - public string $baseUrl = ''; + /** + * URL representing the main selfoss entry. + * + * Absolute URL or absolute path, with no query string or hash fragment, and path ending with a slash. + * + * @validator normalizeBaseUrl + */ + public ?Uri $baseUrl = null; public string $username = ''; @@ -214,6 +222,8 @@ public function __construct(?string $configPath = null, array $environment = []) $value = (bool) $value; } elseif ($propertyType === 'int') { $value = (int) $value; + } elseif ($propertyType === Uri::class) { + $value = new Uri($value); } elseif ($propertyType === 'string') { // Should already be a string. } elseif ($propertyType === 'self::LOGGER_LEVEL_*') { @@ -224,6 +234,11 @@ public function __construct(?string $configPath = null, array $environment = []) throw new Exception("Unknown type “{$propertyType}” for property “{$propertyName}”.", 1); } + if ($doc !== false && preg_match('(@validator (?P[^\s]+))', $doc, $matches) === 1) { + $validator = $matches['validator']; + $value = $this->$validator($value); + } + $this->{$propertyName} = $value; $this->modifiedOptions[$propertyName] = true; } @@ -242,4 +257,30 @@ public function __construct(?string $configPath = null, array $environment = []) public function isChanged(string $key): bool { return isset($this->modifiedOptions[$key]); } + + private function normalizeBaseUrl(Uri $uri): Uri { + if ($uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '') { + throw new Exception('base_url cannot be empty'); + } + + if ($uri->getQuery() !== '') { + throw new Exception('base_url cannot contain query string'); + } + + if ($uri->getFragment() !== '') { + throw new Exception('base_url cannot contain hash fragment'); + } + + if (!Uri::isAbsolute($uri) && !Uri::isAbsolutePathReference($uri)) { + throw new Exception('base_url must be absolute or absolute path reference'); + } + + $path = $uri->getPath(); + + if (!str_ends_with($path, '/')) { + $uri = $uri->withPath($path . '/'); + } + + return $uri; + } } diff --git a/src/helpers/Session.php b/src/helpers/Session.php index e1c42b585d..f552238385 100644 --- a/src/helpers/Session.php +++ b/src/helpers/Session.php @@ -11,7 +11,6 @@ namespace Selfoss\helpers; -use GuzzleHttp\Psr7\Uri; use Monolog\Logger; /** @@ -35,7 +34,7 @@ public function start(): void { $this->started = true; - $base_url = new Uri($this->view->getBaseUrl()); + $base_url = $this->view->getBaseUrl(); // session cookie will be valid for one month. $cookie_expire = 3600 * 24 * 30; diff --git a/src/helpers/View.php b/src/helpers/View.php index 8df328695e..8f84299443 100644 --- a/src/helpers/View.php +++ b/src/helpers/View.php @@ -5,6 +5,7 @@ namespace Selfoss\helpers; use Exception; +use GuzzleHttp\Psr7\Uri; use function Http\Response\send; use function json_encode; use const JSON_ERROR_NONE; @@ -21,7 +22,7 @@ */ final class View { /** Current base url */ - private ?string $baseUrl = null; + private ?Uri $baseUrl = null; /** * set global view vars @@ -36,7 +37,7 @@ public function __construct( * config.ini this will be used. Otherwise base url will be generated by * globale server variables ($_SERVER). */ - public function getBaseUrl(): string { + public function getBaseUrl(): Uri { if ($this->baseUrl === null) { $this->baseUrl = $this->makeBaseUrl(); } @@ -44,12 +45,11 @@ public function getBaseUrl(): string { return $this->baseUrl; } - private function makeBaseUrl(): string { + private function makeBaseUrl(): Uri { // base url in config.ini file $base = $this->configuration->baseUrl; - if ($base !== '') { - $base = str_ends_with($base, '/') ? $base : ($base . '/'); - } else { // auto generate base url + if ($base === null) { + // auto generate base url $protocol = 'http'; if ((isset($_SERVER['HTTPS']) && !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') @@ -81,7 +81,7 @@ private function makeBaseUrl(): string { $port = ':' . $_SERVER['HTTP_X_FORWARDED_PORT']; } - $base = $protocol . '://' . $host . $port . $subdir . '/'; + $base = new Uri($protocol . '://' . $host . $port . $subdir . '/'); } return $base; From dd6ff85cde5a21d751dbc3e0c5f21e442ce31e42 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Fri, 3 Oct 2025 23:14:45 +0200 Subject: [PATCH 4/7] index: Respect `base_url` config for `` 0c8e13394df4b97bdc05f23e59687a1c481749c1 introduced `` tag into the template, which was based on directory of `SCRIPT_NAME`. This configurations where selfoss was behind a reverse proxy mapping it into a subdirectory, using `base_url` config. --- src/Web/Routes.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Web/Routes.php b/src/Web/Routes.php index 83772f1946..bc2051d631 100644 --- a/src/Web/Routes.php +++ b/src/Web/Routes.php @@ -11,6 +11,7 @@ use Bramus\Router\Router; use Psr\Container\ContainerInterface; use Selfoss\controllers; +use Selfoss\helpers\Configuration; /** * Defines API routes serving as an entry point to the web app. @@ -18,7 +19,8 @@ final class Routes { public function __construct( private Router $router, - private ContainerInterface $container + private ContainerInterface $container, + private Configuration $configuration, ) { } @@ -174,6 +176,10 @@ private function setupRoutes(): void { header('HTTP/1.1 404 Not Found'); echo 'Page not found.'; }); + + if ($this->configuration->baseUrl !== null) { + $this->router->setBasePath($this->configuration->baseUrl->getPath()); + } } public function run(): bool { From b8a0f9d8a51845eb0720eb05371c22c0befcd262 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 4 Oct 2025 05:06:18 +0200 Subject: [PATCH 5/7] helpers\View: Reduce nesting in `makeBaseUrl` Use early return to make the code slightly more legible. This is also a prerequisite for overriding just the path when non-absolute URL is configured. --- src/helpers/View.php | 66 +++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/src/helpers/View.php b/src/helpers/View.php index 8f84299443..dd50ab0dc5 100644 --- a/src/helpers/View.php +++ b/src/helpers/View.php @@ -46,43 +46,47 @@ public function getBaseUrl(): Uri { } private function makeBaseUrl(): Uri { - // base url in config.ini file $base = $this->configuration->baseUrl; - if ($base === null) { - // auto generate base url - $protocol = 'http'; - if ((isset($_SERVER['HTTPS']) && !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') - || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') - || (isset($_SERVER['HTTP_HTTPS']) && $_SERVER['HTTP_HTTPS'] === 'https')) { - $protocol = 'https'; - } - // check for SSL proxy - if (isset($_SERVER['HTTP_X_FORWARDED_SERVER']) && isset($_SERVER['HTTP_X_FORWARDED_HOST']) - && ($_SERVER['HTTP_X_FORWARDED_SERVER'] === $_SERVER['HTTP_X_FORWARDED_HOST'])) { - $subdir = '/' . preg_replace('/\/[^\/]+$/', '', $_SERVER['PHP_SELF']); - $host = $_SERVER['HTTP_X_FORWARDED_SERVER']; - } else { - $subdir = ''; - if (PHP_SAPI !== 'cli') { - $subdir = rtrim(strtr(dirname($_SERVER['SCRIPT_NAME']), '\\', '/'), '/'); - } - $host = $_SERVER['SERVER_NAME']; - } + if ($base !== null) { + // base url in config.ini file + return $base; + } - $port = ''; - if (isset($_SERVER['SERVER_PORT']) - && (($protocol === 'http' && $_SERVER['SERVER_PORT'] != '80') - || ($protocol === 'https' && $_SERVER['SERVER_PORT'] != '443'))) { - $port = ':' . $_SERVER['SERVER_PORT']; - } - // Override the port if nginx is the front end and the traffic is being forwarded - if (isset($_SERVER['HTTP_X_FORWARDED_PORT'])) { - $port = ':' . $_SERVER['HTTP_X_FORWARDED_PORT']; + // auto generate base url + + $protocol = 'http'; + if ((isset($_SERVER['HTTPS']) && !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') + || (isset($_SERVER['HTTP_HTTPS']) && $_SERVER['HTTP_HTTPS'] === 'https')) { + $protocol = 'https'; + } + + // check for SSL proxy + if (isset($_SERVER['HTTP_X_FORWARDED_SERVER']) && isset($_SERVER['HTTP_X_FORWARDED_HOST']) + && ($_SERVER['HTTP_X_FORWARDED_SERVER'] === $_SERVER['HTTP_X_FORWARDED_HOST'])) { + $subdir = '/' . preg_replace('/\/[^\/]+$/', '', $_SERVER['PHP_SELF']); + $host = $_SERVER['HTTP_X_FORWARDED_SERVER']; + } else { + $subdir = ''; + if (PHP_SAPI !== 'cli') { + $subdir = rtrim(strtr(dirname($_SERVER['SCRIPT_NAME']), '\\', '/'), '/'); } + $host = $_SERVER['SERVER_NAME']; + } - $base = new Uri($protocol . '://' . $host . $port . $subdir . '/'); + $port = ''; + if (isset($_SERVER['SERVER_PORT']) + && (($protocol === 'http' && $_SERVER['SERVER_PORT'] != '80') + || ($protocol === 'https' && $_SERVER['SERVER_PORT'] != '443'))) { + $port = ':' . $_SERVER['SERVER_PORT']; } + // Override the port if nginx is the front end and the traffic is being forwarded + if (isset($_SERVER['HTTP_X_FORWARDED_PORT'])) { + $port = ':' . $_SERVER['HTTP_X_FORWARDED_PORT']; + } + + $base = new Uri($protocol . '://' . $host . $port . $subdir . '/'); return $base; } From 2a2219b27d35852ba943966b92c91f24ca750092 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 4 Oct 2025 05:12:26 +0200 Subject: [PATCH 6/7] helpers\View: Ensure `makeBaseUrl` returns absolute URI This is required by both uses: - RSS controller: RSS feeds are supposed to contain absolute paths - Session: Cookies depend on URI scheme and host If `base_url` was configured to an absolute path reference, not an absolute URI, those would fail. --- src/helpers/View.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/helpers/View.php b/src/helpers/View.php index dd50ab0dc5..bf02c510a0 100644 --- a/src/helpers/View.php +++ b/src/helpers/View.php @@ -33,9 +33,13 @@ public function __construct( } /** - * Returns the base url of the page. If a base url was configured in the - * config.ini this will be used. Otherwise base url will be generated by - * globale server variables ($_SERVER). + * Returns the absolute base URL of the page, with path ending with a /. + * + * The returned value depends on the value of the `base_url` option in `config.ini`: + * + * - unset – implicit base URI automatically determined from global server variables ($_SERVER) + * - absolute path – the implicit base URI with path replaced with the configured value + * - absolute URL – the configured value as is */ public function getBaseUrl(): Uri { if ($this->baseUrl === null) { @@ -48,7 +52,7 @@ public function getBaseUrl(): Uri { private function makeBaseUrl(): Uri { $base = $this->configuration->baseUrl; - if ($base !== null) { + if ($base !== null && Uri::isAbsolute($base)) { // base url in config.ini file return $base; } @@ -86,7 +90,8 @@ private function makeBaseUrl(): Uri { $port = ':' . $_SERVER['HTTP_X_FORWARDED_PORT']; } - $base = new Uri($protocol . '://' . $host . $port . $subdir . '/'); + $base = (new Uri($protocol . '://' . $host . $port)) + ->withPath($base !== null ? $base->getPath() : ($subdir . '/')); return $base; } From 116bd2f0bf98b3924820d1a1926ee88055a791b4 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 4 Oct 2025 04:52:59 +0200 Subject: [PATCH 7/7] docs: Clarify usage of `base_url` option --- docs/content/docs/administration/options.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/administration/options.md b/docs/content/docs/administration/options.md index 5ffc386507..f8536a13c2 100644 --- a/docs/content/docs/administration/options.md +++ b/docs/content/docs/administration/options.md @@ -98,7 +98,12 @@ Number of days since the item has been last seen after which it can be deleted. ### `base_url`
-base URL of the selfoss page; use this option if you use a ssl proxy which changes the `$_SERVER` globals, most notably the URL path in which the app is installed. +base URL of the selfoss page. Can be absolute URL or absolute path. + +Use this option if you use some kind of proxy and routing does not work: + +- Set this option to an absolute URL if you use a ssl proxy which changes the `$_SERVER` globals, most notably the URL path in which the app is installed. +- Set this option to an absolute path if you use a reverse proxy and want to map selfoss into a subdirectory.
### `username`