Files
ignacionelson 3917cb2af3 Stop a file expiry date sent as a number from 500ing
Laravel's `date` rule accepts a JSON number when it reads as a real day
(20301231 passes) and hands it on unconverted. Every file expiry field
then passes it to a method that only takes a string, so the request
failed with a 500 instead of a validation error.

Affected: PATCH /api/v1/files/{file}, the staff file editor, the bulk
editor, new share links and the client file editor. Each now also
requires `string`, so a number is a 422 on `expires_at`. Dates sent as
text behave exactly as before. A form never sent a number, so this was
only reachable with a hand-written JSON body.
2026-09-13 15:34:05 -03:00

547 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\PublicUrl;
use App\Support\Rules;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class FilesController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
private readonly PublicUrl $publicUrl,
private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly CommentingRules $commenting,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {}
public function create(Request $request): Response
{
$user = $request->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', 'string', '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) {
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
// And one they may publish into, if it is public. Reparenting
// through the edit form is the same privileged write as move().
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
// 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, and one
// they are allowed to put content into. Those are two questions:
// a file in a public folder is published by being there, so the
// destination reaches the property `upload_public` guards without
// anybody touching the switch. Asking only the first let an editor
// who is deliberately not allowed to publish do it by dragging
// (GHSA-rxf8-wh8v-jm9j — the move half of GHSA-237r-jx85-j3hr,
// whose fix was wired into the upload paths and no further).
if ($folderId !== null && $user !== null && $folderId !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
$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', 'string', '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, and one
// they may put content into — the same two questions move() asks of
// a single file's target. Checked once, on the destination, rather
// than per file: the destination is one folder for the whole batch,
// and if putting content there publishes it then no file in the
// batch may go.
$targetFolderId = null;
if ($validated['folder_action'] === 'move') {
$targetFolderId = $validated['folder_id'] ?? null;
if ($targetFolderId !== null) {
$destination = $this->scope->folders($user)->whereKey($targetFolderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
}
// 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.'));
}
}