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,318 @@
<?php
declare(strict_types=1);
namespace App\Services\Media;
use App\Models\Media;
use GdImage;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
/**
* Turns an uploaded file into everything the frontend needs to render it well:
* its real dimensions, a set of narrower versions in modern formats, and a
* placeholder to paint while the real thing loads.
*
* Built on GD, which ships with essentially every PHP, rather than on an image
* library the project would have to carry. Formats are asked for rather than
* assumed — a runtime without AVIF simply produces WebP and says so.
*/
class ImageProcessor
{
/**
* Fill in everything derivable about a stored file.
*
* Never throws: a failure is recorded on the row and the image keeps
* working as a plain one, because half an image is better than a page
* that will not render.
*/
public function process(Media $media): void
{
try {
$this->run($media);
} catch (Throwable $e) {
report($e);
$media->forceFill([
'error' => $e->getMessage(),
'processed_at' => now(),
])->save();
}
}
protected function run(Media $media): void
{
$disk = Storage::disk($media->disk);
if (! $disk->exists($media->path)) {
throw new RuntimeException("Media file is missing: {$media->disk}:{$media->path}");
}
$contents = (string) $disk->get($media->path);
$mime = $disk->mimeType($media->path) ?: null;
$attributes = [
'mime' => $mime,
'size' => strlen($contents),
'variants' => [],
'placeholder' => null,
'error' => null,
'processed_at' => now(),
];
// A vector or an animated GIF is copied through: resampling the first
// loses the point of it, and the second loses the animation.
if (in_array($mime, (array) config('media.passthrough_mimes'), true)) {
[$width, $height] = $this->passthroughDimensions($mime, $contents);
$media->forceFill([...$attributes, 'width' => $width, 'height' => $height])->save();
return;
}
$size = @getimagesizefromstring($contents);
if ($size === false) {
throw new RuntimeException("Not a readable image: {$media->path}");
}
[$width, $height] = [(int) $size[0], (int) $size[1]];
// Above the ceiling the dimensions are still recorded — they are what
// stops the page reflowing — but nothing is resampled. A 100-megapixel
// upload is nearly always a mistake, and resizing one costs more than
// a small panel is worth.
if ($width * $height > (int) config('media.max_pixels', 50_000_000)) {
$media->forceFill([...$attributes, 'width' => $width, 'height' => $height])->save();
return;
}
$image = @imagecreatefromstring($contents);
if (! $image instanceof GdImage) {
throw new RuntimeException("GD could not read: {$media->path}");
}
// A photo taken sideways carries its rotation in EXIF rather than in
// the pixels, and everything downstream would inherit the mistake.
[$image, $width, $height] = $this->applyOrientation($image, $contents, $width, $height);
$this->preserveTransparency($image);
try {
$media->deleteDerived();
$media->forceFill([
...$attributes,
'width' => $width,
'height' => $height,
'variants' => $this->variants($media, $image, $width),
'placeholder' => $this->placeholder($image, $width, $height),
])->save();
} finally {
imagedestroy($image);
}
}
/**
* One file per width and format, never wider than the original.
*
* @return array<int, array{format: string, width: int, path: string, size: int}>
*/
protected function variants(Media $media, GdImage $image, int $width): array
{
$disk = Storage::disk($media->disk);
$formats = array_filter((array) config('media.formats'), $this->canEncode(...));
$variants = [];
foreach ((array) config('media.widths') as $target) {
$target = (int) $target;
// Never upscale: a 900px original must not be republished as a
// blurry "1600px" version.
if ($target >= $width) {
continue;
}
$scaled = imagescale($image, $target);
if (! $scaled instanceof GdImage) {
continue;
}
$this->preserveTransparency($scaled);
foreach ($formats as $format) {
$encoded = $this->encode($scaled, $format);
if ($encoded === null) {
continue;
}
$path = $this->variantPath($media->path, $target, $format);
$disk->put($path, $encoded);
$variants[] = [
'format' => $format,
'width' => $target,
'path' => $path,
'size' => strlen($encoded),
];
}
imagedestroy($scaled);
}
return $variants;
}
/**
* A tiny blurred version, inlined as a data URI.
*
* It travels inside every JSON response that mentions the image, so it is
* kept to a few hundred bytes on purpose.
*/
protected function placeholder(GdImage $image, int $width, int $height): ?string
{
if (! config('media.placeholder.enabled', true)) {
return null;
}
$target = min((int) config('media.placeholder.width', 20), $width);
$scaled = imagescale($image, $target);
if (! $scaled instanceof GdImage) {
return null;
}
$this->preserveTransparency($scaled);
$format = $this->canEncode('webp') ? 'webp' : 'jpeg';
$encoded = $this->encode($scaled, $format, quality: 40);
imagedestroy($scaled);
if ($encoded === null) {
return null;
}
return "data:image/{$format};base64,".base64_encode($encoded);
}
protected function encode(GdImage $image, string $format, ?int $quality = null): ?string
{
$quality ??= (int) config("media.quality.{$format}", 80);
ob_start();
$ok = match ($format) {
'webp' => imagewebp($image, null, $quality),
'avif' => imageavif($image, null, $quality),
'jpeg' => imagejpeg($image, null, $quality),
default => false,
};
$contents = (string) ob_get_clean();
return $ok && $contents !== '' ? $contents : null;
}
/**
* Whether this PHP can actually write the format, rather than whether the
* configuration would like it to.
*/
protected function canEncode(string $format): bool
{
return match ($format) {
'webp' => function_exists('imagewebp'),
'avif' => function_exists('imageavif'),
'jpeg' => function_exists('imagejpeg'),
default => false,
};
}
/**
* `projects/cover.jpg` at 800px in WebP becomes `projects/cover-800.webp`,
* which keeps derived files next to their original and makes them easy to
* recognise and to clean up.
*/
protected function variantPath(string $path, int $width, string $format): string
{
$directory = trim(pathinfo($path, PATHINFO_DIRNAME), '.');
$name = pathinfo($path, PATHINFO_FILENAME);
return ltrim("{$directory}/{$name}-{$width}.{$format}", '/');
}
/**
* PNGs with transparency turn black when resampled without this.
*/
protected function preserveTransparency(GdImage $image): void
{
imagealphablending($image, false);
imagesavealpha($image, true);
}
/**
* @return array{0: GdImage, 1: int, 2: int}
*/
protected function applyOrientation(GdImage $image, string $contents, int $width, int $height): array
{
if (! function_exists('exif_read_data')) {
return [$image, $width, $height];
}
$exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($contents));
$orientation = (int) ($exif['Orientation'] ?? 0);
$degrees = match ($orientation) {
3 => 180,
6 => 270,
8 => 90,
default => 0,
};
if ($degrees === 0) {
return [$image, $width, $height];
}
$rotated = imagerotate($image, $degrees, 0);
if (! $rotated instanceof GdImage) {
return [$image, $width, $height];
}
imagedestroy($image);
return [$rotated, imagesx($rotated), imagesy($rotated)];
}
/**
* Dimensions for the formats that are copied through. An SVG carries them
* in its markup, which is worth reading: without a size the frontend
* cannot reserve the box either.
*
* @return array{0: ?int, 1: ?int}
*/
protected function passthroughDimensions(?string $mime, string $contents): array
{
if ($mime !== 'image/svg+xml') {
$size = @getimagesizefromstring($contents);
return $size === false ? [null, null] : [(int) $size[0], (int) $size[1]];
}
if (preg_match('/viewBox\s*=\s*["\']\s*[\d.+-]+[,\s]+[\d.+-]+[,\s]+([\d.]+)[,\s]+([\d.]+)/i', $contents, $m)) {
return [(int) round((float) $m[1]), (int) round((float) $m[2])];
}
$width = preg_match('/<svg[^>]*\swidth\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null;
$height = preg_match('/<svg[^>]*\sheight\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null;
return [$width, $height];
}
}