user(); assert($user !== null); return Inertia::render('files/create', [ 'max_file_size_mb' => app(Settings::class)->get(Setting::MaxFileSizeMb), 'part_size_mb' => (int) config('projectsend.upload_part_size_mb'), 'allowed_extensions' => app(UploadExtensionPolicy::class)->hintFor($user), ]); } public function store(Request $request): RedirectResponse { $validated = $request->validate([ // Intake is a plain upload for now; the resumable // direct-to-storage flow replaces this without touching the // domain (brief §3, open question §12.1). 'file' => ['required', 'file', 'max:102400'], 'name' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:2000'], 'folder_id' => Rules::folderId(), ]); /** @var UploadedFile $upload */ $upload = $validated['file']; $maxMb = (int) app(Settings::class)->get(Setting::MaxFileSizeMb); if ($maxMb > 0 && (int) $upload->getSize() > $maxMb * 1024 * 1024) { throw ValidationException::withMessages([ 'file' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]), ]); } $user = $request->user(); assert($user !== null); // The check the other two upload paths make and this one did not: // a folder outside the uploader's library is not a place to put a // file. Without it this route reached any folder on the // installation, and File::scopeVisibleToClient then hands the file // to whoever that folder's subtree is shared with. $folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null; abort_unless(Folder::uploadableBy($user, $folder), 403); if (! app(UploadExtensionPolicy::class)->isAllowed($user, $upload->getClientOriginalName())) { throw ValidationException::withMessages([ 'file' => __('This file type is not allowed for upload.'), ]); } $diskEvent = new ResolvingUploadDisk($user); Event::dispatch($diskEvent); $disk = $diskEvent->disk; $path = $upload->storeAs( now()->format('Y/m'), Str::uuid()->toString().'.'.strtolower($upload->getClientOriginalExtension()), $disk, ); abort_unless(is_string($path), 500); $file = app(StoreUploadedFile::class)->create( uploader: $user, originalName: $upload->getClientOriginalName(), path: $path, mimeType: $upload->getMimeType() ?? 'application/octet-stream', size: (int) $upload->getSize(), checksum: hash_file('sha256', $upload->getRealPath()) ?: '', name: $validated['name'] ?? null, description: $validated['description'] ?? null, folderId: $validated['folder_id'] ?? null, disk: $disk, ); return redirect()->route('files.edit', $file)->with('success', __('File uploaded.')); } public function edit(Request $request, File $file): Response { $viewer = $request->user(); assert($viewer !== null); Gate::forUser($viewer)->authorize('view', $file); // A revision's recipients belong to the file it revises, so the // Sharing tab has to point at that file rather than offer controls // that would be ignored. Resolved here (not in the page) because // "can this staffer edit the original" is a policy question — the // original may be a colleague's upload, or outside a scoped // staffer's library, and linking to a page they'd get a 404 from is // worse than saying so. $sharingRoot = $file->isRevision() ? File::query()->find($file->sharingOwnerId()) : null; return Inertia::render('files/edit', [ 'file' => [ 'id' => $file->id, 'name' => $file->name, 'description' => $file->description, 'original_name' => $file->original_name, 'size' => $file->size, 'mime_type' => $file->mime_type, 'uploader' => $this->identity->nameOf($viewer, $file->uploader), 'folder_id' => $file->folder_id, 'public' => $file->public, 'commentable' => $file->commentable, 'slug' => $file->slug, // The date picker's value, so it has to be the same // calendar date the editor typed — read back in their // zone, not the server's, or a file set to expire on the // 12th reopens showing the 11th. 'expires_at' => $this->expiry->asShown($file, $request->user()), 'expired' => $file->isExpired(), 'download_limit' => $file->download_limit, 'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value, // The file's total downloads, so the editor can see what // a total limit is being measured against. A per-user // limit is a different number for every person, which no // single figure on this screen can show. 'downloads_used' => $file->downloads()->count(), 'public_url' => $file->isEffectivelyPublic() ? $this->publicUrl->for($file) : null, 'public_via_folder' => $file->folder?->publicSourceName(), 'created_at' => $file->created_at?->toIso8601String(), 'is_revision' => $file->isRevision(), 'version' => $this->versionLinks->for( $file, $viewer, fn (File $other): string => route('files.edit', $other, false), ), ], 'sharing_root' => $sharingRoot === null ? null : [ 'id' => $sharingRoot->id, 'name' => $sharingRoot->name, 'url' => route('files.edit', $sharingRoot, false).'?tab=sharing', ], 'can_update_root' => $sharingRoot !== null && Gate::forUser($viewer)->allows('update', $sharingRoot), 'can_set_version' => Gate::forUser($viewer)->allows('setVersion', $file), 'version_chain' => $this->versions->chain($file, $viewer) ->map(fn (File $member): array => [ 'id' => $member->id, 'name' => $member->name, 'url' => route('files.edit', $member, false), 'is_current' => $member->id === $file->id, ])->values(), // Narrowed like every other folder listing: an unscoped staff // member gets the whole tree, a client-scoped one only their // own. Unfiltered this handed a scoped staffer every folder // name and id on the installation. 'folder_options' => $this->scope->folders($viewer)->orderBy('path')->orderBy('name')->get() ->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(), 'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color']) ->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(), 'assigned_category_ids' => $file->categories()->pluck('categories.id')->map(fn ($id): int => (int) $id)->all(), 'can_set_categories' => $viewer->can('set_file_categories'), 'can_update' => Gate::forUser($viewer)->allows('update', $file), 'can_delete' => Gate::forUser($viewer)->allows('delete', $file), 'can_manage_public' => $viewer->can('upload_public'), // Whether this page offers its Activity tab. The file's own // page is where somebody lands from a link, a search or a // notification, so "what happened to this file" has to be // answerable here and not only from the library's list. 'can_view_activity' => $viewer->can('view_actions_log'), // The per-file switch only does anything while the comment // scope is `selected`; under every other value the page hides // it rather than offer a control with no current effect. 'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles, // The file's own page carries the conversation too, not just // the library's slide-over — it is where the activity log's // "View" link and a comment notification both land. 'comments_enabled' => $this->commenting->enabled(), ...$this->shareTargets->forSubject($file, $viewer), 'share_links' => $file->shareLinks()->orderByDesc('created_at')->get() ->map(fn (ShareLink $link): array => [ 'id' => $link->id, 'url' => route('share.show', $link->token), 'expires_at' => $link->expires_at?->toIso8601String(), 'max_downloads' => $link->max_downloads, 'downloads_count' => $link->downloads_count, 'revoke_url' => route('share-links.destroy', $link, false), ])->values(), 'share_link_store_url' => route('files.share-links.store', $file, false), 'can_set_expiration' => $viewer->can('set_file_expiration_date'), 'can_limit_downloads' => $viewer->can('limit_downloads'), ]); } public function update(Request $request, File $file): RedirectResponse { Gate::authorize('update', $file); $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:2000'], 'folder_id' => Rules::folderId(), 'public' => ['sometimes', 'boolean'], 'commentable' => ['sometimes', 'boolean'], // The slug only matters (and is only shown) once a file is // public — otherwise fall back to one derived from the name. 'slug' => Rules::slug('files', $file->id), 'categories' => ['array'], 'categories.*' => ['integer', 'exists:categories,id'], 'expires_at' => ['nullable', 'date'], 'download_limit' => ['nullable', 'integer', 'min:1'], 'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)], ]); // The edit form posts folder_id as a string; cast so the strict // change comparison below matches the model's int. $folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null; $user = $request->user(); // Gate::authorize above cannot pass without one. assert($user !== null); // Reparenting through update() is the same privileged write as // move()/bulkUpdate(), so it needs the same guard: the destination // must be a folder this user can actually see. Only checked when the // folder actually changes, so re-saving a file that already sits in // an out-of-scope folder (reachable via a direct client share) still // works. if ($folderId !== null && $folderId !== $file->folder_id) { $this->scope->folders($user)->findOrFail($folderId); } // Normalised into the shape ApplyFileEdits reads, then handed // over: which of these the actor may actually write is that // class's decision, and it is the same decision the API and the // client portal get. See its docblock for why the split is here. $changes = [ 'name' => $validated['name'], 'description' => $validated['description'] ?? null, 'folder_id' => $folderId, // Present unconditionally; the comment scope decides whether it // is honoured. Defaulted to the stored value so a form that // does not render the field cannot clear it. 'commentable' => $validated['commentable'] ?? $file->commentable, 'download_limit' => $validated['download_limit'] ?? null, 'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value, 'public' => $validated['public'] ?? $file->public, 'slug' => $validated['slug'] ?? '', 'categories' => $validated['categories'] ?? [], ]; // The one field that is conditionally *present* rather than // conditionally honoured, and the reason it cannot move into // ApplyFileEdits: the form was rendered with the stored instant // read back as a date in this viewer's zone, and posts it again // untouched with every other edit. Re-deriving it unconditionally // would move the expiry by the difference between two people's // zones each time somebody merely renamed the file. Compared // against the same string the form was given, so "unchanged" means // what the editor actually saw. $posted = $validated['expires_at'] ?? null; if ($posted !== $this->expiry->asShown($file, $user)) { $changes['expires_at'] = $this->expiry->instant($posted, $user); } $this->fileEdits->apply($user, $file, $changes); return back()->with('success', __('File updated.')); } /** * Reparent a file into a folder (or the root) — the drag-and-drop * move. Unlike update() this touches only the folder, so it needs no * name/description payload. */ public function move(Request $request, File $file): RedirectResponse { Gate::authorize('update', $file); $validated = $request->validate([ 'folder_id' => Rules::folderId(), ]); $folderId = $validated['folder_id'] ?? null; $user = $request->user(); // The target folder must be one the mover can actually see. if ($folderId !== null && $user !== null) { $this->scope->folders($user)->findOrFail($folderId); } $file->update(['folder_id' => $folderId]); $this->activity->log(Action::FileUpdated, subject: $file); return back(); } /** * WordPress-style "Bulk Edit": one shared set of changes applied to * every selected file. Every field defaults to "no change" via its own * *_action sentinel, so a field left untouched in the dialog truly * isn't touched here — critical for categories, where naively applying * an empty list would wipe every file's existing categories instead of * leaving them alone. */ public function bulkUpdate(Request $request): RedirectResponse { $user = $request->user(); assert($user !== null); $validated = $request->validate([ 'file_ids' => ['required', 'array', 'min:1'], 'file_ids.*' => ['integer', 'distinct'], 'folder_action' => ['required', Rule::in(['no_change', 'move'])], 'folder_id' => Rules::folderId(), 'description_action' => ['required', Rule::in(['no_change', 'set'])], 'description' => ['nullable', 'string', 'max:2000'], 'expiration_action' => ['required', Rule::in(['no_change', 'set', 'clear'])], 'expires_at' => ['nullable', 'date', 'required_if:expiration_action,set'], // `sometimes` rather than `required` like the fields above: // a browser still running the previous build would start // getting 422s on every bulk edit the moment this deployed. 'download_limit_action' => ['sometimes', Rule::in(['no_change', 'set', 'clear'])], 'download_limit' => ['nullable', 'integer', 'min:1', 'required_if:download_limit_action,set'], 'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)], 'add_category_ids' => ['array'], 'add_category_ids.*' => ['integer', 'exists:categories,id'], 'remove_category_ids' => ['array'], 'remove_category_ids.*' => ['integer', 'exists:categories,id'], ]); $touchesNothing = $validated['folder_action'] === 'no_change' && $validated['description_action'] === 'no_change' && $validated['expiration_action'] === 'no_change' && ($validated['download_limit_action'] ?? 'no_change') === 'no_change' && ($validated['add_category_ids'] ?? []) === [] && ($validated['remove_category_ids'] ?? []) === []; abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.')); // The target folder must be one this user can actually see — same // rule move() already applies to a single file's target. $targetFolderId = null; if ($validated['folder_action'] === 'move') { $targetFolderId = $validated['folder_id'] ?? null; if ($targetFolderId !== null) { $this->scope->folders($user)->findOrFail($targetFolderId); } } // Silently drop anything this user isn't allowed to edit — same // convention as ZipDownloadsController::store's Gate::allows filter // — rather than 403ing the whole batch over one file. $files = File::query()->whereIn('id', $validated['file_ids'])->get() ->filter(fn (File $file): bool => Gate::forUser($user)->allows('update', $file)); abort_if($files->isEmpty(), 422, __('None of the selected files could be edited.')); $canSetExpiration = $user->can('set_file_expiration_date'); $canLimitDownloads = $user->can('limit_downloads'); $canSetCategories = $user->can('set_file_categories'); $addCategoryIds = $validated['add_category_ids'] ?? []; $removeCategoryIds = $validated['remove_category_ids'] ?? []; $updated = 0; DB::transaction(function () use ($files, $user, $validated, $targetFolderId, $canSetExpiration, $canLimitDownloads, $canSetCategories, $addCategoryIds, $removeCategoryIds, &$updated): void { foreach ($files as $file) { $attributes = []; if ($validated['folder_action'] === 'move') { $attributes['folder_id'] = $targetFolderId; } if ($validated['description_action'] === 'set') { $attributes['description'] = $validated['description'] ?? null; } // Same "leave it alone if you lack the permission" rule as // update()'s expires_at handling. if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) { $attributes['expires_at'] = $validated['expiration_action'] === 'set' ? $this->expiry->instant($validated['expires_at'], $user) : null; } // Same rule again, behind its own permission. $limitAction = $validated['download_limit_action'] ?? 'no_change'; if ($limitAction !== 'no_change' && $canLimitDownloads) { $setting = $limitAction === 'set'; $attributes['download_limit'] = $setting ? (int) $validated['download_limit'] : null; $attributes['download_limit_scope'] = $setting ? ($validated['download_limit_scope'] ?? DownloadLimitScope::Total->value) : DownloadLimitScope::Total->value; } if ($attributes !== []) { $file->update($attributes); } // Add/remove, never sync() — bulk edit must never wipe // categories the admin didn't ask to touch. $categoriesTouched = false; if ($canSetCategories) { if ($addCategoryIds !== []) { $file->categories()->syncWithoutDetaching($addCategoryIds); $categoriesTouched = true; } if ($removeCategoryIds !== []) { $file->categories()->detach($removeCategoryIds); $categoriesTouched = true; } } if ($attributes !== [] || $categoriesTouched) { $this->activity->log(Action::FileUpdated, subject: $file); $updated++; } } }); $requested = count($validated['file_ids']); // Two different reasons a selected file can go unchanged, and they // are not the same sentence. Files dropped by the Gate::allows // filter above are ones this user may not edit at all. A file that // survived the filter and still changed nothing was editable -- // every field they asked to change was one their role does not let // them set, which is the case the single-file editor states // separately too. Reporting the first reason for the second told a // staff member with edit_files but without set_file_expiration_date // that three files they own are not theirs to edit. $unreachable = $requested - $files->count(); $message = match (true) { $updated === $requested => trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]), $updated + $unreachable === $requested => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested]), default => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to make those changes.', ['updated' => $updated, 'requested' => $requested]), }; return back()->with('success', $message); } public function destroy(File $file): RedirectResponse { Gate::authorize('delete', $file); $name = $file->name; // Soft delete of the row — but not of the bytes. File::booted()'s // `deleted` hook runs FileDiskCleanup on commit, so the upload and // every cached rendition of it are gone from disk by the time this // returns. The row is kept because version chains, the activity // log and the erasure grace period all still point at it; nothing // serves it (route-model binding 404s), and nothing ever // forceDelete()s it either. $file->delete(); $this->activity->log(Action::FileDeleted, context: ['name' => $name]); return redirect()->route('files.index')->with('success', __('File deleted.')); } }