mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
c8de16101f
FilePolicy::view() has two halves for staff: one of the three file keys
(upload / edit_files / edit_others_files), AND StaffLibraryScope. Every
comment surface that spans files narrowed by the library half alone.
A role holding moderate_comments and no file key therefore got a 403 on
every file in the installation while reading every comment written about
them on /comments: the text, staff-only notes, the client name a
Clients-visibility comment carries, and a visitor's IP address. The API
queue answered the same way, and approving through it hands the body back
in the response, so it was a reading door as well as a writing one.
The class says this is not supposed to happen -- across()'s own docblock
("a moderation screen is not a way around the visibility model"), the
route comment on /comments ("the list itself is still narrowed by
VisibleCommentScope, so holding the permission does not widen what a
viewer may read"), and routes/api.php ("reading and writing a comment is
gated by 'may see this file', the same three keys the file endpoints
use"). FileCommentPolicy::view() enforces it for a single comment, by
running the file's own gate first. Only the cross-file queries did not.
So they now take their files from ViewableFileScope, which is
FilePolicy::view() expressed as a query, instead of from StaffLibraryScope,
which is only its second half: across(), pendingTotal() and the API's
pending list. The permission half moves into a named method on that class,
since three modules now ask the same question.
FileCommentPolicy::moderate() gets it too, in both forms. Its row form is
otherwise unchanged -- the library check still runs by file id, so a
comment on a soft-deleted file behaves exactly as before.
No system role changes behaviour: Account Manager and System Administrator
are the two that ship with moderate_comments, and both hold upload. What
changes is a hand-built role that holds moderation and nothing else.
Seven tests. Without the fix, five go red; the other two are the premise
(that the viewer really is refused the file itself) and the guard that a
moderator who may read files still moderates the whole installation.
docs/api/openapi.json regenerated for the one changed description.
106 lines
3.7 KiB
PHP
106 lines
3.7 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 App\Modules\Files\Access\ViewableFileScope;
|
|
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,
|
|
private readonly ViewableFileScope $viewable,
|
|
) {}
|
|
|
|
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;
|
|
}
|
|
|
|
// Moderating is deciding about comments you can already see, so the
|
|
// permission half of file reading is part of the answer in both
|
|
// forms. Without one of the three file keys this user gets a 403 on
|
|
// every file these comments are about, and approving one hands back
|
|
// its body — so this is a reading door, not only a writing one.
|
|
if (! $this->viewable->permitsAnyFile($user)) {
|
|
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;
|
|
}
|
|
}
|