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');