Files
ladill-transfer/app/Services/Transfer/FileStorageService.php
T
isaaccladandCursor 1a95915e21
Deploy Ladill Transfer / deploy (push) Successful in 28s
Separate share transfers from Files storage and add folder upload to New transfer.
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>
2026-06-08 22:31:45 +00:00

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';
}
}