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

124 lines
5.3 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\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Sharing\CreateShareLink;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Public share links for a file — same "can share this" gate as
* assigning to a client or group (Gate::update), not a dedicated
* permission. The expiry/download-limit fields are separately gated by
* the set_file_expiration_date/limit_downloads permissions: without
* them the field is simply absent, not a 403.
*/
class ShareLinksController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly TimezoneRegistry $timezones,
private readonly CreateShareLink $links,
) {}
public function store(Request $request, File $file): RedirectResponse
{
Gate::authorize('update', $file);
$validated = $request->validate([
// Deliberately not `after:now`: that rule reads the bare
// YYYY-MM-DD the picker posts as midnight UTC, so a creator
// far enough east would be told today's date is in the past
// while it is plainly still today where they are. The check
// moves below, onto the instant the date actually resolves to.
'expires_at' => ['nullable', 'date'],
'max_downloads' => ['nullable', 'integer', 'min:1'],
// A custom token is optional — leave blank for a random one,
// same as before. Must not collide with the file's own
// public slug: the two live in different URL namespaces
// (/s/{token} vs the public group listing), but sharing the
// same string between them is confusing enough to reject.
// The token IS the authorization for /s/{token} — there is no
// second factor behind it — so it has to be long enough not to
// be guessable. 6 chars of [A-Za-z0-9_-] is ~36 bits, within
// reach of sustained guessing (the route's 30/min IP throttle
// is not a bound when the attacker has many IPs). Random tokens
// are Str::random(32); 12 is the floor for a chosen one.
'token' => ['nullable', 'string', 'min:12', 'max:64', 'regex:/^[A-Za-z0-9_-]+$/', Rule::unique('share_links', 'token')],
]);
if (($validated['token'] ?? null) !== null && $validated['token'] === $file->slug) {
throw ValidationException::withMessages([
'token' => __('This link cannot match the file\'s own public URL slug.'),
]);
}
$user = $request->user();
assert($user !== null);
// End of that day in the creator's own zone — a link "expiring on
// the 12th" stays usable through the 12th, which is what they
// will have told the recipient.
$expiresAt = ($validated['expires_at'] ?? null) === null
? null
: LocalDay::end($validated['expires_at'], $this->timezones->resolve($user));
if ($expiresAt !== null && $expiresAt->isPast()) {
throw ValidationException::withMessages([
'expires_at' => __('The expiry date must be in the future.'),
]);
}
// The permission gates stay here, where the request is: whether
// this person may set an expiry or a cap is a fact about them,
// not about link creation, and the action has no viewer to ask.
$this->links->for(
file: $file,
creator: $user,
expiresAt: $user->can('set_file_expiration_date') ? $expiresAt : null,
// Cast, and null kept as null rather than falling through a
// bare (int) that would turn "no cap" into a cap of zero. The
// `integer` rule validates a numeric string without converting
// it, and this file is strict_types, so an uncast "5" is a
// TypeError against `?int $maxDownloads`. Nothing sends one
// today only because files/edit.tsx calls Number() first --
// which is a fact about a frontend file, not a guarantee this
// signature has. It cost a 500 on the client form, where the
// same field was typed as a string.
maxDownloads: $user->can('limit_downloads') && ($validated['max_downloads'] ?? null) !== null
? (int) $validated['max_downloads']
: null,
token: $validated['token'] ?? null,
);
return back()->with('success', __('Public link created.'));
}
public function destroy(ShareLink $shareLink): RedirectResponse
{
$file = $shareLink->shareable;
abort_unless($file instanceof File, 404);
Gate::authorize('update', $file);
$shareLink->delete();
$this->activity->log(Action::ShareLinkRevoked, subject: $file);
return back()->with('success', __('Public link revoked.'));
}
}