Files
SkinbaseNova/app/Http/Controllers/ArtworkDownloadController.php
T
test 455b514d50 Track original artwork file availability more accurately.
Resolve local and object-storage originals, persist download status, and audit missing files without guessing from a single path.
2026-09-20 14:49:19 +02:00

276 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use App\Services\ArtworkOriginalFileLocator;
use App\Services\ArtworkStatsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
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 __construct(
private readonly ArtworkStatsService $stats,
private readonly ArtworkOriginalFileLocator $originalFiles,
) {}
public function __invoke(Request $request, int $id): BinaryFileResponse|RedirectResponse|Response
{
$artwork = Artwork::query()->find($id);
if (! $artwork) {
return $this->notFound();
}
$resolution = $this->originalFiles->resolve($artwork);
$filePath = (string) $resolution['path'];
$objectKey = (string) $resolution['object_key'];
$ext = strtolower((string) ($resolution['file_ext'] ?: pathinfo($filePath !== '' ? $filePath : $objectKey, PATHINFO_EXTENSION)));
if (($filePath === '' && $objectKey === '') || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
return $this->notFound();
}
if (! $resolution['exists']) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'ext' => $ext,
'download_status' => $artwork->download_status,
'canonical_local_exists' => $filePath !== '' && File::isFile($filePath),
'object_exists' => false,
'resolution_status' => $resolution['status'],
]);
return $this->notFound();
}
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
$temporaryUrl = null;
if ($resolution['source'] === 'object') {
try {
$temporaryUrl = Storage::disk((string) $resolution['disk'])->temporaryUrl(
$objectKey,
now()->addMinutes(5),
[
'ResponseContentDisposition' => 'attachment; filename="'.addslashes($downloadName).'"',
],
);
} catch (\Throwable $exception) {
Log::warning('Artwork object temporary URL generation failed.', [
'artwork_id' => $artwork->id,
'disk' => $resolution['disk'],
'object_key' => $objectKey,
'object_exists' => true,
'object_size' => $resolution['size'],
'exception_class' => $exception::class,
'exception_message' => mb_substr($exception->getMessage(), 0, 300),
]);
return $this->notFound();
}
if (! is_string($temporaryUrl) || trim($temporaryUrl) === '') {
Log::warning('Artwork object temporary URL generation failed.', [
'artwork_id' => $artwork->id,
'disk' => $resolution['disk'],
'object_key' => $objectKey,
'resolution_status' => $resolution['status'],
'object_exists' => true,
'object_size' => $resolution['size'],
'exception_class' => 'InvalidTemporaryUrl',
'exception_message' => 'Storage adapter did not return a temporary URL.',
]);
return $this->notFound();
}
}
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
} catch (\Throwable $exception) {
Log::warning('Failed to increment artwork_stats download counter.', [
'artwork_id' => $artwork->id,
'error' => $exception->getMessage(),
]);
}
if (array_key_exists('download_status', $artwork->getAttributes())) {
$artwork->forceFill([
'download_status' => 'available',
'download_source' => $resolution['source'],
'download_checked_at' => now(),
])->saveQuietly();
}
// X-Accel-Redirect is safe only when nginx is explicitly configured to
// map the internal URI to the originals root. Otherwise fallback to the
// normal Laravel download response.
$accelUri = $this->resolveAccelUri($filePath);
if ($accelUri !== null) {
return response('', 200, [
'X-Accel-Redirect' => $accelUri,
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.addslashes($downloadName).'"',
'X-Content-Type-Options' => 'nosniff',
]);
}
if ($resolution['source'] === 'object') {
return redirect()->away($temporaryUrl);
}
return response()->download($filePath, $downloadName);
}
private function notFound(): Response
{
return response('', 404, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
]);
}
private function resolveAccelUri(string $filePath): ?string
{
if (! config('app.download_accel_enabled')) {
return null;
}
$accelBase = rtrim((string) config('app.download_accel_path', ''), '/');
if ($accelBase === '') {
return null;
}
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
if ($root === '') {
return null;
}
$normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root);
$normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath);
$rootPrefix = $normalizedRoot.DIRECTORY_SEPARATOR;
if (! str_starts_with($normalizedFilePath, $rootPrefix)) {
Log::warning('Artwork download accel path skipped because file is outside originals root.', [
'resolved_path' => $filePath,
'originals_root' => $root,
]);
return null;
}
$relativePath = substr($normalizedFilePath, strlen($normalizedRoot));
if ($relativePath === false || $relativePath === '') {
return null;
}
return $accelBase.str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
}
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 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';
}
$baseName = pathinfo($name, PATHINFO_FILENAME);
$baseName = trim((string) $baseName, ". \t\n\r\0\x0B");
if ($baseName === '') {
$baseName = 'artwork';
}
$brandSuffix = $this->downloadBrandSuffix();
if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) {
$baseName .= ' ('.$brandSuffix.')';
}
return $baseName.'.'.$ext;
}
private function downloadBrandSuffix(): string
{
$host = (string) parse_url((string) config('app.url'), PHP_URL_HOST);
$host = strtolower(trim($host));
$host = preg_replace('/^www\./', '', $host) ?? '';
if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) {
return 'skinbase.org';
}
return $host;
}
}