Files
projectsend/app/Modules/Files/FilesServiceProvider.php
T
ignacionelson 0a8b609e8b Build a scoped staff member's library query once per request, not once per row
#1698 moved the library boundary into FileCommentPolicy, where it
belongs, and said plainly what that cost: the moderation screen went
from 65 queries to 465 for a client-scoped moderator with five assigned
clients. Measured here, those numbers are exactly right.

The cost is not in asking. It is that StaffLibraryScope::files() rebuilds
its query every time, and building one runs four immediate lookups per
assigned client — the client's group ids, the same ids again inside
Folder::sharedFolderIds(), that method's own assignment lookup, and the
shared-folder get() in Folder::scopeVisibleToClient(). None of them
depend on the query being built. Gate resolves a fresh policy for every
check, so a listing paid for all of it once per row.

The built query is now memoised per user and handed back as a clone,
since every caller adds to it, and the scope is registered as `scoped`
rather than transient so the memo survives a request. Scoped rather than
a singleton on purpose: a long-lived queue worker keeps singletons
between jobs, and a library query built from one job's data has no
business answering the next one's question.

That is 465 queries down to 60 on the same page — below the 65 it cost
before #1698, because the memo also helps the callers that were already
asking repeatedly. FileVersions::sharedAudience(), which runs the same
helper twice per candidate while resolving notification recipients, gets
it for free.

So the answer to the question #1698 left open is neither of the two it
offered. can_delete stays a real question asked of the policy; nothing
restates the boundary; and the page is faster than it was before the
fix. Three tests: one user's query never answers another's, one caller's
constraints never follow the next, and the moderation screen does not
ask once per row.
2026-08-26 13:30:50 -03:00

106 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Notifications\FileShareDigestNotification;
use App\Modules\Files\Notifications\FileSharedNotification;
use App\Modules\Files\Notifications\NewVersionAvailableNotification;
use App\Modules\Files\Notifications\NewVersionDigestNotification;
use App\Modules\Files\Thumbnails\Events\ImageRenderingChanged;
use App\Modules\Files\Thumbnails\RenderedImageCache;
use App\Modules\Notifications\NotificationTypeDefinition;
use App\Modules\Notifications\NotificationTypeRegistry;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class FilesServiceProvider extends ServiceProvider
{
public function register(): void
{
// Scoped rather than transient: the library query it builds costs
// several lookups per assigned client, and the policies ask for it
// once per row on a listing — Gate resolves a fresh policy for
// every check, so without this the instance memo would never be
// reached twice. Scoped rather than a singleton so a long-lived
// queue worker starts each job with an empty memo.
$this->app->scoped(StaffLibraryScope::class);
}
public function boot(): void
{
Gate::policy(File::class, FilePolicy::class);
Gate::policy(Folder::class, FolderPolicy::class);
// Cached renditions are written once and never revisited, so
// whoever changes how they render has to say so — otherwise the
// change is invisible on every file anyone has already looked at.
// See ImageRenderingChanged for why it carries no payload.
Event::listen(ImageRenderingChanged::class, function (): void {
$this->app->make(RenderedImageCache::class)->flush();
});
// Emailed by the debounced digest rather than by Notifier, so a
// burst of shares is one message — hence digestMail rather than
// mailNotification; setting both would send twice. Defaulted on,
// which is what it has always done, but it is now a type the
// recipient can switch off for themselves: before this it emailed
// through a pipeline the preference screen could not see.
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
key: 'file_shared',
label: 'A file or folder was shared with you',
template: ':itemName was shared with you',
defaultEmailEnabled: true,
digestMail: FileSharedNotification::class,
digestMailMany: FileShareDigestNotification::class,
));
// In-app only, same reasoning as file_shared above — email for
// this event is already sent separately, to whatever raw
// addresses Setting::AdminNotificationEmails lists (which may
// not even correspond to real staff accounts), via
// AdminClientUploadedNotification. Routing it through Notifier's
// mail dispatch too would risk double-emailing any staff member
// who happens to also be in that address list.
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
key: 'client_uploaded',
label: 'A client uploaded a file',
template: ':clientName uploaded ":itemName"',
url: fn (array $data) => route('files.edit', $data['fileId']),
));
// Digested rather than sent one-per-event, same reasoning as
// file_shared: staff re-issuing a whole set of drawings at once is
// the ordinary case for this type, not the exception.
//
// Its recipients are the people who could already see BOTH files
// before the link was made — resolved in FileVersions::link(), and
// resolved there BEFORE the assignment merge, so that anyone newly
// reached by the merge gets file_shared and not both.
$this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition(
key: 'file_new_version',
label: 'A newer version of a file I have is available',
template: 'A newer version of ":previousName" is available: :itemName',
defaultEmailEnabled: true,
digestMail: NewVersionAvailableNotification::class,
digestMailMany: NewVersionDigestNotification::class,
url: fn (array $data): string => route('my-files.index'),
));
if ($this->app->runningInConsole()) {
$this->commands([
Console\PurgeStaleUploadsCommand::class,
Console\PurgeZipDownloadsCommand::class,
Console\PurgeExpiredFilesCommand::class,
Console\PurgeOrphanFilesCommand::class,
Console\CheckFileVersionsCommand::class,
]);
}
}
}