Files
projectsend/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php
T
ignacionelson c15c9c48f8 Close the gaps an end-to-end and security pass found in virus scanning
Run against the dev stack with real ClamAV and queue workers, and a code
review looking for ways around the scanner.

Quarantine now stays quarantined until somebody releases the file. A
rescan only touches files people can download, and changes nothing when
the scanner cannot answer or scanning is off. Before, an old infected file
rescanned while clamd restarted went through the "allow" policy and became
downloadable. The daily missing-files check leaves quarantined files alone,
so a storage outage no longer brings one back as a fresh upload.

A file longer than clamd's StreamMaxLength is "too large" again. clamd
answers and hangs up; the next write raised a warning that became an
exception before the answer was read, so the file was recorded as
"scanner down" and retried past the unscannable policy.

The production compose example gives clamd the settings it needs. On its
own defaults an encrypted zip comes back clean. The Test button now sends a
password-protected zip and fails when it is called clean, and says when an
address answers but is not ClamAV.

Saving the settings restarts the queue workers, which kept the old values
in memory. New scan runs --all, as its name says, and is refused while
scans are queued. A retry scheduled for later no longer counts as a scan
in progress.

Also: quarantine respects client scope for listing, release and
notifications; a zip built before a file was quarantined is refused;
public comments and version links skip unavailable files; a client no
longer sees their own quarantined or missing upload; a file whose bytes
return is scanned at once; clamd listens on IPv6 too, so its container
health check passes.
2026-09-17 02:48:03 -03:00

136 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Comments\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentPresenter;
use App\Modules\Comments\CommentVisibility;
use App\Modules\Comments\FileComments;
use App\Modules\Comments\GuestCommentIdentity;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Captcha\CaptchaForm;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Comments on a publicly-listed file, for visitors who are not logged in.
*
* Separate from FileCommentsController because the gate is different in
* kind, not degree: there is no account to authorize, so reachability of
* the *file* is the whole of it, and every comment here is public by
* construction. Keeping the two apart means the authenticated endpoint
* never has to reason about a null viewer, and this one can never
* accidentally serve a thread-scoped comment.
*
* A signed-in viewer who lands here is served as themselves — being
* logged in should not show you less than a stranger sees, and their own
* comments should be theirs to edit.
*/
class PublicFileCommentsController extends Controller
{
public function __construct(
private readonly FileComments $comments,
private readonly CommentPresenter $presenter,
private readonly CommentingRules $rules,
private readonly Settings $settings,
private readonly GuestCommentIdentity $guests,
) {}
public function index(Request $request, string $publicSlug, File $file): JsonResponse
{
$this->guard($publicSlug, $file);
return response()->json($this->thread($request->user(), $file));
}
public function store(Request $request, string $publicSlug, File $file): JsonResponse
{
$this->guard($publicSlug, $file);
$viewer = $request->user();
$validated = $request->validate([
'body' => ['required', 'string', 'max:5000'],
// A visitor has no account to take a name from, so they give
// one. Ignored for a signed-in author, whose name is real.
'guest_name' => [$viewer === null ? 'required' : 'nullable', 'string', 'max:80'],
// Accepted and ignored: the shared composer sends the whole
// form, and a visitor's only possible visibility is Everyone.
'visibility' => ['nullable', 'string'],
// Only a visitor is challenged — see CommentingRules. A signed
// in viewer reaching this endpoint is served as themselves, and
// proving they are human on a page that knows who they are
// would be friction with nothing behind it.
...($this->rules->captchaRequiredFor($viewer) ? Rules::captcha(CaptchaForm::Comment) : []),
]);
$comment = $this->comments->post(
$file,
$viewer,
CommentVisibility::Everyone,
$validated['body'],
null,
$validated['guest_name'] ?? null,
);
// So a visitor keeps seeing their own comment while it waits. The
// only place this is recorded, because it is the only place a
// comment is written without an account.
if ($viewer === null) {
$this->guests->remember($comment->id);
}
return response()->json($this->thread($viewer, $file), 201);
}
/**
* The thread as this endpoint may serve it.
*
* guard() establishes the guest half of VisibleCommentScope's
* precondition — the file is reachable without logging in — and that
* is the whole of it for a visitor. It says nothing about an account,
* and handing a signed-in viewer to the authenticated reading anyway
* is what let any staff account read a public file's StaffOnly notes
* and any client account read the messages addressed to that file's
* clients. The file's own gate decides which reading applies; the one
* it does not admit still reads what a visitor reads plus their own
* comments, which is what this endpoint has always promised them.
*
* @return array<string, mixed>
*/
private function thread(?User $viewer, File $file): array
{
return $this->presenter->thread(
$viewer,
$file,
viewerMaySeeFile: $viewer !== null && Gate::forUser($viewer)->allows('view', $file),
);
}
/**
* The file must be reachable without logging in, and the public
* listing itself must be switched on — the same two conditions
* PublicGroupsController applies before rendering the page this
* endpoint belongs to. Commenting being configured off 404s rather
* than returning an empty thread: the endpoint should not exist.
*/
private function guard(string $publicSlug, File $file): void
{
abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
// Same answer as the file's own public page, which 404s a file
// that is not available: otherwise a pending or quarantined file
// could be discussed, and found to exist, by anybody.
abort_unless($file->scan_status->isAvailable(), 404);
abort_unless($this->rules->enabled(), 404);
}
}