Files
projectsend/app/Modules/Files/Http/Controllers/MyFilesController.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

639 lines
31 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\OwnFileDownloads;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\ClientHomeFolders;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Sharing\ClientShareLinks;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Theming\PublicThemeRegistry;
use App\Support\ConcatenatedPagination;
use App\Support\Pagination;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* The client's browsable file view. Root shows loose directly-assigned
* files plus top-level shared folders; entering a shared folder shows
* its subfolders and files. Group and internal folder names never leak:
* a file assigned directly inside an unshared folder appears loose.
*
* index()'s props are consumed by every portal/themes/{key}/my-files.tsx
* — see docs/theming-files-checklist.md before changing this method's
* Inertia props or the behavior every theme is expected to preserve.
*/
class MyFilesController extends Controller
{
/**
* Folders and files share one page window — see FoldersController's
* equivalent constant/docblock for why (folders were previously
* fetched fully unbounded and repeated identically on every file
* page; this is the client-portal twin of that same fix).
*/
private const PER_PAGE = 25;
public function __construct(
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly UploadExtensionPolicy $extensionPolicy,
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
private readonly BreadcrumbBuilder $breadcrumbs,
private readonly ClientHomeFolders $homeFolders,
private readonly CommentingRules $commenting,
private readonly VisibleCommentScope $comments,
private readonly DownloadAllowance $allowance,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
private readonly ClientShareLinks $shareLinks,
private readonly OwnFileDownloads $ownDownloads,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
private readonly ActivityLogger $activity,
) {}
public function index(Request $request): Response|RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
'folder' => ['nullable', 'integer'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'owner' => ['nullable', Rule::in(['mine', 'shared'])],
'sort' => ['nullable', Rule::in(['name', 'size', 'date'])],
'direction' => ['nullable', Rule::in(['asc', 'desc'])],
]);
$search = trim($validated['search'] ?? '');
$searching = $search !== '';
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
$owner = $validated['owner'] ?? null;
$sort = $validated['sort'] ?? 'date';
$direction = $validated['direction'] ?? 'desc';
// Every folder the client may see: staff-shared subtrees plus any
// folder they created themselves, anywhere in that visible tree.
$visibleIds = array_values(Folder::query()->visibleToClient($client)->pluck('id')->map(fn ($id): int => (int) $id)->all());
// Where this installation gives clients a folder of their own, it
// stands in for the root: the client opens the portal and sees what
// is inside it, not a folder named after themselves that they have
// to click through. Their own name is not information to them.
//
// It does not replace what else they can see. Folders staff shared
// with them still sit alongside -- the home is where their own
// things live, not a boundary around them.
$home = $this->homeFolders->for($client);
$homeId = $home?->id;
// A search term, a category filter, or an owner filter all switch to
// a flat, global view across everything the client may see — same
// convention as the staff library (FoldersController) uses for
// search/category. Sorting never does: it only changes the order of
// whichever set (flat or folder-scoped) is already being shown.
$flat = $searching || $categoryId !== null || $owner !== null;
if ($flat) {
// `visibleToClient` is the single source of truth, so the
// privacy rule (never surfacing an unshared folder's name) holds:
// only shared folders match, and files are shown without folder
// context in the portal rows.
$current = null;
$folders = $searching
? Folder::query()->visibleToClient($client)->where('name', 'like', "%{$search}%")->orderBy('name')
: Folder::query()->whereRaw('1 = 0');
$filesQuery = File::query()->visibleToClient($client)
->when($searching, fn (Builder $q) => $q
->where(fn (Builder $w) => $w->where('name', 'like', "%{$search}%")->orWhere('original_name', 'like', "%{$search}%")));
} else {
// The folder being browsed must itself be visible to the client.
$current = null;
if ($request->integer('folder') > 0) {
$current = Folder::query()->visibleToClient($client)->find($request->integer('folder'));
abort_if($current === null, 404);
}
// Subfolders: at root, every visible folder whose parent isn't
// itself visible (top of each shared subtree, or a client-owned
// folder with no visible parent); inside a folder, the visible
// ones among its direct children.
//
// Both branches narrow to $visibleIds. Being handed a folder is
// not permission to read the names of everything filed inside
// it: a client-created folder is visible through created_by,
// which says nothing about a subfolder staff later put there.
if ($current === null) {
$folders = Folder::query()
->whereIn('id', $visibleIds)
->where(function ($q) use ($visibleIds, $homeId): void {
// The home's own children, standing in for the root's.
if ($homeId !== null) {
$q->where('parent_id', $homeId);
}
// Plus the top of every other subtree they can see,
// with the home itself removed -- it is the level
// they are looking at, not something inside it.
$q->{$homeId === null ? 'where' : 'orWhere'}(function ($outer) use ($visibleIds, $homeId): void {
$outer->where(fn ($inner) => $inner
->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds));
if ($homeId !== null) {
$outer->where('id', '!=', $homeId);
}
});
})
->orderBy('name');
} else {
$folders = Folder::query()
->whereIn('id', $visibleIds)
->where('parent_id', $current->id)
->orderBy('name');
}
// Files: inside a folder, that folder's files; at root, only
// loosely (directly/group) assigned files with no folder — the
// ones in an unshared folder (or a visible one, browsed via its
// own listing) show here with no folder context.
$filesQuery = File::query()->visibleToClient($client);
if ($current === null) {
$filesQuery->where(fn (Builder $q) => $q
->whereNull('folder_id')
->orWhereNotIn('folder_id', $visibleIds)
// Files sitting directly in the home belong to this
// level too, for the same reason its subfolders do.
->when($homeId !== null, fn (Builder $w) => $w->orWhere('folder_id', $homeId)));
} else {
$filesQuery->where('folder_id', $current->id);
}
}
$filesQuery
->when($categoryId !== null, fn (Builder $q) => $q
->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId)))
->when($owner === 'mine', fn (Builder $q) => $q->where('uploaded_by', $client->id))
->when($owner === 'shared', fn (Builder $q) => $q->where('uploaded_by', '!=', $client->id));
$column = match ($sort) {
'name' => 'name',
'size' => 'size',
default => 'created_at',
};
// The download counts a spent-limit badge needs, attached only
// when this installation uses limits at all — otherwise every
// portal listing would pay two correlated subqueries per row for
// a feature nobody here has turned on.
$files = $this->allowance->withCounts($filesQuery, $client)
->with('categories', 'folder')
->orderBy($column, $direction);
$page = Paginator::resolveCurrentPage();
// ConcatenatedPagination is model-agnostic — Larastan's Builder
// generic is invariant, so a heterogeneous array of builders
// needs an explicit widen/narrow at the call site (sound at
// runtime since each key's builder only ever queries its own
// model). Same pattern as FoldersController::index().
/** @var array<string, Builder<Model>> $sequences */
$sequences = ['folders' => $folders, 'files' => $files];
$sliced = ConcatenatedPagination::slice(
$sequences,
$page,
self::PER_PAGE,
['path' => $request->url(), 'query' => $request->query()],
);
if (Pagination::isPastLastPage($sliced['paginator'], $page)) {
return redirect()->route('my-files.index', array_filter([
'search' => $search !== '' ? $search : null,
'folder' => $current?->id,
'category' => $categoryId,
'owner' => $owner,
'sort' => $sort !== 'date' ? $sort : null,
'direction' => $direction !== 'desc' ? $direction : null,
'page' => Pagination::redirectPage($sliced['paginator']),
]));
}
/** @var Collection<int, Folder> $folderRows */
$folderRows = $sliced['items']['folders'];
/** @var Collection<int, File> $fileRows */
$fileRows = $sliced['items']['files'];
$commentCounts = $this->comments->countsFor($client, $fileRows);
// Two queries for the page, not two per row. Still no URL resolver:
// the portal's per-file page is an *editor* for a client's own
// uploads, and a version counterpart is frequently neither theirs
// nor editable — so a counterpart stays named and not linked (see
// docs/theming-files-checklist.md).
$versions = $this->versionLinks->forMany($fileRows, $client);
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
// One query for the page. Already narrowed to links this client
// minted on files this client uploaded — see ClientShareLinks for
// why both halves are required.
$shareUrls = $this->shareLinks->forMany($fileRows, $client);
// Also one query for the page, and also own files only — see
// OwnFileDownloads for why telling a recipient the count would be
// telling them about the other recipients.
$downloads = $this->ownDownloads->forMany($fileRows, $client);
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
// Trimmed of the home, which is the root here and so is not a
// step in the trail -- a client browsing their own subfolder
// should see "Invoices", not "Acme Ltd / Invoices".
'breadcrumb' => $flat ? [] : $this->trimHome($this->breadcrumbs->visible($current, $visibleIds), $home),
'folders' => $folderRows->map(fn (Folder $folder): array => [
'id' => $folder->id,
'name' => $folder->name,
'is_mine' => $folder->created_by === $client->id,
'public' => $folder->isEffectivelyPublic(),
'can_update' => Gate::forUser($client)->allows('update', $folder),
'can_delete' => Gate::forUser($client)->allows('delete', $folder),
])->values()->all(),
// Comment counts for the page's rows, resolved in two queries
// for the whole page rather than one per row — see
// VisibleCommentScope::countsFor.
'comments_enabled' => $this->commenting->enabled(),
'files' => $fileRows->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'description' => $file->description,
'original_name' => $file->original_name,
'mime_type' => $file->mime_type,
'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(),
'is_mine' => $file->uploaded_by === $client->id,
// Decided per row by FilePolicy, exactly as the folder rows
// above are: a client's own uploads are theirs to manage
// and files shared with them are not, and both kinds sit in
// the same list. A theme reads these and never works them
// out from is_mine — holding the file is only half of it,
// the role's keys are the other half.
'can_update' => Gate::forUser($client)->allows('update', $file),
'can_delete' => Gate::forUser($client)->allows('delete', $file),
// Effective status (own flag or inherited from a public
// folder) — same "will visitors on the public site see
// this" badge as the staff library shows.
'public' => $file->isEffectivelyPublic(),
'comments_count' => $commentCounts[$file->id] ?? 0,
'unread_comments_count' => $unreadComments[$file->id] ?? 0,
// Already narrowed to what this client may be told: a
// counterpart they were not given is null, not hidden by
// the theme. A theme must never filter this itself.
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
// The public URL for a file of their own, where one
// exists. Null on a file somebody shared with them, and
// null on their own file that has no link — a client has
// no way to mint one, so this is populated only where the
// installation did it for them. Never derived from
// is_mine: a theme renders what is here and nothing else.
'share_url' => $shareUrls[$file->id] ?? null,
// How often this went out and when it last did — the
// answer to "did it arrive?", which on a link-only
// account is the only evidence there is. Null on a file
// somebody shared with this client: not zero, which would
// be a claim about other people's activity, but nothing.
'downloads' => $downloads[$file->id] ?? null,
'categories' => $file->categories->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->values()->all(),
// Already decided — see DownloadAllowance::summaryFor.
// `blocked` means this client may no longer take a copy;
// the row still shows, which is the whole difference from
// an expired file.
'download_limit' => $this->allowance->summaryFor($file, $client),
])->values()->all(),
'pagination' => Pagination::meta($sliced['paginator']),
'search' => $search,
'searching' => $flat,
'category' => $categoryId,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
'owner' => $owner,
'sort' => $sort,
'direction' => $direction,
'can_upload' => $client->can('upload'),
'can_upload_here' => Folder::uploadableBy($client, $current),
'can_create_folders' => $client->can('create_own_folders'),
// Whether a row is clickable to look at rather than only to
// take. Per page rather than per file: the mime type decides
// which files can be previewed and every theme already knows
// how to read one, so all this has to carry is whether the
// installation offers it here at all. See
// FileThumbnailController::preview, which re-checks it.
'preview_enabled' => $this->settings->get(Setting::ClientsCanPreviewFiles),
]);
}
public function upload(Request $request): Response
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
abort_unless($client->can('upload'), 403);
$folder = null;
if ($request->integer('folder') > 0) {
$folder = Folder::query()->visibleToClient($client)->find($request->integer('folder'));
abort_if($folder === null, 404);
abort_unless(Folder::uploadableBy($client, $folder), 403);
}
return Inertia::render('portal/upload', [
'allowed_extensions' => $this->extensionPolicy->hintFor($client),
'max_file_size_mb' => (int) $this->settings->get(Setting::MaxFileSizeMb),
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
'remaining_bytes' => $this->storageUsage->remainingBytes($client),
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
'used_bytes' => $this->storageUsage->usedBytes($client),
'theme' => $this->themeKey(),
'folder' => $folder === null ? null : ['id' => $folder->id, 'name' => $folder->name],
// Upload time is the only place a client can set this: there is
// no per-file editor in the portal, and none is being added.
'version_candidates_url' => route('my-files.version-candidates', [], false),
]);
}
/**
* The editor page for a file this client uploaded.
*
* One page for every theme, not one per theme — the same shape
* `upload()` uses, and for the same reason: this is a form, and a form
* rebuilt four times is four places for a field to go missing. The
* `theme` prop picks the shell (see portal/edit-file.tsx), which is the
* only part that differs.
*
* Every `can_*` prop below is the *same* question ApplyFileEdits will
* ask when the form posts. A control this page hides is not a control
* the server then trusts: hiding it is a courtesy so a client is not
* shown a switch that will silently do nothing, and the refusal is
* server-side either way.
*/
public function edit(Request $request, File $file): Response
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('update', $file);
$file->loadMissing('categories');
return Inertia::render('portal/edit-file', [
'theme' => $this->themeKey(),
'file' => [
'id' => $file->id,
'name' => $file->name,
'description' => $file->description,
'original_name' => $file->original_name,
'size' => $file->size,
'public' => $file->public,
'commentable' => $file->commentable,
// The stored instant as the calendar day this client's own
// zone shows — the value the form posts back untouched, and
// the one update() compares against to tell a real change
// from a date that merely came along with a rename.
'expires_at' => $this->expiry->asShown($file, $client),
'download_limit' => $file->download_limit,
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
'folder_id' => $file->folder_id,
'categories' => $file->categories->pluck('id')->all(),
],
'can_delete' => Gate::forUser($client)->allows('delete', $file),
'can_publish' => $client->can('upload_public'),
'can_set_expiration' => $client->can('set_file_expiration_date'),
'can_set_categories' => $client->can('set_file_categories'),
'can_limit_downloads' => $client->can('limit_downloads'),
// Only while the installation asks per file; otherwise the
// setting decides and the switch would be a lie.
'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->all(),
// Somewhere this client could have uploaded it in the first
// place — the same rule update() enforces, so the picker cannot
// offer a destination the save would refuse.
'folders' => Folder::query()->visibleToClient($client)->orderBy('name')->get()
->filter(fn (Folder $folder): bool => Folder::uploadableBy($client, $folder))
->map(fn (Folder $folder): array => [
'id' => $folder->id,
'name' => $folder->name,
// A destination can publish the file without the public
// switch being touched: File::isEffectivelyPublic() is
// "my own flag OR my folder's", and a client holding
// upload_to_public_folders may move into a public
// folder without holding upload_public. That is the
// established meaning of the two keys, and it is what
// uploading there has always done — but in a picker of
// bare names it would be invisible, so the name carries
// the consequence with it.
'public' => $folder->isEffectivelyPublic(),
])
->values()->all(),
// Public files are reachable at the installation's one public
// slug; without it configured, publishing shows nowhere and the
// page says so rather than offering a switch that does nothing
// visible.
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
]);
}
/**
* Edit a file this client uploaded.
*
* The client portal's counterpart to the staff file editor, and
* deliberately a separate route rather than the staff one opened up:
* `files.*` renders assignments, share links, activity and download
* history, which are staff surfaces, and its folder guard asks
* StaffLibraryScope — which answers "allowed" for every client (see
* FilePolicy::update()).
*
* Who may edit at all is FilePolicy: the file must be this client's own
* upload and they must hold `edit_files`. Which *fields* they may
* write is ApplyFileEdits, the same decision the staff editor and the
* API get, so a client holding `set_file_categories` but not
* `upload_public` gets exactly what those keys say and nothing is
* decided twice.
*/
public function update(Request $request, File $file): RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('update', $file);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => Rules::folderId(),
'public' => ['sometimes', 'boolean'],
'commentable' => ['sometimes', 'boolean'],
'categories' => ['array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['nullable', 'string', 'date'],
'download_limit' => ['nullable', 'integer', 'min:1'],
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
// No `slug`, on purpose, and its absence is what makes
// ApplyFileEdits derive one from the name. An installation-wide
// unique slug that a client picks is a name to squat and an
// existence oracle to probe against every file on the
// installation, for nothing a derived slug does not already give
// them.
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
// The client rule, not the staff one: somewhere they could have
// uploaded it in the first place. Same check the upload path makes,
// so moving a file cannot reach a folder that uploading it could
// not. Only when the folder actually changes, so re-saving a file
// that already sits somewhere unusual still works.
if ($folderId !== null && $folderId !== $file->folder_id) {
$folder = Folder::query()->visibleToClient($client)->find($folderId);
abort_unless($folder !== null && Folder::uploadableBy($client, $folder), 403);
}
$changes = [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'folder_id' => $folderId,
'commentable' => $validated['commentable'] ?? $file->commentable,
'download_limit' => $validated['download_limit'] ?? null,
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
'public' => $validated['public'] ?? $file->public,
'categories' => $validated['categories'] ?? [],
];
// Only when the date actually moved — the form posts back what it
// was rendered with, and re-deriving it on every save would shift
// the expiry by a timezone difference each time somebody renamed
// the file. See FileExpiry.
$posted = $validated['expires_at'] ?? null;
if ($posted !== $this->expiry->asShown($file, $client)) {
$changes['expires_at'] = $this->expiry->instant($posted, $client);
}
$this->fileEdits->apply($client, $file, $changes);
return back()->with('success', __('File updated.'));
}
/**
* Delete a file this client uploaded.
*
* Their own upload and `delete_files`, both settled by
* FilePolicy::delete(). A file merely shared with them is not theirs to
* remove, and no permission changes that.
*
* The row is soft-deleted and the bytes are not: File::booted()'s
* `deleted` hook removes the upload and every cached rendition on
* commit, so the client's storage quota — which sums untrashed rows —
* frees up by exactly what the disk does.
*/
public function destroy(Request $request, File $file): RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('delete', $file);
$name = $file->name;
$file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
return redirect()->route('my-files.index')->with('success', __('File deleted.'));
}
/**
* Files this client may name as the previous version of what they are
* uploading — THEIR OWN UPLOADS ONLY.
*
* Not the files visible to them: a revision inherits the recipients of
* the file it revises, so offering a file staff shared with them would
* be offering a way to publish their upload to a recipient list they do
* not own. FilePolicy::setVersion enforces the same rule server-side
* when the upload completes; this only keeps the picker honest.
*/
public function versionCandidates(Request $request): JsonResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
abort_unless($client->can('upload'), 403);
$candidates = $this->versions
->candidates(null, $client, trim((string) $request->query('search', '')))
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'original_name' => $file->original_name,
])->values();
return response()->json(['files' => $candidates]);
}
private function themeKey(): string
{
$value = $this->settings->get(Setting::Theme);
return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities);
}
/**
* Drop the home folder from the front of a breadcrumb.
*
* Only from the front, and only when it is actually there: a folder
* shared with the client from elsewhere in the library has a trail of
* its own that the home has nothing to do with.
*
* @param list<array{id: int, name: string}> $trail
* @return list<array{id: int, name: string}>
*/
private function trimHome(array $trail, ?Folder $home): array
{
if ($home === null || $trail === [] || $trail[0]['id'] !== $home->id) {
return $trail;
}
return array_slice($trail, 1);
}
}