learning-website-nextjs1-10 Exercise 3: The Browser Side, and What Stays Static =============================================================================== A page that is the same for everyone cannot say who is looking. So the page is sent with the LOGGED-OUT version ("Log in" in the header, no "Mark as finished" button), and after it loads, the browser asks /api/me and fills in the rest. Save as packages/ui/src/session.ts: "use client"; import { useSyncExternalStore } from "react"; /** * Who is logged in, and which pages they have finished, as far as this browser knows. * * The pages of the site are made once, when the site is built, and sent to everybody alike, so they cannot say who is looking. The browser * asks /api/me after the page has loaded and fills this in. Until it has answered (and always for a visitor who is not logged in) the page * shows the ordinary logged-out version. */ export interface Me { readonly user: { readonly username: string } | null; readonly done: readonly string[]; } let state: Me | undefined; // undefined: the browser has not heard yet const listeners = new Set<() => void>(); let started = false; function set(next: Me) { state = next; listeners.forEach((listener) => listener()); } export async function refreshMe(): Promise { try { const response = await fetch("/api/me", { cache: "no-store" }); const body = (await response.json()) as { user: { username: string } | null; done?: string[] }; set({ user: body.user, done: body.done ?? [] }); } catch { set({ user: null, done: [] }); // the API is not there (or the network is): behave as logged out } } function subscribe(listener: () => void) { listeners.add(listener); if (!started) { started = true; void refreshMe(); } return () => { listeners.delete(listener); }; } export function useMe(): Me | undefined { return useSyncExternalStore(subscribe, () => state, () => undefined); } async function post(url: string, body: unknown): Promise { return fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); } export async function logIn(username: string, password: string): Promise<{ ok: true } | { ok: false; message: string }> { const response = await post("/api/login", { username, password }); if (response.ok) { await refreshMe(); return { ok: true }; } const body = (await response.json().catch(() => ({}))) as { error?: string }; return { ok: false, message: body.error ?? "Could not log in." }; } export async function logOut(): Promise { await post("/api/logout", {}); set({ user: null, done: [] }); } /** Mark a page finished or not. Returns false if the server refused (for example, the session has ended). */ export async function markFinished(path: string, done: boolean): Promise { const response = await post("/api/progress", { path, done }); if (response.status === 401) { set({ user: null, done: [] }); return false; } if (!response.ok) return false; const current = state?.done ?? []; set({ user: state?.user ?? null, done: done ? [...new Set([...current, path])] : current.filter((p) => p !== path) }); return true; } /** Only an address inside this site is followed after logging in: "/x" yes; "//evil.example", "https://evil.example" and "/\\evil" no. */ export function safeNext(value: string | null | undefined): string { if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\") || /[\u0000-\u001f]/.test(value)) return "/"; return value; } Save as packages/ui/src/Account.tsx: "use client"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { useState, type FormEvent } from "react"; import styles from "./Account.module.css"; import { logIn, logOut, markFinished, safeNext, useMe } from "./session"; /** In the header. The page is the same for everyone, so it starts as "Log in"; a logged-in visitor sees their name once the browser has asked. */ export function AccountBar() { const me = useMe(); const pathname = usePathname(); return (
{me?.user ? ( <> {me.user.username} ) : ( Log in )}
); } /** At the bottom of a lesson: shown only to a logged-in visitor. */ export function MarkFinished({ path }: { path: string }) { const me = useMe(); const [busy, setBusy] = useState(false); const [problem, setProblem] = useState(null); if (!me?.user) return null; const finished = me.done.includes(path); const toggle = async () => { setBusy(true); setProblem(null); const ok = await markFinished(path, !finished); if (!ok) setProblem("That did not save. Log in again and try once more."); setBusy(false); }; return (
{finished ? ✓ You have finished this page. : null} {problem ? {problem} : null}
); } export function LoginForm() { const params = useSearchParams(); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); const submit = async (event: FormEvent) => { event.preventDefault(); setBusy(true); setMessage(null); const form = new FormData(event.currentTarget); const result = await logIn(String(form.get("username") ?? ""), String(form.get("password") ?? "")); if (result.ok) window.location.assign(safeNext(params.get("next"))); else { setMessage(result.message); setBusy(false); } }; return (
void submit(event)}> {message ?

{message}

: null}
); } export interface CourseSummary { readonly folder: string; readonly name: string; readonly paths: readonly string[] } /** How many chapters of each course the visitor has finished. The totals are fixed when the site is built; the finished ones come from the browser. */ export function ProgressOverview({ courses }: { courses: readonly CourseSummary[] }) { const me = useMe(); if (me === undefined) return

Loading…

; if (!me.user) return

Progress is kept for people who are logged in. Log in to see yours.

; const done = new Set(me.done); const rows = courses.map((course) => ({ ...course, finished: course.paths.filter((p) => done.has(p)).length })); const started = rows.filter((row) => row.finished > 0); return ( <>

{me.done.length} page{me.done.length === 1 ? "" : "s"} finished.

{started.length === 0 ?

Nothing finished yet. Use “Mark as finished” at the bottom of a lesson.

: (
    {started.map((row) => (
  • {row.name} {row.finished} of {row.paths.length}
  • ))}
)}

{rows.length - started.length} courses not started.

); } Save as apps/languages/app/login/page.tsx: import type { Metadata } from "next"; import { Suspense } from "react"; import { LoginForm } from "@lw/ui"; export const metadata: Metadata = { title: "Log in | Philip's Learning Notes", robots: { index: false }, }; export default function LoginPage() { return ( <>

Log in

Logging in is optional. It only lets this site remember which chapters you have finished.

); } Save as apps/languages/app/progress/page.tsx: import type { Metadata } from "next"; import { groupCourses } from "@lw/content"; import { ProgressOverview } from "@lw/ui"; import { content } from "../../lib/site"; export const metadata: Metadata = { title: "My progress | Philip's Learning Notes", robots: { index: false }, }; export default function ProgressPage() { // what is the same for everyone (each course and its chapters) is fixed when the site is built; what each visitor has finished is asked for later const courses = groupCourses(content.all()).map((course) => ({ folder: course.folder, name: course.name, paths: course.chapters.map((chapter) => chapter.path), })); return ( <>

My progress

); } (MarkFinished is placed after the lesson in apps/languages/app/[...path]/page.tsx; SiteLayout takes an "accounts" option that adds AccountBar.) The /progress page shows the same split: each course and its chapters are fixed when the site is built; which chapters THIS visitor has finished comes from the browser. After logging in, the visitor is sent to the address in ?next=. If that were allowed to be anything, a link such as /login?next=//evil.example would send a freshly logged-in visitor to another site that looks the same (an open redirect). Only an address inside the site is followed: "/hungary/x?page=2" yes "//evil.example" no "https://evil.example" no "javascript:alert(1)" no "/\evil.example" no "\n//evil.example" no an empty value or none: "/" Save as packages/ui/src/session.test.ts: import assert from "node:assert/strict"; import { test } from "node:test"; import { safeNext } from "./session.ts"; test("after logging in only an address inside this site is followed", () => { for (const ok of ["/", "/hungary/hungarian-basic-3", "/hungary/x?page=2", "/search?q=a%20b", "/progress#top"]) assert.equal(safeNext(ok), ok); for (const bad of [ "//evil.example", "//evil.example/x", "https://evil.example", "http://localhost:3001/", "javascript:alert(1)", "evil.example", "hungary/x", "/\\evil.example", "/\\\\evil.example", "/x\\y", "/\u0000", "/\n//evil.example", "/\t/evil.example", "", null, undefined, ]) assert.equal(safeNext(bad), "/", JSON.stringify(bad)); }); TESTED IN A REAL BROWSER (a headless Chrome typing into the real form, with a made-up password): Save as accounts-ui-test.mjs: // Log in, mark a lesson finished, look at progress and log out, in a real headless Chrome. // LW_DATA_DIR= node accounts-ui-test.mjs import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createUser, openDatabase } from "./packages/accounts/src/index.ts"; const base = process.argv[2] ?? "http://localhost:3001"; const db = openDatabase(`${process.env.LW_DATA_DIR}/accounts.sqlite`); const username = `reader-${process.pid}`; const password = randomBytes(12).toString("hex"); // made up for this run, typed into the form, never printed await createUser(db, username, password); const chrome = spawn("C:/Program Files/Google/Chrome/Application/chrome.exe", [ "--headless=new", "--disable-gpu", "--remote-debugging-port=9339", `--user-data-dir=${mkdtempSync(join(tmpdir(), "lw-chrome-"))}`, "about:blank", ], { stdio: "ignore" }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let targets; for (let i = 0; i < 40; i++) { try { targets = await (await fetch("http://127.0.0.1:9339/json")).json(); if (targets.length) break; } catch { /* not up yet */ } await sleep(250); } const ws = new WebSocket(targets.find((t) => t.type === "page").webSocketDebuggerUrl); await new Promise((r) => ws.addEventListener("open", r)); let id = 0; const pending = new Map(); ws.addEventListener("message", (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); const send = (method, params = {}) => new Promise((r) => { const n = ++id; pending.set(n, r); ws.send(JSON.stringify({ id: n, method, params })); }); const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true })).result?.result?.value; const open = async (path) => { await send("Page.enable"); await send("Page.navigate", { url: base + path }); await sleep(1800); }; async function waitFor(expression, ms = 4000) { const started = Date.now(); while (Date.now() - started < ms) { if (await evaluate(expression)) return Date.now() - started; await sleep(40); } return null; } const text = () => evaluate("document.body.innerText"); const rows = []; const record = (ok, what, detail = "") => rows.push([ok ? "PASS" : "FAIL", what, String(detail)]); const LESSON = "/hungary/hungarian-basic-3/hungarian_basic_conversation_3_1"; const typeInto = (selector, value) => evaluate(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); e.focus(); e.value = ${JSON.stringify(value)}; return true; })()`); const submit = () => evaluate('document.querySelector("form[class] button[type=submit]").click()'); const hasBar = (label) => `[...document.querySelectorAll("header a, header button")].some(e => e.textContent.trim() === ${JSON.stringify(label)})`; // 1. a visitor who is not logged in await open(LESSON); record(await evaluate(hasBar("Log in")), "not logged in: the header offers 'Log in'"); record(!(await text()).includes("Mark as finished"), "not logged in: no 'Mark as finished' button"); const loginHref = await evaluate('[...document.querySelectorAll("header a")].find(a => a.textContent.trim() === "Log in")?.getAttribute("href")'); record(loginHref === `/login?next=${encodeURIComponent(LESSON)}`, "the Log in link remembers the page to come back to", loginHref); // 2. a wrong password, then the right one await open(`/login?next=${encodeURIComponent(LESSON)}`); await typeInto("#login-username", username); await typeInto("#login-password", "not-the-password-at-all"); await submit(); record((await waitFor('document.body.innerText.includes("Wrong user name or password.")')) !== null, "a wrong password shows a message and stays on the page", await evaluate("location.pathname")); await typeInto("#login-password", password); await submit(); await waitFor(`location.pathname === ${JSON.stringify(LESSON)}`); record((await evaluate("location.pathname")) === LESSON, "the right password returns to the lesson that was asked for", await evaluate("location.pathname")); const flash = await waitFor(hasBar("Log out")); record(flash !== null, "the header then shows the user's name and 'Log out'", `${flash} ms after the page loaded`); record((await evaluate('[...document.querySelectorAll("header a")].some(a => a.textContent.trim() === ' + JSON.stringify(username) + ")")), "the name is a link to the progress page"); // 3. mark a lesson finished record((await waitFor('[...document.querySelectorAll("button")].some(b => b.textContent === "Mark as finished")')) !== null, "'Mark as finished' appears for a logged-in visitor"); await evaluate('[...document.querySelectorAll("button")].find(b => b.textContent === "Mark as finished").click()'); record((await waitFor('document.body.innerText.includes("You have finished this page.")')) !== null, "clicking it says the page is finished"); await open(LESSON); record((await waitFor('document.body.innerText.includes("You have finished this page.")')) !== null, "after a full reload it is still finished (kept on the server)"); // 4. the progress page await open("/progress"); const shown = await waitFor('document.body.innerText.includes("1 of 12")'); record(shown !== null && (await text()).includes("Hungarian Basic Conversation 3"), "/progress lists the course with 1 of 12 finished", (await text()).replace(/\s+/g, " ").slice(0, 110)); record((await evaluate('document.querySelector("progress")?.value + "/" + document.querySelector("progress")?.max')) === "1/12", "with a progress bar", await evaluate('document.querySelector("progress")?.value + "/" + document.querySelector("progress")?.max')); // 5. undo, by a click, with no reload await open(LESSON); await waitFor('[...document.querySelectorAll("button")].some(b => b.textContent === "Mark as not finished")'); await evaluate("window.__marker = 1; true"); await evaluate('[...document.querySelectorAll("button")].find(b => b.textContent === "Mark as not finished").click()'); record((await waitFor('[...document.querySelectorAll("button")].some(b => b.textContent === "Mark as finished")')) !== null && (await evaluate("window.__marker === 1")), "'Mark as not finished' undoes it without reloading the page"); // 6. log out await evaluate('[...document.querySelectorAll("header button")].find(b => b.textContent === "Log out").click()'); record((await waitFor(hasBar("Log in"))) !== null && !(await text()).includes("Mark as finished"), "logging out brings back 'Log in' and removes the button"); await open("/progress"); record((await waitFor('document.body.innerText.includes("Progress is kept for people who are logged in")')) !== null, "logged out, /progress asks you to log in"); // 7. a login link cannot send you to another site await open("/login?next=" + encodeURIComponent("//evil.example/x")); await typeInto("#login-username", username); await typeInto("#login-password", password); await submit(); await waitFor('location.pathname === "/" && !location.href.includes("login")'); record((await evaluate("location.host")) === new URL(base).host && (await evaluate("location.pathname")) === "/", "next=//evil.example is not followed: it lands on this site's front page", await evaluate("location.href")); await evaluate('[...document.querySelectorAll("header button")].find(b => b.textContent === "Log out")?.click()'); for (const [status, what, detail] of rows) console.log(status, what.padEnd(84), detail.slice(0, 90)); console.log(`\n${rows.filter((r) => r[0] === "PASS").length} passed, ${rows.filter((r) => r[0] === "FAIL").length} failed`); ws.close(); chrome.kill(); process.exit(0); 16 passed, 0 failed logged out: header shows "Log in", no "Mark as finished"; the Log in link remembers the page; a wrong password shows "Wrong user name or password." and stays; the right password returns to the lesson; the header then shows the user's name and "Log out" 77 ms after the page loaded; "Mark as finished" appears; clicking it says "You have finished this page."; after a FULL reload it still does (kept on the server); /progress shows "Hungarian Basic Conversation 3 1 of 12" with a progress bar at 1/12; "Mark as not finished" undoes it without reloading; logging out brings back "Log in" and removes the button, and /progress asks you to log in; next=//evil.example lands on this site's front page. CAN IT FAIL? A first attempt to plant a mistake (removing the redirect check) did nothing: my edit did not match the code, and the test passed because the code was unchanged (I noticed the count of changed lines was 0). Done properly, with the check replaced by "return the address as it is", the test failed: the browser went to //evil.example. Put back. The takeaway: confirm that a planted mistake is really in the code before believing the test that passes. WHAT IS STATIC AND WHAT IS NOT. The build's own route table: ○ (static) the front page, /login, /progress, /search, /sitemap.xml, /robots.txt, /search-index.json ● (static, made from a list) every one of the 561 pages and the folder pages, one HTML file each (605 files in all) ƒ (dynamic, runs when asked) /api/login /api/logout /api/me /api/progress Nothing about the content changed: it is still built in advance. But the site is no longer just files: it now needs a Node process running ("next start"), for four routes. Two ways to deploy it (the choice is Chapter 12's): 1. The whole app as a Node server (what was tested here). 2. Pages as plain files and only the accounts as a tiny separate server behind Apache's ProxyPass for /api. The accounts package has no web framework in it, so a small server around it is possible; it was NOT built. THE FLASH: a logged-in visitor first sees "Log in" in the header and no button, and about 77 ms later (on this machine, from the same computer) the name and the button. A page that cannot be personalised in advance has to do this; logged-out visitors, who are most people, see nothing change. NOT DONE: no password change or reset (no email is set up; the owner changes a password by deleting and re-making the user); no sign-up (on purpose); other sites have no accounts yet; other browsers; a screen reader; a password manager (the fields have the autocomplete names that password managers look for, but none was tried). WHY THIS WORKS AS AN ANSWER --------------------------- It keeps the pages static and puts everything personal behind four small routes, says what that costs (a Node process, a short flash), and tests the whole thing the way a visitor uses it, including the attack a link could carry.