Deploy Ladill Transfer / deploy (push) Successful in 27s
Folder upload now names and creates the destination folder from the picked directory, uploads all files into it, and redirects to the new folder view. Co-authored-by: Cursor <cursoragent@cursor.com>
238 lines
7.7 KiB
PHP
238 lines
7.7 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 createFolderForUpload(User $user, string $name): Transfer
|
|
{
|
|
$title = trim($name);
|
|
if ($title === '') {
|
|
throw new RuntimeException('Enter a folder name.');
|
|
}
|
|
|
|
return $this->createFolder($user, $this->uniqueFolderTitle($user, $title));
|
|
}
|
|
|
|
public function uniqueFolderTitle(User $user, string $title): string
|
|
{
|
|
$candidate = $title;
|
|
$counter = 1;
|
|
|
|
while ($this->folderTitleExists($user, $candidate)) {
|
|
$candidate = $title.' ('.$counter.')';
|
|
$counter++;
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
private function folderTitleExists(User $user, string $title): bool
|
|
{
|
|
return Transfer::query()
|
|
->where('user_id', $user->id)
|
|
->where('is_folder', true)
|
|
->where('status', '!=', Transfer::STATUS_DELETED)
|
|
->where('title', $title)
|
|
->exists();
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|