Files
projectsend/app/Http/Middleware/HandleInertiaRequests.php
ignacionelson b128b114b5 Make an announcement say who it is for
The first version refused clients outright. That was right for the only
message that existed — a hosted instance telling its administrator about
their plan — and it stopped being right the moment a message needed to
reach the *clients* of a shared instance, where the administrator is the
operator and the customers are client accounts.

The unsafe fix would have been to drop the guard and let each listener
check `isStaff`. The safe one is to make every caller say who it is
talking to and have core enforce it: `show()` now takes a required
`audience` with no default, and a message aimed elsewhere is dropped
before it reaches the props. A listener that forgets therefore reaches
nobody rather than everybody, which is the direction a mistake should
fall.

An unrecognised audience reaches nobody either, and is ignored rather
than thrown — a listener aimed at the wrong people should show nothing,
not break the page it was decorating.

The old "a client is never shown one" test became "a message for staff
reaches no client, even from a listener that never checks", which is the
property that actually matters and the one the enforcement provides. Two
more pin the other directions: a client message reaches clients and no
staff, and an unknown audience reaches neither.

cloud-modules declares `staff` for the free-plan band, and its test fake
enforces the same rule, so a listener aimed at the wrong audience fails
in the package's own suite rather than passing there and misbehaving in
the host.
2026-09-08 15:48:29 -03:00

365 lines
15 KiB
PHP

