Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
194 lines
6.2 KiB
PHP
194 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Media;
|
|
|
|
use App\Models\Attachment;
|
|
use App\Settings\GeneralSettings;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Throwable;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
|
|
|
class AttachmentStorageService
|
|
{
|
|
public function uploadLocalFileToDisk(
|
|
string $localPath,
|
|
string $destinationPath,
|
|
?string $disk = null,
|
|
string $visibility = Attachment::VISIBILITY_PUBLIC,
|
|
): Attachment {
|
|
if (! is_file($localPath)) {
|
|
throw new RuntimeException("Local file [{$localPath}] does not exist.");
|
|
}
|
|
|
|
$disk ??= config('larablog.attachments_disk', 'attachments');
|
|
$normalizedPath = ltrim(str_replace('\\', '/', $destinationPath), '/');
|
|
$mime = mime_content_type($localPath) ?: null;
|
|
$allowed = config('larablog.allowed_attachment_mimes', []);
|
|
if (is_string($mime) && $allowed !== [] && ! in_array($mime, $allowed, true)) {
|
|
throw new RuntimeException("MIME type [{$mime}] is not allowed for attachments.");
|
|
}
|
|
|
|
$stream = fopen($localPath, 'r');
|
|
|
|
if ($stream === false) {
|
|
throw new RuntimeException("Unable to read local file [{$localPath}].");
|
|
}
|
|
|
|
Storage::disk($disk)->put($normalizedPath, $stream, [
|
|
'visibility' => $visibility === Attachment::VISIBILITY_PUBLIC ? 'public' : 'private',
|
|
]);
|
|
|
|
if (is_resource($stream)) {
|
|
fclose($stream);
|
|
}
|
|
|
|
return Attachment::query()->create([
|
|
'disk' => $disk,
|
|
'path' => $normalizedPath,
|
|
'filename' => basename($normalizedPath),
|
|
'mime' => $mime,
|
|
'size' => filesize($localPath) ?: 0,
|
|
'checksum' => hash_file('sha256', $localPath) ?: null,
|
|
'visibility' => $visibility,
|
|
'synced_at' => now(),
|
|
]);
|
|
}
|
|
|
|
public function publicUrl(Attachment $attachment): string
|
|
{
|
|
if ($attachment->visibility !== Attachment::VISIBILITY_PUBLIC) {
|
|
return $this->temporaryUrl($attachment);
|
|
}
|
|
|
|
$disk = Storage::disk($attachment->disk);
|
|
|
|
if (method_exists($disk, 'url')) {
|
|
return $disk->url($attachment->path);
|
|
}
|
|
|
|
return $this->temporaryUrl($attachment);
|
|
}
|
|
|
|
public function temporaryUrl(Attachment $attachment, int $minutes = 30): string
|
|
{
|
|
return Storage::disk($attachment->disk)->temporaryUrl(
|
|
$attachment->path,
|
|
now()->addMinutes($minutes),
|
|
[
|
|
'ResponseContentDisposition' => 'attachment; filename="'.addslashes($attachment->filename).'"',
|
|
],
|
|
);
|
|
}
|
|
|
|
public function resolveRedirectResponseById(int $id, ?string $ip = null): RedirectResponse|Response
|
|
{
|
|
$attachment = Attachment::query()->find($id);
|
|
|
|
if ($attachment === null) {
|
|
abort(SymfonyResponse::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
return $this->resolveRedirectResponse($attachment, $ip);
|
|
}
|
|
|
|
public function resolveRedirectResponseByLegacyPath(string $legacyPath, ?string $ip = null): RedirectResponse|Response
|
|
{
|
|
$raw = ltrim(str_replace('\\', '/', $legacyPath), '/');
|
|
$normalized = $this->normalizeLegacyPath($legacyPath);
|
|
$prefix = $this->attachmentsUrlPrefix();
|
|
$withPrefix = ($prefix !== '' && ! Str::startsWith($raw, $prefix.'/'))
|
|
? $prefix.'/'.$raw
|
|
: $raw;
|
|
|
|
$candidates = array_values(array_unique(array_filter([$raw, $normalized, $withPrefix])));
|
|
|
|
$attachment = Attachment::query()
|
|
->where(function ($query) use ($candidates) {
|
|
$query->whereIn('legacy_filepath', $candidates)
|
|
->orWhereIn('path', $candidates);
|
|
})
|
|
->first();
|
|
|
|
if ($attachment === null) {
|
|
abort(SymfonyResponse::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
return $this->resolveRedirectResponse($attachment, $ip);
|
|
}
|
|
|
|
public function resolveRedirectResponse(Attachment $attachment, ?string $ip = null): RedirectResponse
|
|
{
|
|
if ($attachment->visibility !== Attachment::VISIBILITY_PUBLIC) {
|
|
abort(SymfonyResponse::HTTP_FORBIDDEN);
|
|
}
|
|
|
|
$this->incrementDownloads($attachment, $ip);
|
|
|
|
$targetUrl = $attachment->visibility === Attachment::VISIBILITY_PUBLIC
|
|
? $this->publicUrl($attachment)
|
|
: $this->temporaryUrl($attachment);
|
|
|
|
return redirect()->away($targetUrl, SymfonyResponse::HTTP_FOUND);
|
|
}
|
|
|
|
public function incrementDownloads(Attachment $attachment, ?string $ip = null): void
|
|
{
|
|
$ip ??= request()->ip() ?? 'unknown';
|
|
$lockKey = "attachment-download:{$attachment->id}:{$ip}";
|
|
|
|
$lock = Cache::lock($lockKey, 60);
|
|
|
|
if (! $lock->get()) {
|
|
return;
|
|
}
|
|
|
|
$attachment->increment('downloads');
|
|
}
|
|
|
|
protected function normalizeLegacyPath(string $legacyPath): string
|
|
{
|
|
$path = str_replace('\\', '/', $legacyPath);
|
|
$path = ltrim($path, '/');
|
|
|
|
$prefix = $this->attachmentsUrlPrefix();
|
|
|
|
if ($prefix !== '' && Str::startsWith($path, $prefix.'/')) {
|
|
$path = Str::after($path, $prefix.'/');
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
public function deleteFromDisk(Attachment $attachment): void
|
|
{
|
|
$disk = Storage::disk($attachment->disk);
|
|
if ($attachment->path !== '' && $disk->exists($attachment->path)) {
|
|
$disk->delete($attachment->path);
|
|
}
|
|
if (filled($attachment->thumb_path) && $disk->exists($attachment->thumb_path)) {
|
|
$disk->delete($attachment->thumb_path);
|
|
}
|
|
}
|
|
|
|
protected function attachmentsUrlPrefix(): string
|
|
{
|
|
try {
|
|
$fromSettings = app(GeneralSettings::class)->attachments_url_prefix;
|
|
if (is_string($fromSettings) && $fromSettings !== '') {
|
|
return trim($fromSettings, '/');
|
|
}
|
|
} catch (Throwable) {
|
|
// settings unavailable during early boot / migrate
|
|
}
|
|
|
|
return trim((string) config('larablog.attachments_url_prefix', 'attachments'), '/');
|
|
}
|
|
}
|