Files
core/src/File/Services/FileService.php
T

136 lines
4.5 KiB
PHP
Raw Normal View History

<?php
namespace Modules\Core\File\Services;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Modules\Core\File\Contracts\FileAdapterInterface;
use Modules\Core\File\Models\File;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Orchestrates the `files` table (ownership, purpose, cleanup — see that
* migration's own docblock) on top of whichever FileAdapterInterface a
* disk resolves to (see Providers\FileServiceProvider's contextual
* binding) — never touches a disk or a raw path itself. Knows nothing
* about custom fields, carts, or orders specifically; every caller
* (3dealer's custom-field upload flow today, some other future
* file-upload need tomorrow) supplies its own `purpose` string and owner
* model, and scopes its own queries by them.
*
* `purpose` also doubles as the storage directory a file lands under
* (store() passes it straight through as the adapter's own $directory) —
* one string to name both, rather than every caller supplying two
* near-identical values for what's really the same distinction ("which
* kind of upload is this").
*/
class FileService
{
public function __construct(
private readonly Container $container,
) {}
public function store(UploadedFile $file, string $purpose, string $disk = 'local'): File
{
$path = $this->adapter($disk)->store($file, $purpose);
return File::create([
'disk' => $disk,
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
'size' => $file->getSize(),
'purpose' => $purpose,
]);
}
/**
* Re-points an existing File at its owner — called once an owner
* actually exists (e.g. a shopper's picked-but-not-yet-added photo
* gets a CartLine the moment it's added to the cart). Never creates a
* second row for the same physical file.
*/
public function attachOwner(File $file, Model $owner): File
{
$file->update([
'owner_type' => $owner->getMorphClass(),
'owner_id' => $owner->getKey(),
]);
return $file;
}
public function retrieve(File $file): StreamedResponse
{
return $this->adapter($file->disk)->retrieve($file->path, $file->original_name);
}
/**
* Same file as retrieve(), forced as a download (Content-Disposition:
* attachment) rather than served inline — for a button distinct from
* a preview link/thumbnail pointing at the same File.
*/
public function download(File $file): StreamedResponse
{
return $this->adapter($file->disk)->download($file->path, $file->original_name);
}
public function exists(File $file): bool
{
return $this->adapter($file->disk)->exists($file->path);
}
public function delete(File $file): void
{
$this->adapter($file->disk)->delete($file->path);
$file->delete();
}
/**
* @return Collection<int, File>
*/
public function list(string $purpose, ?Model $owner = null): Collection
{
return File::query()
->where('purpose', $purpose)
->when($owner, fn ($query) => $query
->where('owner_type', $owner->getMorphClass())
->where('owner_id', $owner->getKey()))
->get();
}
/**
* Deletes every File of the given purpose that has no owner yet and
* is older than $olderThan — the grace period covers a shopper still
* on the page with a picked-but-not-yet-added file. An owned File
* (whatever the owner type) is never touched here; callers that want
* owned files gone too should delete() them explicitly wherever that
* ownership itself ends (e.g. a CartLine being removed).
*
* @return int number of files deleted
*/
public function pruneUnowned(string $purpose, Carbon $olderThan): int
{
$files = File::query()
->where('purpose', $purpose)
->whereNull('owner_type')
->where('created_at', '<', $olderThan)
->get();
foreach ($files as $file) {
$this->delete($file);
}
return $files->count();
}
private function adapter(string $disk): FileAdapterInterface
{
return $this->container->make(FileAdapterInterface::class, ['disk' => $disk]);
}
}