From 222676e45a936ec66bd349ce07d36b19ad77a3ad Mon Sep 17 00:00:00 2001 From: Buster Neece Date: Thu, 28 Dec 2023 14:07:55 -0600 Subject: [PATCH] Public static asset cache overhaul: - Make all public static assets (album art, streamer art, podcast/episode art, etc) serve with no sessions or other user-specific information - Make "timestamp" an explicit URL parameter and avoid weird parsing of IDs mixed with timestamps - Make static caching a universal middleware for uniform results across controllers - Update public art URL paths to reference station short names instead of numeric IDs --- config/events.php | 4 +- config/routes.php | 34 +++++- config/routes/api.php | 100 ------------------ config/routes/api_internal.php | 46 ++++++++ config/routes/api_public.php | 100 ++++++++++++++++++ config/routes/api_station.php | 30 ++---- .../Api/Admin/Backups/DownloadAction.php | 4 +- ...ingController.php => NowPlayingAction.php} | 50 ++------- src/Controller/Api/NowPlayingArtAction.php | 44 ++++++++ .../Api/Stations/Art/GetArtAction.php | 10 -- .../Api/Stations/Files/ListAction.php | 24 +++-- .../Stations/PodcastEpisodesController.php | 9 +- .../Stations/Podcasts/Art/GetArtAction.php | 8 +- .../Podcasts/Episodes/Art/GetArtAction.php | 11 +- .../Api/Stations/PodcastsController.php | 10 +- .../Stations/Streamers/Art/GetArtAction.php | 6 +- .../Api/Stations/StreamersController.php | 10 +- .../Stations/Waveform/GetWaveformAction.php | 6 +- .../PublicPages/PodcastFeedAction.php | 18 +++- .../ApiGenerator/NowPlayingApiGenerator.php | 5 +- src/Entity/ApiGenerator/SongApiGenerator.php | 18 ++-- src/Http/Response.php | 57 ---------- src/Middleware/Auth/PublicAuth.php | 9 ++ src/Middleware/Cache/SetCache.php | 51 +++++++++ src/Middleware/Cache/SetDefaultCache.php | 37 +++++++ src/Middleware/Cache/SetStaticFileCache.php | 31 ++++++ src/Middleware/InjectSession.php | 1 + src/Middleware/Module/Api.php | 9 -- src/Middleware/RequireLogin.php | 9 +- ...quirePublishedPodcastEpisodeMiddleware.php | 9 +- util/docker/web/nginx/azuracast.conf.tmpl | 3 +- 31 files changed, 448 insertions(+), 315 deletions(-) delete mode 100644 config/routes/api.php create mode 100644 config/routes/api_internal.php create mode 100644 config/routes/api_public.php rename src/Controller/Api/{NowPlayingController.php => NowPlayingAction.php} (69%) create mode 100644 src/Controller/Api/NowPlayingArtAction.php create mode 100644 src/Middleware/Auth/PublicAuth.php create mode 100644 src/Middleware/Cache/SetCache.php create mode 100644 src/Middleware/Cache/SetDefaultCache.php create mode 100644 src/Middleware/Cache/SetStaticFileCache.php diff --git a/config/events.php b/config/events.php index ebd90cb77..9f7d2b970 100644 --- a/config/events.php +++ b/config/events.php @@ -92,12 +92,10 @@ return static function (CallableEventDispatcherInterface $dispatcher) { $app->addRoutingMiddleware(); // Redirects and updates that should happen before system middleware. + $app->add(new Middleware\Cache\SetDefaultCache()); $app->add(new Middleware\RemoveSlashes()); $app->add(new Middleware\ApplyXForwardedProto()); - // Use PSR-7 compatible sessions. - $app->add(Middleware\InjectSession::class); - // Add an error handler for most in-controller/task situations. $errorMiddleware = $app->addErrorMiddleware( $environment->showDetailedErrors(), diff --git a/config/routes.php b/config/routes.php index ccf3a1ea2..ef42e21c7 100644 --- a/config/routes.php +++ b/config/routes.php @@ -10,15 +10,41 @@ return static function (App $app) { $app->group( '', function (RouteCollectorProxy $group) { - call_user_func(include(__DIR__ . '/routes/base.php'), $group); call_user_func(include(__DIR__ . '/routes/public.php'), $group); } - )->add(Middleware\Auth\StandardAuth::class); + )->add(Middleware\Auth\PublicAuth::class); $app->group( '', function (RouteCollectorProxy $group) { - call_user_func(include(__DIR__ . '/routes/api.php'), $group); + call_user_func(include(__DIR__ . '/routes/base.php'), $group); } - )->add(Middleware\Auth\ApiAuth::class); + )->add(Middleware\Auth\StandardAuth::class) + ->add(Middleware\InjectSession::class); + + $app->group( + '/api', + function (RouteCollectorProxy $group) { + $group->group( + '', + function (RouteCollectorProxy $group) { + call_user_func(include(__DIR__ . '/routes/api_public.php'), $group); + } + )->add(Middleware\Module\Api::class) + ->add(Middleware\Auth\PublicAuth::class); + + $group->group( + '', + function (RouteCollectorProxy $group) { + call_user_func(include(__DIR__ . '/routes/api_internal.php'), $group); + call_user_func(include(__DIR__ . '/routes/api_admin.php'), $group); + call_user_func(include(__DIR__ . '/routes/api_frontend.php'), $group); + call_user_func(include(__DIR__ . '/routes/api_station.php'), $group); + } + ) + ->add(Middleware\Module\Api::class) + ->add(Middleware\Auth\ApiAuth::class) + ->add(Middleware\InjectSession::class); + } + ); }; diff --git a/config/routes/api.php b/config/routes/api.php deleted file mode 100644 index 318fb2db9..000000000 --- a/config/routes/api.php +++ /dev/null @@ -1,100 +0,0 @@ -group( - '/api', - function (RouteCollectorProxy $group) { - $group->options( - '/{routes:.+}', - function (ServerRequest $request, Response $response, ...$params) { - return $response - ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - ->withHeader( - 'Access-Control-Allow-Headers', - 'x-api-key, x-requested-with, Content-Type, Accept, Origin, Authorization' - ) - ->withHeader('Access-Control-Allow-Origin', '*'); - } - ); - - $group->get( - '', - function (ServerRequest $request, Response $response, ...$params): ResponseInterface { - return $response->withRedirect('/docs/api/'); - } - )->setName('api:index:index'); - - $group->get('/openapi.yml', Controller\Api\OpenApiAction::class) - ->setName('api:openapi'); - - $group->get('/status', Controller\Api\IndexController::class . ':statusAction') - ->setName('api:index:status'); - - $group->get('/time', Controller\Api\IndexController::class . ':timeAction') - ->setName('api:index:time'); - - $group->group( - '/internal', - function (RouteCollectorProxy $group) { - $group->group( - '/{station_id}', - function (RouteCollectorProxy $group) { - $group->map( - ['GET', 'POST'], - '/liquidsoap/{action}', - Controller\Api\Internal\LiquidsoapAction::class - )->setName('api:internal:liquidsoap'); - - // Icecast internal auth functions - $group->map( - ['GET', 'POST'], - '/listener-auth', - Controller\Api\Internal\ListenerAuthAction::class - )->setName('api:internal:listener-auth'); - } - )->add(Middleware\GetStation::class); - - $group->post('/sftp-auth', Controller\Api\Internal\SftpAuthAction::class) - ->setName('api:internal:sftp-auth'); - - $group->post('/sftp-event', Controller\Api\Internal\SftpEventAction::class) - ->setName('api:internal:sftp-event'); - - $group->get('/relays', Controller\Api\Internal\RelaysController::class) - ->setName('api:internal:relays') - ->add(Middleware\RequireLogin::class); - - $group->post('/relays', Controller\Api\Internal\RelaysController::class . ':updateAction') - ->add(Middleware\RequireLogin::class); - } - ); - - $group->get( - '/nowplaying[/{station_id}]', - Controller\Api\NowPlayingController::class . ':getAction' - )->setName('api:nowplaying:index'); - - $group->get( - '/nowplaying/{station_id}/art[/{timestamp}.jpg]', - Controller\Api\NowPlayingController::class . ':getArtAction' - )->setName('api:nowplaying:art'); - - $group->get('/stations', Controller\Api\Stations\IndexController::class . ':listAction') - ->setName('api:stations:list') - ->add(new Middleware\RateLimit('api')); - - call_user_func(include(__DIR__ . '/api_admin.php'), $group); - call_user_func(include(__DIR__ . '/api_frontend.php'), $group); - call_user_func(include(__DIR__ . '/api_station.php'), $group); - } - )->add(Middleware\Module\Api::class); -}; diff --git a/config/routes/api_internal.php b/config/routes/api_internal.php new file mode 100644 index 000000000..0f842af65 --- /dev/null +++ b/config/routes/api_internal.php @@ -0,0 +1,46 @@ +group( + '/internal', + function (RouteCollectorProxy $group) { + $group->group( + '/{station_id}', + function (RouteCollectorProxy $group) { + $group->map( + ['GET', 'POST'], + '/liquidsoap/{action}', + Controller\Api\Internal\LiquidsoapAction::class + )->setName('api:internal:liquidsoap'); + + // Icecast internal auth functions + $group->map( + ['GET', 'POST'], + '/listener-auth', + Controller\Api\Internal\ListenerAuthAction::class + )->setName('api:internal:listener-auth'); + } + )->add(Middleware\GetStation::class); + + $group->post('/sftp-auth', Controller\Api\Internal\SftpAuthAction::class) + ->setName('api:internal:sftp-auth'); + + $group->post('/sftp-event', Controller\Api\Internal\SftpEventAction::class) + ->setName('api:internal:sftp-event'); + + $group->get('/relays', Controller\Api\Internal\RelaysController::class) + ->setName('api:internal:relays') + ->add(Middleware\RequireLogin::class); + + $group->post('/relays', Controller\Api\Internal\RelaysController::class . ':updateAction') + ->add(Middleware\RequireLogin::class); + } + ); +}; diff --git a/config/routes/api_public.php b/config/routes/api_public.php new file mode 100644 index 000000000..dd8f76363 --- /dev/null +++ b/config/routes/api_public.php @@ -0,0 +1,100 @@ +options( + '/{routes:.+}', + function (ServerRequest $request, Response $response, ...$params) { + return $response + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ->withHeader( + 'Access-Control-Allow-Headers', + 'x-api-key, x-requested-with, Content-Type, Accept, Origin, Authorization' + ) + ->withHeader('Access-Control-Allow-Origin', '*'); + } + ); + + $group->get( + '', + function (ServerRequest $request, Response $response, ...$params): ResponseInterface { + return $response->withRedirect('/docs/api/'); + } + )->setName('api:index:index'); + + $group->get('/openapi.yml', Controller\Api\OpenApiAction::class) + ->setName('api:openapi') + ->add(new Middleware\Cache\SetCache(60)); + + $group->get('/status', Controller\Api\IndexController::class . ':statusAction') + ->setName('api:index:status'); + + $group->get('/time', Controller\Api\IndexController::class . ':timeAction') + ->setName('api:index:time') + ->add(new Middleware\Cache\SetCache(1)); + + $group->group( + '/nowplaying/{station_id}', + function (RouteCollectorProxy $group) { + $group->get( + '', + Controller\Api\NowPlayingAction::class + )->setName('api:nowplaying:index'); + + $group->get( + '/art[/{timestamp}.jpg]', + Controller\Api\NowPlayingArtAction::class + )->setName('api:nowplaying:art') + ->add(Middleware\RequireStation::class); + } + )->add(new Middleware\Cache\SetCache(15)) + ->add(Middleware\GetStation::class); + + $group->get('/stations', Controller\Api\Stations\IndexController::class . ':listAction') + ->setName('api:stations:list') + ->add(new Middleware\RateLimit('api')); + + $group->group( + '/station/{station_id}', + function (RouteCollectorProxy $group) { + // Media Art + $group->get( + '/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]', + Controller\Api\Stations\Art\GetArtAction::class + )->setName('api:stations:media:art'); + + // Streamer Art + $group->get( + '/streamer/{id}/art[-{timestamp}.jpg]', + Controller\Api\Stations\Streamers\Art\GetArtAction::class + )->setName('api:stations:streamer:art'); + + // Podcast and Episode Art + $group->group( + '/podcast/{podcast_id}', + function (RouteCollectorProxy $group) { + $group->get( + '/art[-{timestamp}.jpg]', + Controller\Api\Stations\Podcasts\Art\GetArtAction::class + )->setName('api:stations:podcast:art'); + + $group->get( + '/episode/{episode_id}/art[-{timestamp}.jpg]', + Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class + )->setName('api:stations:podcast:episode:art'); + } + )->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class); + } + )->add(new Middleware\Cache\SetStaticFileCache()) + ->add(Middleware\RequireStation::class) + ->add(Middleware\GetStation::class); +}; diff --git a/config/routes/api_station.php b/config/routes/api_station.php index 7ce2f9ce5..18d53ec4c 100644 --- a/config/routes/api_station.php +++ b/config/routes/api_station.php @@ -19,7 +19,7 @@ return static function (RouteCollectorProxy $group) { ->setName('api:stations:index') ->add(new Middleware\RateLimit('api', 5, 2)); - $group->get('/nowplaying', Controller\Api\NowPlayingController::class . ':getAction'); + $group->get('/nowplaying', Controller\Api\NowPlayingAction::class . ':getAction'); $group->get('/schedule', Controller\Api\Stations\ScheduleAction::class) ->setName('api:stations:schedule'); @@ -55,10 +55,7 @@ return static function (RouteCollectorProxy $group) { $group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction') ->setName('api:stations:podcast'); - $group->get( - '/art', - Controller\Api\Stations\Podcasts\Art\GetArtAction::class - )->setName('api:stations:podcast:art'); + // See ./api_public for podcast art. $group->get( '/episodes', @@ -73,11 +70,6 @@ return static function (RouteCollectorProxy $group) { Controller\Api\Stations\PodcastEpisodesController::class . ':getAction' )->setName('api:stations:podcast:episode'); - $group->get( - '/art', - Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class - )->setName('api:stations:podcast:episode:art'); - $group->get( '/download', Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class @@ -87,18 +79,7 @@ return static function (RouteCollectorProxy $group) { } )->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class); - // Media Art - $group->get('/art/{media_id:[a-zA-Z0-9\-]+}.jpg', Controller\Api\Stations\Art\GetArtAction::class) - ->setName('api:stations:media:art'); - - $group->get('/art/{media_id:[a-zA-Z0-9\-]+}', Controller\Api\Stations\Art\GetArtAction::class) - ->setName('api:stations:media:art-internal'); - - // Streamer Art - $group->get( - '/streamer/{id}/art', - Controller\Api\Stations\Streamers\Art\GetArtAction::class - )->setName('api:stations:streamer:art'); + // NOTE: See ./api_public.php for media art public path. /* * Authenticated Functions @@ -256,9 +237,10 @@ return static function (RouteCollectorProxy $group) { ->add(new Middleware\Permissions(StationPermissions::View, true)); $group->get( - '/waveform/{media_id:[a-zA-Z0-9\-]+}.json', + '/waveform/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.json]', Controller\Api\Stations\Waveform\GetWaveformAction::class - )->setName('api:stations:media:waveform'); + )->setName('api:stations:media:waveform') + ->add(new Middleware\Cache\SetStaticFileCache()); $group->post('/art/{media_id:[a-zA-Z0-9]+}', Controller\Api\Stations\Art\PostArtAction::class) ->add(new Middleware\Permissions(StationPermissions::Media, true)); diff --git a/src/Controller/Api/Admin/Backups/DownloadAction.php b/src/Controller/Api/Admin/Backups/DownloadAction.php index 973913e6d..5498b7cf7 100644 --- a/src/Controller/Api/Admin/Backups/DownloadAction.php +++ b/src/Controller/Api/Admin/Backups/DownloadAction.php @@ -22,8 +22,6 @@ final class DownloadAction extends AbstractFileAction [$path, $fs] = $this->getFile($path); /** @var ExtendedFilesystemInterface $fs */ - return $response - ->withNoCache() - ->streamFilesystemFile($fs, $path); + return $response->streamFilesystemFile($fs, $path); } } diff --git a/src/Controller/Api/NowPlayingController.php b/src/Controller/Api/NowPlayingAction.php similarity index 69% rename from src/Controller/Api/NowPlayingController.php rename to src/Controller/Api/NowPlayingAction.php index dea760962..b02559f3d 100644 --- a/src/Controller/Api/NowPlayingController.php +++ b/src/Controller/Api/NowPlayingAction.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Api; use App\Cache\NowPlayingCache; +use App\Controller\SingleActionInterface; use App\Entity\Api\Error; use App\Entity\Api\NowPlaying\NowPlaying; use App\Exception\InvalidRequestAttribute; @@ -50,25 +51,28 @@ use Psr\Http\Message\ResponseInterface; ] ) ] -final class NowPlayingController +final class NowPlayingAction implements SingleActionInterface { public function __construct( private readonly NowPlayingCache $nowPlayingCache ) { } - public function getAction( + public function __invoke( ServerRequest $request, Response $response, array $params ): ResponseInterface { - /** @var string|null $stationId */ - $stationId = $params['station_id'] ?? null; + try { + $station = $request->getStation(); + } catch (InvalidRequestAttribute) { + $station = null; + } $router = $request->getRouter(); - if (!empty($stationId)) { - $np = $this->nowPlayingCache->getForStation($stationId); + if (null !== $station) { + $np = $this->nowPlayingCache->getForStation($station); if ($np instanceof NowPlaying) { $np->resolveUrls($router->getBaseUrl()); @@ -83,15 +87,7 @@ final class NowPlayingController $baseUrl = $router->getBaseUrl(); - // If unauthenticated, hide non-public stations from full view. - try { - $user = $request->getUser(); - } catch (InvalidRequestAttribute) { - $user = null; - } - - $np = $this->nowPlayingCache->getForAllStations(null === $user); - + $np = $this->nowPlayingCache->getForAllStations(true); $np = array_map( function (NowPlaying $npRow) use ($baseUrl) { $npRow->resolveUrls($baseUrl); @@ -103,28 +99,4 @@ final class NowPlayingController return $response->withJson($np); } - - public function getArtAction( - ServerRequest $request, - Response $response, - array $params - ): ResponseInterface { - /** @var string $stationId */ - $stationId = $params['station_id']; - - $np = $this->nowPlayingCache->getForStation($stationId); - - if ($np instanceof NowPlaying) { - $np->resolveUrls($request->getRouter()->getBaseUrl()); - $np->update(); - - $currentArt = $np->now_playing?->song?->art; - if (null !== $currentArt) { - return $response->withRedirect((string)$currentArt, 302); - } - } - - return $response->withStatus(404) - ->withJson(Error::notFound()); - } } diff --git a/src/Controller/Api/NowPlayingArtAction.php b/src/Controller/Api/NowPlayingArtAction.php new file mode 100644 index 000000000..d11bb6ef8 --- /dev/null +++ b/src/Controller/Api/NowPlayingArtAction.php @@ -0,0 +1,44 @@ +getStation(); + + $np = $this->nowPlayingCache->getForStation($station); + + if ($np instanceof NowPlaying) { + $np->resolveUrls($request->getRouter()->getBaseUrl()); + $np->update(); + + $currentArt = $np->now_playing?->song?->art; + if (null !== $currentArt) { + return $response->withRedirect((string)$currentArt, 302); + } + } + + return $response->withStatus(404) + ->withJson(Error::notFound()); + } +} diff --git a/src/Controller/Api/Stations/Art/GetArtAction.php b/src/Controller/Api/Stations/Art/GetArtAction.php index 209c368f1..2be0af108 100644 --- a/src/Controller/Api/Stations/Art/GetArtAction.php +++ b/src/Controller/Api/Stations/Art/GetArtAction.php @@ -62,16 +62,6 @@ final class GetArtAction implements SingleActionInterface $station = $request->getStation(); - if (str_contains($mediaId, '-')) { - $response = $response->withCacheLifetime( - Response::CACHE_ONE_YEAR, - Response::CACHE_ONE_DAY - ); - } - - // If a timestamp delimiter is added, strip it automatically. - $mediaId = explode('-', $mediaId, 2)[0]; - $fsMedia = $this->stationFilesystems->getMediaFilesystem($station); $mediaPath = $this->getMediaPath($station, $fsMedia, $mediaId); diff --git a/src/Controller/Api/Stations/Files/ListAction.php b/src/Controller/Api/Stations/Files/ListAction.php index 8288960a0..c7eac5928 100644 --- a/src/Controller/Api/Stations/Files/ListAction.php +++ b/src/Controller/Api/Stations/Files/ListAction.php @@ -432,17 +432,18 @@ final class ListAction implements SingleActionInterface int $stationId ): FileList { if (null !== $row->media->media_id) { - $artMediaId = $row->media->unique_id; + $routeParams = [ + 'station_id' => $stationId, + 'media_id' => $row->media->unique_id, + ]; + if (0 !== $row->media->art_updated_at) { - $artMediaId .= '-' . $row->media->art_updated_at; + $routeParams['timestamp'] = $row->media->art_updated_at; } $row->media->art = $router->named( 'api:stations:media:art', - [ - 'station_id' => $stationId, - 'media_id' => $artMediaId, - ] + routeParams: $routeParams ); $row->media->links = [ @@ -457,14 +458,19 @@ final class ListAction implements SingleActionInterface ['station_id' => $stationId, 'id' => $row->media->media_id], ), 'art' => $router->named( - 'api:stations:media:art-internal', - ['station_id' => $stationId, 'media_id' => $row->media->media_id] + 'api:stations:media:art', + [ + 'station_id' => $stationId, + 'media_id' => $row->media->media_id, + 'timestamp' => $row->media->art_updated_at, + ] ), 'waveform' => $router->named( 'api:stations:media:waveform', [ 'station_id' => $stationId, - 'media_id' => $row->media->unique_id . '-' . $row->media->art_updated_at, + 'media_id' => $row->media->unique_id, + 'timestamp' => $row->media->art_updated_at, ] ), ]; diff --git a/src/Controller/Api/Stations/PodcastEpisodesController.php b/src/Controller/Api/Stations/PodcastEpisodesController.php index 97114e7b1..4b1fe95fb 100644 --- a/src/Controller/Api/Stations/PodcastEpisodesController.php +++ b/src/Controller/Api/Stations/PodcastEpisodesController.php @@ -329,9 +329,16 @@ final class PodcastEpisodesController extends AbstractApiCrudController $return->art_updated_at = $record->getArtUpdatedAt(); $return->has_custom_art = (0 !== $return->art_updated_at); + $routeParams = [ + 'episode_id' => $record->getId(), + ]; + if ($return->has_custom_art) { + $routeParams['timestamp'] = $return->art_updated_at; + } + $return->art = $router->fromHere( routeName: 'api:stations:podcast:episode:art', - routeParams: ['episode_id' => $record->getId() . '|' . $record->getArtUpdatedAt()], + routeParams: $routeParams, absolute: true ); diff --git a/src/Controller/Api/Stations/Podcasts/Art/GetArtAction.php b/src/Controller/Api/Stations/Podcasts/Art/GetArtAction.php index 303222bc4..2ed9d6dfc 100644 --- a/src/Controller/Api/Stations/Podcasts/Art/GetArtAction.php +++ b/src/Controller/Api/Stations/Podcasts/Art/GetArtAction.php @@ -58,16 +58,12 @@ final class GetArtAction implements SingleActionInterface $station = $request->getStation(); - // If a timestamp delimiter is added, strip it automatically. - $podcast_id = explode('|', $podcastId, 2)[0]; - - $podcastPath = Podcast::getArtPath($podcast_id); + $podcastPath = Podcast::getArtPath($podcastId); $fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station); if ($fsPodcasts->fileExists($podcastPath)) { - return $response->withCacheLifetime(Response::CACHE_ONE_YEAR, Response::CACHE_ONE_DAY) - ->streamFilesystemFile($fsPodcasts, $podcastPath, null, 'inline', false); + return $response->streamFilesystemFile($fsPodcasts, $podcastPath, null, 'inline', false); } return $response->withRedirect( diff --git a/src/Controller/Api/Stations/Podcasts/Episodes/Art/GetArtAction.php b/src/Controller/Api/Stations/Podcasts/Episodes/Art/GetArtAction.php index cb023b654..a7a7b1f6a 100644 --- a/src/Controller/Api/Stations/Podcasts/Episodes/Art/GetArtAction.php +++ b/src/Controller/Api/Stations/Podcasts/Episodes/Art/GetArtAction.php @@ -69,22 +69,17 @@ final class GetArtAction implements SingleActionInterface $station = $request->getStation(); - // If a timestamp delimiter is added, strip it automatically. - $episode_id = explode('|', $episodeId, 2)[0]; - - $episodeArtPath = PodcastEpisode::getArtPath($episode_id); + $episodeArtPath = PodcastEpisode::getArtPath($episodeId); $fsPodcasts = $this->stationFilesystems->getPodcastsFilesystem($station); if ($fsPodcasts->fileExists($episodeArtPath)) { - return $response->withCacheLifetime(Response::CACHE_ONE_YEAR, Response::CACHE_ONE_DAY) - ->streamFilesystemFile($fsPodcasts, $episodeArtPath, null, 'inline', false); + return $response->streamFilesystemFile($fsPodcasts, $episodeArtPath, null, 'inline', false); } $podcastArtPath = Podcast::getArtPath($podcastId); if ($fsPodcasts->fileExists($podcastArtPath)) { - return $response->withCacheLifetime(Response::CACHE_ONE_DAY, Response::CACHE_ONE_MINUTE) - ->streamFilesystemFile($fsPodcasts, $podcastArtPath, null, 'inline', false); + return $response->streamFilesystemFile($fsPodcasts, $podcastArtPath, null, 'inline', false); } return $response->withRedirect( diff --git a/src/Controller/Api/Stations/PodcastsController.php b/src/Controller/Api/Stations/PodcastsController.php index 7e291d78c..0324e4ae8 100644 --- a/src/Controller/Api/Stations/PodcastsController.php +++ b/src/Controller/Api/Stations/PodcastsController.php @@ -255,9 +255,17 @@ final class PodcastsController extends AbstractApiCrudController $return->episodes = $episodes; $return->has_custom_art = (0 !== $record->getArtUpdatedAt()); + + $routeParams = [ + 'podcast_id' => $record->getId(), + ]; + if ($return->has_custom_art) { + $routeParams['timestamp'] = $record->getArtUpdatedAt(); + } + $return->art = $router->fromHere( routeName: 'api:stations:podcast:art', - routeParams: ['podcast_id' => $record->getId() . '|' . $record->getArtUpdatedAt()], + routeParams: $routeParams, absolute: true ); diff --git a/src/Controller/Api/Stations/Streamers/Art/GetArtAction.php b/src/Controller/Api/Stations/Streamers/Art/GetArtAction.php index 61f035ad0..fb014bcd2 100644 --- a/src/Controller/Api/Stations/Streamers/Art/GetArtAction.php +++ b/src/Controller/Api/Stations/Streamers/Art/GetArtAction.php @@ -27,17 +27,13 @@ final class GetArtAction implements SingleActionInterface /** @var string $id */ $id = $params['id']; - // If a timestamp delimiter is added, strip it automatically. - $id = explode('|', $id, 2)[0]; - $station = $request->getStation(); $artworkPath = StationStreamer::getArtworkPath($id); $fsConfig = StationFilesystems::buildConfigFilesystem($station); if ($fsConfig->fileExists($artworkPath)) { - return $response->withCacheLifetime(Response::CACHE_ONE_YEAR, Response::CACHE_ONE_DAY) - ->streamFilesystemFile($fsConfig, $artworkPath, null, 'inline', false); + return $response->streamFilesystemFile($fsConfig, $artworkPath, null, 'inline', false); } return $response->withRedirect( diff --git a/src/Controller/Api/Stations/StreamersController.php b/src/Controller/Api/Stations/StreamersController.php index 20cda2509..d6463df02 100644 --- a/src/Controller/Api/Stations/StreamersController.php +++ b/src/Controller/Api/Stations/StreamersController.php @@ -278,9 +278,17 @@ final class StreamersController extends AbstractScheduledEntityController $isInternal = ('true' === $request->getParam('internal', 'false')); $return['has_custom_art'] = (0 !== $record->getArtUpdatedAt()); + + $routeParams = [ + 'id' => $record->getIdRequired(), + ]; + if ($return['has_custom_art']) { + $routeParams['timestamp'] = $record->getArtUpdatedAt(); + } + $return['art'] = $router->fromHere( routeName: 'api:stations:streamer:art', - routeParams: ['id' => $record->getIdRequired() . '|' . $record->getArtUpdatedAt()], + routeParams: $routeParams, absolute: !$isInternal ); diff --git a/src/Controller/Api/Stations/Waveform/GetWaveformAction.php b/src/Controller/Api/Stations/Waveform/GetWaveformAction.php index cf7c0e448..d2b1ccc7d 100644 --- a/src/Controller/Api/Stations/Waveform/GetWaveformAction.php +++ b/src/Controller/Api/Stations/Waveform/GetWaveformAction.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Api\Stations\Waveform; use App\Controller\SingleActionInterface; +use App\Controller\Traits\ResponseHasCacheLifetime; use App\Entity\Repository\StationMediaRepository; use App\Entity\StationMedia; use App\Flysystem\StationFilesystems; @@ -28,15 +29,10 @@ final class GetWaveformAction implements SingleActionInterface /** @var string $mediaId */ $mediaId = $params['media_id']; - $response = $response->withCacheLifetime(Response::CACHE_ONE_YEAR, Response::CACHE_ONE_DAY); - $station = $request->getStation(); $fsMedia = $this->stationFilesystems->getMediaFilesystem($station); - // If a timestamp delimiter is added, strip it automatically. - $mediaId = explode('-', $mediaId, 2)[0]; - if (StationMedia::UNIQUE_ID_LENGTH === strlen($mediaId)) { $waveformPath = StationMedia::getWaveformPath($mediaId); if ($fsMedia->fileExists($waveformPath)) { diff --git a/src/Controller/Frontend/PublicPages/PodcastFeedAction.php b/src/Controller/Frontend/PublicPages/PodcastFeedAction.php index 9a35ea2a8..ec2f7166a 100644 --- a/src/Controller/Frontend/PublicPages/PodcastFeedAction.php +++ b/src/Controller/Frontend/PublicPages/PodcastFeedAction.php @@ -240,9 +240,16 @@ final class PodcastFeedAction implements SingleActionInterface ); if ($podcastsFilesystem->fileExists(Podcast::getArtPath($podcast->getIdRequired()))) { + $routeParams = [ + 'podcast_id' => $podcast->getIdRequired(), + ]; + if (0 !== $podcast->getArtUpdatedAt()) { + $routeParams['timestamp'] = $podcast->getArtUpdatedAt(); + } + $podcastArtworkSrc = $this->router->fromHere( routeName: 'api:stations:podcast:art', - routeParams: ['podcast_id' => $podcast->getIdRequired() . '|' . $podcast->getArtUpdatedAt()], + routeParams: $routeParams, absolute: true ); } @@ -348,9 +355,16 @@ final class PodcastFeedAction implements SingleActionInterface ); if ($podcastsFilesystem->fileExists(PodcastEpisode::getArtPath($episode->getIdRequired()))) { + $routeParams = [ + 'episode_id' => $episode->getId(), + ]; + if (0 !== $episode->getArtUpdatedAt()) { + $routeParams['timestamp'] = $episode->getArtUpdatedAt(); + } + $episodeArtworkSrc = $this->router->fromHere( routeName: 'api:stations:podcast:episode:art', - routeParams: ['episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt()], + routeParams: $routeParams, absolute: true ); } diff --git a/src/Entity/ApiGenerator/NowPlayingApiGenerator.php b/src/Entity/ApiGenerator/NowPlayingApiGenerator.php index 25f130ada..7d3d06ac3 100644 --- a/src/Entity/ApiGenerator/NowPlayingApiGenerator.php +++ b/src/Entity/ApiGenerator/NowPlayingApiGenerator.php @@ -142,8 +142,9 @@ final class NowPlayingApiGenerator $live->art = $this->router->namedAsUri( routeName: 'api:stations:streamer:art', routeParams: [ - 'station_id' => $station->getIdRequired(), - 'id' => $currentStreamer->getIdRequired() . '|' . $currentStreamer->getArtUpdatedAt(), + 'station_id' => $station->getShortName(), + 'id' => $currentStreamer->getIdRequired(), + 'timestamp' => $currentStreamer->getArtUpdatedAt(), ], ); } diff --git a/src/Entity/ApiGenerator/SongApiGenerator.php b/src/Entity/ApiGenerator/SongApiGenerator.php index 4802feb3f..b60d2f096 100644 --- a/src/Entity/ApiGenerator/SongApiGenerator.php +++ b/src/Entity/ApiGenerator/SongApiGenerator.php @@ -68,18 +68,19 @@ final class SongApiGenerator bool $isNowPlaying = false, ): UriInterface { if (null !== $station && $song instanceof StationMedia) { + $routeParams = [ + 'station_id' => $station->getShortName(), + 'media_id' => $song->getUniqueId(), + ]; + $mediaUpdatedTimestamp = $song->getArtUpdatedAt(); - $mediaId = $song->getUniqueId(); if (0 !== $mediaUpdatedTimestamp) { - $mediaId .= '-' . $mediaUpdatedTimestamp; + $routeParams['timestamp'] = $mediaUpdatedTimestamp; } return $this->router->namedAsUri( routeName: 'api:stations:media:art', - routeParams: [ - 'station_id' => $station->getId(), - 'media_id' => $mediaId, - ] + routeParams: $routeParams ); } @@ -96,8 +97,9 @@ final class SongApiGenerator return $this->router->namedAsUri( routeName: 'api:stations:streamer:art', routeParams: [ - 'station_id' => $station->getIdRequired(), - 'id' => $currentStreamer->getIdRequired() . '|' . $currentStreamer->getArtUpdatedAt(), + 'station_id' => $station->getShortName(), + 'id' => $currentStreamer->getIdRequired(), + 'timestamp' => $currentStreamer->getArtUpdatedAt(), ], ); } diff --git a/src/Http/Response.php b/src/Http/Response.php index f81cbdecd..5769536c4 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -14,63 +14,6 @@ use Slim\Http\Response as SlimResponse; final class Response extends SlimResponse { - public const CACHE_ONE_MINUTE = 60; - public const CACHE_ONE_HOUR = 3600; - public const CACHE_ONE_DAY = 86400; - public const CACHE_ONE_MONTH = 2592000; - public const CACHE_ONE_YEAR = 31536000; - - /** - * Send headers that expire the content immediately and prevent caching. - * @return static - */ - public function withNoCache(): Response - { - $response = $this->response - ->withHeader('Pragma', 'no-cache') - ->withHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', 0)) - ->withHeader('Cache-Control', 'private, no-cache, no-store') - ->withHeader('X-Accel-Buffering', 'no') // Nginx - ->withHeader('X-Accel-Expires', '0'); // CloudFlare/nginx - - return new Response($response, $this->streamFactory); - } - - /** - * Send headers that expire the content in the specified number of seconds. - * - * @param int $seconds - * - * @return static - */ - public function withCacheLifetime( - int $seconds = self::CACHE_ONE_MONTH, - ?int $serverCacheSeconds = null - ): Response { - $serverCacheSeconds ??= $seconds; - - $response = $this->response - ->withoutHeader('Pragma') - ->withHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + $seconds)) - ->withHeader('Cache-Control', 'public, max-age=' . $seconds) - ->withHeader('X-Accel-Buffering', 'yes') // Nginx - ->withHeader('X-Accel-Expires', (string)$serverCacheSeconds); // CloudFlare/nginx - - return new Response($response, $this->streamFactory); - } - - /** - * Returns whether the request has a "cache lifetime" assigned to it. - */ - public function hasCacheLifetime(): bool - { - if ($this->response->hasHeader('Pragma')) { - return (!str_contains($this->response->getHeaderLine('Pragma'), 'no-cache')); - } - - return (!str_contains($this->response->getHeaderLine('Cache-Control'), 'no-cache')); - } - /** * Don't escape forward slashes by default on JSON responses. * diff --git a/src/Middleware/Auth/PublicAuth.php b/src/Middleware/Auth/PublicAuth.php new file mode 100644 index 000000000..ebe43447b --- /dev/null +++ b/src/Middleware/Auth/PublicAuth.php @@ -0,0 +1,9 @@ +serverLifetime ??= $this->browserLifetime; + } + + public function __invoke(ServerRequest $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + + return $this->responseWithCacheLifetime( + $response, + $this->browserLifetime, + $this->serverLifetime + ); + } + + protected function responseWithCacheLifetime( + ResponseInterface $response, + int $browserLifetime, + ?int $serverLifetime = null + ): ResponseInterface { + $serverLifetime ??= $browserLifetime; + + return $response->withoutHeader('Pragma') + ->withHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', time() + $browserLifetime)) + ->withHeader('Cache-Control', 'public, max-age=' . $browserLifetime) + ->withHeader('X-Accel-Buffering', 'yes') // Nginx + ->withHeader('X-Accel-Expires', (string)$serverLifetime); // CloudFlare/nginx + } +} diff --git a/src/Middleware/Cache/SetDefaultCache.php b/src/Middleware/Cache/SetDefaultCache.php new file mode 100644 index 000000000..b57594494 --- /dev/null +++ b/src/Middleware/Cache/SetDefaultCache.php @@ -0,0 +1,37 @@ +handle($request); + + if (!$this->hasCacheLifetime($response)) { + return $response->withHeader('Pragma', 'no-cache') + ->withHeader('Expires', gmdate('D, d M Y H:i:s \G\M\T', 0)) + ->withHeader('Cache-Control', 'private, no-cache, no-store') + ->withHeader('X-Accel-Buffering', 'no') // Nginx + ->withHeader('X-Accel-Expires', '0'); // CloudFlare/nginx + } + + return $response; + } + + private function hasCacheLifetime(ResponseInterface $response): bool + { + if ($response->hasHeader('Pragma')) { + return (!str_contains($response->getHeaderLine('Pragma'), 'no-cache')); + } + + return (!str_contains($response->getHeaderLine('Cache-Control'), 'no-cache')); + } +} diff --git a/src/Middleware/Cache/SetStaticFileCache.php b/src/Middleware/Cache/SetStaticFileCache.php new file mode 100644 index 000000000..662a207c4 --- /dev/null +++ b/src/Middleware/Cache/SetStaticFileCache.php @@ -0,0 +1,31 @@ +handle($request); + + $routeArgs = RouteContext::fromRequest($request)->getRoute()?->getArguments(); + $hasLongCacheParam = !empty($routeArgs[$this->longCacheParam]); + + return ($hasLongCacheParam) + ? $this->responseWithCacheLifetime($response, self::CACHE_ONE_YEAR, self::CACHE_ONE_DAY) + : $this->responseWithCacheLifetime($response, self::CACHE_ONE_HOUR, self::CACHE_ONE_MINUTE); + } +} diff --git a/src/Middleware/InjectSession.php b/src/Middleware/InjectSession.php index 6aa9f0a5b..0d21926bf 100644 --- a/src/Middleware/InjectSession.php +++ b/src/Middleware/InjectSession.php @@ -70,6 +70,7 @@ final class InjectSession extends AbstractMiddleware ->withAttribute(ServerRequest::ATTR_SESSION_FLASH, $flash); $response = $handler->handle($request); + return $sessionPersistence->persistSession($session, $response); } } diff --git a/src/Middleware/Module/Api.php b/src/Middleware/Module/Api.php index e927e3b14..d894b61dd 100644 --- a/src/Middleware/Module/Api.php +++ b/src/Middleware/Module/Api.php @@ -8,7 +8,6 @@ use App\Container\EnvironmentAwareTrait; use App\Container\SettingsAwareTrait; use App\Entity\User; use App\Exception\InvalidRequestAttribute; -use App\Http\Response; use App\Http\ServerRequest; use App\Middleware\AbstractMiddleware; use App\Utilities\Urls; @@ -103,14 +102,6 @@ final class Api extends AbstractMiddleware $response = $response->withHeader('Access-Control-Allow-Origin', '*'); } - if ($response instanceof Response && !$response->hasCacheLifetime()) { - if ($preferBrowserUrl || $request->getAttribute(ServerRequest::ATTR_USER) instanceof User) { - $response = $response->withNoCache(); - } else { - $response = $response->withCacheLifetime(15); - } - } - return $response; } } diff --git a/src/Middleware/RequireLogin.php b/src/Middleware/RequireLogin.php index 6a5c87dd6..b7b5af4da 100644 --- a/src/Middleware/RequireLogin.php +++ b/src/Middleware/RequireLogin.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Middleware; use App\Exception\NotLoggedInException; -use App\Http\Response; use App\Http\ServerRequest; use Exception; use Psr\Http\Message\ResponseInterface; @@ -24,12 +23,6 @@ final class RequireLogin extends AbstractMiddleware throw new NotLoggedInException(); } - $response = $handler->handle($request); - - if ($response instanceof Response) { - $response = $response->withNoCache(); - } - - return $response; + return $handler->handle($request); } } diff --git a/src/Middleware/RequirePublishedPodcastEpisodeMiddleware.php b/src/Middleware/RequirePublishedPodcastEpisodeMiddleware.php index eea394162..e995e40e9 100644 --- a/src/Middleware/RequirePublishedPodcastEpisodeMiddleware.php +++ b/src/Middleware/RequirePublishedPodcastEpisodeMiddleware.php @@ -11,7 +11,6 @@ use App\Entity\Station; use App\Entity\User; use App\Enums\StationPermissions; use App\Exception\NotFoundException; -use App\Http\Response; use App\Http\ServerRequest; use Exception; use Psr\Http\Message\ResponseInterface; @@ -47,13 +46,7 @@ final class RequirePublishedPodcastEpisodeMiddleware extends AbstractMiddleware throw NotFoundException::podcast(); } - $response = $handler->handle($request); - - if ($response instanceof Response) { - $response = $response->withNoCache(); - } - - return $response; + return $handler->handle($request); } private function getLoggedInUser(ServerRequest $request): ?User diff --git a/util/docker/web/nginx/azuracast.conf.tmpl b/util/docker/web/nginx/azuracast.conf.tmpl index d8d4221f7..dce789bd4 100644 --- a/util/docker/web/nginx/azuracast.conf.tmpl +++ b/util/docker/web/nginx/azuracast.conf.tmpl @@ -112,8 +112,7 @@ server { fastcgi_param DOCUMENT_ROOT $realpath_root; fastcgi_cache app; - fastcgi_cache_valid 200 301 302 1m; - fastcgi_cache_lock on; + fastcgi_cache_use_stale timeout updating; {{if eq .Env.APPLICATION_ENV "development"}} add_header X-FastCGI-Cache $upstream_cache_status;