Files
ignacionelson d7e639b7af Close the two-request version of the deleted-folder target, and say why it failed
Follow-up to #1703, which made `exists:folders,id` mean what its ten
readers already assumed. Two things it named and deliberately left.

**The chunked upload is two requests.** store()'s rule only ever sees the
first: POST /uploads records the resolved folder on the UploadSession and
complete() reads it back from the session rather than from the caller, so
deleting the folder while the bytes are in flight still files the
assembled file into it -- the same orphan state #1703 removes, reached by
a door a validation rule cannot watch. complete() now re-resolves through
Folder::query() and files at the root when the folder has gone.

Root rather than a refusal, because the two moments cost different
things. At store() nothing has been sent, so refusing is free and honest,
which is the call #1703 made. Here the bytes are already uploaded, and
discarding somebody's finished transfer over a folder that vanished
underneath them is the harsher of the two surprises. The file lands
somewhere they can see it and move it.

**The refusal now explains itself.** "The selected folder id is invalid"
says nothing when the answer is that the folder has been deleted -- and
that is the usual way to meet this rule, since a live id picked from a
list is how anybody gets here. It matters most on the chunked path, the
one place #1703 makes a previously-working request fail. A small
ValidationRule object carries the message, which keeps the single
definition Rules::folderId() exists for: a messages() array would have to
be repeated at all ten call sites, and rules meaning different things in
ten places is what went wrong in the first place.

One note for whoever writes the next test here. Upload parts live in
storage_path('app/uploads-tmp/{session_id}'), which is a real shared
directory rather than a faked disk, and each parallel worker's database
restarts session ids at 1 -- so two files writing parts on two workers
collide, and ChunkedUploadsTest's afterEach deletes the whole tree for
everybody. Six test files write parts today. These two cases live in
ChunkedUploadsTest rather than beside the rest of their subject so this
change does not add a seventh racer; the underlying isolation problem
predates it and is worth its own fix.
2026-08-26 17:41:07 -03:00

55 lines
1.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Folders;
use App\Modules\Files\Models\Folder;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* An id naming a folder that is actually there.
*
* A rule object rather than `Rule::exists(...)->whereNull('deleted_at')`
* for one reason: the message. The generic form says "The selected folder
* id is invalid", which tells somebody nothing when the real answer is
* that the folder they picked has since been deleted — and that is the
* usual way to meet this rule, since a live id they chose from a list is
* how they got here. It matters most on the chunked upload path, which is
* the one place a request that used to succeed now fails.
*
* Carrying the message on the rule keeps the single definition
* Rules::folderId() exists for: a `messages()` array would have to be
* repeated at every call site, which is how the plain `exists:folders,id`
* it replaced came to mean two different things in ten places.
*/
class FolderExistsRule implements ValidationRule
{
/**
* @param Closure(string, string|null=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_numeric($value)) {
// Reached only when a caller drops `integer`; the message
// still has to make sense to whoever sees it.
$fail(__('That folder could not be found.'));
return;
}
// Folder::query() honours the soft delete, which is the whole
// point — the table-level `exists` rule this replaces does not.
if (Folder::query()->whereKey((int) $value)->exists()) {
return;
}
$fail(__('That folder no longer exists. Pick another one and try again.'));
}
}