Files
ladill-transfer/app/Services/Transfer/FileStorageService.php
T
isaaccladandCursor f9b16f6b61
Deploy Ladill Transfer / deploy (push) Successful in 44s
Add Files page upload flow with manual folders and duplicate handling.
Users can create folders, upload files or folders, and choose replace or keep both on name conflicts instead of auto-grouping by transfer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:27:39 +00:00

197 lines
6.6 KiB
PHP

<?php
namespace App\Services\Transfer;
use App\Models\Transfer;
use App\Models\TransferFile;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
/** User-managed file storage: folders, root bucket, uploads, and duplicate handling. */
class FileStorageService
{
public function __construct(
private TransferService $transfers,
) {}
public function rootStorage(User $user): Transfer
{
$existing = Transfer::query()
->where('user_id', $user->id)
->where('is_root_storage', true)
->where('status', '!=', Transfer::STATUS_DELETED)
->first();
if ($existing) {
return $existing;
}
$billingPeriodDays = (int) config('transfer.billing_period_days', 30);
return Transfer::create([
'user_id' => $user->id,
'title' => 'My files',
'status' => Transfer::STATUS_ACTIVE,
'is_root_storage' => true,
'retention_days' => $billingPeriodDays,
]);
}
public function createFolder(User $user, string $name, ?Transfer $parent = null): Transfer
{
$title = trim($name);
if ($title === '') {
throw new RuntimeException('Enter a folder name.');
}
$billingPeriodDays = (int) config('transfer.billing_period_days', 30);
return Transfer::create([
'user_id' => $user->id,
'title' => $title,
'status' => Transfer::STATUS_ACTIVE,
'is_folder' => true,
'retention_days' => $billingPeriodDays,
]);
}
public function uploadDestination(User $user, ?Transfer $folder): Transfer
{
if ($folder !== null) {
abort_unless($folder->user_id === $user->id && $folder->is_folder, 403);
return $folder;
}
return $this->rootStorage($user);
}
/** @param list<string> $filenames */
public function findConflicts(User $user, ?Transfer $folder, array $filenames): array
{
$conflicts = [];
foreach ($filenames as $filename) {
$name = trim((string) $filename);
if ($name === '') {
continue;
}
$existing = $this->findByName($user, $folder, $name);
if ($existing) {
$conflicts[] = [
'filename' => $name,
'existing_id' => $existing->id,
];
}
}
return $conflicts;
}
/**
* @param list<UploadedFile> $files
* @param array<string, 'replace'|'keep_both'> $resolutions
* @return list<TransferFile>
*/
public function uploadFiles(User $user, ?Transfer $folder, array $files, array $resolutions = []): array
{
if ($files === []) {
throw new RuntimeException('Choose at least one file to upload.');
}
$destination = $this->uploadDestination($user, $folder);
$maxFiles = (int) config('transfer.max_files_per_transfer', 20);
if (count($files) > $maxFiles) {
throw new RuntimeException("You can upload up to {$maxFiles} files at a time.");
}
$maxBytes = (int) config('transfer.max_file_bytes', 0);
$uploaded = [];
DB::transaction(function () use ($user, $destination, $files, $resolutions, $maxBytes, &$uploaded) {
$destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id);
$wasEmpty = $destination->files()->count() === 0;
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
if ($maxBytes > 0 && $file->getSize() > $maxBytes) {
throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.');
}
$originalName = $file->getClientOriginalName() ?: 'file';
$resolution = $resolutions[$originalName] ?? null;
if ($resolution === 'replace') {
$existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName);
if ($existing) {
$this->transfers->deleteFile($existing);
}
$storedName = $originalName;
} elseif ($resolution === 'keep_both') {
$storedName = $this->uniqueFilename($user, $destination->is_folder ? $destination : null, $originalName);
} else {
$existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName);
if ($existing) {
throw new RuntimeException("A file named \"{$originalName}\" already exists. Choose replace or keep both.");
}
$storedName = $originalName;
}
$stored = $this->transfers->addStorageFile($user, $destination, $file, $storedName);
$uploaded[] = $stored;
}
if ($uploaded === []) {
throw new RuntimeException('No valid files were uploaded.');
}
if ($wasEmpty && ! $destination->qr_code_id) {
app(TransferBillingService::class)->chargeInitialPeriod($destination->fresh(), $user);
}
});
return $uploaded;
}
public function findByName(User $user, ?Transfer $folder, string $filename): ?TransferFile
{
return TransferFile::query()
->where('original_name', $filename)
->whereHas('transfer', function ($query) use ($user, $folder) {
$query->where('user_id', $user->id)
->where('status', '!=', Transfer::STATUS_DELETED);
if ($folder !== null) {
$query->whereKey($folder->id);
} else {
$query->where('is_folder', false);
}
})
->first();
}
public function uniqueFilename(User $user, ?Transfer $folder, string $filename): string
{
$base = pathinfo($filename, PATHINFO_FILENAME);
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$candidate = $filename;
$counter = 1;
while ($this->findByName($user, $folder, $candidate)) {
$suffix = ' ('.$counter.')';
$candidate = $base.$suffix.($ext !== '' ? '.'.$ext : '');
$counter++;
}
return $candidate;
}
}