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
This commit is contained in:
Buster Neece 2023-12-28 14:07:55 -06:00
parent 246709b431
commit 222676e45a
No known key found for this signature in database
31 changed files with 448 additions and 315 deletions

View File

@ -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(),

View File

@ -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);
}
);
};

View File

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
use App\Controller;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Slim\Routing\RouteCollectorProxy;
return static function (RouteCollectorProxy $app) {
$app->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);
};

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Controller;
use App\Middleware;
use Slim\Routing\RouteCollectorProxy;
// Internal API endpoints (called by other programs hosted on the same machine).
return static function (RouteCollectorProxy $group) {
$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);
}
);
};

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Controller;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Slim\Routing\RouteCollectorProxy;
// Public-facing API endpoints (unauthenticated).
return static 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')
->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);
};

View File

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

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,44 @@
<?php
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\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
final class NowPlayingArtAction implements SingleActionInterface
{
public function __construct(
private readonly NowPlayingCache $nowPlayingCache
) {
}
public function __invoke(
ServerRequest $request,
Response $response,
array $params
): ResponseInterface {
$station = $request->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());
}
}

View File

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

View File

@ -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,
]
),
];

View File

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

View File

@ -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(

View File

@ -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(

View File

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

View File

@ -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(

View File

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

View File

@ -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)) {

View File

@ -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
);
}

View File

@ -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(),
],
);
}

View File

@ -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(),
],
);
}

View File

@ -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.
*

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Middleware\Auth;
final class PublicAuth extends AbstractAuth
{
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Middleware\Cache;
use App\Http\ServerRequest;
use App\Middleware\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
class SetCache extends AbstractMiddleware
{
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;
public function __construct(
protected int $browserLifetime,
protected ?int $serverLifetime = null
) {
$this->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
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Middleware\Cache;
use App\Http\ServerRequest;
use App\Middleware\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class SetDefaultCache extends AbstractMiddleware
{
public function __invoke(ServerRequest $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->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'));
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Middleware\Cache;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Routing\RouteContext;
class SetStaticFileCache extends SetCache
{
public function __construct(
protected string $longCacheParam = 'timestamp'
) {
parent::__construct(0, 0);
}
public function __invoke(ServerRequest $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->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);
}
}

View File

@ -70,6 +70,7 @@ final class InjectSession extends AbstractMiddleware
->withAttribute(ServerRequest::ATTR_SESSION_FLASH, $flash);
$response = $handler->handle($request);
return $sessionPersistence->persistSession($session, $response);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;