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:
parent
246709b431
commit
222676e45a
|
@ -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(),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
),
|
||||
];
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware\Auth;
|
||||
|
||||
final class PublicAuth extends AbstractAuth
|
||||
{
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -70,6 +70,7 @@ final class InjectSession extends AbstractMiddleware
|
|||
->withAttribute(ServerRequest::ATTR_SESSION_FLASH, $flash);
|
||||
|
||||
$response = $handler->handle($request);
|
||||
|
||||
return $sessionPersistence->persistSession($session, $response);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue