mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
0ae3f3d0f0
Sharing a file that is still being checked writes the assignment and says nothing. The announcement goes out when the file becomes available — a clean scan, a file let through while the scanner was down, or an administrator releasing it from quarantine — so nobody is ever sent to a page that refuses them, and a file about to be quarantined is not announced to everyone before anybody knows. Recipients are derived from the assignments as they stand at that moment, not remembered from the moment of sharing: a share taken back in the meantime produces no email, and one added does. New-version notices ride the same path, which they had to anyway — the audience rule re-checks visibility, and a file being scanned is not visible. Two bugs found while writing the tests, both in the hourly command: Re-queuing a file marked it pending first. Pending means withheld, so running --existing over a library that predates scanning would have hidden every file in it from every client for as long as the backfill ran, and then announced each one to its recipients a second time when it came back. The job now knows which state it expects instead, and a rescan leaves the file downloadable until a verdict actually arrives.
107 lines
4.0 KiB
PHP
107 lines
4.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Sharing;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Scanning\FileAvailability;
|
|
use App\Modules\Files\Models\FileAssignment;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Notifications\NotificationDigester;
|
|
use App\Modules\Notifications\Notifier;
|
|
|
|
/**
|
|
* What actually happens when a file is shared with a client or a group —
|
|
* the assignment row, the activity entry, the in-app notification and the
|
|
* debounced digest email, in that order.
|
|
*
|
|
* Extracted so the web controller and the API controller cannot answer the
|
|
* question differently. Sharing is not one insert: it is an insert plus
|
|
* three side effects, and the next side effect added here should not
|
|
* depend on someone remembering there are two callers. Same reasoning as
|
|
* StoreUploadedFile, which is the single seam for "a payload becomes a
|
|
* File".
|
|
*
|
|
* Authorization is the caller's job — both callers reach this after
|
|
* Gate::authorize('update', $file), and the target has already been
|
|
* resolved and scope-checked by ResolvesShareTargets.
|
|
*/
|
|
class FileSharing
|
|
{
|
|
public function __construct(
|
|
private readonly ActivityLogger $activity,
|
|
private readonly NotificationDigester $digester,
|
|
private readonly Notifier $notifier,
|
|
private readonly FileAvailability $availability,
|
|
) {}
|
|
|
|
/**
|
|
* Idempotent: assigning a file to the same target twice is a no-op for
|
|
* the row, which matters for an API caller retrying a request.
|
|
*/
|
|
public function assign(File $file, User|Group $assignable, string $targetName): void
|
|
{
|
|
FileAssignment::query()->firstOrCreate([
|
|
'file_id' => $file->id,
|
|
'assignable_type' => $assignable->getMorphClass(),
|
|
'assignable_id' => $assignable->getKey(),
|
|
]);
|
|
|
|
$this->activity->log(Action::FileAssigned, subject: $file, context: ['target' => $targetName]);
|
|
|
|
// Sharing itself is never held up — the assignment above is
|
|
// written, and the file is theirs the moment it can be had. What
|
|
// waits is the telling: a file still being checked for viruses
|
|
// cannot be downloaded, so an email now would send somebody to a
|
|
// page that refuses them, and a file about to be quarantined would
|
|
// have been announced to everyone before anybody knew. The
|
|
// announcement goes out from AnnounceAvailableFile instead, on the
|
|
// event that says the file can be handed over.
|
|
if (! $this->availability->isAvailable($file)) {
|
|
return;
|
|
}
|
|
|
|
$recipients = $this->recipients($assignable);
|
|
|
|
$this->notifier->send('file_shared', $recipients, subject: $file, data: ['itemName' => $file->name]);
|
|
|
|
// The master switch and each recipient's own preference are the
|
|
// digester's job now — every caller was repeating them.
|
|
$this->digester->queue('file_shared', $recipients, $file->name, ['is_folder' => false]);
|
|
}
|
|
|
|
/**
|
|
* @return bool whether an assignment was actually removed
|
|
*/
|
|
public function unassign(File $file, User|Group $assignable, string $targetName): bool
|
|
{
|
|
$deleted = FileAssignment::query()
|
|
->where('file_id', $file->id)
|
|
->where('assignable_type', $assignable->getMorphClass())
|
|
->where('assignable_id', $assignable->getKey())
|
|
->delete();
|
|
|
|
if ($deleted > 0) {
|
|
$this->activity->log(Action::FileUnassigned, subject: $file, context: ['target' => $targetName]);
|
|
}
|
|
|
|
return $deleted > 0;
|
|
}
|
|
|
|
/**
|
|
* Notifier performs no authorization of its own — see its SECURITY
|
|
* CONTRACT docblock — so the recipient list is resolved here, from the
|
|
* assignment itself.
|
|
*
|
|
* @return iterable<User>
|
|
*/
|
|
private function recipients(User|Group $assignable): iterable
|
|
{
|
|
return $assignable instanceof Group ? $assignable->members : [$assignable];
|
|
}
|
|
}
|