<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Groups\Models\MembershipRequest;
use App\Modules\Identity\Passwords\PasswordPolicy;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\Social\SocialSettings;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\InAppNotification;
use App\Modules\Platform\Attribution\Attribution;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Files\Queue\StalledZipBuilds;
use App\Modules\Platform\Localization\LocaleRegistry;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\OfficialLinks;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
$capabilities = app(CapabilityRegistry::class);
return array_merge(parent::share($request), [
...parent::share($request),
'name' => app(Settings::class)->get(Setting::SiteName),
'quote' => ['message' => trim((string) $message), 'author' => trim((string) $author)],
'auth' => [
'user' => $request->user(),
'permissions' => ($user = $request->user()) !== null
? app(PermissionChecker::class)->grantedKeys($user)
: [],
],
'edition' => $capabilities->edition()->value,
'noindex' => app(Settings::class)->get(Setting::DiscourageSearchIndexing),
'version' => config('projectsend.version'),
// Resolved rather than handed over raw: each edition has its
// own front door, and a managed installation offers no
// donation link at all. See OfficialLinks.
'links' => app(OfficialLinks::class)->toArray(),
// Whether the client- and visitor-facing surfaces name
// ProjectSend. True everywhere unless a package answers
// otherwise — see ResolvingAttribution. Staff surfaces
// ignore this and always show it.
'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(),
// Sidebar entries a package asked for. Shared rather than
// passed per page because the sidebar is on every page, and
// dispatched unconditionally so that with nothing listening
// the list is empty and the sidebar is exactly what it was.
// See ResolvingNavigationLinks for why core never learns what
// is in it.
'extra_nav_links' => $this->extraNavLinks($request),
// Shared rather than a dashboard prop, because it is shown in
// two places — the band on the dashboard and the icon beside
// the notification bell everywhere else — and "the same
// message" is the requirement. Two props would drift the day
// somebody edited one.
'announcement' => $this->announcement($request),
// Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with
// nothing on it is worse than no entry.
'social_login' => SocialSettings::available(),
// Shared for the same reason: seven unrelated surfaces — three
// auth pages and the file page of each public theme — need the
// identical provider and site key. Null when nothing is
// configured, and never the secret.
'captcha' => app(Captcha::class)->forDisplay(),
// Shared for the same reason again: eight forms across the auth
// pages, the account settings and the staff/client editors all
// ask somebody to choose a password, and each has to be able to
// say what this installation will accept *before* the submit
// rather than only in the error afterwards.
'password_policy' => app(PasswordPolicy::class)->descriptor(),
'pending' => $this->pendingCounts($request),
'update_notice' => $this->updateNotice($request),
'code_notice' => $this->codeNotice($request),
'worker_notice' => $this->workerNotice($request),
'locale' => app()->getLocale(),
// The clock this viewer reads dates by, and whether it is a
// choice or a fallback. The frontend needs both: the first to
// format with, the second because a viewer still on the
// fallback is one whose browser we have not asked yet — see
// timezone-detector.tsx.
'timezone' => app(TimezoneRegistry::class)->resolve($request->user()),
'timezone_is_explicit' => $request->user()?->timezone !== null,
'locales' => app(LocaleRegistry::class)->enabled(),
'locales_disabled' => $this->disabledLocaleCount($request),
'translations' => $this->translations(app()->getLocale()),
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
]);
}
/**
* Pending-approval counts for sidebar badges, computed only for
* viewers holding the matching approval permission.
*
* @return array<string, int>
*/
protected function pendingCounts(Request $request): array
{
$user = $request->user();
if ($user === null) {
return [];
}
$checker = app(PermissionChecker::class);
$counts = [];
if ($checker->allows($user, Permission::ApproveAccountRequests)) {
$counts['account_requests'] = User::query()
->where('type', UserType::Client)
->where('account_requested', true)
->count();
}
if ($checker->allows($user, Permission::ApproveGroupsMembershipsRequests)) {
// Narrowed like the queue it badges, and by the same scope —
// a client-scoped staff member is not shown a number they
// cannot act on. Same rule the comments badge below states.
$counts['membership_requests'] = MembershipRequest::query()
->pending()
->whereHas('user')
->whereHas('group')
->approvableBy($user)
->count();
}
if ($checker->allows($user, Permission::ModerateComments)) {
// Library-scoped, like the screen it badges: a client-scoped
// staff member is not shown a number they cannot act on. The
// permission check inside pendingTotal is therefore redundant
// here and deliberately kept — the scope owns that rule, and
// this middleware should not be a second place it lives.
$counts['comments'] = app(VisibleCommentScope::class)->pendingTotal($user);
}
// Unlike the counts above, every authenticated user (staff or
// client) has their own personal notifications — no permission
// gate here.
$counts['notifications_unread'] = InAppNotification::query()
->where('user_id', $user->id)
->whereNull('read_at')
->count();
return $counts;
}
/**
* How many installed translation catalogues are currently switched off,
* for the "N more languages available" line the switcher shows above its
* link to the Languages screen.
*
* Zero for everyone who cannot act on it — clients, anonymous visitors on
* the public pages and the login screen, and staff without edit_settings.
* A dead-end link is worse than none, and how an installation is
* configured is nobody else's business.
*/
protected function disabledLocaleCount(Request $request): int
{
$user = $request->user();
if ($user === null || ! $user->isStaff() || ! app(PermissionChecker::class)->allows($user, Permission::EditSettings)) {
return 0;
}
$locales = app(LocaleRegistry::class);
return count($locales->installed()) - count($locales->enabled());
}
/**
* The topbar's persistent "update available" icon — unlike the
* dashboard System card (informational, gated only on
* view_system_info), this is the actionable surface, so it's
* restricted to staff who actually hold manage_updates.
*
* Carries install_kind so the dialog can print instructions this
* particular server can actually follow — see Installation. Attached
* here rather than shared globally: it describes the deployment, which
* is nobody's business but the staff who maintain it.
*
* @return array{version: string, title: string, notes: string, url: string, published_at: string, install_kind: string}|null
*/
protected function updateNotice(Request $request): ?array
{
$user = $request->user();
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ManageUpdates)) {
return null;
}
$release = app(LatestReleaseInfo::class)->current();
return $release === null
? null
: [...$release, 'install_kind' => app(Installation::class)->kind()->value];
}
/**
* Whether this process is running the code the installation was last
* updated to — see RunningCodeState for the failure it catches.
*
* Gated on view_system_info rather than manage_updates: the latter is
* edition-gated (Capability::SystemUpdates), and a server executing
* code that does not match its own database is not a feature anyone
* buys, it is a fact about the machine.
*
* @return array{reason: string, applied: string, running: string, applied_at: string, install_kind: string}|null
*/
protected function codeNotice(Request $request): ?array
{
$user = $request->user();
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ViewSystemInfo)) {
return null;
}
return app(RunningCodeState::class)->current();
}
/**
* Whether anything is serving the queue zip builds run on.
*
* Gated on view_system_info for the reason codeNotice() gives above:
* a background worker that is not picking work up is a fact about the
* machine rather than a feature of an edition. Same audience, same
* banner slot, one question further along.
*
* @return array{waiting_since: string}|null
*/
protected function workerNotice(Request $request): ?array
{
$user = $request->user();
if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ViewSystemInfo)) {
return null;
}
$waitingSince = app(StalledZipBuilds::class)->oldestUnstarted();
return $waitingSince === null ? null : ['waiting_since' => $waitingSince->toIso8601String()];
}
/**
* App strings use English text as the translation key, so "en" ships no
* messages — the key itself is the fallback.
*
* Asked of the framework's own loader rather than read out of
* lang/{locale}.json directly, so that a package which registers its
* catalogue with loadJsonTranslationsFrom() reaches the frontend too.
* Reading the file worked for as long as every translatable string
* belonged to this repository; the companion packages own screens of
* their own, and theirs were rendering in English in every language
* because their catalogue never got this far.
*
* Precedence comes from the loader and is the useful way round: an
* installation's own lang/{locale}.json is merged last and therefore
* wins, so a package string can be overridden locally.
*
* @return array<string, string>
*/
protected function translations(string $locale): array
{
if ($locale === 'en') {
return [];
}
/** @var array<string, string> */
return app('translator')->getLoader()->load($locale, '*', '*');
}
/**
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
*/
private function extraNavLinks(Request $request): array
{
$user = $request->user();
// Staff only, decided here rather than in each listener: these
// render in the administration area, and a client's portal shows
// their own files and nothing about the installation.
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
if (! $event->isStaff) {
return [];
}
Event::dispatch($event);
return $event->links;
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function announcement(Request $request): ?array
{
$user = $request->user();
if ($user === null) {
return null;
}
// Dispatched for clients too, unlike the sidebar links beside it.
// A client is somebody a shared instance may legitimately need to
// address — about their own account, not about the installation —
// and the event refuses anything not aimed at them, so widening
// this does not widen what reaches them.
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->announcement;
}
}