Files
projectsend/app/Modules/Clients/Http/Controllers/ClientsController.php
T
ignacionelson 7c7ba7cd53 Stop a typed-in storage quota from 500ing when a client is created
Filling the "Storage quota (MB)" field on the new-client form raised a
TypeError and the request died with a 500. Leaving it blank worked, which
is why it reached a release: that path goes through `null ?? 0`, and the
0 is an int.

The `integer` validation rule checks that a value looks like an integer.
It does not convert it. `$request->validate()` returns the raw input, so
the form field arrives as the string "2048" -- and the create form types
that field as a string in React, so it is a string even over JSON. Both
controllers declare strict_types, so handing it to
`ClientAccounts::create()`'s `int $storageQuotaMb` is a TypeError.

Fixed on both surfaces that call create(): the staff screen and
/api/v1/clients. The API twin had the same defect, reachable by sending
the quota as a quoted JSON value or a form-encoded body -- its own create
test only ever sent a JSON number.

Two more call sites had the same shape and are cast too, though nothing
sends them a string today: the share-link download cap and a comment's
reply_to. Both are safe only because a frontend file happens to call
Number() first, which is a fact about that file rather than anything the
signature guarantees. The null in each is preserved rather than collapsed
to 0 -- "no cap" is not a cap of zero.

`storage_quota_mb` is also cast on User and Invitation. The column is an
unsignedInteger and both docblocks already promise int; it is read
straight into provision()'s typed parameter when an invitation is
redeemed, and which type a driver hands back is not something that call
site should depend on.

Found on the new files-test rehearsal instance, on its first real use,
against the same build the whole fleet is running.
2026-09-12 21:16:49 -03:00

