Files
projectsend/app/Modules/Comments/Http/Controllers/Api/CommentModerationController.php
T
denkfabrik-li 3439537efe Keep comment moderation inside the moderator's own library
FileCommentPolicy::moderate() asked only whether somebody is staff and
holds moderate_comments. It never weighed the file the comment sits on,
and delete() returns true the moment moderate() does — so a client-scoped
moderator could delete any comment on the installation by naming its id.

Three call sites already knew this and wrote the boundary out by hand,
each with its own abort_unless($library->allowsFile(...), 403) after the
gate. The two that did not are FileCommentsController::destroy(), web and
API: both bind a comment directly, so nothing earlier in the request
establishes that the viewer may see its file.

The intent was documented in three places and enforced in none of them by
the policy — StaffLibraryScope says "the policies consult allowsFile() so
direct access respects the same boundary", VisibleCommentScope says "a
moderation screen is not a way around the visibility model". Put the rule
where those docblocks already say it lives.

moderate() now takes the comment when there is one. Named against the
class it still answers the coarser "does this user moderate at all",
which is what the queue's gate and the affordances ask. Membership is
tested by file id, so a file soft-deleted out from under its comments is
not in a scoped moderator's library either.

The author branch of delete() is deliberately untouched: deleting your
own words inside the edit window is not moderation, and a client is not
client-scoped in StaffLibraryScope's sense.

Approving through the API now derives its 403 from Gate::authorize rather
than the removed abort_unless, so the committed OpenAPI document gains
the shared AuthorizationException ref in place of an inline "An error"
schema — the shape nine of the other twelve documented 403s already use.
2026-08-26 01:09:47 +02:00

82 lines
2.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Comments\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Modules\Comments\FileComments;
use App\Modules\Comments\Http\Resources\Api\FileCommentResource;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Access\StaffLibraryScope;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
/**
* Comments from visitors, waiting for a decision.
*
* Only anonymous comments ever land here — an account's comment is
* published the moment it is written.
*
* This exists so the two halves of moderation match. Deleting somebody
* else's comment was already reachable through `DELETE /comments/{id}`,
* which runs the same policy the web does; approving one was not, so a
* token carried the destructive half and none of the constructive one, and
* `moderate_comments` was not selectable as an ability at all because no
* route named it. Both are fixed by these two endpoints existing.
*/
class CommentModerationController extends Controller
{
public function __construct(
private readonly FileComments $comments,
private readonly StaffLibraryScope $library,
) {}
/**
* List comments awaiting approval.
*
* Scoped by the same library boundary as everything else: a
* client-scoped token sees pending comments only on files its owner
* could already open. Oldest first, so working through the list means
* working through the backlog.
*/
public function index(Request $request): AnonymousResourceCollection
{
$viewer = $request->user();
assert($viewer !== null);
Gate::forUser($viewer)->authorize('moderate', FileComment::class);
$pending = FileComment::query()
->whereNull('approved_at')
->whereIn('file_id', $this->library->files($viewer)->select('id'))
->with(['author', 'clientContext'])
->orderBy('created_at')
->orderBy('id')
->get();
return FileCommentResource::collection($pending);
}
/**
* Approve a comment left by a visitor.
*
* Nobody can see it until this happens. Approving an already-approved
* comment changes nothing and announces nothing, so a retried request
* is safe — which matters more here than on the web, where a human does
* not retry automatically.
*/
public function approve(Request $request, FileComment $comment): FileCommentResource
{
$viewer = $request->user();
assert($viewer !== null);
// Moderation rights are not a way around the library boundary; the
// policy weighs the comment's file, so name the comment.
Gate::authorize('moderate', $comment);
$this->comments->approve($comment, $viewer);
return new FileCommentResource($comment->fresh()?->load(['author', 'clientContext']) ?? $comment);
}
}