209 lines
6.7 KiB
PHP
209 lines
6.7 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\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
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|Response
|
|
{
|
|
$artwork = Artwork::query()->find($id);
|
|
|
|
if (! $artwork) {
|
|
abort(404);
|
|
}
|
|
|
|
$filePath = $this->originalFiles->resolveLocalPath($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);
|
|
|
|
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 (! 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);
|
|
|
|
// 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',
|
|
]);
|
|
}
|
|
|
|
return response()->download($filePath, $downloadName);
|
|
}
|
|
|
|
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.top';
|
|
}
|
|
|
|
return $host;
|
|
}
|
|
}
|