Files
projectsend/app/Modules/Files/FilesServiceProvider.php
T
ignacionelson 616a355d54 Give each client a folder of their own, standing in for the root
A client who may create folders creates them at the top of the library,
beside the ones staff made, and their uploads land at the root too. An
administrator opening /files gets one flat pile with nothing saying which
parts belong to whom.

With the new "Give each client a folder of their own" setting, every new
client gets a folder named after them and it acts as their root: what they
upload and any folder they create goes inside it. /files becomes a list of
clients rather than a pile.

The sentence this feature has to keep true: **the home is a default
location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a
folder staff shared with a client still reaches them and sits beside their
own. Making the home a jail would have silently revoked every share that
already exists -- a data-access change wearing the clothes of a tidying-up
feature. There is a test named after that rule.

What the client sees is the *inside* of their folder, not a folder wearing
their own name, which is not information to them. The breadcrumb is trimmed
of it for the same reason: "Invoices", not "Acme Ltd / Invoices".

Some decisions worth naming:

- **A column, not a convention.** `folders.home_for_user_id`, unique.
  Matching on the name breaks the moment two clients share one, and
  `created_by` plus a null parent catches every root folder a client ever
  made themselves. The question is asked on each upload and each portal
  listing and the answer has to be exact.
- **created_by is the client**, because that is how scopeVisibleToClient
  already grants somebody their own folder -- no assignment row to keep in
  step with it. That is also why this writes the row rather than calling
  FolderService::create(), which takes created_by from auth()->id().
- **On model events**, not in the services that make and rename clients.
  There are nine of those (ClientAccounts, ClientProvisioning, the profile
  screen, two update endpoints, AccountConversion, invitations, LDAP,
  social) and a rule repeated in nine places is missing from the tenth.
- **Turning the setting on creates nothing.** Existing clients get a folder
  when an administrator presses a button that says how many are waiting,
  and it reports created/total/already-had afterwards. Somebody should be
  able to switch this on, look, and switch it off without having
  reorganised a library. It moves no files either.
- **Nobody deletes a home from a folder screen**, staff included, and the
  client cannot rename theirs -- they own it, so ownership alone would have
  let them, and its name follows the account anyway.
- **The name always follows the client**, over a hand-typed one. A folder
  still called "Acme Ltd" under an account now called something else
  misleads the administrator the feature exists for.

Verified in a real browser as well as in tests: the screen mounts, the
panel reads "24 of your existing clients have no folder yet", and pressing
the button answers "24 of 24 clients got a folder. 0 already had one."
2026-09-17 00:22:29 -03:00

213 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Models\User;
use App\Modules\Files\Events\FileBecameAvailable;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Files\Events\FileWasStored;
use App\Modules\Files\Listeners\AnnounceAvailableFile;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Jobs\ScanFileJob;
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\Scanning\ClamAvScanner;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\VirusScanner;
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);
// Same lifetime, same reason: the identity rule memoises a roster
// per viewer and the file listings ask it once per row.
$this->app->scoped(ClientIdentityScope::class);
// Scoped, so the settings screen and the scanner it resolves share
// one instance: that is what lets the Test button try the address
// being typed rather than the one on file. Scoped rather than a
// singleton so a queue worker starts each job with a clean one.
$this->app->scoped(ScanningConfig::class);
// One implementation ships, and the interface exists so the test
// suite can state a verdict instead of producing a file that
// provokes one — and so a commercial engine can be added later
// without touching the job or the policy.
$this->app->bind(VirusScanner::class, ClamAvScanner::class);
}
public function boot(): void
{
Gate::policy(File::class, FilePolicy::class);
Gate::policy(Folder::class, FolderPolicy::class);
$this->keepClientHomeFolders();
// 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'),
));
// Two audiences, two types, because they need different words and
// different links. Staff get a queue to act on; the person who
// uploaded gets told their file did not go through.
$registry = $this->app->make(NotificationTypeRegistry::class);
$registry->register(new NotificationTypeDefinition(
key: 'file_quarantined',
label: 'A file was quarantined by the virus scanner',
template: 'A virus was found in ":itemName", uploaded by :uploaderName',
url: fn (array $data): string => route('files.quarantine'),
));
$registry->register(new NotificationTypeDefinition(
key: 'upload_blocked',
label: 'One of your uploads was blocked',
template: 'Your file ":itemName" was blocked: :threat',
// Their own files list. Deliberately not the quarantine
// screen, which they cannot open.
url: fn (array $data): string => route('my-files.index'),
));
// The other half of holding an announcement back while a file is
// being checked — see FileSharing::assign and
// AnnounceAvailableFile.
Event::listen(FileBecameAvailable::class, AnnounceAvailableFile::class);
// Every upload path converges on FileWasStored, so this is the
// one place a scan is started from. Dispatched rather than run
// inline: a 5 GB file takes minutes to read, and an upload must
// not wait for it — the file is already withheld until the
// verdict arrives.
Event::listen(FileWasStored::class, function (FileWasStored $event): void {
if ($event->file->scan_status === ScanStatus::Pending) {
ScanFileJob::dispatch($event->file->id);
}
});
if ($this->app->runningInConsole()) {
$this->commands([
Console\ScanFilesCommand::class,
Console\CheckMissingFilesCommand::class,
Console\PurgeStaleUploadsCommand::class,
Console\PurgeZipDownloadsCommand::class,
Console\PurgeExpiredFilesCommand::class,
Console\PurgeOrphanFilesCommand::class,
Console\CheckFileVersionsCommand::class,
]);
}
}
/**
* Give a new client their home folder, and keep its name in step.
*
* On model events rather than in the handful of services that create
* and rename clients, because there are more of those than anyone
* remembers: ClientAccounts for the staff screens, the API and the
* control plane; ClientProvisioning for self-registration, LDAP,
* social sign-in and invitation redemption; the profile screen and two
* update endpoints for a rename; AccountConversion for a staff member
* becoming a client. A rule that had to be repeated in nine places
* would be missing from the tenth.
*
* Here rather than in User::booted() so the identity model does not
* have to know the files module exists -- the dependency points one
* way, and this is the end that cares.
*
* Both listeners are cheap when the feature is off: `created` asks the
* setting and returns, and `updated` asks whether the name actually
* changed before it asks anything else.
*/
private function keepClientHomeFolders(): void
{
User::created(function (User $user): void {
if ($user->isClient()) {
$this->app->make(ClientHomeFolders::class)->ensureFor($user);
}
});
User::updated(function (User $user): void {
// wasChanged, not isDirty: by `updated` the write has happened
// and isDirty is empty. A save that did not touch the name --
// which is most of them, every sign-in timestamp included --
// costs one array lookup and stops here.
if ($user->isClient() && $user->wasChanged('name')) {
$this->app->make(ClientHomeFolders::class)->syncName($user);
}
});
}
}