Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
81
app/Http/Middleware/AllowPreview.php
Normal file
81
app/Http/Middleware/AllowPreview.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\PreviewLink;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Lets the frontend render unpublished content when it presents the preview
|
||||
* secret.
|
||||
*
|
||||
* Because the frontend renders on the server, this is simple: the preview route
|
||||
* forwards the secret on its own fetch, and nothing about it reaches the
|
||||
* visitor's browser. The flag is read by the public controllers to decide
|
||||
* whether to apply the `published` scope.
|
||||
*/
|
||||
class AllowPreview
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$request->attributes->set('preview', $this->isPreview($request));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function isPreview(Request $request): bool
|
||||
{
|
||||
return $this->hasSharedSecret($request) || $this->hasSignedLink($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared secret: one value, held by the frontend, that unlocks
|
||||
* everything unpublished.
|
||||
*/
|
||||
protected function hasSharedSecret(Request $request): bool
|
||||
{
|
||||
$secret = (string) config('site.preview_token');
|
||||
|
||||
// An unset secret must never mean "everyone gets preview access".
|
||||
if (blank($secret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$presented = $request->header('X-Preview-Token')
|
||||
?? $request->query('preview_token');
|
||||
|
||||
return is_string($presented) && hash_equals($secret, $presented);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link generated for one record.
|
||||
*
|
||||
* Signed and short-lived, and it says which record it is for — so one
|
||||
* forwarded to the wrong person leaks a single draft rather than every
|
||||
* unpublished thing on the site.
|
||||
*/
|
||||
protected function hasSignedLink(Request $request): bool
|
||||
{
|
||||
$token = $request->header('X-Preview-Link') ?? $request->query('preview');
|
||||
|
||||
if (! is_string($token) || blank($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = PreviewLink::open($token);
|
||||
|
||||
if ($payload === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handed to the controllers so a link for one draft cannot be used to
|
||||
// read another.
|
||||
$request->attributes->set('preview_scope', $payload);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
89
app/Http/Middleware/CachePublicResponse.php
Normal file
89
app/Http/Middleware/CachePublicResponse.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* HTTP caching for the public API.
|
||||
*
|
||||
* With an SSR frontend this API sits in the critical path of every page view,
|
||||
* so these headers are not a nicety: they are what lets a CDN or the frontend
|
||||
* server avoid a round trip per visitor.
|
||||
*
|
||||
* `stale-while-revalidate` matters most — it means a brief outage or a slow
|
||||
* response here degrades into slightly stale content rather than a broken page.
|
||||
*/
|
||||
class CachePublicResponse
|
||||
{
|
||||
public function handle(Request $request, Closure $next, int $seconds = 60): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
// Never cache a preview: the whole point is seeing unpublished work
|
||||
// the moment it changes. Nor anything that was not a read — a
|
||||
// submission's confirmation belongs to one visitor only.
|
||||
if ($request->attributes->get('preview', false)
|
||||
|| ! $request->isMethodCacheable()
|
||||
|| $response->getStatusCode() >= 400) {
|
||||
$response->headers->set('Cache-Control', 'no-store, private');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response->headers->set(
|
||||
'Cache-Control',
|
||||
"public, max-age=0, s-maxage={$seconds}, stale-while-revalidate=".($seconds * 10),
|
||||
);
|
||||
|
||||
// Responses vary by language, and the language is negotiated from the
|
||||
// header as well as the query string. Without this a shared cache
|
||||
// would serve Catalan to a Spanish visitor.
|
||||
$response->headers->set('Vary', 'Accept-Language, Accept, Origin');
|
||||
|
||||
$this->applyEntityTag($request, $response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content-derived ETag, so an unchanged payload costs the caller a 304
|
||||
* with an empty body instead of the full JSON.
|
||||
*/
|
||||
protected function applyEntityTag(Request $request, Response $response): void
|
||||
{
|
||||
if (! $response->getContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$etag = '"'.hash('xxh128', $response->getContent()).'"';
|
||||
$response->headers->set('ETag', $etag);
|
||||
|
||||
$ifNoneMatch = $request->headers->get('If-None-Match');
|
||||
|
||||
if ($ifNoneMatch !== null && $this->matches($ifNoneMatch, $etag)) {
|
||||
$response->setNotModified();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `If-None-Match` may carry several tags, and a proxy may have weakened
|
||||
* ours with a `W/` prefix along the way.
|
||||
*/
|
||||
protected function matches(string $ifNoneMatch, string $etag): bool
|
||||
{
|
||||
foreach (explode(',', $ifNoneMatch) as $candidate) {
|
||||
$candidate = ltrim(trim($candidate), 'W/');
|
||||
|
||||
if ($candidate === '*' || hash_equals($etag, $candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
61
app/Http/Middleware/EnsureSiteIsAvailable.php
Normal file
61
app/Http/Middleware/EnsureSiteIsAvailable.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\Maintenance;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Answers 503 on the public API while the site is in maintenance.
|
||||
*
|
||||
* Applied only to `/api/public/*`, never to the panel or the admin API: the
|
||||
* collective must be able to keep working — and to fix whatever caused the
|
||||
* maintenance — while visitors see the notice.
|
||||
*
|
||||
* The body carries the translated message so the frontend can render a proper
|
||||
* maintenance page from it rather than having to hardcode one.
|
||||
*/
|
||||
class EnsureSiteIsAvailable
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Maintenance::isEnabled() || $this->mayBypass($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$until = Maintenance::until();
|
||||
|
||||
return response()->json([
|
||||
'message' => Maintenance::message(),
|
||||
'maintenance' => true,
|
||||
'locale' => app()->getLocale(),
|
||||
'since' => Maintenance::startedAt()?->toIso8601String(),
|
||||
'until' => $until?->toIso8601String(),
|
||||
], Response::HTTP_SERVICE_UNAVAILABLE, [
|
||||
'Retry-After' => (string) Maintenance::retryAfter(),
|
||||
// 503 must never be cached, or the notice would outlive the
|
||||
// maintenance itself.
|
||||
'Cache-Control' => 'no-store, private',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The team still sees the real site.
|
||||
*
|
||||
* The preview secret already means "I am the frontend acting for someone
|
||||
* trusted", and the frontend's own address is equally trusted — so both
|
||||
* pass straight through and can verify the fix before lifting the flag.
|
||||
*/
|
||||
protected function mayBypass(Request $request): bool
|
||||
{
|
||||
if ($request->attributes->get('preview', false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($request->ip(), (array) config('site.trusted_ips'), true);
|
||||
}
|
||||
}
|
||||
93
app/Http/Middleware/SetLocale.php
Normal file
93
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Resolves the locale for the request and applies it to the framework, to
|
||||
* Carbon, and to translatable model attributes.
|
||||
*
|
||||
* Precedence, most specific first:
|
||||
*
|
||||
* 1. ?lang=xx — an explicit, single-request override (API friendly)
|
||||
* 2. session — what the signed-in visitor picked in the switcher
|
||||
* 3. users.locale — their stored preference
|
||||
* 4. Accept-Language — what their client asked for
|
||||
* 5. locales.default
|
||||
*/
|
||||
class SetLocale
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $this->resolve($request);
|
||||
|
||||
app()->setLocale($locale);
|
||||
|
||||
// Dates rendered in the panel and serialised by the API follow suit.
|
||||
CarbonImmutable::setLocale($locale);
|
||||
Carbon::setLocale($locale);
|
||||
|
||||
// Translatable attributes read this locale, and fall back to
|
||||
// locales.fallback when a record has no translation for it.
|
||||
app()->setFallbackLocale((string) config('locales.fallback'));
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Lets caches and clients see which language they actually received.
|
||||
$response->headers->set('Content-Language', $locale);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function resolve(Request $request): string
|
||||
{
|
||||
$supported = array_keys(config('locales.supported'));
|
||||
|
||||
$candidates = [
|
||||
$request->query(config('locales.query_parameter')),
|
||||
// The API stack is stateless, so there is no session to consult.
|
||||
// hasSession() is the safe check — session() throws when unset.
|
||||
$request->hasSession()
|
||||
? $request->session()->get(config('locales.session_key'))
|
||||
: null,
|
||||
$request->user()?->locale,
|
||||
$this->fromAcceptLanguage($request, $supported),
|
||||
config('locales.default'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && in_array($candidate, $supported, true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) config('locales.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best supported locale from the Accept-Language header, honouring
|
||||
* its quality values. "ca-ES" matches the "ca" we support.
|
||||
*
|
||||
* @param array<int, string> $supported
|
||||
*/
|
||||
protected function fromAcceptLanguage(Request $request, array $supported): ?string
|
||||
{
|
||||
foreach ($request->getLanguages() as $language) {
|
||||
$base = strtolower(str_replace('_', '-', $language));
|
||||
$base = explode('-', $base)[0];
|
||||
|
||||
if (in_array($base, $supported, true)) {
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
47
app/Http/Middleware/VerifyIngestToken.php
Normal file
47
app/Http/Middleware/VerifyIngestToken.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Guards the endpoints the frontend writes to on its own behalf.
|
||||
*
|
||||
* Everything else under /api/public is either read-only or written by a
|
||||
* visitor filling in a form. This is neither: it is one server talking to
|
||||
* another, so it can hold a secret, and it should — an open write endpoint is
|
||||
* an invitation to fill a table with someone else's idea of a URL.
|
||||
*
|
||||
* The secret travels in a header rather than the query string so it stays out
|
||||
* of access logs. An unset token disables the endpoint rather than opening it,
|
||||
* the same rule the preview secret follows.
|
||||
*/
|
||||
class VerifyIngestToken
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$secret = (string) config('site.telemetry.token');
|
||||
|
||||
if (blank($secret)) {
|
||||
return $this->refuse(__('site.telemetry.disabled'));
|
||||
}
|
||||
|
||||
$presented = $request->header('X-Ingest-Token');
|
||||
|
||||
if (! is_string($presented) || ! hash_equals($secret, $presented)) {
|
||||
return $this->refuse(__('site.telemetry.forbidden'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function refuse(string $message): Response
|
||||
{
|
||||
return response()->json(['message' => $message], Response::HTTP_FORBIDDEN)
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user