Featured artworks thumbnails

This commit is contained in:
2026-05-06 19:11:31 +02:00
parent 82f2b1f660
commit 0c5dde9b22
36 changed files with 55994 additions and 30 deletions

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\News\NewsCoverImageService;
use App\Support\News\NewsCoverImage;
use Illuminate\Console\Command;
use cPad\Plugins\News\Models\NewsArticle;
final class GenerateNewsCoverThumbnailsCommand extends Command
{
protected $signature = 'news:generate-cover-thumbnails {--id=* : Restrict to one or more news article IDs} {--force : Regenerate variants even when they already exist}';
protected $description = 'Generate missing responsive cover thumbnails for managed news cover images';
public function __construct(
private readonly NewsCoverImageService $covers,
private readonly ArtworkCdnPurgeService $cdnPurge,
)
{
parent::__construct();
}
public function handle(): int
{
$ids = collect((array) $this->option('id'))
->map(static fn (mixed $id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
$force = (bool) $this->option('force');
$query = NewsArticle::query()
->select(['id', 'title', 'cover_image'])
->whereNotNull('cover_image')
->where('cover_image', '!=', '');
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$generated = 0;
$skipped = 0;
$failed = 0;
$purged = 0;
$query->orderBy('id')->chunkById(100, function ($articles) use (&$generated, &$skipped, &$failed, &$purged, $force): void {
foreach ($articles as $article) {
$path = trim((string) $article->cover_image);
if (! NewsCoverImage::isManagedPath($path)) {
$skipped++;
continue;
}
try {
$result = $this->covers->ensureVariants($path, $force);
} catch (\Throwable $e) {
$failed++;
$this->warn(sprintf('Article %d failed: %s', (int) $article->id, $e->getMessage()));
continue;
}
if (($result['generated'] ?? 0) > 0) {
$generated++;
if ($force && $this->purgeVariantCache($path, (int) $article->id)) {
$purged++;
}
continue;
}
$skipped++;
}
});
$this->info(sprintf('News cover thumbnail generation complete: generated=%d skipped=%d failed=%d purged=%d', $generated, $skipped, $failed, $purged));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function purgeVariantCache(string $path, int $articleId): bool
{
$variantPaths = array_values(array_map(
static fn (string $variant): string => NewsCoverImage::variantPath($path, $variant),
array_keys(NewsCoverImage::VARIANTS),
));
return $this->cdnPurge->purgeArtworkObjectPaths($variantPaths, [
'article_id' => $articleId,
'reason' => 'news_cover_thumbnails_regenerated',
]);
}
}

View File

@@ -0,0 +1,502 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\Cdn\ArtworkCdnPurgeService;
use App\Services\Uploads\UploadDerivativesService;
use App\Services\Uploads\UploadStorageService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class RepairArtworkThumbnailsCommand extends Command
{
private const SOURCE_IMAGE_EXTENSIONS = [
'avif',
'bmp',
'gif',
'jpg',
'jpeg',
'png',
'tif',
'tiff',
'webp',
];
protected $signature = 'artworks:repair-missing-thumbnails
{--id= : Repair only this artwork ID}
{--limit= : Stop after processing this many artworks}
{--chunk=200 : Number of artworks to scan per batch}
{--variant=* : Specific thumbnail variants to repair (defaults to all configured derivatives)}
{--only-missing-flagged : Scan only artworks already marked with has_missing_thumbnails=1}
{--csv= : Optional path to write a CSV report}
{--force : Regenerate the selected variants even when they already exist}
{--dry-run : Report repairs without writing files}';
protected $description = 'Scan artworks from newest to oldest, detect missing CDN thumbnails, and rebuild only the missing derivatives from local source files.';
public function handle(
UploadStorageService $storage,
UploadDerivativesService $derivatives,
ArtworkFileRepository $artworkFiles,
ArtworkOriginalFileLocator $locator,
ArtworkCdnPurgeService $cdnPurge,
): int {
$artworkId = $this->option('id') !== null ? max(1, (int) $this->option('id')) : null;
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunkSize = max(1, min((int) $this->option('chunk'), 1000));
$onlyMissingFlagged = (bool) $this->option('only-missing-flagged');
$csvPath = trim((string) $this->option('csv'));
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$allVariants = $this->resolveConfiguredVariants();
$selectedVariants = $this->resolveSelectedVariants($allVariants);
if ($selectedVariants === []) {
return self::FAILURE;
}
$auditColumnsAvailable = Schema::hasColumns('artworks', [
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
]);
if ($onlyMissingFlagged && ! $auditColumnsAvailable) {
$this->error('The --only-missing-flagged option requires thumbnail audit columns on the artworks table.');
return self::FAILURE;
}
$diskName = $storage->objectDiskName();
$disk = Storage::disk($diskName);
$csvHandle = $this->openCsvHandle($csvPath);
$baseQuery = $this->baseQuery($onlyMissingFlagged);
$totalCandidates = $this->resolveTotalCandidates($baseQuery, $artworkId, $limit);
$progressBar = $totalCandidates > 0 ? $this->output->createProgressBar($totalCandidates) : null;
$this->info(sprintf(
'Starting thumbnail repair. order=id_desc include_trashed=yes disk=%s variants=%s chunk=%d limit=%s flagged_only=%s force=%s dry_run=%s csv=%s',
$diskName,
implode(',', $selectedVariants),
$chunkSize,
$limit !== null ? (string) $limit : 'all',
$onlyMissingFlagged ? 'yes' : 'no',
$force ? 'yes' : 'no',
$dryRun ? 'yes' : 'no',
$csvPath !== '' ? $csvPath : 'off',
));
if ($progressBar !== null) {
$progressBar->start();
}
$processed = 0;
$healthy = 0;
$planned = 0;
$repaired = 0;
$failed = 0;
$lastSeenId = null;
try {
do {
$artworks = $this->nextChunk($baseQuery, $artworkId, $chunkSize, $lastSeenId);
if ($artworks->isEmpty()) {
break;
}
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
break 2;
}
try {
$targetVariants = $force
? $selectedVariants
: $this->resolveMissingVariants($artwork, $selectedVariants, $storage, $disk);
if ($targetVariants === []) {
$healthy++;
$processed++;
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'healthy',
'variants' => '',
'source_file' => '',
'message' => '',
]);
$progressBar?->advance();
continue;
}
$sourcePath = $this->resolveLocalSourcePath($artwork, $locator);
if ($sourcePath === '') {
throw new \RuntimeException('No local original source file was found in the configured artwork roots.');
}
if ($dryRun) {
$planned++;
$this->line(sprintf(
'Artwork %d would repair thumbnails: %s',
(int) $artwork->id,
implode(',', $targetVariants),
));
$this->line(' source_file: ' . $sourcePath);
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'planned',
'variants' => implode(',', $targetVariants),
'source_file' => $sourcePath,
'message' => '',
]);
$processed++;
$progressBar?->advance();
continue;
}
$assets = $derivatives->generateSelectedPublicDerivatives($sourcePath, (string) $artwork->hash, $targetVariants);
if ($assets === []) {
throw new \RuntimeException('No thumbnail assets were generated for the requested variants.');
}
DB::transaction(function () use ($artwork, $assets, $artworkFiles, $storage, $disk, $allVariants, $auditColumnsAvailable): void {
foreach ($assets as $variant => $asset) {
$artworkFiles->upsert((int) $artwork->id, (string) $variant, $asset['path'], $asset['mime'], $asset['size']);
}
$update = [
'thumb_ext' => 'webp',
];
if ($auditColumnsAvailable) {
$remainingMissing = $this->resolveMissingVariants($artwork, $allVariants, $storage, $disk);
$update['has_missing_thumbnails'] = $remainingMissing !== [];
$update['missing_thumbnail_variants_json'] = $remainingMissing === []
? null
: json_encode(array_values($remainingMissing), JSON_UNESCAPED_SLASHES);
$update['thumbnails_checked_at'] = now();
}
Artwork::query()->withTrashed()->whereKey($artwork->id)->update($update);
});
$cdnPurge->purgeArtworkObjectPaths(array_map(
static fn (array $asset): string => (string) $asset['path'],
array_values($assets),
), [
'artwork_id' => (int) $artwork->id,
'reason' => 'thumbnail_repair',
]);
$repaired++;
$this->info(sprintf(
'Artwork %d repaired thumbnails: %s',
(int) $artwork->id,
implode(',', array_keys($assets)),
));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'repaired',
'variants' => implode(',', array_keys($assets)),
'source_file' => $sourcePath,
'message' => '',
]);
} catch (Throwable $exception) {
$failed++;
$this->warn(sprintf('Artwork %d repair failed: %s', (int) $artwork->id, $exception->getMessage()));
$this->writeCsvRow($csvHandle, [
'artwork_id' => (int) $artwork->id,
'status' => 'failed',
'variants' => isset($targetVariants) && is_array($targetVariants) ? implode(',', $targetVariants) : '',
'source_file' => isset($sourcePath) ? (string) $sourcePath : '',
'message' => $exception->getMessage(),
]);
}
$processed++;
$progressBar?->advance();
}
$lastSeenId = (int) $artworks->last()->id;
} while (true);
} finally {
if ($progressBar !== null) {
$progressBar->finish();
$this->newLine(2);
}
if (is_resource($csvHandle)) {
fclose($csvHandle);
}
}
$this->info(sprintf(
'Thumbnail repair complete. processed=%d healthy=%d planned=%d repaired=%d failed=%d',
$processed,
$healthy,
$planned,
$repaired,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return Collection<int, Artwork>
*/
private function nextChunk(mixed $baseQuery, ?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
{
$query = clone $baseQuery;
if ($artworkId !== null) {
$query->whereKey($artworkId);
} elseif ($lastSeenId !== null) {
$query->where('id', '<', $lastSeenId);
}
return $query->limit($chunkSize)->get();
}
private function baseQuery(bool $onlyMissingFlagged): mixed
{
$query = Artwork::query()
->withTrashed()
->select(['id', 'slug', 'hash', 'file_path', 'file_ext', 'thumb_ext'])
->whereNotNull('hash')
->where('hash', '!=', '')
->orderByDesc('id');
if ($onlyMissingFlagged) {
$query->where('has_missing_thumbnails', true);
}
return $query;
}
private function resolveTotalCandidates(mixed $baseQuery, ?int $artworkId, ?int $limit): int
{
$countQuery = clone $baseQuery;
if ($artworkId !== null) {
$countQuery->whereKey($artworkId);
}
$count = (int) $countQuery->count();
if ($limit !== null) {
return min($count, $limit);
}
return $count;
}
/**
* @return list<string>
*/
private function resolveConfiguredVariants(): array
{
return array_values(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
array_keys((array) config('uploads.derivatives', [])),
)));
}
/**
* @param list<string> $configuredVariants
* @return list<string>
*/
private function resolveSelectedVariants(array $configuredVariants): array
{
if ($configuredVariants === []) {
$this->error('No thumbnail variants are configured. Check uploads.derivatives.');
return [];
}
$requested = (array) $this->option('variant');
if ($requested === []) {
return $configuredVariants;
}
$normalizedRequested = array_values(array_unique(array_filter(array_map(
static fn ($variant): string => strtolower(trim((string) $variant)),
$requested,
))));
$invalid = array_values(array_diff($normalizedRequested, $configuredVariants));
if ($invalid !== []) {
$this->error('Unknown thumbnail variants: ' . implode(', ', $invalid));
$this->line('Configured variants: ' . implode(', ', $configuredVariants));
return [];
}
return $normalizedRequested;
}
/**
* @param list<string> $variants
* @return list<string>
*/
private function resolveMissingVariants(Artwork $artwork, array $variants, UploadStorageService $storage, mixed $disk): array
{
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
if ($hash === '') {
return $variants;
}
$missing = [];
foreach ($variants as $variant) {
$objectPath = $storage->objectPathForVariant($variant, $hash, $hash . '.webp');
if (! $disk->exists($objectPath)) {
$missing[] = $variant;
}
}
return $missing;
}
private function resolveLocalSourcePath(Artwork $artwork, ArtworkOriginalFileLocator $locator): string
{
$hash = strtolower((string) ($artwork->hash ?? ''));
if (! $this->isValidHash($hash)) {
return '';
}
$preferred = $locator->resolveLocalPath($artwork);
if ($this->isUsableSourceFile($preferred)) {
return $preferred;
}
foreach ($this->candidateOriginalRoots() as $root) {
$candidatePath = $this->findNonZipSourceInRoot($root, $hash);
if ($candidatePath !== '') {
return $candidatePath;
}
}
return '';
}
/**
* @return list<string>
*/
private function candidateOriginalRoots(): array
{
$roots = [
trim((string) config('uploads.local_originals_root', '')),
trim((string) config('uploads.readonly_backup_originals_root', '')),
];
$normalizedRoots = [];
foreach ($roots as $root) {
if ($root === '') {
continue;
}
$normalizedRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root), DIRECTORY_SEPARATOR);
if ($normalizedRoot === '' || in_array($normalizedRoot, $normalizedRoots, true)) {
continue;
}
$normalizedRoots[] = $normalizedRoot;
}
return $normalizedRoots;
}
private function findNonZipSourceInRoot(string $root, string $hash): string
{
$directory = $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2);
if (! File::isDirectory($directory)) {
return '';
}
$matches = File::glob($directory . DIRECTORY_SEPARATOR . $hash . '.*');
if (! is_array($matches)) {
return '';
}
foreach ($matches as $path) {
if ($this->isUsableSourceFile($path)) {
return $path;
}
}
return '';
}
private function isUsableSourceFile(string $path): bool
{
if ($path === '' || ! File::isFile($path)) {
return false;
}
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
if ($extension === '' || ! in_array($extension, self::SOURCE_IMAGE_EXTENSIONS, true)) {
return false;
}
$mime = strtolower((string) (File::mimeType($path) ?? ''));
return str_starts_with($mime, 'image/');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
/**
* @return resource|null
*/
private function openCsvHandle(string $csvPath)
{
if ($csvPath === '') {
return null;
}
File::ensureDirectoryExists(dirname($csvPath));
$handle = fopen($csvPath, 'wb');
if (! is_resource($handle)) {
throw new \RuntimeException('Unable to open CSV output path for writing: ' . $csvPath);
}
fputcsv($handle, ['artwork_id', 'status', 'variants', 'source_file', 'message']);
return $handle;
}
/**
* @param resource|null $csvHandle
* @param array<string, scalar|null> $row
*/
private function writeCsvRow($csvHandle, array $row): void
{
if (! is_resource($csvHandle)) {
return;
}
fputcsv($csvHandle, [
$row['artwork_id'] ?? '',
$row['status'] ?? '',
$row['variants'] ?? '',
$row['source_file'] ?? '',
$row['message'] ?? '',
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Moderation\Traffic;
use App\Http\Controllers\Controller;
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
final class OnlineVisitorsController extends Controller
{
public function __construct(private readonly OnlineVisitorRepository $visitors)
{
}
public function index(): View
{
$summary = $this->visitors->summary();
$visitors = $this->visitors->all();
$activePages = $this->visitors->activePages();
return view('moderation.traffic.online', [
'summary' => $summary,
'visitors' => $visitors,
'activePages' => $activePages,
'generatedAt' => now()->toIso8601String(),
'dataUrl' => route('moderation.traffic.online.data'),
]);
}
public function data(): JsonResponse
{
return response()->json([
'summary' => $this->visitors->summary(),
'visitors' => $this->visitors->all(),
'active_pages' => $this->visitors->activePages(),
'generated_at' => now()->toIso8601String(),
]);
}
}

View File

@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class AcademyLessonMediaApiController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const ASSET_CACHE_TTL_MINUTES = 15;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function store(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'slot' => ['nullable', 'string', 'in:cover,body'],
'image' => [
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
]);
/** @var UploadedFile $file */
$file = $validated['image'];
$slot = $this->normalizeSlot($validated['slot'] ?? null);
try {
$stored = $this->storeMediaFile($file, $slot);
$this->forgetAssetCache();
return response()->json([
'success' => true,
'slot' => $slot,
'path' => $stored['path'],
'url' => $this->publicUrlForPath($stored['path']),
'width' => $stored['width'],
'height' => $stored['height'],
'mime_type' => 'image/webp',
'size_bytes' => $stored['size_bytes'],
]);
} catch (RuntimeException $e) {
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Academy lesson media upload failed', [
'user_id' => (int) ($request->user()?->id ?? 0),
'message' => $e->getMessage(),
]);
return response()->json([
'error' => 'Upload failed',
'message' => 'Could not upload lesson media right now.',
], 500);
}
}
public function destroy(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'path' => ['required', 'string', 'max:2048'],
]);
$this->deleteMediaFile((string) $validated['path']);
$this->forgetAssetCache();
return response()->json([
'success' => true,
]);
}
public function assets(Request $request): JsonResponse
{
$this->authorizeStaff($request);
$validated = $request->validate([
'limit' => ['nullable', 'integer', 'min:1', 'max:48'],
'page' => ['nullable', 'integer', 'min:1'],
'q' => ['nullable', 'string', 'max:100'],
]);
$limit = (int) ($validated['limit'] ?? 24);
$page = (int) ($validated['page'] ?? 1);
$query = Str::lower(trim((string) ($validated['q'] ?? '')));
$manifest = $this->academyAssetManifest();
if ($query !== '') {
$manifest = $manifest->filter(function (array $item) use ($query): bool {
return Str::contains($item['search_text'], $query);
})->values();
}
$total = $manifest->count();
$lastPage = max(1, (int) ceil(max($total, 1) / max($limit, 1)));
$page = min(max($page, 1), $lastPage);
$items = $manifest
->forPage($page, $limit)
->values()
->map(function (array $item): array {
return [
'path' => $item['path'],
'url' => $item['url'],
'name' => $item['name'],
'slot' => $item['slot'],
'modified_at' => $item['modified_at'] ? now()->setTimestamp($item['modified_at'])->toIso8601String() : null,
];
})
->all();
return response()->json([
'success' => true,
'items' => $items,
'pagination' => [
'page' => $page,
'per_page' => $limit,
'total' => $total,
'last_page' => $lastPage,
'has_more' => $page < $lastPage,
],
]);
}
/**
* @return array{path:string,width:int,height:int,size_bytes:int}
*/
private function storeMediaFile(UploadedFile $file, string $slot): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$constraints = $this->mediaConstraints($slot);
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < $constraints['min_width'] || $height < $constraints['min_height']) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
$constraints['min_width'],
$constraints['min_height'],
));
}
$image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']);
$encoded = (string) $image->encode(new WebpEncoder(85));
$hash = hash('sha256', $encoded);
$path = $this->mediaPath($hash, $slot);
$disk = Storage::disk($this->mediaDiskName());
$written = $disk->put($path, $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
return [
'path' => $path,
'width' => (int) $image->width(),
'height' => (int) $image->height(),
'size_bytes' => strlen($encoded),
];
}
private function authorizeStaff(Request $request): void
{
abort_unless((bool) $request->user()?->hasStaffAccess(), 403);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function mediaPath(string $hash, string $slot): string
{
$folder = $slot === 'body' ? 'body' : 'covers';
return sprintf(
'academy/lessons/%s/%s/%s/%s.webp',
$folder,
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
private function publicUrlForPath(string $path): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
private function academyAssetManifest(): Collection
{
return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection {
$disk = Storage::disk($this->mediaDiskName());
return collect($disk->allFiles('academy/lessons'))
->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png']))
->map(function (string $path) use ($disk): array {
$modifiedAt = null;
try {
$modifiedAt = $disk->lastModified($path);
} catch (\Throwable) {
$modifiedAt = null;
}
$folder = Str::contains($path, '/body/') ? 'body' : (Str::contains($path, '/covers/') ? 'cover' : 'asset');
return [
'path' => $path,
'url' => $this->publicUrlForPath($path),
'name' => $this->humanAssetName($path),
'slot' => $folder,
'modified_at' => $modifiedAt ? (int) $modifiedAt : null,
'search_text' => Str::lower(implode(' ', [$path, $folder, $this->humanAssetName($path)])),
];
})
->sortByDesc(fn (array $item): int => (int) ($item['modified_at'] ?? 0))
->values();
});
}
private function academyAssetCacheKey(): string
{
return 'academy.lesson.assets.' . md5($this->mediaDiskName());
}
private function forgetAssetCache(): void
{
Cache::forget($this->academyAssetCacheKey());
}
private function humanAssetName(string $path): string
{
$filename = pathinfo($path, PATHINFO_FILENAME);
$clean = trim(str_replace(['-', '_'], ' ', $filename));
return $clean !== '' ? Str::headline($clean) : 'Academy image';
}
private function safeFileSize($disk, string $path): ?int
{
try {
$size = $disk->size($path);
return is_int($size) ? $size : null;
} catch (\Throwable) {
return null;
}
}
private function deleteMediaFile(string $path): void
{
$trimmed = ltrim(trim($path), '/');
if ($trimmed === '' || ! Str::startsWith($trimmed, ['academy/lessons/covers/', 'academy/lessons/body/'])) {
return;
}
Storage::disk($this->mediaDiskName())->delete($trimmed);
}
private function normalizeSlot(mixed $slot): string
{
return Str::lower(trim((string) $slot)) === 'body' ? 'body' : 'cover';
}
/**
* @return array{min_width:int,min_height:int,max_width:int,max_height:int}
*/
private function mediaConstraints(string $slot): array
{
if ($slot === 'body') {
return [
'min_width' => 64,
'min_height' => 64,
'max_width' => 2400,
'max_height' => 2400,
];
}
return [
'min_width' => 1200,
'min_height' => 630,
'max_width' => 2200,
'max_height' => 1400,
];
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
$disk = Storage::disk($this->mediaDiskName());
if (! method_exists($disk, 'put')) {
throw new RuntimeException('Object storage is not configured for academy lesson uploads.');
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Traffic\OnlineVisitorRepository;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class TrackOnlineVisitor
{
public function __construct(private readonly OnlineVisitorRepository $visitors)
{
}
public function handle(Request $request, Closure $next): Response
{
$shouldTrack = $this->shouldTrack($request);
$response = $next($request);
if ($shouldTrack) {
try {
$this->visitors->track($request);
} catch (\Throwable) {
// Presence tracking is best-effort only.
}
}
return $response;
}
private function shouldTrack(Request $request): bool
{
if (! in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
return false;
}
if ($this->matchesAny($request, [
'build/*',
'storage/*',
'favicon.ico',
'livewire/*',
'_debugbar/*',
'telescope/*',
'horizon/*',
'moderation/*',
])) {
return false;
}
if ($request->path() === 'moderation/traffic/online/data') {
return false;
}
if ($this->matchesAny($request, [
'api/*',
'admin/*',
'dashboard*',
'studio*',
'settings*',
'messages*',
'creator*',
'login',
'register',
'forgot-password',
'reset-password/*',
'email/*',
'logout',
'up',
])) {
return false;
}
return ! $this->isStaticAssetPath($request->path());
}
/**
* @param array<int, string> $patterns
*/
private function matchesAny(Request $request, array $patterns): bool
{
foreach ($patterns as $pattern) {
if ($request->is($pattern)) {
return true;
}
}
return false;
}
private function isStaticAssetPath(string $path): bool
{
$normalizedPath = '/' . ltrim($path, '/');
if (in_array($normalizedPath, ['/robots.txt', '/sitemap.xml'], true) || str_starts_with($normalizedPath, '/sitemaps/')) {
return false;
}
return (bool) preg_match('/\.(?:css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|otf|mp4|webm|mp3|wav|pdf|zip)$/i', $normalizedPath);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class AcademyAiComparisonResult extends Model
{
use SoftDeletes;
protected $fillable = [
'lesson_block_id',
'provider',
'model_name',
'image_path',
'thumb_path',
'settings',
'strengths',
'weaknesses',
'best_for',
'score',
'sort_order',
'active',
];
protected $casts = [
'score' => 'integer',
'sort_order' => 'integer',
'active' => 'boolean',
];
public function block(): BelongsTo
{
return $this->belongsTo(AcademyLessonBlock::class, 'lesson_block_id');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('id');
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class AcademyLessonBlock extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::deleting(function (self $block): void {
$block->comparisonResults()->get()->each(function (AcademyAiComparisonResult $result) use ($block): void {
if ($block->isForceDeleting()) {
$result->forceDelete();
return;
}
$result->delete();
});
});
}
protected $fillable = [
'lesson_id',
'type',
'title',
'payload',
'sort_order',
'active',
];
protected $casts = [
'payload' => 'array',
'sort_order' => 'integer',
'active' => 'boolean',
];
public function lesson(): BelongsTo
{
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
}
public function comparisonResults(): HasMany
{
return $this->hasMany(AcademyAiComparisonResult::class, 'lesson_block_id')
->orderBy('sort_order')
->orderBy('id');
}
public function activeComparisonResults(): HasMany
{
return $this->comparisonResults()->where('active', true);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('id');
}
}

View File

@@ -1233,7 +1233,7 @@ final class HomepageService
->filter()
->implode(', ');
$xsSources = collect(['xs', 'mobile_sm'])
$xsSources = collect(['mobile_xs', 'mobile_sm'])
->map(function (string $variant) use ($variantUrls, $variants): ?string {
$url = $variantUrls[$variant] ?? null;

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Services\News;
use App\Support\News\NewsCoverImage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
final class NewsCoverImageService
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 6144;
private const MAX_WIDTH = 2200;
private const MAX_HEIGHT = 1400;
private const MIN_WIDTH = 1200;
private const MIN_HEIGHT = 630;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function maxFileSizeKb(): int
{
return self::MAX_FILE_SIZE_KB;
}
public function storeUploadedFile(UploadedFile $file): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$raw = $this->readUploadBytes($file);
$this->assertSupportedMimeType($raw);
$this->assertMinimumDimensions($raw);
$masterImage = $this->manager->read($raw)->scaleDown(width: self::MAX_WIDTH, height: self::MAX_HEIGHT);
$masterEncoded = (string) $masterImage->encode(new WebpEncoder(85));
$path = NewsCoverImage::path(hash('sha256', $masterEncoded));
$this->writeImage($path, $masterEncoded);
foreach (NewsCoverImage::VARIANTS as $variant => $config) {
$variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']);
$variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality']));
$this->writeImage(NewsCoverImage::variantPath($path, $variant), $variantEncoded);
}
return [
'path' => $path,
'url' => NewsCoverImage::url($path),
'width' => (int) $masterImage->width(),
'height' => (int) $masterImage->height(),
'size_bytes' => strlen($masterEncoded),
'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'),
'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'),
'srcset' => NewsCoverImage::srcset($path),
];
}
public function ensureVariants(string $path, bool $force = false): array
{
$trimmed = NewsCoverImage::normalizePath($path);
if (! NewsCoverImage::isManagedPath($trimmed)) {
return ['generated' => 0, 'skipped' => count(NewsCoverImage::VARIANTS)];
}
$this->assertImageManager();
$this->assertStorageIsAllowed();
$disk = Storage::disk($this->mediaDiskName());
if (! $disk->exists($trimmed)) {
throw new RuntimeException('Managed cover image is missing from object storage.');
}
$raw = $disk->get($trimmed);
if (! is_string($raw) || $raw === '') {
throw new RuntimeException('Unable to read managed cover image from object storage.');
}
$generated = 0;
$skipped = 0;
foreach (NewsCoverImage::VARIANTS as $variant => $config) {
$variantPath = NewsCoverImage::variantPath($trimmed, $variant);
if (! $force && $disk->exists($variantPath)) {
$skipped++;
continue;
}
$variantImage = $this->manager->read($raw)->scaleDown(width: (int) $config['width']);
$variantEncoded = (string) $variantImage->encode(new WebpEncoder((int) $config['quality']));
$this->writeImage($variantPath, $variantEncoded);
$generated++;
}
return ['generated' => $generated, 'skipped' => $skipped];
}
public function deleteManagedFiles(string $path): void
{
$paths = NewsCoverImage::managedPaths($path);
if ($paths === []) {
return;
}
Storage::disk($this->mediaDiskName())->delete($paths);
}
private function mediaDiskName(): string
{
return (string) config('uploads.object_storage.disk', 's3');
}
private function writeImage(string $path, string $encoded): void
{
$written = Storage::disk($this->mediaDiskName())->put($path, $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => 'image/webp',
]);
if ($written !== true) {
throw new RuntimeException('Unable to store image in object storage.');
}
}
private function readUploadBytes(UploadedFile $file): string
{
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
return $raw;
}
private function assertSupportedMimeType(string $raw): void
{
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
}
private function assertMinimumDimensions(string $raw): void
{
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < self::MIN_WIDTH || $height < self::MIN_HEIGHT) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
self::MIN_WIDTH,
self::MIN_HEIGHT,
));
}
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->mediaDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production news media storage must use object storage, not local/public disks.');
}
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Services\Traffic;
use Illuminate\Http\Request;
final class BotClassifier
{
/**
* @return array{is_bot: bool, type: ?string, family: ?string}
*/
public function classify(Request $request): array
{
$userAgent = trim((string) $request->userAgent());
if ($userAgent === '') {
return $this->bot('suspicious_bot', 'Empty UA');
}
$normalized = strtolower($userAgent);
if ($family = $this->matchFamily($normalized, [
'curl' => ['curl'],
'wget' => ['wget'],
'python-requests' => ['python-requests'],
'libwww-perl' => ['libwww-perl'],
'Go-http-client' => ['go-http-client'],
'Java' => ['java/'],
'scrapy' => ['scrapy'],
'httpclient' => ['httpclient'],
'masscan' => ['masscan'],
'nikto' => ['nikto'],
'sqlmap' => ['sqlmap'],
])) {
return $this->bot('suspicious_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'Googlebot' => ['googlebot'],
'Bingbot' => ['bingbot'],
'DuckDuckBot' => ['duckduckbot'],
'YandexBot' => ['yandexbot'],
'Baiduspider' => ['baiduspider'],
'Applebot' => ['applebot'],
'Slurp' => ['slurp'],
])) {
return $this->bot('search_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'GPTBot' => ['gptbot'],
'ChatGPT-User' => ['chatgpt-user'],
'OAI-SearchBot' => ['oai-searchbot'],
'ClaudeBot' => ['claudebot'],
'PerplexityBot' => ['perplexitybot'],
'Bytespider' => ['bytespider'],
'CCBot' => ['ccbot'],
'Google-Extended' => ['google-extended'],
'anthropic-ai' => ['anthropic-ai'],
'cohere-ai' => ['cohere-ai'],
])) {
return $this->bot('ai_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'AhrefsBot' => ['ahrefsbot'],
'SemrushBot' => ['semrushbot'],
'MJ12bot' => ['mj12bot'],
'DotBot' => ['dotbot'],
'PetalBot' => ['petalbot'],
'DataForSeoBot' => ['dataforseobot'],
'BLEXBot' => ['blexbot'],
'MauiBot' => ['mauibot'],
'serpstatbot' => ['serpstatbot'],
])) {
return $this->bot('seo_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'facebookexternalhit' => ['facebookexternalhit'],
'Twitterbot' => ['twitterbot'],
'LinkedInBot' => ['linkedinbot'],
'Slackbot' => ['slackbot'],
'Discordbot' => ['discordbot'],
'TelegramBot' => ['telegrambot'],
'WhatsApp' => ['whatsapp'],
'Pinterestbot' => ['pinterestbot'],
])) {
return $this->bot('social_bot', $family);
}
if ($family = $this->matchFamily($normalized, [
'UptimeRobot' => ['uptimerobot'],
'Pingdom' => ['pingdom'],
'StatusCake' => ['statuscake'],
'Better Stack' => ['better stack', 'betterstack'],
'BetterUptime' => ['betteruptime'],
])) {
return $this->bot('monitoring_bot', $family);
}
if (strlen($userAgent) < 8) {
return $this->bot('suspicious_bot', 'Short UA');
}
if ($this->containsAny($normalized, ['bot', 'crawler', 'spider', 'crawl', 'preview'])) {
return $this->bot('unknown_bot', 'Unknown crawler');
}
return [
'is_bot' => false,
'type' => null,
'family' => null,
];
}
/**
* @param array<string, array<int, string>> $families
*/
private function matchFamily(string $normalizedUserAgent, array $families): ?string
{
foreach ($families as $family => $keywords) {
if ($this->containsAny($normalizedUserAgent, $keywords)) {
return $family;
}
}
return null;
}
/**
* @param array<int, string> $keywords
*/
private function containsAny(string $haystack, array $keywords): bool
{
foreach ($keywords as $keyword) {
if ($keyword !== '' && str_contains($haystack, $keyword)) {
return true;
}
}
return false;
}
/**
* @return array{is_bot: bool, type: string, family: string}
*/
private function bot(string $type, string $family): array
{
return [
'is_bot' => true,
'type' => $type,
'family' => $family,
];
}
}

