diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index 3cbd353..e4f1aae 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -27,6 +27,7 @@ import {
usernamesFor,
where,
} from './api'
+import { customAvatarItemIdFromPath, ItemPage } from './Item'
import { ModerationPage } from './Moderation'
import { StatsPage } from './Stats'
@@ -1506,6 +1507,7 @@ export function App() {
const { path, search, navigate } = useRouter()
const roomId = roomIdFromPath(path)
const lookupUsername = usernameFromPath(path)
+ const customAvatarItemId = customAvatarItemIdFromPath(path)
useEffect(() => {
// Config first, and everything else after it: it carries the hostnames every other
@@ -1554,6 +1556,7 @@ export function App() {
account={account}
config={config}
initialTab={path === '/signup' ? 'signup' : 'login'}
+ search={search}
navigate={navigate}
onAuthed={setAccount}
/>
@@ -1580,6 +1583,16 @@ export function App() {
// its data comes from `/api/stats/online`, which the existing `/api/*` allowlist
// entry already sends to the Worker.
+ ) : customAvatarItemId !== null ? (
+ // The game's share link for a store item (`/d/store/customavataritem/`), so
+ // the path is the client's, not ours. Not in `run_worker_first` either: a cold
+ // load falls through to the SPA shell like every other page here. Gated on the
+ // session rather than the config because the lookup behind it needs a token.
+
) : lookupUsername !== null ? (
) : roomId !== null ? (
@@ -1917,16 +1930,29 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
* session exists, however it was obtained.
*/
+/**
+ * A `?next=` worth redirecting to after sign-in, or null. Only an absolute PATH on this
+ * site qualifies: it has to start with one slash and not two (`//host` is scheme-relative,
+ * i.e. another site), and carry no scheme. Anything else is dropped rather than repaired.
+ */
+function safeNextPath(raw: string | null): string | null {
+ if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return null
+ return raw
+}
+
function LoginPage({
account,
config,
initialTab,
+ search,
navigate,
onAuthed,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
initialTab: 'signup' | 'login'
+ /** The query string, for the `?next=` a page that needs a session sends people here with. */
+ search: string
navigate: Navigate
onAuthed: (a: SelfAccount) => void
}) {
@@ -1934,13 +1960,19 @@ function LoginPage({
// never disagree — switching tabs pushes history, and back goes back to the other one.
const tab = initialTab
+ // Where to go once signed in: the page that sent the visitor here, when one did (an
+ // item's share link needs a session to render), otherwise the account page. Only a
+ // same-site path is honoured — one leading slash, so `//evil.example` can't ride in
+ // through a link someone was handed — since this is a redirect after a sign-in.
+ const next = safeNextPath(new URLSearchParams(search).get('next')) ?? '/account'
+
useEffect(() => {
- if (account) navigate('/account')
- }, [account, navigate])
+ if (account) navigate(next)
+ }, [account, navigate, next])
const authed = (a: SelfAccount) => {
onAuthed(a)
- navigate('/account')
+ navigate(next)
}
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
@@ -1950,12 +1982,16 @@ function LoginPage({
{siteKey && (