423 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
/**
* Client management — the recipients files are shared with. Available
* in BOTH editions (clients are never portal-provisioned seats).
* Strictly clients: staff accounts 404 here.
*/
class ClientsController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
) {}
public function index(Request $request): Response
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in(['active', 'inactive'])],
]);
$filters = [
'search' => $validated['search'] ?? null,
'status' => $validated['status'] ?? null,
];
// Narrowed by the same rule the buttons on each row are guarded
// with. A client-scoped staff member is not shown the name and
// email of somebody they can reach nothing of — the same thing
// MembershipRequest::approvableBy does for its queue.
$viewer = $request->user();
assert($viewer !== null);
$clients = $this->scope->clients($viewer)
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")))
->when($filters['status'], fn (Builder $query, string $status) => $query->where('active', $status === 'active'))
->orderBy('name')
->paginate(25)
->withQueryString();
$content = $this->accountContent->summarizeMany($clients->pluck('id'));
$clients->through(fn (User $client): array => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
'active' => $client->active,
'account_requested' => $client->account_requested,
'created_at' => $client->created_at?->toIso8601String(),
'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0],
]);
return Inertia::render('clients/index', [
'clients' => $clients->items(),
'pagination' => Pagination::meta($clients),
'filters' => $filters,
// Only for somebody who may actually reassign: the picker is
// part of the delete dialog, and React filtering it out of the
// page is not the same as it never being on the page.
'reassign_candidates' => $viewer->can('delete_clients')
? $this->accountDeletion->candidates($viewer)
: [],
// Null on a self-hosted install: no limit, nothing to say.
'seats' => $this->seats->clientState(),
]);
}
public function create(): RedirectResponse|Response
{
// The same courtesy UsersController::create() does: a full
// installation is an ordinary state on a managed plan, so say so
// before somebody fills in a form that cannot be submitted. The
// guard in store() is still the rule; this is only the door.
$seats = $this->seats->clientState();
if ($seats !== null && $seats['full']) {
return redirect()->route('clients.index')->with('error', $seats['message']);
}
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
// The resolved default, not the raw setting: a platform can put
// a floor under it from the environment, and both screens
// present this as what will actually happen rather than as a
// value being edited. The edit screen mirrors quotaMb()'s
// resolution client-side to draw the usage bar, and handing it
// the effective number is what keeps that mirror correct
// without it having to know floors exist.
//
// The Client settings form deliberately still reads the raw
// setting (ClientSettingsController): that field is edited and
// saved back, so prefilling it with a floor would write the
// platform's number into the setting as the administrator's own
// choice, where it would outlive the floor.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
'password' => ['required', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
// The seat guard, the type, the role, and the quota's "0 means
// inherit the site default" all live in ClientAccounts, shared
// with the API and the control plane. A client created here is
// approved by construction, so it counts against the cap
// immediately — unlike a self-registration awaiting a decision.
// The welcome waits until the custom fields are saved below.
$client = $this->clients->create(
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
// Cast, because `integer` validates without converting:
// $request->validate() hands back the raw input, so a form
// field arrives as the string "2048" and this file is
// strict_types. Filling the quota in was a 500; leaving it
// blank went through null ?? 0 as an int, which is why it
// survived to the fleet.
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
welcome: false,
);
$creator = $request->user();
assert($creator !== null);
// A client-scoped creator would otherwise lose the client they just
// made. guardTarget() answers 404 for anything off their roster, so
// the record they created is not theirs to open, and
// StaffLibraryScope::clients() leaves it out of their list as well —
// the client exists, is welcomed by email, and is invisible to the
// person who made it. Their own roster is where a client they
// created belongs; an unscoped creator has no roster to add to.
if ($creator->isClientScoped()) {
$creator->assignedClients()->attach($client->id);
}
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
// A role can hold create_clients without edit_clients, and the edit
// page this used to land on unconditionally answers such a role
// with a 403 — after the client was created, logged and welcomed.
// Fall back to the create form: it shares this route's own gate, so
// it is reachable by exactly whoever just created the record, and
// the success toast shows there.
$target = $creator->can('edit_clients')
? redirect()->route('clients.edit', $client)
: redirect()->route('clients.create');
return $target->with('success', __('Client created.'));
}
/**
* The one question every route binding a client has to ask.
*
* A permission is not a boundary: `edit_clients` says this staff
* member manages clients, not that they manage *this* one — the same
* rule ClientFilesController::index applies one route over. 404
* rather than 403, so a client outside the roster is not
* distinguishable from one that is not there.
*/
private function guardTarget(Request $request, User $client): void
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
}
public function edit(Request $request, User $client): Response
{
$this->guardTarget($request, $client);
return Inertia::render('clients/edit', [
'client' => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
'active' => $client->active,
'account_requested' => $client->account_requested,
'storage_quota_mb' => $client->storage_quota_mb,
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
],
// Resolved, not raw — see create() above.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
'custom_fields' => $this->customFieldDefinitions(),
'custom_field_values' => ClientCustomFieldValue::query()
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id'),
'content' => $this->accountContent->summarize($client),
'reassign_candidates' => $request->user()?->can('delete_clients') === true
? $this->accountDeletion->candidates($request->user(), $client->id)
: [],
]);
}
public function update(Request $request, User $client): RedirectResponse
{
$this->guardTarget($request, $client);
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($client->id)],
'active' => ['required', 'boolean'],
'password' => ['nullable', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
$wasActive = $client->active;
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
$client->fill([
'name' => $validated['name'],
'email' => $validated['email'],
'active' => $validated['active'],
// The edit form always submits this field — an empty value
// means the admin explicitly cleared it (ConvertEmptyStringsToNull
// turns it into null before validation), not "leave unchanged".
// 0 = inherit the site default, same as a brand-new client.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
]);
// Activating a pending account through the edit screen counts as
// approval and clears the request flag — which is the moment a
// seat is spent, so the cap is asked here for the same reason
// AccountRequestsController::approve() asks it one screen over.
// Inside the branch, not above it: an installation at its cap must
// still be able to rename a client it already has.
if ($client->account_requested && $validated['active']) {
$this->seats->guardClient('active');
$client->account_requested = false;
}
if ($passwordChanged) {
$client->password = $validated['password'];
}
$client->save();
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
$this->activity->log(Action::UserUpdated, subject: $client);
if ($wasActive && ! $client->active) {
$this->activity->log(Action::UserDeactivated, subject: $client);
} elseif (! $wasActive && $client->active) {
$this->activity->log(Action::UserActivated, subject: $client);
}
// Skip a no-op resubmit (same name/email/active, no new password).
if (($client->wasChanged(['name', 'email', 'active']) || $passwordChanged)
&& $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientAccountEditedNotification);
}
return back()->with('success', __('Client updated.'));
}
/**
* Remove this account's second factor, for the client who has lost
* their authenticator and their recovery codes.
*/
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): RedirectResponse
{
$this->guardTarget($request, $client);
$twoFactor->reset($client);
return back()->with('success', __('Two-factor authentication removed.'));
}
public function destroy(Request $request, User $client): RedirectResponse
{
$this->guardTarget($request, $client);
$validated = $this->accountDeletion->validate($request, $client);
// Soft-deleting the account and disposing of its files are two
// separate writes; keep them in one transaction so a failure in the
// second (e.g. the reassignment target deleted between validation
// and apply()'s findOrFail) cannot leave the account deleted with
// its content still pointing at it.
//
// The erasure stamp goes inside for the same reason: a deletion
// that rolls back must not leave a live account carrying a date
// on which it would be erased.
DB::transaction(function () use ($validated, $client): void {
$name = $client->name;
$this->erasure->apply($client);
$client->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->accountDeletion->apply($validated, $client, $name);
});
return redirect()->route('clients.index')->with('success', __('Client deleted.'));
}
/**
* @return list<array<string, mixed>>
*/
private function customFieldDefinitions(): array
{
return array_values(ClientCustomField::query()
->orderBy('sort_order')
->orderBy('id')
->get()
->map(fn (ClientCustomField $field): array => [
'id' => $field->id,
'label' => $field->label,
'type' => $field->type->value,
'options' => $field->options,
'required' => $field->required,
])
->all());
}
/**
* @return array<string, array<int, mixed>>
*/
private function customFieldRules(): array
{
$rules = [];
foreach (ClientCustomField::query()->get() as $field) {
$key = "custom_field_values.{$field->id}";
// Checkboxes are never hard-required here — "required" only
// drives the asterisk shown on the form, not a forced check.
if ($field->type === ClientCustomFieldType::Checkbox) {
$rules[$key] = ['nullable', 'boolean'];
continue;
}
$rules[$key] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) {
$rules[$key][] = Rule::in($field->options);
}
}
return $rules;
}
/**
* @param array<int, mixed> $values field id => submitted value
*/
private function saveCustomFieldValues(User $client, array $values): void
{
foreach (ClientCustomField::query()->get() as $field) {
$submitted = $values[$field->id] ?? null;
$value = $field->type === ClientCustomFieldType::Checkbox
? ($submitted ? '1' : '0')
: (is_string($submitted) ? $submitted : null);
ClientCustomFieldValue::query()->updateOrCreate(
['client_custom_field_id' => $field->id, 'user_id' => $client->id],
['value' => $value === '' ? null : $value],
);
}
}
}