Deploy Ladill Transfer / deploy (push) Successful in 28s
Transfers lists only intentional shares while Files shows all storage, and New transfer accepts folder picks with flattened file uploads. Co-authored-by: Cursor <cursoragent@cursor.com>
239 lines
7.7 KiB
PHP
239 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;
|
|
$location = $destination->isStorageContainer() ? $destination : null;
|
|
|
|
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, $location, $originalName);
|
|
if ($existing) {
|
|
$this->transfers->deleteFile($existing);
|
|
}
|
|
$storedName = $originalName;
|
|
} elseif ($resolution === 'keep_both') {
|
|
$storedName = $this->uniqueFilename($user, $location, $originalName);
|
|
} else {
|
|
$existing = $this->findByName($user, $location, $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)->where('is_root_storage', 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';
|
|
}
|
|
}
|