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

53
docker/Caddyfile Normal file
View File

@@ -0,0 +1,53 @@
{
# The panel is the only thing served, and it is behind a login: no reason
# for the server to announce its version to anybody scanning.
frankenphp
order php_server before file_server
auto_https off
servers {
trusted_proxies static private_ranges
}
}
{$SERVER_NAME:-:80} {
root * /app/public
encode zstd gzip
# Uploaded images. `public/storage` is a symlink into the storage volume,
# so this is what actually serves what the collective uploads — and it is
# the one path an SSR frontend fetches on every page view, so it is cached
# hard and never touched by PHP.
@media path /storage/*
header @media Cache-Control "public, max-age=31536000, immutable"
# The frontend is a separate application on another origin.
@api path /api/*
header @api {
X-Content-Type-Options nosniff
}
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
-Server
}
php_server {
# Laravel's own health route, so the container can be probed without
# touching the database.
try_files {path} /index.php
}
# The health probe runs every 30 seconds and would otherwise be most of
# the access log. `log_skip` is the directive for this — `exclude` inside
# `log` is not valid in Caddy 2.
@health path /up
log_skip @health
log {
output stdout
format json
}
}

91
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,91 @@
#!/bin/sh
set -e
###############################################################################
# Everything that has to be true before the application answers a request.
#
# Runs in every container built from this image, but not every container is a
# service: `docker compose run app php artisan key:generate` has to work on a
# stack that has never been started, which is exactly when there is no APP_KEY
# and no database yet. So the preparation below applies to the three
# long-running commands, and anything else is passed straight through.
###############################################################################
case "$*" in
frankenphp*|*queue:work*|*schedule:work*) is_service=true ;;
*) is_service=false ;;
esac
# A named volume mounted over storage/ starts as a copy of the image's, but a
# bind mount does not, and neither survives a container being recreated with an
# empty one. Cheap to guarantee, expensive to debug — and needed by one-off
# commands too, since most of them write a log.
mkdir -p \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
if [ "$is_service" = "false" ]; then
exec "$@"
fi
if [ -z "${APP_KEY}" ]; then
echo "APP_KEY is not set. Generate one with:" >&2
echo " docker compose run --rm --no-deps app php artisan key:generate --show" >&2
echo "Without it sessions cannot be decrypted and preview links cannot be signed." >&2
exit 1
fi
# Wait for Postgres rather than crash-looping against it. Compose's own
# healthcheck covers the ordinary case; this covers the database restarting
# under a running application.
if [ "${DB_CONNECTION:-pgsql}" = "pgsql" ]; then
tries=0
until pg_isready -h "${DB_HOST:-postgres}" -p "${DB_PORT:-5432}" -U "${DB_USERNAME:-pireon}" -q; do
tries=$((tries + 1))
if [ "$tries" -ge 30 ]; then
echo "Database at ${DB_HOST:-postgres}:${DB_PORT:-5432} never became ready." >&2
exit 1
fi
echo "Waiting for the database… (${tries}/30)"
sleep 2
done
fi
# Opt-in rather than automatic: a migration is a change to somebody's data, and
# on a multi-container deploy it must run once, not once per container. Set it
# on the web service only.
if [ "${RUN_MIGRATIONS}" = "true" ]; then
echo "Running migrations…"
# CACHE_STORE is overridden for this one command, not passed to
# `--isolated` — that option's value is only an exit code, never a store.
#
# It matters because with CACHE_STORE=database the lock table is created by
# these very migrations, so on a brand new database the lock would be taken
# against a table that does not exist yet and the container would crash-loop
# for ever. The file store lives on the shared storage volume, so it still
# stops two containers migrating at once.
CACHE_STORE=file php artisan migrate --force --isolated
# The permission catalogue is code, so a deploy that adds one has to sync
# it or the new permission exists nowhere.
php artisan permissions:sync
fi
# Serves uploaded images from the storage volume. Idempotent, and it has to run
# here rather than at build time because the volume is mounted after the image
# is built.
php artisan storage:link --quiet 2>/dev/null || true
# Config, routes and views compiled once per container start. This is most of
# the difference between an API answering in 4ms and in 40, which matters when
# an SSR frontend makes several calls per page view.
php artisan config:cache --quiet
php artisan route:cache --quiet
php artisan view:cache --quiet
exec "$@"

28
docker/php.ini Normal file
View File

@@ -0,0 +1,28 @@
; Production PHP settings.
expose_php = Off
memory_limit = 256M
; Uploads are images: a phone photo is routinely over 8 MB, and the processor
; downsizes them afterwards anyway.
upload_max_filesize = 24M
post_max_size = 26M
max_execution_time = 60
; The image processor holds a decoded bitmap in memory — a 24 megapixel photo
; is roughly 100 MB decoded — and config/media.php refuses anything larger
; rather than trying.
;
; opcache is what makes the difference between an SSR frontend waiting 40ms
; and 4ms per API call.
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.interned_strings_buffer = 16
; Errors go to the log, never to the response: this API answers a frontend
; that would otherwise render a stack trace into a public page.
display_errors = Off
log_errors = On
error_log = /dev/stderr