mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
c15c9c48f8
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.
136 lines
5.5 KiB
PHP
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);
|
|
}
|
|
}
|