mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-20 18:43:20 +00:00
51035994ae
Reported by Ricardo Cazati. A client with every file permission could mark their own file public — and was then shown nothing. The screen said "anyone with the link will be able to open and download it" while the only route that makes a link was staff-only, so the link existed for nobody. Version 1 could do this. Making one now asks `upload_public`, the same key that lets them mark the file public, and `update` on the file, which for a client means one they uploaded and nothing else. Staff are not asked for the key, as they never have been: `update` is their boundary and asking now would be a new refusal on every installation that upgrades. Revoking deliberately does not ask for the publishing key. It takes access away, and somebody whose permission to publish was withdrawn must still be able to undo what they published. The portal's file editor grows the section the staff screen has, minus what a client has no business setting: the link, a copy button, and revoke.
136 lines
5.9 KiB
PHP
136 lines
5.9 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);
|
|
|
|
$user = $request->user();
|
|
assert($user !== null);
|
|
|
|
// Making a link is publishing, so it asks the publishing key. Staff
|
|
// are not asked for it, as they never have been: `update` on the
|
|
// file is their boundary and this would be a new refusal on every
|
|
// installation that upgraded.
|
|
abort_unless($user->isStaff() || $user->can('upload_public'), 403);
|
|
|
|
$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', 'string', '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);
|
|
|
|
// Deliberately without the publishing key that store() asks for:
|
|
// revoking takes access away. Somebody whose permission to publish
|
|
// was withdrawn must still be able to undo what they published.
|
|
Gate::authorize('update', $file);
|
|
|
|
$shareLink->delete();
|
|
|
|
$this->activity->log(Action::ShareLinkRevoked, subject: $file);
|
|
|
|
return back()->with('success', __('Public link revoked.'));
|
|
}
|
|
}
|