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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

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

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

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

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

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