Files
projectsend/app/Modules/Files/Http/Controllers/MyFoldersController.php
T
denkfabrik-li 7727ad7616 Say what exists:folders,id was already being read as
Folder uses SoftDeletes. The `exists` rule runs against the table, so a
folder in the trash passes it -- while every resolution that follows goes
through Folder::query(), which honours the soft delete and finds nothing.
Ten rules across five controllers rely on that check, and each one reads
it as "this folder exists".

Two of them then wrote the id anyway. Api\FilesController::store()
resolves the folder, hands the null to Folder::uploadableBy(), is told
yes -- correctly, that is the rule for a root upload -- and passes
$validated['folder_id'] to the write. FilesController::store() is the
same shape once #1694 gives it the guard. FilesController::update() and
its API twin write it straight through with nothing in between.

The result is a live file inside a deleted folder, which is a state
nothing else in the application produces: FolderService::delete() deletes
every file in the subtree along with it. The row is reachable by id, in
search and over the API, and missing from the listing its uploader would
look in.

Rules::folderId() makes the check mean what its readers assume, once,
where the reasoning can be written down -- the same argument slug() makes
for itself one method above. Every site takes it, so the file cannot end
up with two spellings of the same rule and no way to tell which is the
safe one.

What changes, path by path:

  - POST /files, POST /api/v1/files, PATCH /files/{file} and
    PATCH /api/v1/files/{file} refuse a folder in the trash instead of
    writing its id. This is the fix.
  - POST /uploads used to accept it and quietly file the upload at the
    root -- its guard and its write already agreed, on null. It now says
    so instead, which is what the other upload paths do.
  - files/{file}/move, files/bulk-edit, folders, folders/{folder}/move
    and the portal's my-folders already refused, through
    StaffLibraryScope::folders() or Folder::scopeVisibleToClient(), both
    of which drop trashed rows. They still refuse; the answer is now 422
    naming folder_id rather than a bare 404. Those two guards are asking
    a different question -- "is this folder yours" -- and they keep
    asking it.

No live folder id behaves differently anywhere, and the root (a null
folder_id) is untouched.

The published API document is unchanged: `exists` renders the same either
way. Regenerated with php artisan scramble:export and byte-identical.
2026-08-26 06:01:06 +02:00

113 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Support\Rules;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Client-facing folder creation/rename/delete — the client portal's
* counterpart to FoldersController, gated by create_own_folders (see that
* permission's docblock) rather than staff middleware. A client may only
* ever nest a new folder inside one already visible to them
* (Folder::scopeVisibleToClient), so a created folder is automatically
* visible to them and to any staff with access to their content — see
* StaffLibraryScope::folders(), which already ORs in visibleToClient()
* per assigned client.
*/
class MyFoldersController extends Controller
{
public function __construct(
private readonly FolderService $folders,
private readonly ActivityLogger $activity,
) {}
public function store(Request $request): RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
// Creating one requires upload too — an empty folder a client can
// never put anything in isn't useful on its own (renaming/deleting
// an existing one, below, doesn't carry this requirement: it stays
// theirs to manage even if upload is later revoked).
abort_unless($client->can('create_own_folders') && $client->can('upload'), 403);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'parent_id' => Rules::folderId(),
]);
$parent = null;
if (($validated['parent_id'] ?? null) !== null) {
$parent = Folder::query()->visibleToClient($client)->whereKey($validated['parent_id'])->firstOrFail();
}
$folder = $this->folders->create($validated['name'], $parent);
$this->activity->log(Action::FolderCreated, subject: $folder);
return back()->with('success', __('Folder created.'));
}
public function update(Request $request, Folder $folder): RedirectResponse
{
Gate::authorize('update', $folder);
$validated = $request->validate(['name' => ['required', 'string', 'max:255']]);
$folder->update(['name' => $validated['name']]);
$this->activity->log(Action::FolderRenamed, subject: $folder);
return back()->with('success', __('Folder renamed.'));
}
public function destroy(Folder $folder): RedirectResponse
{
Gate::authorize('delete', $folder);
$client = auth()->user();
assert($client !== null);
// Deleting a folder cascades to every file in its subtree, and a
// File's `deleted` hook removes the bytes from disk — there is no
// restore. Owning the folder is not authority over content someone
// else put in it: staff routinely upload into a client's own folder,
// and the client can see those files without being able to delete
// them individually (FilePolicy::delete denies clients outright).
// Refuse rather than silently destroy them.
$foreignFiles = File::query()
->whereIn('folder_id', $folder->subtreeFolderIds())
->where(fn ($query) => $query->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $client->id))
->count();
if ($foreignFiles > 0) {
return back()->with('error', trans_choice(
'This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.',
$foreignFiles,
['count' => (string) $foreignFiles],
));
}
$name = $folder->name;
$parentId = $folder->parent_id;
$this->folders->delete($folder);
$this->activity->log(Action::FolderDeleted, context: ['name' => $name]);
return redirect()->route('my-files.index', $parentId !== null ? ['folder' => $parentId] : [])
->with('success', __('Folder deleted.'));
}
}