diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 0499350..dc0be09 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -63,11 +63,15 @@ class SearchController extends Controller ->limit(8) ->get() ->each(function (TransferFile $file) use (&$results) { + $params = $file->transfer->is_folder + ? ['folder' => $file->transfer_id, 'q' => $file->original_name] + : ['q' => $file->original_name]; + $results[] = [ 'type' => 'file', 'title' => $file->original_name, - 'subtitle' => $file->humanSize().' · '.$file->transfer->title, - 'url' => route('transfer.files.index', ['folder' => $file->transfer_id, 'q' => $file->original_name]), + 'subtitle' => $file->humanSize().($file->transfer->is_folder ? ' · '.$file->transfer->title : ''), + 'url' => route('transfer.files.index', $params), ]; }); diff --git a/app/Http/Controllers/Transfer/FilesController.php b/app/Http/Controllers/Transfer/FilesController.php index 615f645..af7fd96 100644 --- a/app/Http/Controllers/Transfer/FilesController.php +++ b/app/Http/Controllers/Transfer/FilesController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\Transfer; use App\Http\Controllers\Controller; use App\Models\Transfer; use App\Models\TransferFile; +use App\Models\User; +use App\Services\Transfer\FileStorageService; use App\Services\Transfer\TransferRecipientMailService; use App\Services\Transfer\TransferService; use Illuminate\Http\JsonResponse; @@ -21,6 +23,7 @@ class FilesController extends Controller { public function __construct( private TransferService $transfers, + private FileStorageService $storage, ) {} public function index(Request $request): View @@ -35,6 +38,7 @@ class FilesController extends Controller if ($folderId) { $folderTransfer = Transfer::query() ->where('user_id', $account->id) + ->where('is_folder', true) ->where('status', '!=', Transfer::STATUS_DELETED) ->findOrFail($folderId); } @@ -42,8 +46,9 @@ class FilesController extends Controller $filesQuery = TransferFile::query() ->whereHas('transfer', fn ($q) => $q ->where('user_id', $account->id) - ->where('status', '!=', Transfer::STATUS_DELETED)) - ->when($folderTransfer, fn ($q) => $q->where('transfer_id', $folderTransfer->id)) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->when($folderTransfer, fn ($inner) => $inner->whereKey($folderTransfer->id), + fn ($inner) => $inner->where('is_folder', false))) ->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%')) ->with(['transfer.qrCode']) ->when($sort === 'oldest', fn ($q) => $q->oldest()) @@ -56,8 +61,8 @@ class FilesController extends Controller $folders = Transfer::query() ->where('user_id', $account->id) + ->where('is_folder', true) ->where('status', '!=', Transfer::STATUS_DELETED) - ->whereHas('files') ->withCount('files') ->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%')) ->orderBy('title') @@ -65,9 +70,12 @@ class FilesController extends Controller $moveTargets = Transfer::query() ->where('user_id', $account->id) - ->accessible() + ->where('status', '!=', Transfer::STATUS_DELETED) + ->where(function ($query) { + $query->where('is_folder', true)->orWhere('is_root_storage', true); + }) ->orderBy('title') - ->get(['id', 'title']); + ->get(['id', 'title', 'is_root_storage']); $storageBytes = Transfer::query() ->where('user_id', $account->id) @@ -184,14 +192,17 @@ class FilesController extends Controller $destination = Transfer::query() ->where('user_id', ladill_account()->id) - ->accessible() + ->where('status', '!=', Transfer::STATUS_DELETED) + ->where(function ($query) { + $query->where('is_folder', true)->orWhere('is_root_storage', true); + }) ->findOrFail($data['destination']); $files = $this->authorizedFiles($data['files']); $this->transfers->moveFiles($destination, $files->all()); return redirect() - ->route('transfer.files.index', ['folder' => $destination->id]) + ->route('transfer.files.index', $destination->is_folder ? ['folder' => $destination->id] : []) ->with('success', $files->count().' file'.($files->count() === 1 ? '' : 's').' moved to '.$destination->title.'.'); } @@ -273,6 +284,105 @@ class FilesController extends Controller return redirect()->away($url); } + public function storeFolder(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:120', + 'folder' => 'nullable|integer|exists:transfers,id', + ]); + + $account = ladill_account(); + $parent = null; + if (! empty($data['folder'])) { + $parent = Transfer::query() + ->where('user_id', $account->id) + ->where('is_folder', true) + ->findOrFail($data['folder']); + } + + try { + $folder = $this->storage->createFolder($account, $data['name'], $parent); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('transfer.files.index', ['folder' => $folder->id]) + ->with('success', 'Folder created.'); + } + + public function checkUpload(Request $request): JsonResponse + { + $data = $request->validate([ + 'filenames' => 'required|array|min:1', + 'filenames.*' => 'required|string|max:255', + 'folder' => 'nullable|integer|exists:transfers,id', + ]); + + $account = ladill_account(); + $folder = $this->resolveUploadFolder($account, $data['folder'] ?? null); + + $conflicts = $this->storage->findConflicts( + $account, + $folder, + $data['filenames'], + ); + + return response()->json(['conflicts' => $conflicts]); + } + + public function storeUpload(Request $request): RedirectResponse|JsonResponse + { + $data = $request->validate([ + 'files' => 'required|array|min:1', + 'files.*' => 'required|file', + 'folder' => 'nullable|integer|exists:transfers,id', + 'resolutions' => 'nullable|array', + 'resolutions.*' => 'in:replace,keep_both', + ]); + + $account = ladill_account(); + $folder = $this->resolveUploadFolder($account, $data['folder'] ?? null); + $files = array_values($request->file('files', []) ?? []); + + try { + $uploaded = $this->storage->uploadFiles( + $account, + $folder, + $files, + $data['resolutions'] ?? [], + ); + } catch (RuntimeException $e) { + if ($request->expectsJson()) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return back()->with('error', $e->getMessage()); + } + + $count = count($uploaded); + $message = $count.' file'.($count === 1 ? '' : 's').' uploaded.'; + + if ($request->expectsJson()) { + return response()->json(['message' => $message, 'count' => $count]); + } + + return back()->with('success', $message); + } + + private function resolveUploadFolder(User $user, ?int $folderId): ?Transfer + { + if ($folderId === null) { + return null; + } + + return Transfer::query() + ->where('user_id', $user->id) + ->where('is_folder', true) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->findOrFail($folderId); + } + /** @param array|null $ids */ private function authorizedFiles(?array $ids): Collection { diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index eaabaaf..dda0d95 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -34,11 +34,15 @@ class Transfer extends Model 'grace_ends_at', 'last_billed_at', 'status', + 'is_folder', + 'is_root_storage', 'downloads_total', 'storage_bytes', ]; protected $casts = [ + 'is_folder' => 'boolean', + 'is_root_storage' => 'boolean', 'retention_days' => 'integer', 'expires_at' => 'datetime', 'paid_until' => 'datetime', @@ -157,6 +161,21 @@ class Transfer extends Model }); } + public function scopeStorageFolders(Builder $query): Builder + { + return $query->where('is_folder', true); + } + + public function isStorageContainer(): bool + { + return $this->is_folder || $this->is_root_storage; + } + + public function shouldPersistWhenEmpty(): bool + { + return $this->is_folder || $this->is_root_storage; + } + public function wantsRecipientMilestone(string $milestone): bool { return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true); diff --git a/app/Services/Transfer/FileStorageService.php b/app/Services/Transfer/FileStorageService.php new file mode 100644 index 0000000..55bc1b8 --- /dev/null +++ b/app/Services/Transfer/FileStorageService.php @@ -0,0 +1,196 @@ +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 $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 $files + * @param array $resolutions + * @return list + */ + 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; + } +} diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php index 47c30d3..ee8d979 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -122,6 +122,24 @@ class TransferService } private function storeFile(User $user, Transfer $transfer, UploadedFile $file): TransferFile + { + return $this->storeFileWithName( + $user, + $transfer, + $file, + $file->getClientOriginalName() ?: ('file.'.($file->getClientOriginalExtension() ?: 'bin')), + ); + } + + public function addStorageFile(User $user, Transfer $transfer, UploadedFile $file, string $displayName): TransferFile + { + $stored = $this->storeFileWithName($user, $transfer, $file, $displayName); + $transfer->increment('storage_bytes', $stored->size_bytes); + + return $stored; + } + + private function storeFileWithName(User $user, Transfer $transfer, UploadedFile $file, string $displayName): TransferFile { $uuid = Str::uuid()->toString(); $ext = $file->getClientOriginalExtension() ?: 'bin'; @@ -131,7 +149,7 @@ class TransferService return TransferFile::create([ 'transfer_id' => $transfer->id, - 'original_name' => $file->getClientOriginalName() ?: 'file.'.$ext, + 'original_name' => $displayName, 'disk' => 'qr', 'path' => $path, 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', @@ -208,7 +226,7 @@ class TransferService $transfer->decrement('storage_bytes', $size); - if ($transfer->files()->count() === 0) { + if ($transfer->files()->count() === 0 && ! $transfer->shouldPersistWhenEmpty()) { $this->delete($transfer->fresh(['files', 'qrCode'])); } }); @@ -248,7 +266,7 @@ class TransferService foreach ($sourceAdjustments as $sourceId => $bytes) { Transfer::query()->whereKey($sourceId)->decrement('storage_bytes', $bytes); $source = Transfer::query()->with('files')->find($sourceId); - if ($source && $source->files->isEmpty() && $source->status !== Transfer::STATUS_DELETED) { + if ($source && $source->files->isEmpty() && $source->status !== Transfer::STATUS_DELETED && ! $source->shouldPersistWhenEmpty()) { $this->delete($source->fresh(['files', 'qrCode'])); } } diff --git a/database/migrations/2026_06_08_220000_add_storage_flags_to_transfers.php b/database/migrations/2026_06_08_220000_add_storage_flags_to_transfers.php new file mode 100644 index 0000000..4e1704e --- /dev/null +++ b/database/migrations/2026_06_08_220000_add_storage_flags_to_transfers.php @@ -0,0 +1,27 @@ +boolean('is_folder')->default(false)->after('status'); + $table->boolean('is_root_storage')->default(false)->after('is_folder'); + $table->index(['user_id', 'is_folder']); + $table->index(['user_id', 'is_root_storage']); + }); + } + + public function down(): void + { + Schema::table('transfers', function (Blueprint $table) { + $table->dropIndex(['user_id', 'is_folder']); + $table->dropIndex(['user_id', 'is_root_storage']); + $table->dropColumn(['is_folder', 'is_root_storage']); + }); + } +}; diff --git a/public/images/ladill-icons/file.svg b/public/images/ladill-icons/file.svg new file mode 100644 index 0000000..b8dc69b --- /dev/null +++ b/public/images/ladill-icons/file.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/ladill-icons/folder.svg b/public/images/ladill-icons/folder.svg new file mode 100644 index 0000000..cca1317 --- /dev/null +++ b/public/images/ladill-icons/folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/js/app.js b/resources/js/app.js index 58be094..e700fa4 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -515,6 +515,7 @@ Alpine.data('filesManager', (config = {}) => ({ files: config.files || [], routes: config.routes || {}, csrf: config.csrf || '', + currentFolderId: config.currentFolderId || null, moveModalOpen: false, toast: '', _toastTimer: null, @@ -636,6 +637,129 @@ Alpine.data('filesManager', (config = {}) => ({ this.selected.forEach((id) => params.append('files[]', id)); window.location.href = `${this.routes.openInEmail}?${params.toString()}`; }, + + createMenuOpen: false, + folderModalOpen: false, + newFolderName: '', + uploading: false, + duplicateModalOpen: false, + duplicateFile: null, + pendingUploads: null, + pendingResolutions: {}, + duplicateQueue: [], + + triggerFileUpload() { + this.createMenuOpen = false; + this.$refs.fileUploadInput?.click(); + }, + + triggerFolderUpload() { + this.createMenuOpen = false; + this.$refs.folderUploadInput?.click(); + }, + + openFolderModal() { + this.createMenuOpen = false; + this.folderModalOpen = true; + this.newFolderName = ''; + this.$nextTick(() => this.$refs.folderNameInput?.focus()); + }, + + async handleUploadPick(event) { + const picked = Array.from(event.target.files || []); + event.target.value = ''; + if (picked.length === 0) return; + await this.processUploadQueue(picked); + }, + + async processUploadQueue(files) { + this.uploading = true; + try { + const checkRes = await fetch(this.routes.uploadCheck, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + filenames: files.map((f) => f.name), + folder: this.currentFolderId || null, + }), + }); + const checkData = await checkRes.json(); + this.pendingUploads = files; + this.pendingResolutions = {}; + this.duplicateQueue = checkData.conflicts || []; + + if (this.duplicateQueue.length > 0) { + this.showNextDuplicate(); + return; + } + + await this.submitUpload(files, {}); + } catch (e) { + this.showToast('Upload failed. Please try again.'); + } finally { + if (!this.duplicateModalOpen) { + this.uploading = false; + } + } + }, + + showNextDuplicate() { + if (this.duplicateQueue.length === 0) { + this.duplicateModalOpen = false; + this.submitUpload(this.pendingUploads, this.pendingResolutions).finally(() => { + this.uploading = false; + this.pendingUploads = null; + this.pendingResolutions = {}; + }); + return; + } + + this.duplicateFile = this.duplicateQueue.shift(); + this.duplicateModalOpen = true; + }, + + resolveDuplicate(action) { + if (!this.duplicateFile) return; + this.pendingResolutions[this.duplicateFile.filename] = action; + this.duplicateModalOpen = false; + this.duplicateFile = null; + this.showNextDuplicate(); + }, + + async submitUpload(files, resolutions) { + const formData = new FormData(); + files.forEach((file) => formData.append('files[]', file)); + if (this.currentFolderId) { + formData.append('folder', this.currentFolderId); + } + Object.entries(resolutions).forEach(([name, action]) => { + formData.append(`resolutions[${name}]`, action); + }); + + const res = await fetch(this.routes.upload, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: formData, + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.showToast(data.message || 'Upload failed.'); + return; + } + + this.showToast(data.message || 'Upload complete.'); + window.location.reload(); + }, })); window.Alpine = Alpine; diff --git a/resources/views/transfer/files/index.blade.php b/resources/views/transfer/files/index.blade.php index 9abb77c..dd2e3e8 100644 --- a/resources/views/transfer/files/index.blade.php +++ b/resources/views/transfer/files/index.blade.php @@ -1,43 +1,20 @@ Files @php + $iconBase = asset('images/ladill-icons'); $fmtBytes = function (int $bytes) { if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2).' GB'; if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB'; return number_format($bytes / 1024, 0).' KB'; }; - $fileIcon = function (\App\Models\TransferFile $file): string { - $mime = (string) $file->mime_type; - $ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION)); - if (str_starts_with($mime, 'image/') || in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], true)) { - return 'image'; - } - if (str_starts_with($mime, 'video/') || in_array($ext, ['mp4', 'mov', 'avi', 'mkv'], true)) { - return 'video'; - } - if (str_starts_with($mime, 'audio/') || in_array($ext, ['mp3', 'wav', 'aac'], true)) { - return 'audio'; - } - if (in_array($ext, ['zip', 'rar', '7z', 'tar', 'gz'], true)) { - return 'archive'; - } - if (in_array($ext, ['pdf', 'doc', 'docx', 'txt', 'rtf'], true)) { - return 'document'; - } - - return 'file'; - }; $fileRows = $files->map(fn ($file) => [ 'id' => $file->id, 'name' => $file->original_name, 'size' => $file->humanSize(), 'downloads' => $file->downloads_total, 'modified' => $file->updated_at?->format('M j, Y') ?? '—', - 'transferId' => $file->transfer_id, - 'transferTitle' => $file->transfer->title, 'shareUrl' => $file->transfer->qrCode?->publicUrl() ?? '', 'downloadUrl' => route('transfer.files.download', $file), - 'icon' => $fileIcon($file), 'shared' => $file->transfer->recipient_email !== null || $file->downloads_total > 0, ])->values(); @endphp @@ -47,15 +24,21 @@ 'fileIds' => $fileRows->pluck('id')->all(), 'files' => $fileRows->all(), 'csrf' => csrf_token(), + 'currentFolderId' => $folderTransfer?->id, 'routes' => [ 'bulkDownload' => route('transfer.files.bulk-download'), 'bulkDelete' => route('transfer.files.bulk-delete'), 'move' => route('transfer.files.move'), 'share' => route('transfer.files.share'), 'openInEmail' => route('transfer.files.open-in-email'), - 'reshareEmail' => route('transfer.files.reshare-email'), + 'uploadCheck' => route('transfer.files.upload.check'), + 'upload' => route('transfer.files.upload'), + 'createFolder' => route('transfer.files.folders.store'), ], - 'moveTargets' => $moveTargets->map(fn ($t) => ['id' => $t->id, 'title' => $t->title])->values()->all(), + 'moveTargets' => $moveTargets->map(fn ($t) => [ + 'id' => $t->id, + 'title' => $t->is_root_storage ? 'My files (root)' : $t->title, + ])->values()->all(), ]))"> {{-- Header --}} @@ -64,18 +47,51 @@

Files

{{ $fmtBytes($storageBytes) }} stored (~GHS {{ number_format($storageGb * $pricePerGb, 2) }}/month)

+ + {{-- Create or upload --}} +
+ +
+ +
+ + +
+ + +
{{-- Breadcrumbs --}} - {{-- Action toolbar --}} + {{-- Action toolbar + file list --}}