diff --git a/composer.json b/composer.json
index ddd7ef488..cde6e41ab 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/docs/content/docs/administration/options.md b/docs/content/docs/administration/options.md
index 5ffc38650..f8536a13c 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`
diff --git a/index.php b/index.php
index ae685a528..2578cdd64 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/phpstan.dist.neon b/phpstan.dist.neon
index a78908083..1238309f1 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/Web/Routes.php b/src/Web/Routes.php
new file mode 100644
index 000000000..bc2051d63
--- /dev/null
+++ b/src/Web/Routes.php
@@ -0,0 +1,191 @@
+
+// SPDX-FileCopyrightText: 2025 Jan Tojnar
+
+namespace Selfoss\Web;
+
+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.
+ */
+final class Routes {
+ public function __construct(
+ private Router $router,
+ private ContainerInterface $container,
+ private Configuration $configuration,
+ ) {
+ }
+
+ 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.';
+ });
+
+ if ($this->configuration->baseUrl !== null) {
+ $this->router->setBasePath($this->configuration->baseUrl->getPath());
+ }
+ }
+
+ public function run(): bool {
+ $this->setupRoutes();
+
+ // dispatch
+ return $this->router->run();
+ }
+}
diff --git a/src/common.php b/src/common.php
index 708917d1d..83aa3beac 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/src/controllers/Rss.php b/src/controllers/Rss.php
index 9a05f6e2f..4a2464f81 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 453f0da86..608f5d386 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 e1c42b585..f55223838 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 8df328695..bf02c510a 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
@@ -32,11 +33,15 @@ 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(): string {
+ public function getBaseUrl(): Uri {
if ($this->baseUrl === null) {
$this->baseUrl = $this->makeBaseUrl();
}
@@ -44,46 +49,50 @@ public function getBaseUrl(): string {
return $this->baseUrl;
}
- private function makeBaseUrl(): string {
- // base url in config.ini file
+ private function makeBaseUrl(): Uri {
$base = $this->configuration->baseUrl;
- if ($base !== '') {
- $base = str_ends_with($base, '/') ? $base : ($base . '/');
- } else { // 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 && Uri::isAbsolute($base)) {
+ // 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 = $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))
+ ->withPath($base !== null ? $base->getPath() : ($subdir . '/'));
+
return $base;
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 3432a0d2c..976b8baef 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');