Files
SkinbaseNova/app/Http/Controllers/ArtworkDownloadController.php

151 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ArtworkDownloadController extends Controller
{
/**
* Allowed original file extensions for secure server-side download.
*
* @var array<int, string>
*/
private const ALLOWED_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'tiff',
'zip',
'rar',
'7z',
'tar',
'gz',
];
public function __invoke(Request $request, int $id): BinaryFileResponse
{
$artwork = Artwork::query()->find($id);
if (! $artwork) {
abort(404);
}
$filePath = $this->resolveOriginalPath($artwork);
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404);
}
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'ext' => $ext,
'resolved_path' => $filePath,
]);
abort(404);
}
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
return response()->download($filePath, $downloadName);
}
private function resolveOriginalPath(Artwork $artwork): string
{
$relative = trim((string) $artwork->file_path, '/');
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/') . '/original/';
if ($relative !== '' && str_starts_with($relative, $prefix)) {
$suffix = substr($relative, strlen($prefix));
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || $ext === '') {
return '';
}
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
}
private function recordDownload(Request $request, int $artworkId): void
{
try {
$ipAddress = $request->ip();
$ipBinary = $ipAddress ? @inet_pton($ipAddress) : false;
ArtworkDownload::query()->create([
'artwork_id' => $artworkId,
'user_id' => $request->user()?->id,
'ip' => $ipBinary !== false ? $ipBinary : null,
'ip_address' => $ipAddress,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
]);
} catch (\Throwable $exception) {
Log::warning('Failed to record artwork download analytics.', [
'artwork_id' => $artworkId,
'error' => $exception->getMessage(),
]);
}
}
private function incrementDownloadCountIfAvailable(int $artworkId): void
{
if (! Schema::hasColumn('artworks', 'download_count')) {
return;
}
Artwork::query()->whereKey($artworkId)->increment('download_count');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
private function buildDownloadFilename(string $fileName, string $ext): string
{
$name = trim($fileName);
$name = str_replace(['/', '\\'], '-', $name);
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
$name = preg_replace('/\s+/', ' ', $name) ?? '';
$name = trim((string) $name, ". \t\n\r\0\x0B");
if ($name === '') {
$name = 'artwork';
}
if (strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) !== $ext) {
$name .= '.' . $ext;
}
return $name;
}
}