mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-20 20:26:21 +00:00
Stop server-rendered landings flashing for signed-in users (#3707)
The shell renders its anonymous markup — the marketing homepage, an `/app/<name>` landing — off the session cookie alone, and that cookie is set with no maxAge, so a browser drops it on quit while the GUI's localStorage token lives on. A returning user is served the anonymous page and the GUI only tears it down once `whoami` answers, a network round-trip after first paint. That teardown is the flash. Gate it before the paint instead. The shell now emits, as the first thing in <head>, a rule hiding `.hide-if-logged-in` under an <html> class that an inline script adds iff `auth_token_v2` is in localStorage. The rule is already in the cascade when the markup is parsed, so a browser holding a token never paints it at all. `initgui` settles the guess the token represents: `whoami` confirming the session removes the nodes outright (replacing the old `#appLanding` removal), and no session — none stored, or one `whoami` rejected — drops the class so the markup comes back. The gate carries its own 12s failsafe so a bundle that never boots can't strand a blank page. SEO is unaffected: the HTML is byte-identical for every client, nothing branches on user-agent, and a crawler has no stored token so it never adds the class. Unreadable storage fails open the same way. Anonymous markup opts in with `class="hide-if-logged-in"`, which `home.html` already carried.
This commit is contained in:
@@ -401,3 +401,150 @@ describe('PuterHomepageService — head metadata', () => {
|
||||
expect(html).toContain('<meta name="description" content="long">');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PuterHomepageService — pre-paint session gate', () => {
|
||||
/** The gate's inline script, extracted so it can be run against a fake DOM. */
|
||||
const gateScript = (html: string): string => {
|
||||
const head = html.slice(0, html.indexOf('</head>'));
|
||||
const block = [...head.matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.find((body) => body.includes('has-stored-session'));
|
||||
if (!block) throw new Error('no session gate script in rendered head');
|
||||
return block;
|
||||
};
|
||||
|
||||
/** Run the gate with a given localStorage, and report the <html> classes. */
|
||||
const runGate = (
|
||||
html: string,
|
||||
storage: { getItem: (k: string) => string | null },
|
||||
): string[] => {
|
||||
const classes = new Set<string>();
|
||||
const documentElement = {
|
||||
classList: {
|
||||
add: (c: string) => classes.add(c),
|
||||
remove: (c: string) => classes.delete(c),
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(
|
||||
'localStorage',
|
||||
'document',
|
||||
'window',
|
||||
gateScript(html),
|
||||
)(storage, { documentElement }, { addEventListener: () => {} });
|
||||
return [...classes];
|
||||
};
|
||||
|
||||
it('hides opted-in markup for a browser holding a session token', async () => {
|
||||
const html = await render(makeService());
|
||||
expect(html).toContain(
|
||||
'html.has-stored-session .hide-if-logged-in{display:none!important}',
|
||||
);
|
||||
expect(
|
||||
runGate(html, { getItem: (k) => (k === 'auth_token_v2' ? 'tok' : null) }),
|
||||
).toEqual(['has-stored-session']);
|
||||
});
|
||||
|
||||
it('leaves the markup visible for anonymous visitors and crawlers', async () => {
|
||||
const html = await render(makeService());
|
||||
expect(runGate(html, { getItem: () => null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a value under the retired token key', async () => {
|
||||
const html = await render(makeService());
|
||||
expect(
|
||||
runGate(html, { getItem: (k) => (k === 'auth_token' ? 'old' : null) }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails open when storage is unreadable', async () => {
|
||||
const html = await render(makeService());
|
||||
expect(
|
||||
runGate(html, {
|
||||
getItem: () => {
|
||||
throw new Error('storage blocked');
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('hands the markup back if the GUI never settles the guess', async () => {
|
||||
const html = await render(makeService());
|
||||
const classes = new Set<string>(['has-stored-session']);
|
||||
const listeners: Array<() => void> = [];
|
||||
const timers: Array<() => void> = [];
|
||||
const win: Record<string, unknown> = {
|
||||
addEventListener: (_e: string, fn: () => void) => listeners.push(fn),
|
||||
};
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(
|
||||
'localStorage',
|
||||
'document',
|
||||
'window',
|
||||
'setTimeout',
|
||||
gateScript(html),
|
||||
)(
|
||||
{ getItem: () => 'tok' },
|
||||
{
|
||||
documentElement: {
|
||||
classList: {
|
||||
add: (c: string) => classes.add(c),
|
||||
remove: (c: string) => classes.delete(c),
|
||||
},
|
||||
},
|
||||
},
|
||||
win,
|
||||
(fn: () => void) => timers.push(fn),
|
||||
);
|
||||
listeners.forEach((fn) => fn()); // window 'load'
|
||||
timers.forEach((fn) => fn()); // the failsafe deadline
|
||||
expect([...classes]).toEqual([]);
|
||||
|
||||
// ...and stands down once `initgui` has ruled on the token.
|
||||
const settled = new Set<string>(['has-stored-session']);
|
||||
const settledTimers: Array<() => void> = [];
|
||||
const settledListeners: Array<() => void> = [];
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(
|
||||
'localStorage',
|
||||
'document',
|
||||
'window',
|
||||
'setTimeout',
|
||||
gateScript(html),
|
||||
)(
|
||||
{ getItem: () => 'tok' },
|
||||
{
|
||||
documentElement: {
|
||||
classList: {
|
||||
add: (c: string) => settled.add(c),
|
||||
remove: (c: string) => settled.delete(c),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
addEventListener: (_e: string, fn: () => void) =>
|
||||
settledListeners.push(fn),
|
||||
__puter_session_settled: true,
|
||||
},
|
||||
(fn: () => void) => settledTimers.push(fn),
|
||||
);
|
||||
settledListeners.forEach((fn) => fn());
|
||||
settledTimers.forEach((fn) => fn());
|
||||
expect([...settled]).toEqual(['has-stored-session']);
|
||||
});
|
||||
|
||||
it('renders the gate ahead of any extension-contributed markup', async () => {
|
||||
const service = makeService({}, async (_key, event) => {
|
||||
const e = event as { prependHeadContent: string; prependBodyContent: string };
|
||||
e.prependHeadContent += '<meta name="from-extension">';
|
||||
e.prependBodyContent += '<main class="hide-if-logged-in">landing</main>';
|
||||
});
|
||||
const html = await render(service);
|
||||
expect(html.indexOf('has-stored-session')).toBeLessThan(
|
||||
html.indexOf('<meta name="from-extension">'),
|
||||
);
|
||||
expect(html.indexOf('has-stored-session')).toBeLessThan(
|
||||
html.indexOf('<main class="hide-if-logged-in">'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,57 @@ interface PuterGuiAddonsEvent {
|
||||
prependBodyContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-paint gate for server-rendered anonymous markup.
|
||||
*
|
||||
* The shell is rendered from the session cookie alone, and a browser drops that
|
||||
* cookie on quit while the GUI's localStorage token lives on. So a returning
|
||||
* user is served the anonymous markup extensions splice in - the marketing
|
||||
* homepage, an `/app/<name>` landing - and the GUI only tears it down once
|
||||
* `whoami` answers, a network round-trip after first paint. That teardown is
|
||||
* the flash.
|
||||
*
|
||||
* This runs in `<head>`, before any of that markup is parsed, so a browser
|
||||
* holding a token never paints it: the rule is already in the cascade when the
|
||||
* element arrives. `initgui` removes the nodes for good once `whoami` confirms
|
||||
* the session, and puts them back if it doesn't (see `has-stored-session`
|
||||
* there).
|
||||
*
|
||||
* Anonymous markup opts in with `class="hide-if-logged-in"`.
|
||||
*
|
||||
* SEO: the HTML is byte-identical for every client - nothing branches on
|
||||
* user-agent. A crawler has no stored token, so it never adds the class and
|
||||
* sees the landing exactly as served. Storage being unreadable (private modes,
|
||||
* blocked site data) fails open the same way.
|
||||
*
|
||||
* The key is the one `gui/src/globals.js` boots `window.auth_token` from
|
||||
* (`AUTH_TOKEN_KEY_V2`); the retired `auth_token` key can no longer
|
||||
* authenticate, so a value under it must not hide anything.
|
||||
*/
|
||||
const SESSION_GATE = `
|
||||
<style>html.has-stored-session .hide-if-logged-in{display:none!important}</style>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
if (!localStorage.getItem('auth_token_v2')) return;
|
||||
} catch (e) {
|
||||
// Storage unreadable - fail open and show the markup.
|
||||
return;
|
||||
}
|
||||
document.documentElement.classList.add('has-stored-session');
|
||||
// Self-healing: the token is only a guess until \`whoami\` rules on it,
|
||||
// and \`initgui\` is what settles the guess. If it never gets there -
|
||||
// bundle blocked, boot threw - hiding the markup would leave a blank
|
||||
// page for good, so hand it back.
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(function () {
|
||||
if (window.__puter_session_settled) return;
|
||||
document.documentElement.classList.remove('has-stored-session');
|
||||
}, 12000);
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
/**
|
||||
* Serves the root HTML shell that bootstraps the Puter GUI.
|
||||
*
|
||||
@@ -271,6 +322,7 @@ export class PuterHomepageService extends PuterService {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>${e(title)}</title>
|
||||
${SESSION_GATE}
|
||||
${event.prependHeadContent}
|
||||
|
||||
<link rel="preload" href="${guiBundle}" as="script" />
|
||||
|
||||
+52
-8
@@ -1010,6 +1010,40 @@ function authErrorDisplayMessage() {
|
||||
return i18n('auth_error_generic', [], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles the shell's server-rendered anonymous markup — the marketing
|
||||
* homepage, an `/app/<name>` landing — once the client knows whether it has a
|
||||
* session.
|
||||
*
|
||||
* The shell renders that markup from the session cookie alone, and a browser
|
||||
* drops that cookie on quit while our localStorage token lives on. So a
|
||||
* returning user gets served it, and `PuterHomepageService`'s `<head>` gate
|
||||
* hides it pre-paint on the strength of the stored token (the
|
||||
* `has-stored-session` class) rather than letting it flash and be torn down a
|
||||
* round-trip later.
|
||||
*
|
||||
* That gate is a guess about a token nothing has verified yet. Here is where it
|
||||
* is settled:
|
||||
*
|
||||
* - `reveal(false)` — `whoami` confirmed the session. The markup is wrong for
|
||||
* this user, so remove the nodes outright; the class can stay.
|
||||
* - `reveal(true)` — there is no session after all (no token, or one `whoami`
|
||||
* rejected). The markup is the correct thing to show, so drop the class.
|
||||
*
|
||||
* @param {boolean} reveal Whether the markup should end up visible.
|
||||
*/
|
||||
function reveal_anonymous_markup(reveal) {
|
||||
// Stands the gate's own failsafe timer down: the guess has been ruled on.
|
||||
window.__puter_session_settled = true;
|
||||
if (reveal) {
|
||||
document.documentElement.classList.remove('has-stored-session');
|
||||
return;
|
||||
}
|
||||
document
|
||||
.querySelectorAll('.hide-if-logged-in')
|
||||
.forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a Turnstile challenge modal for first-time temp user creation
|
||||
* @param {Object} options - Configuration options
|
||||
@@ -1778,6 +1812,10 @@ window.initgui = async function (options) {
|
||||
* and without authenticating with the server.
|
||||
*/
|
||||
const bad_session_logout = async () => {
|
||||
// The <head> gate hid the anonymous markup on the strength of a stored
|
||||
// token that has just turned out to be dead. Put it back, so the alert
|
||||
// below isn't sitting on an empty page.
|
||||
reveal_anonymous_markup(true);
|
||||
try {
|
||||
// TODO: i18n
|
||||
await UIAlert({
|
||||
@@ -1963,14 +2001,15 @@ window.initgui = async function (options) {
|
||||
}
|
||||
// update local user data
|
||||
if (whoami) {
|
||||
// The server renders the /app/<name> landing overlay only for
|
||||
// requests it saw as anonymous, but its only signal is the session
|
||||
// cookie — which can be gone (e.g. browser restart) while the
|
||||
// localStorage session is still valid. whoami just proved this is
|
||||
// a logged-in user, so drop the overlay. This must happen before
|
||||
// the verification gates below: the overlay's max z-index would
|
||||
// cover them.
|
||||
document.getElementById('appLanding')?.remove();
|
||||
// The shell renders its anonymous markup — the marketing
|
||||
// homepage, an /app/<name> landing — off the session cookie alone,
|
||||
// and that cookie can be gone (e.g. browser restart) while the
|
||||
// localStorage session is still valid. The shell's <head> gate has
|
||||
// kept it from painting; whoami just proved this is a logged-in
|
||||
// user, so drop it for good. This must happen before the
|
||||
// verification gates below: the landing's max z-index would cover
|
||||
// them.
|
||||
reveal_anonymous_markup(false);
|
||||
// Verification gates run in order: email → phone (SMS) → card,
|
||||
// matching the server-side order in assertVerifiedAccount.
|
||||
if (whoami.requires_email_confirmation) {
|
||||
@@ -2056,6 +2095,11 @@ window.initgui = async function (options) {
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Un-authed but not first visit -> try to log in/sign up
|
||||
// -------------------------------------------------------------------------------------
|
||||
// No session after all: the anonymous markup the shell sent is the correct
|
||||
// thing to show, so undo its <head> gate in case a stored token set it and
|
||||
// then failed to authenticate.
|
||||
if (!window.is_auth()) reveal_anonymous_markup(true);
|
||||
|
||||
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
|
||||
// real account even on a first visit — never a temp user. So does a share
|
||||
// link: a share only ever reaches a real account, so a temporary one could
|
||||
|
||||
Reference in New Issue
Block a user