Files
ladill-transfer/app/Services/Transfer/FileStorageService.php
T
isaaccladandCursor 14b00511ef
Deploy Ladill Transfer / deploy (push) Successful in 35s
Add custom upload confirmation and flatten folder uploads to individual files.
Users confirm uploads in a Ladill modal before transfer, and folder picks upload flat file names into the current location instead of preserving local directory paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 22:00:29 +00:00

205 lines
6.8 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 = $this->basenameFilename($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;
}
private function basenameFilename(string $filename): string
{
$normalized = str_replace('\\', '/', trim($filename));
$base = basename($normalized);
return $base !== '' ? $base : 'file';
}
}