mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
e272f19045
file_comments.client_context_id is cascadeOnDelete, but users are soft-deleted, so the cascade never fires: the column keeps pointing at a row that is still there while the Eloquent relation resolves to null. resolveClientContext branched on the relation, and a null context on a Clients comment is the branch every client on the file reads -- so a staff reply into one client's private thread became a circular to all of them, with the canAssignClient check skipped on the way. VisibleCommentScope says so in its own docblock: "A Clients comment carrying client_context_id = C is never returned to any non-staff viewer other than C ... A Clients comment with a null context is a staff message to everyone on the file, and every client with access reads it." Measured on main, with one file shared with two clients and the first of them deleted after commenting: column client_context_id 3 relation clientContext null POST reply into her thread 201, stored with client_context_id null read by the other client yes Ask the column, and refuse when the account behind it is gone. There is nobody left to answer, and the one outcome that must not follow from a filled column is the broadcast, so this throws rather than falling through to it. authorName() had the same root cause from the other column: its docblock claimed author_id cascades so there is no deleted author, and a deleted client's comment was going out as "Anonymous" -- which is what a guest comment looks like, and a guest comment is read by different rules. Guest is now decided by author_id alone, the same question isFromGuest() asks, and a trashed author is read with withTrashed(). Nothing comes back only once the grace-period erasure has removed the row for real. That read costs one query per comment whose author is trashed. Measured on a ten-comment thread: 11 queries before, 21 after, against 20 for the same thread with every author alive. Left as a lazy read rather than eager-loading with withTrashed() at every call site, because the callers would each have to remember it and the cost only applies to comments whose author is gone. Five tests, two measured red against the unfixed code (2 failed / 3 passed) -- one per column. The three that stay green either way are the branches that must not move: a staff message with no context still reaches everybody, a reply into a live client's thread still lands in that thread alone, and a genuine guest comment is still anonymous. Full suite passes (2053 passed / 2 skipped), PHPStan level 8 clean.
145 lines
4.4 KiB
PHP
145 lines
4.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Comments\Models;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Comments\CommentVisibility;
|
|
use App\Modules\Files\Models\File;
|
|
use Database\Factories\FileCommentFactory;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* One comment on a file. See the create_file_comments_table migration for
|
|
* why client_context_id and approved_at are shaped the way they are.
|
|
*
|
|
* There is deliberately no query scope on this model for "the comments a
|
|
* viewer may see" — that lives in Access\VisibleCommentScope, alone, so
|
|
* there is exactly one place to audit. A scope here would be a second,
|
|
* easier-to-reach answer to the same question, and the easier one always
|
|
* wins by accident.
|
|
*
|
|
* @property int $id
|
|
* @property int $file_id
|
|
* @property int|null $author_id
|
|
* @property int|null $client_context_id
|
|
* @property string|null $guest_name
|
|
* @property string|null $ip_address
|
|
* @property CommentVisibility $visibility
|
|
* @property string $body
|
|
* @property Carbon|null $approved_at
|
|
* @property Carbon|null $edited_at
|
|
* @property Carbon|null $created_at
|
|
* @property-read User|null $author
|
|
* @property-read User|null $clientContext
|
|
* @property-read File $file
|
|
*/
|
|
class FileComment extends Model
|
|
{
|
|
/** @use HasFactory<FileCommentFactory> */
|
|
use HasFactory;
|
|
|
|
use SoftDeletes;
|
|
|
|
protected $guarded = [];
|
|
|
|
/**
|
|
* Laravel derives a factory's name by stripping the App\Models prefix,
|
|
* which this module-namespaced model does not have — so name it here
|
|
* rather than have the lookup miss.
|
|
*/
|
|
protected static function newFactory(): FileCommentFactory
|
|
{
|
|
return FileCommentFactory::new();
|
|
}
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'visibility' => CommentVisibility::class,
|
|
'approved_at' => 'datetime',
|
|
'edited_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<File, $this>
|
|
*/
|
|
public function file(): BelongsTo
|
|
{
|
|
return $this->belongsTo(File::class);
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<User, $this>
|
|
*/
|
|
public function author(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'author_id');
|
|
}
|
|
|
|
/**
|
|
* The client whose conversation this comment belongs to — not its
|
|
* author.
|
|
*
|
|
* Null means the comment is not one client's: a Clients comment with no
|
|
* context is staff addressing everyone on the file, and OnlyMe /
|
|
* StaffOnly / Everyone have no client in them to begin with. A
|
|
* staff-only note is its own audience now, not a null context.
|
|
*
|
|
* @return BelongsTo<User, $this>
|
|
*/
|
|
public function clientContext(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'client_context_id');
|
|
}
|
|
|
|
public function isPending(): bool
|
|
{
|
|
return $this->approved_at === null;
|
|
}
|
|
|
|
public function isFromGuest(): bool
|
|
{
|
|
return $this->author_id === null;
|
|
}
|
|
|
|
/**
|
|
* The name to show. Snapshotted for guests at write time; read live
|
|
* for accounts so a rename is reflected everywhere at once.
|
|
*
|
|
* A deleted account is still read. author_id cascades on delete, but
|
|
* a user is soft-deleted and the cascade never fires, so the row
|
|
* behind a deleted commenter is still there — and reading it through
|
|
* the plain relation returned null, which sent a named client's
|
|
* comment out as "Anonymous". That is what a guest comment looks
|
|
* like, and a guest comment is governed by different rules; the two
|
|
* must not be able to look the same. Whether the author is a guest is
|
|
* decided by author_id alone, which is also what isFromGuest() asks.
|
|
*/
|
|
public function authorName(): string
|
|
{
|
|
if ($this->author_id === null) {
|
|
return $this->guest_name ?? (string) __('Anonymous');
|
|
}
|
|
|
|
$author = $this->author;
|
|
|
|
if ($author !== null) {
|
|
return $author->name;
|
|
}
|
|
|
|
// Trashed: the row is still there, the relation simply will not
|
|
// hand it over. Nothing comes back only once the grace-period
|
|
// erasure has removed the row for real.
|
|
$name = $this->author()->withTrashed()->value('name');
|
|
|
|
return is_string($name) ? $name : (string) __('Anonymous');
|
|
}
|
|
}
|