Featured artworks thumbnails
This commit is contained in:
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal file
99
app/Console/Commands/GenerateNewsCoverThumbnailsCommand.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal file
502
app/Console/Commands/RepairArtworkThumbnailsCommand.php
Normal 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'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
103
app/Http/Middleware/TrackOnlineVisitor.php
Normal file
103
app/Http/Middleware/TrackOnlineVisitor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
app/Models/AcademyAiComparisonResult.php
Normal file
51
app/Models/AcademyAiComparisonResult.php
Normal 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');
|
||||
}
|
||||
}
|
||||
73
app/Models/AcademyLessonBlock.php
Normal file
73
app/Models/AcademyLessonBlock.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
217
app/Services/News/NewsCoverImageService.php
Normal file
217
app/Services/News/NewsCoverImageService.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
158
app/Services/Traffic/BotClassifier.php
Normal file
158
app/Services/Traffic/BotClassifier.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
361
app/Services/Traffic/OnlineVisitorRepository.php
Normal file
361
app/Services/Traffic/OnlineVisitorRepository.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
|
||||
131
app/Support/News/NewsCoverImage.php
Normal file
131
app/Support/News/NewsCoverImage.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user