mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-06 05:11:59 +00:00
2a6f77a02a
A staff member limited to some clients may only invite into the groups those clients are in, but the invitation list showed every invitation in the installation, names and addresses included, and revoking took back any pending one. Both now ask InvitationController::visibleTo(): the invitations they sent, and those into a group within their reach. Out of reach reads as 404. Unscoped staff are unaffected. GHSA-phv7-54fm-qh4r
249 lines
10 KiB
PHP
249 lines
10 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\ClientStorageUsage;
|
|
use App\Modules\Clients\Models\Invitation;
|
|
use App\Modules\Clients\Notifications\ClientInvitationNotification;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Groups\Models\Group;
|
|
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
|
use App\Modules\Platform\Seats\SeatAllowance;
|
|
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\Notification;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* Staff sending a client an invitation to register, ahead of the public
|
|
* form — the "New client" button's sibling for an installation that
|
|
* would rather have somebody set their own password than hand them one.
|
|
*/
|
|
class InvitationController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ActivityLogger $activity,
|
|
private readonly StaffLibraryScope $scope,
|
|
private readonly Settings $settings,
|
|
private readonly ClientStorageUsage $storageUsage,
|
|
private readonly SeatAllowance $seats,
|
|
) {}
|
|
|
|
/**
|
|
* Every state the status filter accepts. What each one means lives in
|
|
* applyStateFilter() alone: two of them narrow the same stored status
|
|
* by the clock, and a second copy of that rule is how the filter and
|
|
* the badge start disagreeing about a row whose expiry just passed.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const FILTERABLE_STATES = [
|
|
'pending',
|
|
'expired',
|
|
Invitation::STATUS_REDEEMED,
|
|
Invitation::STATUS_REVOKED,
|
|
Invitation::STATUS_SUPERSEDED,
|
|
];
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$validated = $request->validate([
|
|
'status' => ['nullable', 'string', Rule::in(self::FILTERABLE_STATES)],
|
|
]);
|
|
|
|
$status = $validated['status'] ?? null;
|
|
|
|
// Every invitation ever sent, not only the live ones. The list is a
|
|
// history: what was sent, what became of it, and who is still
|
|
// waiting. A screen that showed only what is outstanding cannot
|
|
// answer "did we ever invite this person", which is the question
|
|
// somebody actually arrives with.
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
|
|
$invitations = $this->visibleTo($viewer)
|
|
->when($status !== null, fn (Builder $query) => $this->applyStateFilter($query, (string) $status))
|
|
->with(['group:id,name', 'invitedBy:id,name'])
|
|
// Newest first, the order a history is read in. What is urgent
|
|
// rather than recent is reachable through the status filter,
|
|
// and the Expires column says the rest.
|
|
->orderByDesc('created_at')
|
|
->orderByDesc('id')
|
|
->paginate(25)
|
|
->withQueryString()
|
|
->through(fn (Invitation $invitation): array => [
|
|
'id' => $invitation->id,
|
|
'name' => $invitation->name,
|
|
'email' => $invitation->email,
|
|
'group' => $invitation->group?->name,
|
|
'invited_by' => $invitation->invitedBy?->name,
|
|
'created_at' => $invitation->created_at?->toIso8601String(),
|
|
'expires_at' => $invitation->expires_at->toIso8601String(),
|
|
// What the screen labels the row, and what the filter above
|
|
// selects on — one definition, so the badge and the filter
|
|
// cannot disagree about a row whose expiry just passed.
|
|
'state' => $invitation->state(),
|
|
]);
|
|
|
|
return Inertia::render('clients/invitations', [
|
|
'invitations' => $invitations->items(),
|
|
'pagination' => Pagination::meta($invitations),
|
|
'filters' => ['status' => $status],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The invitations this staff member may see and revoke.
|
|
*
|
|
* Everyone's, for most staff. For one limited to some clients, the same
|
|
* line create() and store() draw when they send one: the invitations
|
|
* they sent themselves, and those into a group one of their clients is
|
|
* in. Anything else would hand them other invitees' names and addresses
|
|
* and let them revoke invitations other people sent
|
|
* (GHSA-phv7-54fm-qh4r).
|
|
*
|
|
* @return Builder<Invitation>
|
|
*/
|
|
private function visibleTo(User $viewer): Builder
|
|
{
|
|
$query = Invitation::query();
|
|
|
|
if (! $viewer->isClientScoped()) {
|
|
return $query;
|
|
}
|
|
|
|
return $query->where(fn (Builder $mine) => $mine
|
|
->where('invited_by_id', $viewer->id)
|
|
->orWhereIn('group_id', $this->scope->groups($viewer)->select('groups.id')));
|
|
}
|
|
|
|
/**
|
|
* @param Builder<Invitation> $query
|
|
* @return Builder<Invitation>
|
|
*/
|
|
private function applyStateFilter(Builder $query, string $state): Builder
|
|
{
|
|
return match ($state) {
|
|
'pending' => $query->pending()->where('expires_at', '>=', now()),
|
|
'expired' => $query->pending()->where('expires_at', '<', now()),
|
|
default => $query->where('status', $state),
|
|
};
|
|
}
|
|
|
|
public function create(Request $request): Response
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer instanceof User);
|
|
|
|
return Inertia::render('clients/invite', [
|
|
// Scoped, exactly as GroupsController::index() is: a
|
|
// client-scoped staff member is told about a group because one
|
|
// of their clients is in it. Unscoped, this form listed every
|
|
// group on the installation to a viewer who can reach none of
|
|
// them — the hole GHSA-r3hg-3fxw-rcmr closed everywhere else,
|
|
// left open here because invitations were written after it.
|
|
'groups' => $this->scope->groups($viewer)->orderBy('name')->get(['id', 'name']),
|
|
// Resolved, not raw — see ClientsController::create()'s note on
|
|
// the same prop: this is what will actually happen, and the
|
|
// form's own field mirrors this resolution to draw its hint.
|
|
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer instanceof User);
|
|
|
|
$validated = $request->validate([
|
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
|
'name' => ['nullable', 'string', 'max:255'],
|
|
// Against the groups this person may actually put somebody in,
|
|
// not against every group there is: the list above is only what
|
|
// the form drew, and a request does not have to come from it.
|
|
'group_id' => ['required', 'integer', Rule::in([0, ...$this->scope->groups($viewer)->pluck('id')->all()])],
|
|
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
|
]);
|
|
|
|
// Asked here as well as at redemption. An outstanding invitation
|
|
// is not a client and is not counted as one — the same rule a
|
|
// pending account request follows — so this refuses sending a link
|
|
// a full installation could not honour, rather than reserving
|
|
// anything. The redemption door still guards, because the seat can
|
|
// be taken by somebody else in the days between.
|
|
$this->seats->guardClient();
|
|
|
|
$group = $validated['group_id'] > 0
|
|
? Group::query()->whereKey($validated['group_id'])->first()
|
|
: null;
|
|
|
|
$invitation = Invitation::issue(
|
|
email: $validated['email'],
|
|
name: $validated['name'] ?? null,
|
|
group: $group,
|
|
invitedBy: $request->user(),
|
|
expiresAt: now()->addHours((int) $this->settings->get(Setting::ClientInvitationExpiryHours)),
|
|
// The `integer` rule above validates the shape but does not
|
|
// cast it — this arrives as a numeric string from the request,
|
|
// same as group_id, and issue() takes a real int.
|
|
storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0),
|
|
);
|
|
|
|
Notification::route('mail', $invitation->email)->notify(
|
|
new ClientInvitationNotification($invitation->name ?? $invitation->email, $invitation->token),
|
|
);
|
|
|
|
$this->activity->log(Action::ClientInvited, context: ['email' => $invitation->email]);
|
|
|
|
return redirect()->route('invitations.index')->with('success', __('Invitation sent.'));
|
|
}
|
|
|
|
/**
|
|
* Cancels an invitation nobody has used yet.
|
|
*
|
|
* Until this existed, letting one expire was the only way to take it
|
|
* back — and the expired page's own "send me a new one" button undid
|
|
* that, silently, for anybody still holding the link. Revoking is the
|
|
* decision that button cannot reverse: STATUS_REVOKED is outside
|
|
* pending(), which is the scope both the redemption and the resend
|
|
* doors look through.
|
|
*
|
|
* The row is kept rather than deleted, for the reason
|
|
* Invitation::STATUS_SUPERSEDED is kept: the activity log names who
|
|
* invited this address and when, and that trail should still lead
|
|
* somewhere.
|
|
*/
|
|
public function destroy(Request $request, Invitation $invitation): RedirectResponse
|
|
{
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
|
|
// Out of reach reads as not there, like the rest of a scoped
|
|
// staff member's surfaces.
|
|
abort_unless($this->visibleTo($viewer)->whereKey($invitation->id)->exists(), 404);
|
|
|
|
// Already spent, already superseded, already revoked: there is
|
|
// nothing left to cancel, and saying so is better than reporting a
|
|
// success that changed nothing.
|
|
abort_unless($invitation->status === Invitation::STATUS_PENDING, 404);
|
|
|
|
$invitation->forceFill(['status' => Invitation::STATUS_REVOKED])->save();
|
|
|
|
$this->activity->log(Action::ClientInvitationRevoked, context: ['email' => $invitation->email]);
|
|
|
|
return back()->with('success', __('Invitation revoked.'));
|
|
}
|
|
}
|