mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
3439537efe
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.
95 lines
3.1 KiB
PHP
95 lines
3.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Comments;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Comments\Access\VisibleCommentScope;
|
|
use App\Modules\Comments\Models\FileComment;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
/**
|
|
* Per-comment authorization. `view` defers wholly to VisibleCommentScope
|
|
* rather than restating its rules — a policy and a query scope that both
|
|
* describe the same privacy boundary will eventually disagree, and the
|
|
* one that disagrees quietly is the query.
|
|
*/
|
|
class FileCommentPolicy
|
|
{
|
|
public function __construct(
|
|
private readonly VisibleCommentScope $scope,
|
|
private readonly CommentingRules $rules,
|
|
private readonly StaffLibraryScope $library,
|
|
) {}
|
|
|
|
public function view(User $user, FileComment $comment): bool
|
|
{
|
|
if (! Gate::forUser($user)->allows('view', $comment->file)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->scope->for($user, $comment->file)->whereKey($comment->getKey())->exists();
|
|
}
|
|
|
|
/**
|
|
* Editing is the author's alone, inside a short window. Moderators are
|
|
* deliberately excluded: deleting somebody's comment is moderation,
|
|
* rewriting their words is not, and no permission in this app should
|
|
* imply the latter.
|
|
*/
|
|
public function update(User $user, FileComment $comment): bool
|
|
{
|
|
return $comment->author_id === $user->id && $this->withinEditWindow($comment);
|
|
}
|
|
|
|
public function delete(User $user, FileComment $comment): bool
|
|
{
|
|
if ($this->moderate($user, $comment)) {
|
|
return true;
|
|
}
|
|
|
|
return $comment->author_id === $user->id && $this->withinEditWindow($comment);
|
|
}
|
|
|
|
/**
|
|
* Called both ways: with a comment, to decide about that one, and
|
|
* against the class, to ask whether this user moderates at all (the
|
|
* queue's own gate, and the affordances that offer it).
|
|
*
|
|
* The library boundary belongs here rather than in each caller. Named
|
|
* against the class it cannot be applied — there is no file to weigh —
|
|
* so that form answers the coarser question and every caller holding a
|
|
* comment should pass it.
|
|
*/
|
|
public function moderate(User $user, ?FileComment $comment = null): bool
|
|
{
|
|
if (! $user->isStaff() || ! $user->can('moderate_comments')) {
|
|
return false;
|
|
}
|
|
|
|
if ($comment === null || ! $user->isClientScoped()) {
|
|
return true;
|
|
}
|
|
|
|
// By file id rather than through the relation: a file soft-deleted
|
|
// out from under its comments resolves to null there, and the
|
|
// answer for a scoped moderator is the same either way — it is not
|
|
// in their library. Unscoped staff never reach this line.
|
|
return $this->library->files($user)->whereKey($comment->file_id)->exists();
|
|
}
|
|
|
|
private function withinEditWindow(FileComment $comment): bool
|
|
{
|
|
$minutes = $this->rules->editWindowMinutes();
|
|
|
|
if ($minutes <= 0) {
|
|
return false;
|
|
}
|
|
|
|
return $comment->created_at !== null
|
|
&& $comment->created_at->diffInMinutes(now()) < $minutes;
|
|
}
|
|
}
|