View File

@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace App\Services\Traffic;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class OnlineVisitorRepository
{
public const INDEX_KEY = 'skinbase:presence:online:index';
public const KEY_PREFIX = 'skinbase:presence:online';
public const TTL_SECONDS = 300;
public function __construct(private readonly BotClassifier $classifier)
{
}
public function track(Request $request): void
{
try {
$classification = $this->classifier->classify($request);
$visitorKey = $this->resolveVisitorKey($request, $classification);
$existing = $this->readRecord($visitorKey);
$user = $request->user();
$now = now()->toIso8601String();
$record = [
'visitor_key' => $visitorKey,
'type' => $classification['is_bot']
? (string) $classification['type']
: ($user ? 'human_logged' : 'human_guest'),
'bot_family' => $classification['is_bot'] ? $classification['family'] : null,
'user_id' => $this->resolveUserId($user),
'user_name' => $this->resolveUserName($user),
'ip_masked' => $this->maskIp($this->resolveIp($request)),
'ip_hash' => hash('sha256', $this->resolveIp($request)),
'user_agent' => $this->truncate((string) $request->userAgent(), 512),
'browser' => $this->detectBrowser((string) $request->userAgent()),
'platform' => $this->detectPlatform((string) $request->userAgent()),
'current_url' => $this->currentUrl($request),
'route_name' => $request->route()?->getName(),
'referer' => $this->truncate((string) $request->headers->get('referer', ''), 512) ?: null,
'first_seen_at' => is_string($existing['first_seen_at'] ?? null)
? $existing['first_seen_at']
: $now,
'last_seen_at' => $now,
'hits' => (int) ($existing['hits'] ?? 0) + 1,
];
$this->storeRecord($visitorKey, $record, self::TTL_SECONDS);
$this->addIndexMember($visitorKey);
} catch (\Throwable $e) {
Log::warning('Online visitor tracking failed', [
'error' => $e->getMessage(),
'path' => $request->path(),
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
public function all(): array
{
try {
$visitorKeys = array_values(array_unique(array_filter(array_map('strval', $this->readIndexMembers()))));
} catch (\Throwable $e) {
Log::warning('Online visitor index read failed', ['error' => $e->getMessage()]);
return [];
}
$records = [];
$expired = [];
foreach ($visitorKeys as $visitorKey) {
try {
$record = $this->readRecord($visitorKey);
} catch (\Throwable $e) {
Log::warning('Online visitor record read failed', [
'error' => $e->getMessage(),
'visitor_key' => $visitorKey,
]);
$record = null;
}
if ($record === null) {
$expired[] = $visitorKey;
continue;
}
$records[] = $record;
}
if ($expired !== []) {
try {
$this->removeIndexMembers($expired);
} catch (\Throwable $e) {
Log::warning('Online visitor index cleanup failed', ['error' => $e->getMessage()]);
}
}
usort($records, static function (array $left, array $right): int {
return strtotime((string) ($right['last_seen_at'] ?? '')) <=> strtotime((string) ($left['last_seen_at'] ?? ''));
});
return $records;
}
/**
* @return array{total:int,logged:int,guests:int,bots:int,search_bots:int,ai_bots:int,social_bots:int,seo_bots:int,suspicious_bots:int}
*/
public function summary(): array
{
$records = $this->all();
return [
'total' => count($records),
'logged' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_logged')),
'guests' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'human_guest')),
'bots' => count(array_filter($records, static fn (array $record): bool => str_ends_with((string) ($record['type'] ?? ''), '_bot'))),
'search_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'search_bot')),
'ai_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'ai_bot')),
'social_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'social_bot')),
'seo_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'seo_bot')),
'suspicious_bots' => count(array_filter($records, static fn (array $record): bool => ($record['type'] ?? null) === 'suspicious_bot')),
];
}
/**
* @return array<int, array{url:string, visitors:int}>
*/
public function activePages(): array
{
$counts = [];
foreach ($this->all() as $record) {
$url = trim((string) ($record['current_url'] ?? ''));
if ($url === '') {
continue;
}
$counts[$url] = ($counts[$url] ?? 0) + 1;
}
arsort($counts);
$pages = [];
foreach ($counts as $url => $visitors) {
$pages[] = [
'url' => $url,
'visitors' => $visitors,
];
}
return $pages;
}
public function forget(string $visitorKey): void
{
try {
$this->deleteRecord($visitorKey);
$this->removeIndexMembers([$visitorKey]);
} catch (\Throwable $e) {
Log::warning('Online visitor forget failed', [
'error' => $e->getMessage(),
'visitor_key' => $visitorKey,
]);
}
}
/**
* @return array<int, string>
*/
protected function readIndexMembers(): array
{
return array_map('strval', Redis::smembers(self::INDEX_KEY));
}
/**
* @return array<string, mixed>|null
*/
protected function readRecord(string $visitorKey): ?array
{
$raw = Redis::get($this->recordKey($visitorKey));
if (! is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param array<string, mixed> $record
*/
protected function storeRecord(string $visitorKey, array $record, int $ttlSeconds): void
{
Redis::setex(
$this->recordKey($visitorKey),
$ttlSeconds,
(string) json_encode($record, JSON_UNESCAPED_SLASHES)
);
}
protected function addIndexMember(string $visitorKey): void
{
Redis::sadd(self::INDEX_KEY, $visitorKey);
}
/**
* @param array<int, string> $visitorKeys
*/
protected function removeIndexMembers(array $visitorKeys): void
{
if ($visitorKeys === []) {
return;
}
Redis::srem(self::INDEX_KEY, ...$visitorKeys);
}
protected function deleteRecord(string $visitorKey): void
{
Redis::del($this->recordKey($visitorKey));
}
/**
* @param array{is_bot: bool, type: ?string, family: ?string} $classification
*/
private function resolveVisitorKey(Request $request, array $classification): string
{
$user = $request->user();
if ($user) {
return 'user:' . $user->getAuthIdentifier();
}
$ip = $this->resolveIp($request);
$userAgent = (string) $request->userAgent();
if ($classification['is_bot']) {
return 'bot:' . hash('sha256', $ip . '|' . $userAgent);
}
$sessionCookieName = (string) config('session.cookie', 'laravel_session');
$sessionCookie = (string) $request->cookies->get($sessionCookieName, '');
$guestSeed = $sessionCookie !== ''
? 'session:' . $sessionCookie
: 'fingerprint:' . $ip . '|' . $userAgent . '|' . (string) $request->header('Accept-Language', '');
return 'guest:' . hash('sha256', $guestSeed);
}
private function resolveIp(Request $request): string
{
$cloudflareIp = trim((string) $request->headers->get('CF-Connecting-IP', ''));
if ($cloudflareIp !== '' && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
return $cloudflareIp;
}
return (string) ($request->ip() ?: '0.0.0.0');
}
private function maskIp(string $ip): string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$parts = explode('.', $ip);
return sprintf('%s.%s.xxx.xxx', $parts[0] ?? '0', $parts[1] ?? '0');
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$parts = explode(':', $ip);
$parts = array_pad($parts, 4, '');
return sprintf('%s:%s:xxxx:xxxx', $parts[0] ?: '::', $parts[1] ?: '::');
}
return 'unknown';
}
private function detectBrowser(string $userAgent): string
{
$normalized = strtolower($userAgent);
return match (true) {
str_contains($normalized, 'edg/') => 'Edge',
str_contains($normalized, 'opr/') || str_contains($normalized, 'opera') => 'Opera',
str_contains($normalized, 'chrome') && ! str_contains($normalized, 'edg/') => 'Chrome',
str_contains($normalized, 'firefox') => 'Firefox',
str_contains($normalized, 'safari') && ! str_contains($normalized, 'chrome') => 'Safari',
default => 'Unknown',
};
}
private function detectPlatform(string $userAgent): string
{
$normalized = strtolower($userAgent);
return match (true) {
str_contains($normalized, 'windows') => 'Windows',
str_contains($normalized, 'iphone') || str_contains($normalized, 'ipad') || str_contains($normalized, 'ios') => 'iOS',
str_contains($normalized, 'android') => 'Android',
str_contains($normalized, 'mac os') || str_contains($normalized, 'macintosh') => 'macOS',
str_contains($normalized, 'linux') => 'Linux',
default => 'Unknown',
};
}
private function currentUrl(Request $request): string
{
$path = '/' . ltrim($request->path(), '/');
return $path === '//' ? '/' : $path;
}
private function recordKey(string $visitorKey): string
{
return self::KEY_PREFIX . ':' . $visitorKey;
}
private function truncate(string $value, int $limit): string
{
return Str::limit($value, $limit, '');
}
private function resolveUserId(?Authenticatable $user): ?int
{
if ($user === null) {
return null;
}
$identifier = $user->getAuthIdentifier();
return is_numeric($identifier) ? (int) $identifier : null;
}
private function resolveUserName(?Authenticatable $user): ?string
{
if ($user === null) {
return null;
}
$name = data_get($user, 'name')
?? data_get($user, 'username')
?? data_get($user, 'email');
return is_string($name) && $name !== '' ? $name : 'User';
}
}

View File

@@ -65,12 +65,12 @@ final class ArtworkFeaturedImagePath
$variantName = $this->normalizeVariant($variant);
$orders = [
'xs' => ['xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile_sm' => ['mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile' => ['mobile', 'mobile_sm', 'xs', 'tablet', 'desktop', 'desktop_xl'],
'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'xs'],
'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'xs'],
'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'xs'],
'mobile_xs' => ['mobile_xs', 'mobile_sm', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile_sm' => ['mobile_sm', 'mobile_xs', 'mobile', 'tablet', 'desktop', 'desktop_xl'],
'mobile' => ['mobile', 'mobile_sm', 'mobile_xs', 'tablet', 'desktop', 'desktop_xl'],
'tablet' => ['tablet', 'desktop', 'desktop_xl', 'mobile', 'mobile_sm', 'mobile_xs'],
'desktop' => ['desktop', 'desktop_xl', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
'desktop_xl' => ['desktop_xl', 'desktop', 'tablet', 'mobile', 'mobile_sm', 'mobile_xs'],
];
return $orders[$variantName] ?? [$this->defaultVariant()];

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Support\News;
final class NewsCoverImage
{
public const MANAGED_PREFIX = 'news/covers/';
public const VARIANTS = [
'mobile' => [
'width' => 400,
'quality' => 74,
'suffix' => 'mobile',
],
'desktop' => [
'width' => 768,
'quality' => 76,
'suffix' => 'desktop',
],
];
public static function isManagedPath(?string $path): bool
{
$trimmed = ltrim(trim((string) $path), '/');
return $trimmed !== '' && str_starts_with($trimmed, self::MANAGED_PREFIX);
}
public static function normalizePath(?string $path): string
{
return ltrim(trim((string) $path), '/');
}
public static function path(string $hash): string
{
return sprintf(
'news/covers/%s/%s/%s.webp',
substr($hash, 0, 2),
substr($hash, 2, 2),
$hash,
);
}
public static function variantPath(string $path, string $variant): string
{
$trimmed = self::normalizePath($path);
$config = self::VARIANTS[$variant] ?? null;
if ($trimmed === '' || $config === null) {
return $trimmed;
}
$directory = pathinfo($trimmed, PATHINFO_DIRNAME);
$filename = pathinfo($trimmed, PATHINFO_FILENAME);
$extension = pathinfo($trimmed, PATHINFO_EXTENSION) ?: 'webp';
return ($directory !== '.' ? $directory . '/' : '') . $filename . '-' . $config['suffix'] . '.' . $extension;
}
public static function managedPaths(string $path): array
{
$trimmed = self::normalizePath($path);
if (! self::isManagedPath($trimmed)) {
return [];
}
$paths = [$trimmed];
foreach (array_keys(self::VARIANTS) as $variant) {
$paths[] = self::variantPath($trimmed, $variant);
}
return array_values(array_unique($paths));
}
public static function url(?string $path): ?string
{
$trimmed = self::normalizePath($path);
if ($trimmed === '') {
return null;
}
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
return $trimmed;
}
if (self::isManagedPath($trimmed)) {
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . $trimmed;
}
return asset('storage/' . $trimmed);
}
public static function variantUrl(?string $path, string $variant): ?string
{
$trimmed = self::normalizePath($path);
if (! self::isManagedPath($trimmed)) {
return null;
}
return self::url(self::variantPath($trimmed, $variant));
}
public static function srcset(?string $path): ?string
{
$trimmed = self::normalizePath($path);
if (! self::isManagedPath($trimmed)) {
return null;
}
$entries = [];
foreach (self::VARIANTS as $variant => $config) {
$url = self::variantUrl($trimmed, $variant);
if ($url === null) {
continue;
}
$entries[] = $url . ' ' . $config['width'] . 'w';
}
return $entries === [] ? null : implode(', ', $entries);
}
}