Files
projectsend/resources/views/app.blade.php
T
denkfabrik-li 7264c44fd7 Serve the interface font from the installation, not from a font CDN
app.blade.php is the root template for all three interfaces, and it opened
with two lines pointing at a third party:

    <link rel="preconnect" href="https://fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />

Every visitor to /login, /register, /forgot-password, /s/{token} and every
public listing page therefore made a request to a host the operator did
not choose and could not switch off, before they had done anything at all
-- handing it their IP address, their user agent, and through Origin the
hostname of the installation they were visiting. On the signed-out pages
that is a visitor who has agreed to nothing, and an operator who often has
told their own users that this server is where their files live.

There was no self-hosted copy in the repository, no setting, no mention in
INSTALL.md, DOCKER.md or SECURITY.md, and no SRI on the tag.

The font now ships with the application, through @fontsource/instrument-sans
-- the same font, the same three weights the URL asked for, from a
versioned dependency rather than binaries pasted into the repository.
Vite fingerprints and emits them like any other asset.

Cost, measured on this build: twelve files, 192 KB on disk. A browser
fetches only woff2 and only the subsets it needs, which is 73 KB for all
six woff2 files together and typically 41 KB (latin, three weights) for a
page in English. Against that, every page load loses a DNS lookup, a TLS
handshake and a round trip to another origin, so signed-out pages get
faster rather than slower.

This is a privacy change rather than a vulnerability fix, and worth saying
plainly: the share token does not leak this way. Referrer-Policy:
strict-origin-when-cross-origin is set in both nginx configs and in the
INSTALL.md snippet, so the path never travelled in the Referer. What
travelled was the visit itself.

Not changed: public/.htaccess still sets no security headers at all, so an
Apache installation has no Referrer-Policy. That is a real gap and a
separate change.

Verified: `npm run build` succeeds and emits the faces; no reference to
the CDN survives anywhere in public/build; `tsc --noEmit` and prettier are
clean. No test asserts on the font, before or after.
2026-08-29 00:50:02 +02:00

70 lines
3.6 KiB
PHP

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@if($page['props']['noindex'] ?? false)
<meta name="robots" content="noindex">
@endif
{{-- Name only, never the version: this tag is served to anyone
who asks, and publishing the exact release tells a scanner
which advisories apply to this installation. What it buys
is ecosystem visibility surveys like BuiltWith count
ProjectSend installs from this and nothing else. --}}
@if(app(\App\Modules\Platform\Attribution\Attribution::class)->visible())
<meta name="generator" content="ProjectSend">
@endif
{{-- The same name app.tsx suffixes every page title with, from the
same place: the site name in the shared props. Taking it from
APP_NAME instead would show one name in the tab until Inertia
hydrates and a different one after, on any installation whose
administrator renamed the site. --}}
<title inertia>{{ $page['props']['name'] ?? config('app.name', 'ProjectSend') }}</title>
{{-- Which cookie holds this installation's CSRF token. Named after
the installation rather than the framework, so a neighbouring
Laravel app on the same hostname cannot overwrite it — and
there is nothing on the client that could work the name out. --}}
<meta name="xsrf-cookie" content="{{ \App\Http\Middleware\ValidateCsrfToken::cookieName() }}">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@routes
@viteReactRefresh
@php
// A page component may be shipped by an installed package
// under vendor/<vendor>/<name>/resources/js/pages/ instead of
// this app's own resources/js/pages/. This must stay in step
// with the client-side glob in resources/js/app.tsx — they
// are two halves of one lookup, and when they disagreed the
// page built cleanly, type-checked cleanly, and then answered
// 500 with "Unable to locate file in Vite manifest".
$pageComponentPath = "resources/js/pages/{$page['component']}.tsx";
if (! file_exists(base_path($pageComponentPath))) {
$packageMatch = glob(base_path("vendor/*/*/resources/js/pages/{$page['component']}.tsx"))[0] ?? null;
if ($packageMatch !== null) {
$pageComponentPath = ltrim(str_replace(base_path(), '', $packageMatch), '/');
}
}
@endphp
@vite(['resources/js/app.tsx', $pageComponentPath])
@inertiaHead
{{-- Operator-authored snippets (Community only). Raw by design
see CustomAssetsBridge, which returns '' unless the edition
grants the capability and the module is actually installed.
This is the one root view for all three surfaces (public,
portal, staff), so it is the only place they need wiring. --}}
{!! app(\App\Modules\Platform\CustomAssets\CustomAssetsBridge::class)->render('head') !!}
</head>
<body class="font-sans antialiased">
{!! app(\App\Modules\Platform\CustomAssets\CustomAssetsBridge::class)->render('body_top') !!}
@inertia
{!! app(\App\Modules\Platform\CustomAssets\CustomAssetsBridge::class)->render('body_bottom') !!}
</body>
</html>