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.
This commit is contained in:
@@ -8,11 +8,13 @@ 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;
|
||||
|
||||
@@ -43,7 +45,7 @@ final class ArtworkDownloadController extends Controller
|
||||
private readonly ArtworkOriginalFileLocator $originalFiles,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): BinaryFileResponse|Response
|
||||
public function __invoke(Request $request, int $id): BinaryFileResponse|RedirectResponse|Response
|
||||
{
|
||||
$artwork = Artwork::query()->find($id);
|
||||
|
||||
@@ -51,23 +53,70 @@ final class ArtworkDownloadController extends Controller
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$filePath = $this->originalFiles->resolveLocalPath($artwork);
|
||||
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
|
||||
$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 === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
if (($filePath === '' && $objectKey === '') || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
if (! File::isFile($filePath)) {
|
||||
if (! $resolution['exists']) {
|
||||
Log::warning('Artwork original file missing for download.', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'ext' => $ext,
|
||||
'resolved_path' => $filePath,
|
||||
'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);
|
||||
|
||||
@@ -80,7 +129,13 @@ final class ArtworkDownloadController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
|
||||
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
|
||||
@@ -90,11 +145,15 @@ final class ArtworkDownloadController extends Controller
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => $accelUri,
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . addslashes($downloadName) . '"',
|
||||
'Content-Disposition' => 'attachment; filename="'.addslashes($downloadName).'"',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($resolution['source'] === 'object') {
|
||||
return redirect()->away($temporaryUrl);
|
||||
}
|
||||
|
||||
return response()->download($filePath, $downloadName);
|
||||
}
|
||||
|
||||
@@ -124,7 +183,7 @@ final class ArtworkDownloadController extends Controller
|
||||
|
||||
$normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root);
|
||||
$normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath);
|
||||
$rootPrefix = $normalizedRoot . DIRECTORY_SEPARATOR;
|
||||
$rootPrefix = $normalizedRoot.DIRECTORY_SEPARATOR;
|
||||
|
||||
if (! str_starts_with($normalizedFilePath, $rootPrefix)) {
|
||||
Log::warning('Artwork download accel path skipped because file is outside originals root.', [
|
||||
@@ -140,7 +199,7 @@ final class ArtworkDownloadController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return $accelBase . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
|
||||
return $accelBase.str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
|
||||
}
|
||||
|
||||
private function recordDownload(Request $request, int $artworkId): void
|
||||
@@ -195,10 +254,10 @@ final class ArtworkDownloadController extends Controller
|
||||
$brandSuffix = $this->downloadBrandSuffix();
|
||||
|
||||
if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) {
|
||||
$baseName .= ' (' . $brandSuffix . ')';
|
||||
$baseName .= ' ('.$brandSuffix.')';
|
||||
}
|
||||
|
||||
return $baseName . '.' . $ext;
|
||||
return $baseName.'.'.$ext;
|
||||
}
|
||||
|
||||
private function downloadBrandSuffix(): string
|
||||
|
||||
Reference in New Issue
Block a user