mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
50f8b578df
Reported by @skeletonsec as GHSA-rxf8-wh8v-jm9j. A file in a public folder is public: isEffectivelyPublic() is "my own flag, or my folder's", read up the whole ancestry. GHSA-237r-jx85-j3hr settled that three days ago, put the rule in Folder::uploadableBy(), and wired it into the upload paths. Content arrives in a folder four other ways. move() drags one file in, bulkUpdate() moves a selection, update() reparents through the edit form, and FoldersController::move() drags a whole folder — every file in its subtree — under a public parent. Each of them asked whether the destination was *visible* to the mover and then wrote folder_id. Visible is not the same question as publishable, and the difference is the entire permission: a staff member given editing rights and deliberately not given upload_public could publish confidential files to the anonymous site by choosing where they landed. The API twin of update() had the same gap. Both earlier advisories named these paths in their own "suggested fix" sections. Neither demonstrated them, so neither was followed. The fix to a report wants the scrutiny the report got, and this one did not get it. The predicate did not need changing — it needed calling. Four sinks now ask it, plus the API twin. The check stays split in two deliberately: the destination is resolved through StaffLibraryScope as before, so a folder somebody cannot see is still a 404 and not an existence oracle, and the publication clause is a separate 403 on top. They agree by construction — allowsFolder() is folders()->whereKey()->exists() — so nothing that used to resolve can now fail the first half. On the file paths the check fires only when folder_id actually changes, which is the convention already there: re-saving a file that sits in a folder out of the saver's scope must keep working. bulkUpdate() checks its destination once instead, before the loop, because there is one destination for the batch and if it publishes then no file in the batch may go. Folder::uploadableBy()'s docblock now says to read the name as "may place into", with why: the name is what made this easy to miss, and the next folder_id or parent_id write will be written by somebody reading it. Ten tests, one per sink with a private-destination control beside it, plus an editor who *can* publish to show the boundary is about publishing and not about moving. The last one follows the advisory's own chain to the end and asserts the thing actually claimed — a stranger with no session, no token and no assignment fetching the anonymous download URL. It returns 200 on the code before this commit and 404 after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
547 lines
26 KiB
PHP
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', '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', '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.'));
|
|
}
|
|
}
|