mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
21cae2acb1
FileVersions::link() resolves its notification audience before the merge,
and says why:
RESOLVED BEFORE THE MERGE, and the ordering is the whole dedupe:
these are the people who could already see both files, so anyone the
merge below is about to reach for the first time is excluded here and
gets file_shared from FileSharing::assign() instead. Resolve it
afterwards and every newly-added client receives two emails about one
action.
The merge then undoes it. moveAssignmentsToRoot() hands every one of the
revision's targets to FileSharing::assign(), under a comment claiming
that firstOrCreate makes a target the root already has a no-op. It makes
the assignment row idempotent; the three side effects under it --
activity entry, in-app notification, digest -- run unconditionally.
Measured on main:
client already holds the root and the revision, then both are linked
file_shared (Report) <- wrong, they have had it all along
file_new_version (Report v2) <- right
assignment rows on the root: 1
client holds only the revision, then both are linked
file_shared (Report) <- right, the merge does hand it over
Two notifications for one action, for exactly the people the early
resolve was meant to protect.
So a target the root already holds is skipped rather than handed to
assign(). Nobody is gaining access in that case, and the activity entry
would be as untrue as the notification. copyAssignmentsFrom() directly
below already states that rule for its own case, which is why it inserts
directly instead of going through FileSharing. Both stale comments are
corrected with it.
Not changed: FileSharing::assign() itself, and so the behaviour
ShareNotificationsTest pins -- re-posting an existing assignment through
the share endpoint still notifies again. That test names the condition
for ever changing it, "it should stop being sent for both at once", and
that is a decision about files and folders together. This is narrower: a
version merge is not somebody choosing to share again, and it already
had a stated intent to send exactly one notification.
Three cases in ShareNotificationsTest -- the target already on the root,
the target gaining it, and a group already on the root. Reverting
FileVersions alone leaves 2 failed / 8 passed in that file; the middle
case passes without the fix, because it guards against skipping too much
rather than against the duplicate notice.
Suite 2108 passed / 2 skipped, 11415 assertions, PHPStan level 8 clean.
Measured on base 06c364d2, where main itself is 2105 / 2.
656 lines
24 KiB
PHP
656 lines
24 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Versions;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Access\ViewableFileScope;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\FileAssignment;
|
|
use App\Modules\Files\Sharing\FileSharing;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Notifications\NotificationDigester;
|
|
use App\Modules\Notifications\Notifier;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* Marking one file as a revision of another, and everything that follows
|
|
* from it.
|
|
*
|
|
* Its own class rather than part of FilesController for the reason
|
|
* FileSharing is: linking is not one UPDATE. It is an UPDATE plus an
|
|
* authorization pair, a cycle check, a move of assignment rows onto the
|
|
* original, a re-stamp of the chain's roots, an activity entry and a
|
|
* notification — and it has three callers (the file editor, the upload
|
|
* flow, the API) that must not be able to answer any of it differently.
|
|
*
|
|
* Two invariants this class is responsible for, both asserted by
|
|
* `versioning:check`:
|
|
*
|
|
* 1. previous_file_id IS NULL <=> version_root_id IS NULL.
|
|
* 2. A file with a non-null version_root_id owns no file_assignments rows.
|
|
* Its audience is the root's (see File::sharingOwnerId and
|
|
* Access\SharingIdentity). This is what makes "a revision is always
|
|
* shared with the same people as the original" a property of the
|
|
* schema rather than a convention some future write path can forget.
|
|
*
|
|
* Authorization is NOT the caller's job here, unlike FileSharing: the
|
|
* escalation this feature can produce is specific to versioning (inheriting
|
|
* a stranger's recipients), so the check lives at the seam every caller
|
|
* reaches. See FilePolicy::setVersion.
|
|
*/
|
|
class FileVersions
|
|
{
|
|
/**
|
|
* How deep a version history may go.
|
|
*
|
|
* Doubles as the termination guard for the chain walks below: if the
|
|
* table were ever corrupted into a cycle, every walk here still ends.
|
|
*/
|
|
public const MAX_CHAIN = 50;
|
|
|
|
public function __construct(
|
|
private readonly ViewableFileScope $viewable,
|
|
private readonly FileSharing $sharing,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly Notifier $notifier,
|
|
private readonly NotificationDigester $digester,
|
|
) {}
|
|
|
|
/**
|
|
* Mark $file as a revision of $previous. Idempotent: re-linking an
|
|
* existing pair is a no-op rather than an error, which matters for an
|
|
* API caller retrying a request.
|
|
*
|
|
* @throws ValidationException when a rule rejects the pair
|
|
* @throws AuthorizationException when $actor may not version either file
|
|
*/
|
|
public function link(File $file, File $previous, User $actor): void
|
|
{
|
|
$this->guard($file, $previous, $actor);
|
|
|
|
if ($file->previous_file_id === $previous->id) {
|
|
return;
|
|
}
|
|
|
|
$root = $previous->version_root_id ?? $previous->id;
|
|
|
|
// RESOLVED BEFORE THE MERGE, and the ordering is the whole dedupe:
|
|
// these are the people who could already see both files, so anyone
|
|
// the merge below is about to reach for the first time is excluded
|
|
// here and gets file_shared from FileSharing::assign() instead.
|
|
// Resolve it afterwards and every newly-added client receives two
|
|
// emails about one action.
|
|
$audience = $this->sharedAudience($file, $previous);
|
|
|
|
try {
|
|
DB::transaction(function () use ($file, $previous, $root, $actor): void {
|
|
// Move, never drop: a revision holds no recipients of its
|
|
// own, but the people who already had this file must not
|
|
// lose it. Through FileSharing, so a target the root does
|
|
// not hold yet still gets its activity entry, notification
|
|
// and digest — and only such a target, see below.
|
|
$this->moveAssignmentsToRoot($file, $root);
|
|
|
|
$file->update([
|
|
'previous_file_id' => $previous->id,
|
|
'version_root_id' => $root,
|
|
]);
|
|
|
|
// $file may itself have revisions; the whole subchain now
|
|
// belongs to $root.
|
|
$this->recomputeRoots($root);
|
|
|
|
$this->activity->log(
|
|
Action::FileVersionLinked,
|
|
actor: $actor,
|
|
subject: $file,
|
|
context: ['previous' => $previous->name],
|
|
);
|
|
});
|
|
} catch (UniqueConstraintViolationException) {
|
|
// Two staff picked the same original at once. The pre-check in
|
|
// guard() lost the race; the index is the authority. The
|
|
// message deliberately omits the winner's name — it may be a
|
|
// file this actor is not allowed to know about.
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('That file has just been revised by another file. Reload and try again.'),
|
|
]);
|
|
}
|
|
|
|
if ($audience->isNotEmpty()) {
|
|
$this->notifier->send('file_new_version', $audience, subject: $file, data: [
|
|
'itemName' => $file->name,
|
|
'previousName' => $previous->name,
|
|
]);
|
|
|
|
$this->digester->queue('file_new_version', $audience, $file->name, [
|
|
'previousName' => $previous->name,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The clients who could see BOTH files before this link was made.
|
|
*
|
|
* Notifier performs no authorization of its own — see its SECURITY
|
|
* CONTRACT docblock — so this is where the list has to be exact. It is
|
|
* an INTERSECTION, never a difference: a client who holds only the old
|
|
* file must be told nothing, because the new one was never shared with
|
|
* them and naming it would leak a file they were not granted. That is
|
|
* the same rule the badge follows, shaped as people.
|
|
*
|
|
* Candidates come from the previous file's own audience rather than a
|
|
* broad user query, then each is re-checked against both files with the
|
|
* authoritative visibility scope.
|
|
*
|
|
* @return Collection<int, User>
|
|
*/
|
|
private function sharedAudience(File $file, File $previous): Collection
|
|
{
|
|
$candidateIds = FileAssignment::query()
|
|
->where('file_id', $previous->sharingOwnerId())
|
|
->get()
|
|
->flatMap(function (FileAssignment $assignment): array {
|
|
$target = $assignment->assignable;
|
|
|
|
if ($target instanceof Group) {
|
|
return $target->members->pluck('id')->all();
|
|
}
|
|
|
|
return $target instanceof User ? [$target->id] : [];
|
|
})
|
|
->unique()
|
|
->values();
|
|
|
|
if ($candidateIds->isEmpty()) {
|
|
/** @var Collection<int, User> $empty */
|
|
$empty = new Collection;
|
|
|
|
return $empty;
|
|
}
|
|
|
|
return User::query()->whereIn('id', $candidateIds)->get()
|
|
->filter(fn (User $candidate): bool => File::query()->whereKey($file->id)->visibleToClient($candidate)->exists()
|
|
&& File::query()->whereKey($previous->id)->visibleToClient($candidate)->exists())
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Detach $file from its original, making it a root again.
|
|
*
|
|
* The root's current recipients are copied onto it first: it is about
|
|
* to stop inheriting them, and silently revoking access people already
|
|
* have is never the right reading of "unlink".
|
|
*
|
|
* @return bool whether a link was actually removed
|
|
*/
|
|
public function unlink(File $file, User $actor): bool
|
|
{
|
|
if ($file->previous_file_id === null) {
|
|
return false;
|
|
}
|
|
|
|
Gate::forUser($actor)->authorize('setVersion', $file);
|
|
|
|
DB::transaction(function () use ($file): void {
|
|
$this->copyAssignmentsFrom($file->sharingOwnerId(), $file);
|
|
|
|
$file->update(['previous_file_id' => null, 'version_root_id' => null]);
|
|
|
|
// $file is the head of whatever followed it; that subchain
|
|
// roots at $file now.
|
|
$this->recomputeRoots($file->id);
|
|
});
|
|
|
|
$this->activity->log(Action::FileVersionUnlinked, actor: $actor, subject: $file);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* The full lineage $viewer may see, oldest first, including $file
|
|
* itself. Files in the chain the viewer cannot see are omitted rather
|
|
* than replaced with a placeholder — a gap is less informative than a
|
|
* hint that something exists.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
public function chain(File $file, ?User $viewer): Collection
|
|
{
|
|
$members = $this->walkBack($file)->reverse()->values();
|
|
|
|
foreach ($this->walkForward($file) as $successor) {
|
|
$members->push($successor);
|
|
}
|
|
|
|
if ($viewer === null) {
|
|
return $members->filter(
|
|
fn (File $member): bool => $member->isEffectivelyPublic() && ! $member->isExpired()
|
|
)->values();
|
|
}
|
|
|
|
$visibleIds = $this->viewable->for($viewer)
|
|
->whereIn('files.id', $members->pluck('id')->all())
|
|
->pluck('files.id')
|
|
->all();
|
|
|
|
return $members->filter(
|
|
fn (File $member): bool => in_array($member->id, $visibleIds, true)
|
|
)->values();
|
|
}
|
|
|
|
/**
|
|
* Files $actor may pick as $file's original.
|
|
*
|
|
* Staff get their viewable library. A CLIENT GETS THEIR OWN UPLOADS
|
|
* ONLY — deliberately not ViewableFileScope, which includes every file
|
|
* staff shared with them. Since a revision inherits the original's
|
|
* recipients, offering a shared file here would be offering a way to
|
|
* publish an upload to a list the client does not own. The picker
|
|
* agreeing with FilePolicy::setVersion is a courtesy; that policy is
|
|
* the control.
|
|
*
|
|
* $file is null in the upload flow, where the original is chosen
|
|
* before the row exists.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
public function candidates(?File $file, User $actor, string $search = '', int $limit = 25): Collection
|
|
{
|
|
return $this->candidateQuery($file, $actor)
|
|
->when($search !== '', fn (Builder $q) => $q->where(
|
|
fn (Builder $w) => $w->where('name', 'like', '%'.$search.'%')
|
|
->orWhere('original_name', 'like', '%'.$search.'%')
|
|
))
|
|
->orderByDesc('created_at')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* One candidate by id, or null — the same rule as candidates(), for a
|
|
* caller that already has an id (the upload flow, the API).
|
|
*
|
|
* Shares candidateQuery() rather than filtering a candidates() result,
|
|
* so resolving an id never depends on that id happening to fall inside
|
|
* the list's limit.
|
|
*/
|
|
public function resolveCandidate(?File $file, User $actor, int $previousFileId): ?File
|
|
{
|
|
return $this->candidateQuery($file, $actor)->whereKey($previousFileId)->first();
|
|
}
|
|
|
|
/**
|
|
* @return Builder<File>
|
|
*/
|
|
private function candidateQuery(?File $file, User $actor): Builder
|
|
{
|
|
$query = $actor->isStaff()
|
|
? $this->viewable->for($actor)
|
|
: File::query()->where('uploaded_by', $actor->id)->notExpired();
|
|
|
|
// Already revised by something: the unique index would refuse it.
|
|
//
|
|
// Except when that something is $file itself. Without this exemption
|
|
// a file's *current* original is not resolvable, so re-submitting an
|
|
// existing link 404s instead of being the no-op link() is careful to
|
|
// be — which an API client retrying a request will do.
|
|
$query->where(function (Builder $eligible) use ($file): void {
|
|
$eligible->whereDoesntHave('nextVersion');
|
|
|
|
if ($file !== null) {
|
|
$eligible->orWhereHas('nextVersion', fn (Builder $successor) => $successor->whereKey($file->id));
|
|
}
|
|
});
|
|
|
|
if ($file !== null) {
|
|
// Neither itself nor anything downstream of it — both would be
|
|
// rejected by guardAgainstCycle().
|
|
$query->whereKeyNot($file->id)
|
|
->whereNotIn('files.id', $this->walkForward($file)->pluck('id')->all());
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* The recipients link() would move from $file onto $previous's root.
|
|
*/
|
|
public function previewLink(File $file, File $previous): LinkPreview
|
|
{
|
|
$rootId = $previous->version_root_id ?? $previous->id;
|
|
|
|
$moving = $file->assignments()->get();
|
|
|
|
if ($moving->isEmpty()) {
|
|
return new LinkPreview;
|
|
}
|
|
|
|
$existing = FileAssignment::query()->where('file_id', $rootId)->get()
|
|
->map(fn (FileAssignment $a): string => $a->assignable_type.':'.$a->assignable_id)
|
|
->all();
|
|
|
|
$clients = [];
|
|
$groups = [];
|
|
|
|
foreach ($moving as $assignment) {
|
|
if (in_array($assignment->assignable_type.':'.$assignment->assignable_id, $existing, true)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $assignment->assignable;
|
|
|
|
if ($target instanceof User) {
|
|
$clients[] = $target->name;
|
|
} elseif ($target instanceof Group) {
|
|
$groups[] = $target->name;
|
|
}
|
|
}
|
|
|
|
return new LinkPreview($clients, $groups);
|
|
}
|
|
|
|
/**
|
|
* Stamp version_root_id across the chain containing $anyFileIdInChain.
|
|
*
|
|
* Idempotent, and the repair path for `versioning:check --repair`:
|
|
* it derives every root from previous_file_id alone, so a table whose
|
|
* version_root_id column has drifted is fixed by running it.
|
|
*/
|
|
public function recomputeRoots(?int $anyFileIdInChain): void
|
|
{
|
|
if ($anyFileIdInChain === null) {
|
|
return;
|
|
}
|
|
|
|
$member = File::withTrashed()->find($anyFileIdInChain);
|
|
|
|
if ($member === null) {
|
|
return;
|
|
}
|
|
|
|
$head = $this->walkBack($member)->last();
|
|
assert($head instanceof File);
|
|
|
|
File::withTrashed()->whereKey($head->id)->update(['version_root_id' => null]);
|
|
|
|
foreach ($this->walkForward($head) as $successor) {
|
|
File::withTrashed()->whereKey($successor->id)->update(['version_root_id' => $head->id]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Repair the chain around a file being deleted. Called from
|
|
* File::booted(); not user-initiated, so it authorizes nothing.
|
|
*
|
|
* Heals rather than breaks: deleting v2 out of v1 <- v2 <- v3 leaves
|
|
* v1 <- v3, which is true — v3 does revise v1. The alternative (null
|
|
* out the successor's pointer) would silently amputate a history that
|
|
* is still accurate.
|
|
*
|
|
* Two things make this load-bearing rather than tidying:
|
|
*
|
|
* - A trashed row still occupies its original's unique
|
|
* previous_file_id slot. Without this, deleting a revision means the
|
|
* original can never be revised again, and the error message names a
|
|
* file the staffer can no longer see.
|
|
* - Deleting a ROOT would strand every revision below it with no
|
|
* recipients at all, since revisions hold none of their own. The
|
|
* promoted revision inherits the root's assignment rows here, inside
|
|
* the same transaction, before the row goes.
|
|
*/
|
|
public function detachOnDelete(File $file): void
|
|
{
|
|
$previousId = $file->previous_file_id;
|
|
|
|
$successor = File::withTrashed()->where('previous_file_id', $file->id)->first();
|
|
|
|
if ($previousId === null && $successor === null) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($file, $previousId, $successor): void {
|
|
// A root going means its successor becomes the new root and
|
|
// must take the recipients with it, or the whole chain below
|
|
// loses its audience. ($successor is non-null here: the
|
|
// both-null case already returned above.)
|
|
if ($previousId === null) {
|
|
$this->copyAssignmentsFrom($file->id, $successor);
|
|
}
|
|
|
|
// MUST come first: this frees $file's claim on $previousId's
|
|
// unique slot before $successor below tries to take it.
|
|
File::withTrashed()->whereKey($file->id)
|
|
->update(['previous_file_id' => null, 'version_root_id' => null]);
|
|
|
|
if ($successor !== null) {
|
|
// Literal ids, never a subquery over `files` — MySQL
|
|
// cannot reopen a table it is updating.
|
|
File::withTrashed()->whereKey($successor->id)
|
|
->update(['previous_file_id' => $previousId]);
|
|
|
|
$this->recomputeRoots($successor->id);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Every guard link() applies, in the order that produces the most
|
|
* useful message.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
private function guard(File $file, File $previous, User $actor): void
|
|
{
|
|
if ($previous->is($file)) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('A file cannot be a revision of itself.'),
|
|
]);
|
|
}
|
|
|
|
// Both ends, always. The subject alone is not enough: linking
|
|
// widens the ORIGINAL's audience, so reaching one is reaching both.
|
|
Gate::forUser($actor)->authorize('setVersion', $file);
|
|
Gate::forUser($actor)->authorize('setVersion', $previous);
|
|
|
|
// A client links at upload time, when the file has no recipients.
|
|
// Allowing it later would let them push their own file's audience
|
|
// onto an original — the mirror of the escalation setVersion
|
|
// blocks, so it is closed here rather than left to the UI.
|
|
if (! $actor->isStaff() && $file->assignments()->exists()) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('This file is already shared with other people, so it can\'t be marked as a new version. Ask your contact to do it for you.'),
|
|
]);
|
|
}
|
|
|
|
// Queried rather than read off $previous->nextVersion: a model
|
|
// instance caches a relation once loaded, so a caller reusing one
|
|
// across two link() calls would see a stale null here and get the
|
|
// race-condition message from the unique index instead of this
|
|
// one, which names the file in the way.
|
|
$existing = File::query()->where('previous_file_id', $previous->id)->first();
|
|
|
|
if ($existing !== null && ! $existing->is($file)) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('That file has already been revised by ":name". Remove that link first.', ['name' => $existing->name]),
|
|
]);
|
|
}
|
|
|
|
$this->guardAgainstCycle($file, $previous);
|
|
}
|
|
|
|
/**
|
|
* previous_file_id is unique and single-valued, so in-degree and
|
|
* out-degree are both at most one and every component of the graph is
|
|
* a path or a cycle. Pointing $file at $previous therefore closes a
|
|
* cycle exactly when $file is already reachable by walking back from
|
|
* $previous — which makes this walk exact, not a heuristic.
|
|
*
|
|
* A PHP loop rather than a recursive CTE on purpose: chains are a
|
|
* handful of rows, and this stays identical on the sqlite test
|
|
* database and on MySQL/Postgres.
|
|
*
|
|
* @throws ValidationException
|
|
*/
|
|
private function guardAgainstCycle(File $file, File $previous): void
|
|
{
|
|
$steps = 0;
|
|
|
|
foreach ($this->walkBack($previous) as $ancestor) {
|
|
if ($ancestor->is($file)) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('That file is already part of this file\'s version history.'),
|
|
]);
|
|
}
|
|
|
|
$steps++;
|
|
}
|
|
|
|
// $steps counts $previous and everything it revises, so the chain
|
|
// this link would produce is one longer.
|
|
if ($steps >= self::MAX_CHAIN) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('This version history is too long. Remove an older link first.'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* $file's own recipients become the root's, then stop being $file's.
|
|
*/
|
|
private function moveAssignmentsToRoot(File $file, int $rootId): void
|
|
{
|
|
$assignments = $file->assignments()->with('assignable')->get();
|
|
|
|
foreach ($assignments as $assignment) {
|
|
$target = $assignment->assignable;
|
|
|
|
if (! $target instanceof User && ! $target instanceof Group) {
|
|
continue;
|
|
}
|
|
|
|
$root = File::query()->find($rootId);
|
|
|
|
if ($root === null) {
|
|
continue;
|
|
}
|
|
|
|
// A target the root already holds gains nothing here, so it
|
|
// is skipped rather than handed to FileSharing::assign().
|
|
// That method's firstOrCreate makes the assignment row
|
|
// idempotent but not the three side effects under it, so such
|
|
// a target was told a file had been shared with it about a
|
|
// file it already had — on top of the file_new_version it
|
|
// gets from sharedAudience(), which is exactly the two
|
|
// notifications for one action link() resolves that audience
|
|
// early to avoid. copyAssignmentsFrom() below states the rule
|
|
// outright for its own case: nobody is gaining access, so the
|
|
// notification would be a lie.
|
|
$alreadyOnRoot = FileAssignment::query()
|
|
->where('file_id', $root->id)
|
|
->where('assignable_type', $target->getMorphClass())
|
|
->where('assignable_id', $target->getKey())
|
|
->exists();
|
|
|
|
if ($alreadyOnRoot) {
|
|
continue;
|
|
}
|
|
|
|
$this->sharing->assign($root, $target, $target->name);
|
|
}
|
|
|
|
$file->assignments()->delete();
|
|
}
|
|
|
|
/**
|
|
* Give $target its own copy of $sourceFileId's recipients — used when a
|
|
* file stops inheriting them (unlink) or when a promoted revision has
|
|
* to take over as root (detachOnDelete).
|
|
*
|
|
* A direct insert rather than FileSharing::assign(): nobody is gaining
|
|
* access here, the same people keep exactly what they had, so a
|
|
* "shared with you" notification would be a lie.
|
|
*/
|
|
private function copyAssignmentsFrom(int $sourceFileId, File $target): void
|
|
{
|
|
if ($sourceFileId === $target->id) {
|
|
return;
|
|
}
|
|
|
|
$rows = FileAssignment::query()->where('file_id', $sourceFileId)->get();
|
|
|
|
foreach ($rows as $row) {
|
|
FileAssignment::query()->firstOrCreate([
|
|
'file_id' => $target->id,
|
|
'assignable_type' => $row->assignable_type,
|
|
'assignable_id' => $row->assignable_id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* $file and every file it revises, nearest first.
|
|
*
|
|
* withTrashed() throughout: these walks maintain structure, and a
|
|
* trashed row still holds a pointer that has to be accounted for.
|
|
* Display paths use the relations instead, which hide trashed rows.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
private function walkBack(File $file): Collection
|
|
{
|
|
/** @var Collection<int, File> $chain */
|
|
$chain = new Collection([$file]);
|
|
$cursor = $file;
|
|
$steps = 0;
|
|
|
|
while ($cursor->previous_file_id !== null && ++$steps <= self::MAX_CHAIN) {
|
|
$cursor = File::withTrashed()->find($cursor->previous_file_id);
|
|
|
|
if ($cursor === null) {
|
|
break;
|
|
}
|
|
|
|
$chain->push($cursor);
|
|
}
|
|
|
|
return $chain;
|
|
}
|
|
|
|
/**
|
|
* Every file that revises $file, transitively, nearest first.
|
|
*
|
|
* @return Collection<int, File>
|
|
*/
|
|
private function walkForward(File $file): Collection
|
|
{
|
|
/** @var Collection<int, File> $chain */
|
|
$chain = new Collection;
|
|
$cursor = $file;
|
|
$steps = 0;
|
|
|
|
while (++$steps <= self::MAX_CHAIN) {
|
|
$next = File::withTrashed()->where('previous_file_id', $cursor->id)->first();
|
|
|
|
if ($next === null) {
|
|
break;
|
|
}
|
|
|
|
$chain->push($next);
|
|
$cursor = $next;
|
|
}
|
|
|
|
return $chain;
|
|
}
|
|
}
|