diff --git a/backtoschool/index.html b/backtoschool/index.html index 1a75c1e..6720303 100644 --- a/backtoschool/index.html +++ b/backtoschool/index.html @@ -60,7 +60,7 @@

Install the web app

- Your NixAmp account + Your BackToSchool account

Welcome back

Sign in to host, chat, or raise your hand.

@@ -73,6 +73,57 @@

Welcome back

+ + + + Settings +

Your profile

+

What every class you host shows about you. Say it once here.

+

+
+
+ + ? +
+ + + +

PNG, JPEG, WebP or GIF, up to 1 MB. Kept here, shown on every class you host.

+
+
+ + + + +

Used when no photo is uploaded.

+ + +
+
+ OpenProfile +

Keep one profile for every site (OpenProfile). Paste its address and the card above is filled from it.

+
+ + +
+ +
+
+ Broadcast backend +

Optional. Connect a nixamp account to pick one of your live streams when you go live, instead of pasting a link. You never need nixamp to teach here.

+

+
+ Connect nixamp + +
+
+ +
+ Start a conversation @@ -82,6 +133,8 @@

Go live

Broadcast your class

Share your screen with Pairux, or upload a file and go live in Nixamp. Paste the share link below.

+ +

For TV playback, use a Nixamp Share link or a direct HTTPS media URL. Pairux viewing opens separately.

@@ -122,11 +175,9 @@

Go live

Repeating classes keep this same link. Ending a session schedules the next one at the same local time.

-
Host profile - - - -
+ +
diff --git a/backtoschool/src/main.ts b/backtoschool/src/main.ts index 8ece6f0..9bf4da8 100644 --- a/backtoschool/src/main.ts +++ b/backtoschool/src/main.ts @@ -12,6 +12,10 @@ import { mountClassroomPlayer } from "./player.ts"; import { installTvNavigation } from "./tv.ts"; installTvNavigation(); import { api, ApiError, send, type Account, type EventEnvelope } from "./api.ts"; +import type { LinkView, ServerStreams } from "../../src/nixamp-link-types.ts"; + +/** The card as /api/v1/me/profile answers it. */ +interface Profile { name: string; homepage: string; avatarUrl: string; bio: string; photo: string } const main = document.querySelector("#main")!; const accountButton = document.querySelector("#account-button")!; @@ -28,8 +32,30 @@ const eventKicker = document.querySelector("#event-kicker")!; const eventSubmit = document.querySelector("#event-submit")!; const eventError = document.querySelector("#event-error")!; const scheduleFields = document.querySelector("#schedule-fields")!; +const settingsDialog = document.querySelector("#settings-dialog")!; +const settingsStatus = document.querySelector("#settings-status")!; +const profileForm = document.querySelector("#profile-form")!; +const profileError = document.querySelector("#profile-error")!; +const profilePhoto = document.querySelector("#profile-photo")!; +const profileInitial = document.querySelector("#profile-initial")!; +const profilePhotoFile = document.querySelector("#profile-photo-file")!; +const profilePhotoRemove = document.querySelector("#profile-photo-remove")!; +const openProfileForm = document.querySelector("#openprofile-form")!; +const openProfileUrl = document.querySelector("#profile-openprofile")!; +const openProfileError = document.querySelector("#openprofile-error")!; +const nixampConnection = document.querySelector("#nixamp-connection")!; +const nixampConnect = document.querySelector("#nixamp-connect")!; +const nixampDisconnect = document.querySelector("#nixamp-disconnect")!; +const nixampStreamsField = document.querySelector("#nixamp-streams-field")!; +const nixampStreams = document.querySelector("#nixamp-streams")!; +const nixampStreamsNote = document.querySelector("#nixamp-streams-note")!; +const eventHostCard = document.querySelector("#event-host-card")!; let account: Account | null = null; +/** The card and the connection, as last read; null until signed in. */ +type ProfileView = Profile & { card: { hostName: string; homepageUrl: string; avatarUrl: string }; openProfile: string; handle: string }; +let profile: ProfileView | null = null; +let connection: (LinkView & { available: boolean }) | null = null; let creatingAccount = false; let scheduling = false; let editing: LiveEvent | null = null; @@ -116,7 +142,7 @@ function hasPermission(permission: string): boolean { } function updateAccountButton(): void { - uiText(accountButton, () => account ? account.email.split("@")[0] || uiMessage("Account") : uiMessage("Sign in")); + uiText(accountButton, () => account ? profile?.name || account.email.split("@")[0] || uiMessage("Account") : uiMessage("Sign in")); accountButton.classList.toggle("signed-in", Boolean(account)); } @@ -127,17 +153,23 @@ async function readAccount(): Promise { account = null; } updateAccountButton(); + await readProfile(); +} + +/** The card and the connection follow the account: read after it, dropped with it. */ +async function readProfile(): Promise { + if (!account) { profile = null; connection = null; return; } + const [card, link] = await Promise.all([ + api("/api/v1/me/profile").catch(() => null), + api("/api/v1/nixamp/connection").catch(() => null), + ]); + profile = card; + connection = link; + updateAccountButton(); } function openAccount(next?: () => void): void { - if (account) { - accountDialog.showModal(); - const email = account.email; - accountTitle.textContent = `Signed in as ${email}`; - accountCopy.textContent = "Your BackToSchool identity is your NixAmp account."; - accountForm.hidden = true; - return; - } + if (account) { openSettings(); return; } afterSignIn = next ?? null; accountForm.hidden = false; accountError.textContent = ""; @@ -145,10 +177,111 @@ function openAccount(next?: () => void): void { accountDialog.showModal(); } +// --- settings: the card said once, and the optional nixamp connection ------ + +function drawProfile(): void { + const card = profile ?? { name: "", homepage: "", avatarUrl: "", bio: "", photo: "", card: { hostName: "", homepageUrl: "", avatarUrl: "" }, openProfile: "", handle: "" }; + for (const key of ["name", "homepage", "bio", "avatarUrl"] as const) { + (profileForm.elements.namedItem(key) as HTMLInputElement | HTMLTextAreaElement).value = card[key]; + } + const picture = card.card.avatarUrl; + profilePhoto.hidden = picture === ""; + profileInitial.hidden = picture !== ""; + if (picture !== "") profilePhoto.src = picture; + profileInitial.textContent = (card.name || account?.email || "?").slice(0, 1).toUpperCase(); + profilePhotoRemove.hidden = card.photo === ""; + if (openProfileUrl.value === "" || document.activeElement !== openProfileUrl) openProfileUrl.value = card.openProfile; + document.querySelector("#settings-email")!.textContent = account ? `Signed in as ${account.email}` : ""; + drawConnection(); +} + +function drawConnection(): void { + const link = connection; + if (!link || !link.available) { + nixampConnection.textContent = link ? "Connecting is not available on this site." : ""; + nixampConnect.hidden = true; + nixampDisconnect.hidden = true; + return; + } + nixampConnect.hidden = link.connected; + nixampDisconnect.hidden = !link.connected; + nixampConnection.textContent = link.connected + ? `Connected as @${link.handle || link.nixampUserId} on nixamp.com. Your live streams appear when you go live.` + : "Not connected."; +} + +function openSettings(status = ""): void { + if (!account) { openAccount(() => openSettings(status)); return; } + settingsStatus.textContent = status; + profileError.textContent = ""; + openProfileError.textContent = ""; + drawProfile(); + settingsDialog.showModal(); + void readProfile().then(drawProfile); +} + +async function saveProfile(input: Record): Promise { + profileError.textContent = ""; + try { + profile = await api("/api/v1/me/profile", { method: "PUT", body: JSON.stringify(input) }); + drawProfile(); + updateAccountButton(); + return true; + } catch (error) { + profileError.textContent = error instanceof Error ? error.message : "Could not save your profile."; + return false; + } +} + +/** What the class form shows about the host: the card, or where to make one. */ +function drawHostCard(): void { + const card = profile?.card; + const named = card && (card.hostName !== "" || card.avatarUrl !== ""); + eventHostCard.innerHTML = named + ? `${card.avatarUrl ? `` : `${escape((card.hostName || "?").slice(0, 1))}`}
Your host card${escape(card.hostName || uiMessage("Class host"))}${card.homepageUrl ? `${escape(new URL(card.homepageUrl).hostname)}` : ""}
` + : `?
No host card yetYour name and photo go on every class you host.
`; +} + +/** The streams a connected nixamp account could go live with, as a pick list. */ +async function drawStreams(): Promise { + const link = connection; + nixampStreamsField.hidden = true; + nixampStreamsNote.textContent = ""; + if (!link?.available) return; + if (!link.connected) { + nixampStreamsNote.innerHTML = ``; + return; + } + nixampStreamsNote.textContent = "Looking at your nixamp servers…"; + try { + const { servers } = await api<{ servers: ServerStreams[] }>("/api/v1/nixamp/streams"); + while (nixampStreams.options.length > 1) nixampStreams.remove(1); + let offered = 0; + for (const server of servers) { + const group = document.createElement("optgroup"); + group.label = server.reachable ? server.name : `${server.name} (not reachable)`; + if (server.live) { + const option = new Option(`What ${server.name} is playing now${server.nowPlaying ? `: ${server.nowPlaying}` : ""}`, server.live); + group.append(option); offered += 1; + } + for (const channel of server.channels) { + group.append(new Option(`${channel.name} (${channel.kind}${channel.listeners ? `, ${channel.listeners} listening` : ""})`, channel.link)); offered += 1; + } + if (group.childElementCount) nixampStreams.append(group); + } + nixampStreamsField.hidden = offered === 0; + nixampStreamsNote.textContent = offered === 0 + ? (servers.length === 0 ? "Your nixamp account remembers no servers yet. Start one, then come back." : "Nothing is live on your servers right now. Start a channel in nixamp, then pick it here.") + : "Picking one fills in the broadcast link below."; + } catch (error) { + nixampStreamsNote.textContent = error instanceof Error ? error.message : "Your nixamp streams could not be read."; + } +} + function drawAccountMode(): void { uiText(accountTitle, () => creatingAccount ? uiMessage("Create your account") : uiMessage("Welcome back")); accountCopy.textContent = creatingAccount - ? "One NixAmp account works here and everywhere NixAmp goes." + ? "One account for every class on BackToSchool.help. Nothing else to sign up for." : "Sign in to host, chat, or raise your hand."; uiText(accountMode, () => creatingAccount ? uiMessage("Already have an account? Sign in") : uiMessage("New here? Create an account")); document.querySelector("#account-forgot")!.hidden = creatingAccount; @@ -176,8 +309,10 @@ function openEventForm(mode: "live" | "scheduled", existing: LiveEvent | null = eventError.textContent = ""; const startsAt = eventForm.elements.namedItem("startsAt") as HTMLInputElement; startsAt.required = scheduling && (!existing || Boolean(existing.startsAt)); + drawHostCard(); + void drawStreams(); if (existing) { - for (const key of ["title", "description", "topic", "visibility", "broadcastUrl", "hostName", "homepageUrl", "avatarUrl", "recurrence"] as const) { + for (const key of ["title", "description", "topic", "visibility", "broadcastUrl", "recurrence"] as const) { (eventForm.elements.namedItem(key) as HTMLInputElement | HTMLSelectElement).value = existing[key] ?? (key === "recurrence" ? "none" : ""); } for (const key of ["chatEnabled", "handRaiseEnabled"] as const) (eventForm.elements.namedItem(key) as HTMLInputElement).checked = existing[key]; @@ -317,7 +452,7 @@ function panelBody(type: string, event: LiveEvent): string | null { case "stage": return `
${escape(event.title.slice(0, 1).toUpperCase())}
Host stage

${escape(event.title)}

${event.status === "live" ? "This event is live." : "Start when you’re ready."}

`; case "host": return `
${event.avatarUrl ? `` : `${escape((event.hostName || event.title).slice(0, 1))}`}
Your host${escape(event.hostName || uiMessage("Class host"))}${event.homepageUrl ? `Visit homepage ↗` : ""}
`; case "about": return `

${escape(event.description || "Come listen, learn, and ask a question live.")}

${event.topic ? `${escape(event.topic)}` : ""}`; - case "join": return account ? `

You’re signed in and ready to participate.

` : `
Want to ask something?

Join with your NixAmp account.

`; + case "join": return account ? `

You’re signed in and ready to participate.

` : `
Want to ask something?

Sign in to BackToSchool to join.

`; case "chat": return chatPanel(event); case "questions": return `
Questions shared in chat can be brought onto the stage.
`; case "resources": return `
The host hasn’t added resources yet.
`; @@ -576,19 +711,77 @@ async function route(): Promise { } accountButton.addEventListener("click", () => { - if (!account) { - openAccount(); - return; - } - const leave = confirm(`Signed in as ${account.email}. Sign out?`); - if (!leave) return; + if (!account) { openAccount(); return; } + openSettings(); +}); + +document.querySelector("#settings-signout")!.addEventListener("click", () => { + if (!account) return; void api("/api/v1/auth/logout", { method: "POST" }).finally(() => { - account = null; + account = null; profile = null; connection = null; updateAccountButton(); + settingsDialog.close(); void route(); }); }); +profileForm.addEventListener("submit", (submit) => { + submit.preventDefault(); + const data = new FormData(profileForm); + const button = profileForm.querySelector('button[type="submit"]')!; + button.disabled = true; + void saveProfile({ name: data.get("name"), homepage: data.get("homepage"), bio: data.get("bio"), avatarUrl: data.get("avatarUrl") }) + .then((saved) => { if (saved) settingsStatus.textContent = "Saved. Every class you host shows this."; }) + .finally(() => { button.disabled = false; }); +}); + +profilePhotoFile.addEventListener("change", () => { + const file = profilePhotoFile.files?.[0]; + if (!file) return; + profileError.textContent = ""; + if (file.size > 1024 * 1024) { profileError.textContent = "A photo may be up to 1 MB."; profilePhotoFile.value = ""; return; } + void fetch("/api/v1/me/profile/photo", { method: "PUT", credentials: "same-origin", body: file }) + .then(async (response) => { + const body = await response.json() as ProfileView & { error?: string }; + if (!response.ok) throw new Error(body.error ?? "That photo could not be kept."); + profile = body; + drawProfile(); + settingsStatus.textContent = "Photo saved."; + }) + .catch((error) => { profileError.textContent = error instanceof Error ? error.message : "That photo could not be kept."; }) + .finally(() => { profilePhotoFile.value = ""; }); +}); + +profilePhotoRemove.addEventListener("click", () => { + void api("/api/v1/me/profile/photo", { method: "DELETE" }) + .then((next) => { profile = next; drawProfile(); settingsStatus.textContent = "Photo removed."; }) + .catch((error) => { profileError.textContent = error instanceof Error ? error.message : "Could not remove the photo."; }); +}); + +openProfileForm.addEventListener("submit", (submit) => { + submit.preventDefault(); + openProfileError.textContent = ""; + const button = openProfileForm.querySelector('button[type="submit"]')!; + button.disabled = true; + void api("/api/v1/me/profile/import", { method: "POST", body: JSON.stringify({ url: openProfileUrl.value.trim() }) }) + .then((next) => { profile = next; drawProfile(); updateAccountButton(); settingsStatus.textContent = "Filled from your OpenProfile."; }) + .catch((error) => { openProfileError.textContent = error instanceof Error ? error.message : "That profile could not be read."; }) + .finally(() => { button.disabled = false; }); +}); + +nixampDisconnect.addEventListener("click", () => { + nixampDisconnect.disabled = true; + void api("/api/v1/nixamp/connection", { method: "DELETE" }) + .then(() => readProfile()) + .then(() => { drawProfile(); settingsStatus.textContent = "nixamp disconnected. Your classes here are untouched."; }) + .finally(() => { nixampDisconnect.disabled = false; }); +}); + +nixampStreams.addEventListener("change", () => { + if (nixampStreams.value === "") return; + (eventForm.elements.namedItem("broadcastUrl") as HTMLInputElement).value = nixampStreams.value; +}); + accountMode.addEventListener("click", () => { creatingAccount = !creatingAccount; accountError.textContent = ""; @@ -607,6 +800,7 @@ accountForm.addEventListener("submit", (submit) => { }).then((result) => { account = result.account; updateAccountButton(); + void readProfile(); accountForm.reset(); accountDialog.close(); const next = afterSignIn; @@ -642,8 +836,9 @@ eventForm.addEventListener("submit", (submit) => { eventError.textContent = ""; const input = { title: data.get("title"), description: data.get("description"), topic: data.get("topic"), - visibility: data.get("visibility"), broadcastUrl: data.get("broadcastUrl"), - hostName: data.get("hostName"), homepageUrl: data.get("homepageUrl"), avatarUrl: data.get("avatarUrl"), recurrence, + visibility: data.get("visibility"), broadcastUrl: data.get("broadcastUrl"), recurrence, + // The host card is not sent: a new class takes the account's, and an + // edited one keeps what it has. chatEnabled: data.get("chatEnabled") === "on", handRaiseEnabled: data.get("handRaiseEnabled") === "on", ...(existing ? { version: existing.version } : {kind: "class"}), ...(scheduling ? { @@ -678,6 +873,7 @@ document.addEventListener("click", (click) => { const eventMode = target.closest("[data-event-mode]")?.dataset.eventMode; if (eventMode === "live" || eventMode === "scheduled") openEventForm(eventMode); if (target.closest("[data-sign-in]")) openAccount(() => void route()); + if (target.closest("[data-open-settings]")) { eventDialog.close(); openSettings(); } const close = target.closest("[data-close]"); if (close) (close.closest("dialog") as HTMLDialogElement | null)?.close(); const hand = target.closest("[data-hand]"); @@ -692,4 +888,27 @@ document.addEventListener("click", (click) => { window.addEventListener("popstate", () => void route()); -void readAccount().then(() => route()); +/** + * Back from nixamp.com's consent page: the callback lands here with a word + * in the query. Said in settings, then the query is dropped so a reload + * does not say it again. + */ +function landedFromNixamp(): string { + const params = new URLSearchParams(location.search); + const outcome = params.get("nixamp"); + if (!outcome) return ""; + const reason = params.get("reason") ?? ""; + history.replaceState(null, "", `${location.pathname}${location.hash}`); + if (outcome === "connected") return "nixamp connected. Your live streams appear when you go live."; + if (outcome === "denied") return "You said not now on nixamp.com. Nothing was connected."; + return `nixamp could not be connected${reason ? `: ${reason}` : "."}`; +} + +void readAccount().then(() => { + const landed = landedFromNixamp(); + if (landed !== "" || location.hash === "#settings") { + if (location.hash === "#settings") history.replaceState(null, "", location.pathname); + openSettings(landed); + } + return route(); +}); diff --git a/backtoschool/src/styles.css b/backtoschool/src/styles.css index 9c2f418..ebca85a 100644 --- a/backtoschool/src/styles.css +++ b/backtoschool/src/styles.css @@ -469,3 +469,19 @@ input[type="url"], input[type="email"], input[type="tel"], input[type="datetime- .tv .event-heading p { margin: 0; } .tv .classroom-controls { gap: 10px; padding: 14px; } .tv .classroom-controls .button { padding: 12px 16px; font-size: 1rem; } + +/* Settings: the host card said once, and the optional nixamp connection. */ +.profile-photo-row { display: flex; align-items: center; gap: 18px; } +.profile-photo { width: 72px; height: 72px; font-size: 1.8rem; } +.profile-photo-actions { display: grid; gap: 6px; } +.profile-photo-actions .button { width: fit-content; cursor: pointer; } +.settings-section { margin-top: 22px; } +.settings-section legend { font-weight: 700; padding-inline: 6px; } +.settings-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } +.settings-footer { justify-content: space-between; margin-top: 26px; padding-top: 16px; border-top: 1px solid var(--line); } +.host-card { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; } +.host-card > div { display: grid; flex: 1; min-width: 0; } +.host-card small { color: var(--muted); font-size: .76rem; font-weight: 790; } +.host-card strong { color: var(--purple); } +.host-card a { color: var(--muted); font-size: .84rem; overflow-wrap: anywhere; } +.form-stack #nixamp-streams-field + .form-note { margin-top: -8px; } diff --git a/src/live-api.ts b/src/live-api.ts index ee9eee9..4f24554 100644 --- a/src/live-api.ts +++ b/src/live-api.ts @@ -36,6 +36,12 @@ export interface LiveApiOptions { site?: string; email?: (to: string, note: { title: string; body: string; url: string }) => Promise; onEventUpdated?: (event: LiveEvent, previous: LiveEvent) => void; + /** + * The account's own card -- name, homepage, photo -- so a class made + * without them carries the host's instead of nothing. What the class + * says explicitly still wins. + */ + hostProfile?: (userId: string) => Promise<{ hostName: string; homepageUrl: string; avatarUrl: string }>; } const CORS = { @@ -309,6 +315,13 @@ export async function handleLiveApi( const account = await requiredAccount(request, response, options); if (!account) return true; const input = await body(request); + if (options.hostProfile) { + const card = await options.hostProfile(account.id); + for (const key of ["hostName", "homepageUrl", "avatarUrl"] as const) { + const given = input[key]; + if (card[key] && (given === undefined || given === null || given === "")) input[key] = card[key]; + } + } const event = await options.events.create(account.id, { ...input, title: input["title"] }); json(response, 201, view(event, account)); return true; diff --git a/src/nixamp-link-api.ts b/src/nixamp-link-api.ts new file mode 100644 index 0000000..4ab73f8 --- /dev/null +++ b/src/nixamp-link-api.ts @@ -0,0 +1,220 @@ +/** + * The routes behind "Connect nixamp" on a site that runs on this codebase + * but is not nixamp.com. + * + * GET /api/v1/nixamp/connect send the browser to nixamp.com's consent page + * GET /api/v1/nixamp/callback the code comes back; tokens are kept + * GET /api/v1/nixamp/connection whether this account is connected, and as whom + * DELETE /api/v1/nixamp/connection withdraw the grant, both sides + * GET /api/v1/nixamp/streams the servers you run on nixamp, and what is live + * + * All five want the site's own session: the person, signed in here. What + * they hold on nixamp's side is a client token, and it never leaves the + * server -- the page only ever learns a handle and a list of streams. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { tokenFrom, type Accounts } from "./accounts.ts"; +import type { NixampLinks } from "./nixamp-link.ts"; +import type { ServerStreams, StreamPick } from "./nixamp-link-types.ts"; +export type { ServerStreams, StreamPick } from "./nixamp-link-types.ts"; + +export interface NixampLinkApiOptions { + links: NixampLinks; + accounts: Accounts; + /** nixamp.com, or whatever is the issuer. */ + issuer: string; + /** This site, as the redirect URI is built from it: https://backtoschool.help. */ + site: string; + /** The redirect URIs the client registered on the issuer; the callback must be one. */ + redirectUris: string[]; + /** Where the browser lands after the callback. */ + home?: string; + secureCookies?: boolean; + fetcher?: typeof fetch; +} + +const COOKIE = "nixamp_connect"; +const CALLBACK = "/api/v1/nixamp/callback"; + +export function nixampLinkPath(path: string): boolean { + return path === "/api/v1/nixamp/connect" || path === CALLBACK || path === "/api/v1/nixamp/connection" || path === "/api/v1/nixamp/streams"; +} + +function json(response: ServerResponse, code: number, body: unknown, headers: Record = {}): void { + const value = JSON.stringify(body); + response.writeHead(code, { + ...headers, + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(value), + "cache-control": "no-store", + }); + response.end(value); +} + +function cookieValue(headers: IncomingMessage["headers"], name: string): string { + const raw = headers.cookie ?? ""; + for (const part of raw.split(";")) { + const [key, ...rest] = part.trim().split("="); + if (key === name) return decodeURIComponent(rest.join("=")); + } + return ""; +} + +function legCookie(value: string, secure: boolean): string { + const parts = [`${COOKIE}=${encodeURIComponent(value)}`, `Path=${CALLBACK}`, "Max-Age=600", "SameSite=Lax", "HttpOnly"]; + if (secure) parts.push("Secure"); + return parts.join("; "); +} + +function clearedLegCookie(): string { + return `${COOKIE}=; Path=${CALLBACK}; Max-Age=0; SameSite=Lax; HttpOnly`; +} + +function landing(home: string, outcome: string, reason = ""): string { + const url = new URL(home, "http://placeholder.invalid"); + url.searchParams.set("nixamp", outcome); + if (reason) url.searchParams.set("reason", reason.slice(0, 200)); + return `${url.pathname}${url.search}${url.hash || "#settings"}`; +} + +/** + * Every server the nixamp account remembers, asked what it is doing. + * + * The account's own list comes from nixamp.com with the client token; each + * server is then asked directly, with the share key the account kept for it, + * and given four seconds. One that does not answer is listed as out of + * reach rather than dropped, so a host sees why a stream is missing. + */ +export async function streamsFor(token: string, issuer: string, pageSite: string, fetcher: typeof fetch): Promise { + const base = issuer.replace(/\/+$/, ""); + let servers: { id: string; name: string; url: string; key: string }[] = []; + const answer = await fetcher(`${base}/api/v1/servers`, { + headers: { authorization: `Bearer ${token}`, accept: "application/json" }, + signal: AbortSignal.timeout(8000), + }); + if (!answer.ok) throw new Error(answer.status === 401 ? "nixamp.com no longer accepts this connection" : `nixamp.com answered ${answer.status}`); + const body = (await answer.json().catch(() => ({}))) as { servers?: typeof servers }; + servers = Array.isArray(body.servers) ? body.servers : []; + const page = pageSite.replace(/\/+$/, ""); + const linkTo = (address: string, play: string): string => `${page}/?url=${encodeURIComponent(address)}&play=${encodeURIComponent(play)}`; + return Promise.all(servers.map(async (server): Promise => { + const out: ServerStreams = { id: server.id, name: server.name || server.url, url: server.url, reachable: false, playing: false, nowPlaying: "", live: "", channels: [] }; + try { + const url = new URL("/api/streams", server.url.endsWith("/") ? server.url : `${server.url}/`); + if (server.key) url.searchParams.set("k", server.key); + const response = await fetcher(url.href, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(4000) }); + if (!response.ok) return out; + const data = (await response.json()) as { + server?: { name?: string; nowPlaying?: string; playing?: boolean; url?: string }; + channels?: { id: string; name: string; kind?: string; listeners?: number }[]; + }; + // What a viewer link is built on: the server's own view-only address + // when it publishes one, else its plain address. Never the key the + // account kept, which may open the controls. + const viewer = data.server?.url && /^https?:\/\//.test(data.server.url) ? data.server.url : server.url; + out.reachable = true; + out.name = data.server?.name || out.name; + out.playing = data.server?.playing === true; + out.nowPlaying = data.server?.nowPlaying ?? ""; + out.live = out.playing ? linkTo(viewer, "live") : ""; + out.channels = (data.channels ?? []).map((one): StreamPick => ({ + server: out.name, + serverUrl: server.url, + id: one.id, + name: one.name, + kind: one.kind ?? "audio", + listeners: typeof one.listeners === "number" ? one.listeners : 0, + link: linkTo(viewer, `channel:${one.id}`), + })); + } catch { + // Out of reach: said so, above. + } + return out; + })); +} + +export async function handleNixampLinkApi( + request: IncomingMessage, + response: ServerResponse, + url: URL, + options: NixampLinkApiOptions, +): Promise { + const path = url.pathname; + if (!nixampLinkPath(path)) return false; + const home = options.home ?? "/"; + const redirectUri = `${options.site.replace(/\/+$/, "")}${CALLBACK}`; + const registered = options.redirectUris.includes(redirectUri); + + const account = await options.accounts.whoIs(tokenFrom(request.headers)); + if (account === null) { + if (path === CALLBACK) { + response.writeHead(302, { location: landing(home, "failed", "sign in here first, then connect again"), "set-cookie": clearedLegCookie() }); + response.end(); + return true; + } + json(response, 401, { error: "sign in first" }); + return true; + } + + if (path === "/api/v1/nixamp/connect") { + if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; } + if (!registered) { json(response, 400, { error: "this site is not registered as a nixamp client" }); return true; } + const leg = options.links.begin(redirectUri); + response.writeHead(302, { location: leg.url, "set-cookie": legCookie(`${leg.state}.${leg.verifier}`, options.secureCookies ?? false), "cache-control": "no-store" }); + response.end(); + return true; + } + + if (path === CALLBACK) { + if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; } + const kept = cookieValue(request.headers, COOKIE); + const dot = kept.indexOf("."); + const state = dot > 0 ? kept.slice(0, dot) : ""; + const verifier = dot > 0 ? kept.slice(dot + 1) : ""; + const headers = { "set-cookie": clearedLegCookie(), "cache-control": "no-store" }; + const refused = url.searchParams.get("error"); + if (refused) { + response.writeHead(302, { ...headers, location: landing(home, refused === "access_denied" ? "denied" : "failed", url.searchParams.get("error_description") ?? refused) }); + response.end(); + return true; + } + const code = url.searchParams.get("code") ?? ""; + if (state === "" || url.searchParams.get("state") !== state || code === "") { + response.writeHead(302, { ...headers, location: landing(home, "failed", "that link is not the one this browser started; try again") }); + response.end(); + return true; + } + try { + await options.links.finish(account.id, { code, verifier, redirectUri }); + response.writeHead(302, { ...headers, location: landing(home, "connected") }); + } catch (error) { + response.writeHead(302, { ...headers, location: landing(home, "failed", (error as Error).message) }); + } + response.end(); + return true; + } + + if (path === "/api/v1/nixamp/connection") { + if (request.method === "GET") { + json(response, 200, { ...(await options.links.of(account.id)), available: registered }); + return true; + } + if (request.method === "DELETE") { + json(response, 200, { ok: true, withdrawn: await options.links.disconnect(account.id) }); + return true; + } + json(response, 405, { error: "GET or DELETE" }); + return true; + } + + // /api/v1/nixamp/streams + if (request.method !== "GET") { json(response, 405, { error: "GET only" }); return true; } + const token = await options.links.accessToken(account.id); + if (token === "") { json(response, 409, { error: "connect your nixamp account first", connected: false }); return true; } + try { + json(response, 200, { servers: await streamsFor(token, options.issuer, options.issuer, options.fetcher ?? fetch) }); + } catch (error) { + json(response, 502, { error: (error as Error).message }); + } + return true; +} diff --git a/src/nixamp-link-types.ts b/src/nixamp-link-types.ts new file mode 100644 index 0000000..b49e57c --- /dev/null +++ b/src/nixamp-link-types.ts @@ -0,0 +1,36 @@ +/** + * What "Connect nixamp" looks like from a page: a leaf module, so a client + * bundle can name these shapes without pulling the server in behind them. + */ + +/** The connection as the account holder sees it. Never the tokens. */ +export interface LinkView { + connected: boolean; + handle: string; + nixampUserId: string; + scope: string; + since: number | null; +} + +/** A stream somebody could pick: a channel on one of their servers, as a link a classroom accepts. */ +export interface StreamPick { + server: string; + serverUrl: string; + id: string; + name: string; + kind: string; + listeners: number; + link: string; +} + +export interface ServerStreams { + id: string; + name: string; + url: string; + reachable: boolean; + playing: boolean; + nowPlaying: string; + /** A link to what the server itself is playing, when it is. */ + live: string; + channels: StreamPick[]; +} diff --git a/src/nixamp-link.ts b/src/nixamp-link.ts new file mode 100644 index 0000000..e80e607 --- /dev/null +++ b/src/nixamp-link.ts @@ -0,0 +1,265 @@ +/** + * A nixamp account, connected to an account here. + * + * backtoschool.help runs on the nixamp codebase, and for a while its sign-in + * dialog said so: "your BackToSchool identity is your NixAmp account". That + * made a password manager file the nixamp password under the school's + * address, and it made nixamp a thing every teacher had to have. Neither is + * wanted. A BackToSchool account is a BackToSchool account; nixamp is the + * broadcast backend a host may plug in, and plugging it in is a choice. + * + * So the school is an OAuth 2.1 client of nixamp.com, the way bittorrented + * is: PKCE, a consent page on nixamp.com, a refresh token kept here against + * the school account, and a grant the person can withdraw from either side. + * What the connection buys is the list of the servers they run on nixamp and + * what is live on them, so "go live" is a pick from a list rather than a + * link pasted from a terminal. + * + * The client half of OAuth is behind an interface, because the server on the + * other end is this same program in production and a stub in a test. + */ +import { randomBytes } from "node:crypto"; +import type { Queryable } from "./follows.ts"; +import { challengeFor } from "./oauth-server.ts"; +import type { LinkView } from "./nixamp-link-types.ts"; +export type { LinkView } from "./nixamp-link-types.ts"; + +const TABLE = "nixamp_links"; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + user_id TEXT PRIMARY KEY, + nixamp_user_id TEXT NOT NULL, + handle TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + access_expires_at TIMESTAMPTZ, + refresh_token TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); +`; + +/** What the school asks nixamp for: who you are, your servers, and to keep it. */ +export const LINK_SCOPE = "profile streams offline_access"; + +/** How long before an access token's end it is treated as spent. */ +const EARLY_MS = 60_000; + +export interface TokenGrant { + access_token: string; + refresh_token?: string; + expires_in?: number; + scope?: string; +} + +export interface WhoAmI { + sub: string; + handle?: string; +} + +/** The client side of OAuth 2.1, against the issuer, however it is reached. */ +export interface OAuthExchange { + authorizeUrl(query: Record): string; + /** The token endpoint. Rejects with an Error whose message is fit to show. */ + token(form: URLSearchParams): Promise; + userinfo(accessToken: string): Promise; + revoke(token: string): Promise; +} + +export const NOT_CONNECTED: LinkView = { connected: false, handle: "", nixampUserId: "", scope: "", since: null }; + +/** The real thing: nixamp.com over HTTP. */ +export function nixampExchange(issuer: string, clientId: string, fetcher: typeof fetch = fetch): OAuthExchange { + const base = issuer.replace(/\/+$/, ""); + async function post(path: string, form: URLSearchParams, accept: "json" | "none"): Promise> { + let response: Response; + try { + response = await fetcher(`${base}${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, + body: form.toString(), + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new Error("nixamp.com could not be reached"); + } + const body = (await response.json().catch(() => ({}))) as Record; + if (!response.ok && accept === "json") { + const description = typeof body["error_description"] === "string" ? body["error_description"] : typeof body["error"] === "string" ? body["error"] : `nixamp.com answered ${response.status}`; + throw new Error(description); + } + return body; + } + return { + authorizeUrl(query) { + const url = new URL(`${base}/api/v1/oauth/authorize`); + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value); + return url.href; + }, + async token(form) { + form.set("client_id", clientId); + const body = await post("/api/v1/oauth/token", form, "json"); + if (typeof body["access_token"] !== "string" || body["access_token"] === "") throw new Error("nixamp.com sent no token"); + return body as unknown as TokenGrant; + }, + async userinfo(accessToken) { + let response: Response; + try { + response = await fetcher(`${base}/api/v1/oauth/userinfo`, { + headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new Error("nixamp.com could not be reached"); + } + if (!response.ok) throw new Error("nixamp.com did not say whose token that is"); + const body = (await response.json().catch(() => ({}))) as Record; + if (typeof body["sub"] !== "string" || body["sub"] === "") throw new Error("nixamp.com did not say whose token that is"); + return { sub: body["sub"], ...(typeof body["handle"] === "string" ? { handle: body["handle"] } : {}) }; + }, + async revoke(token) { + const form = new URLSearchParams({ token, client_id: clientId }); + await post("/api/v1/oauth/revoke", form, "none").catch(() => undefined); + }, + }; +} + +function asTime(value: unknown): number | null { + if (value instanceof Date) return value.getTime(); + if (typeof value === "string") { + const at = Date.parse(value); + return Number.isNaN(at) ? null : at; + } + return null; +} + +function view(row: Record | undefined): LinkView { + if (!row) return { ...NOT_CONNECTED }; + return { + connected: true, + handle: String(row["handle"] ?? ""), + nixampUserId: String(row["nixamp_user_id"] ?? ""), + scope: String(row["scope"] ?? ""), + since: asTime(row["created_at"]), + }; +} + +export class NixampLinks { + private ready: Promise | null = null; + + constructor( + private readonly db: Queryable, + private readonly exchange: OAuthExchange, + private readonly clientId: string, + private readonly now: () => number = () => Date.now(), + /** How nixamp.com and the servers it lists are reached, for what the tokens are used on. */ + readonly fetcher: typeof fetch = fetch, + ) {} + + private async ensure(): Promise { + this.ready ??= this.db.query(SCHEMA).then(() => undefined); + await this.ready; + } + + /** + * The first leg: where to send the browser, and the two secrets the + * callback must bring back. The state is the CSRF check, the verifier is + * PKCE; both live in a short cookie on the school's origin, never here. + */ + begin(redirectUri: string): { url: string; state: string; verifier: string } { + const state = randomBytes(24).toString("base64url"); + const verifier = randomBytes(48).toString("base64url"); + const url = this.exchange.authorizeUrl({ + response_type: "code", + client_id: this.clientId, + redirect_uri: redirectUri, + scope: LINK_SCOPE, + state, + code_challenge: challengeFor(verifier), + code_challenge_method: "S256", + }); + return { url, state, verifier }; + } + + /** The second leg: the code becomes tokens, the tokens say whose, and that is kept. */ + async finish(userId: string, leg: { code: string; verifier: string; redirectUri: string }): Promise { + const grant = await this.exchange.token(new URLSearchParams({ + grant_type: "authorization_code", + code: leg.code, + code_verifier: leg.verifier, + redirect_uri: leg.redirectUri, + })); + const who = await this.exchange.userinfo(grant.access_token); + await this.ensure(); + await this.db.query( + `INSERT INTO ${TABLE} (user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_id) DO UPDATE SET nixamp_user_id = EXCLUDED.nixamp_user_id, handle = EXCLUDED.handle, + scope = EXCLUDED.scope, access_token = EXCLUDED.access_token, access_expires_at = EXCLUDED.access_expires_at, + refresh_token = EXCLUDED.refresh_token, updated_at = NOW()`, + [userId, who.sub, who.handle ?? "", grant.scope ?? LINK_SCOPE, grant.access_token, this.expiry(grant), grant.refresh_token ?? ""], + ); + return this.of(userId); + } + + private expiry(grant: TokenGrant): Date { + const seconds = typeof grant.expires_in === "number" && grant.expires_in > 0 ? grant.expires_in : 3600; + return new Date(this.now() + seconds * 1000); + } + + async of(userId: string): Promise { + await this.ensure(); + const { rows } = await this.db.query( + `SELECT nixamp_user_id, handle, scope, created_at FROM ${TABLE} WHERE user_id = $1`, + [userId], + ); + return view(rows[0]); + } + + /** + * A token good for a call right now, refreshed when the one kept is about + * to end. "" when there is no connection, or the refresh was refused -- + * which is what a grant withdrawn on nixamp.com looks like from here, and + * the row is dropped so the settings page says so too. + */ + async accessToken(userId: string): Promise { + await this.ensure(); + const { rows } = await this.db.query( + `SELECT access_token, access_expires_at, refresh_token FROM ${TABLE} WHERE user_id = $1`, + [userId], + ); + const row = rows[0]; + if (!row) return ""; + const access = String(row["access_token"] ?? ""); + const until = asTime(row["access_expires_at"]); + if (access !== "" && until !== null && until - this.now() > EARLY_MS) return access; + const refresh = String(row["refresh_token"] ?? ""); + if (refresh === "") return ""; + let grant: TokenGrant; + try { + grant = await this.exchange.token(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh })); + } catch { + await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]); + return ""; + } + await this.db.query( + `UPDATE ${TABLE} SET access_token = $2, access_expires_at = $3, refresh_token = $4, updated_at = NOW() WHERE user_id = $1`, + [userId, grant.access_token, this.expiry(grant), grant.refresh_token ?? refresh], + ); + return grant.access_token; + } + + /** Withdraw the grant on nixamp.com and forget it here. True when there was one. */ + async disconnect(userId: string): Promise { + await this.ensure(); + const { rows } = await this.db.query(`SELECT refresh_token, access_token FROM ${TABLE} WHERE user_id = $1`, [userId]); + const row = rows[0]; + if (!row) return false; + const refresh = String(row["refresh_token"] ?? ""); + const access = String(row["access_token"] ?? ""); + await this.exchange.revoke(refresh !== "" ? refresh : access); + await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1`, [userId]); + return true; + } +} diff --git a/src/oauth-server.ts b/src/oauth-server.ts index 393a7cc..9e0eed0 100644 --- a/src/oauth-server.ts +++ b/src/oauth-server.ts @@ -52,6 +52,7 @@ export const SCOPES = { profile: "who you are on nixamp (your handle)", email: "the address on your account", parties: "host and join watch parties as you", + streams: "see the servers you run on nixamp, and what is live on them", offline_access: "stay connected without asking again", } as const; @@ -83,13 +84,33 @@ export const BITTORRENTED_CLIENT: OAuthClient = { }; /** - * The registered clients: the built-in one, plus whatever NIXAMP_OAUTH_CLIENTS + * backtoschool.help runs on this codebase and used to tell its teachers that + * their school identity *was* a nixamp account. It is not, any more: a school + * account is a school account, and nixamp is the broadcast backend a host may + * connect -- through this client, like any other site. Public, PKCE only. + */ +export const BACKTOSCHOOL_CLIENT: OAuthClient = { + id: "backtoschool", + name: "BackToSchool.help", + homepage: "https://backtoschool.help", + redirectUris: [ + "https://backtoschool.help/api/v1/nixamp/callback", + "https://www.backtoschool.help/api/v1/nixamp/callback", + "http://localhost:5174/api/v1/nixamp/callback", + ], +}; + +/** + * The registered clients: the built-in ones, plus whatever NIXAMP_OAUTH_CLIENTS * names. The variable is a JSON list of `{id, name, redirectUris, secret?, * homepage?}`; an entry with the built-in id replaces it, so a staging * bittorrented can point the callback somewhere else. */ export function clientsFrom(env: Record): OAuthClient[] { - const byId = new Map([[BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT]]); + const byId = new Map([ + [BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT], + [BACKTOSCHOOL_CLIENT.id, BACKTOSCHOOL_CLIENT], + ]); const raw = env["NIXAMP_OAUTH_CLIENTS"]; if (raw) { let parsed: unknown = []; @@ -116,6 +137,7 @@ export function clientsFrom(env: Record): OAuthClien } } if (env["NIXAMP_OAUTH_BITTORRENTED"] === "off") byId.delete(BITTORRENTED_CLIENT.id); + if (env["NIXAMP_OAUTH_BACKTOSCHOOL"] === "off") byId.delete(BACKTOSCHOOL_CLIENT.id); return [...byId.values()]; } diff --git a/src/profiles.ts b/src/profiles.ts new file mode 100644 index 0000000..9258240 Binary files /dev/null and b/src/profiles.ts differ diff --git a/src/server.ts b/src/server.ts index 0500167..806dc8e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,6 +43,9 @@ import { passwordResetPage } from "./password-reset-page.ts"; import { anonymousHandle, Handles } from "./handles.ts"; import { OpenDirs } from "./opendirs.ts"; import { Servers } from "./servers.ts"; +import { AccountProfiles, hostCard, PHOTO_LIMIT } from "./profiles.ts"; +import { NixampLinks, nixampExchange } from "./nixamp-link.ts"; +import { handleNixampLinkApi, nixampLinkPath } from "./nixamp-link-api.ts"; import { DeviceGrants } from "./device.ts"; import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts"; import { @@ -53,7 +56,7 @@ import { signInFailedPage, SignIn, } from "./oauth.ts"; -import { AuthorizationServer, SCOPE_NAMES, clientsFrom } from "./oauth-server.ts"; +import { AuthorizationServer, BACKTOSCHOOL_CLIENT, SCOPE_NAMES, clientsFrom } from "./oauth-server.ts"; import { handleOAuthApi, oauthApiPath } from "./oauth-api.ts"; import { WatchParties } from "./watch-party.ts"; import { needsAdmin, needsMember, Owner } from "./owner.ts"; @@ -1387,6 +1390,12 @@ export function isSignInPath(path: string): boolean { path === "/api/v1/servers" || path.startsWith("/api/v1/servers/") || path === "/api/v1/me/handle" || + path === "/api/v1/me/profile" || + path.startsWith("/api/v1/me/profile/") || + // A stored photo is public: it is on every class the host runs. + path.startsWith("/api/v1/profiles/") || + // "Connect nixamp" on a site that is a client of nixamp.com. + nixampLinkPath(path) || // Public to read, so it must not be behind a share key either. path === "/api/v1/opendirs" || path.startsWith("/api/v1/opendirs/") || @@ -1729,6 +1738,10 @@ export interface HandlerOptions { servers?: Servers; /** The name other people see, which is never the address they signed up with. */ handles?: Handles; + /** The account's card: a name, a homepage, a photo, a line. One table, read by every site here. */ + accountProfiles?: AccountProfiles; + /** nixamp accounts connected to accounts here, where this site is a client of nixamp.com. */ + links?: NixampLinks; /** Open directories people have found, which anyone may read. */ openDirs?: OpenDirs; /** True when this instance is reached over https, for the cookie's Secure. */ @@ -1946,6 +1959,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) { secureCookies: options.secureCookies ?? false, })) return; + // A site here that is a client of nixamp.com: connect, callback, streams. + if (options.links && options.accounts && options.authServer && await handleNixampLinkApi(request, response, url, { + links: options.links, + accounts: options.accounts, + issuer: options.site ?? DEFAULT_DIRECTORY, + site: accountSite, + redirectUris: options.authServer.client(BACKTOSCHOOL_CLIENT.id)?.redirectUris ?? [], + home: "/#settings", + secureCookies: options.secureCookies ?? false, + fetcher: options.links.fetcher, + })) return; + if (options.events && await handleLiveApi(request, response, url, { events: options.events, ...(options.eventWriter ? {eventWriter: options.eventWriter} : {}), @@ -1956,6 +1981,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) { ...(eventSite ? { site: eventSite } : {}), ...(options.invites?.email ? { email: options.invites.email } : {}), ...(options.onEventUpdated ? { onEventUpdated: options.onEventUpdated } : {}), + ...(options.accountProfiles + ? { hostProfile: async (userId: string) => hostCard(await options.accountProfiles!.of(userId), userId, accountSite) } + : {}), })) return; // The page explaining the reminder texts. Public for the same reason the @@ -3560,6 +3588,127 @@ export function createHandler(engine: Engine, options: HandlerOptions) { return; } + // --- the account's card ------------------------------------------------- + // + // A name, a homepage, a photo and a line, said once. A class is made from + // it, a settings page edits it, and an OpenProfile.md fills it in one move. + if ((path === "/api/v1/me/profile" || path.startsWith("/api/v1/me/profile/")) && options.accountProfiles && options.accounts) { + const profiles = options.accountProfiles; + const who = await options.accounts.whoIs(tokenFrom(request.headers)); + if (who === null) { + json(response, 401, { error: "not signed in" }); + return; + } + const persona = options.handles ? await options.handles.persona(who.id) : null; + const answer = async (): Promise => { + const profile = await profiles.of(who.id); + // Read again: an import may just have remembered the address. + const now = options.handles ? await options.handles.persona(who.id) : null; + json(response, 200, { + ...profile, + card: hostCard(profile, who.id, accountSite), + openProfile: now?.profile ?? "", + handle: now?.handle || fallbackHandle(who.id), + }); + }; + + if (path === "/api/v1/me/profile") { + if (request.method === "GET") { await answer(); return; } + if (request.method === "PUT" || request.method === "POST") { + let body: Record; + try { + body = JSON.parse(await readBody(request)) as Record; + } catch { + json(response, 400, { error: "bad JSON" }); + return; + } + const written = await profiles.set(who.id, body); + if (written.error) { json(response, 422, { error: written.error }); return; } + await answer(); + return; + } + json(response, 405, { error: "GET or PUT" }); + return; + } + + if (path === "/api/v1/me/profile/import") { + if (request.method !== "POST") { json(response, 405, { error: "POST only" }); return; } + let body: { url?: unknown } = {}; + try { + const raw = await readBody(request); + body = raw.trim() === "" ? {} : (JSON.parse(raw) as typeof body); + } catch { + json(response, 400, { error: "bad JSON" }); + return; + } + const given = typeof body.url === "string" ? body.url.trim() : ""; + const from = given || persona?.profile || ""; + if (from === "") { + json(response, 422, { error: "give the URL of your OpenProfile.md, like https://you.example/.well-known/openprofile.md" }); + return; + } + const imported = await profiles.importFrom(who.id, from); + if (imported.error) { json(response, 422, { error: imported.error }); return; } + // The address is remembered, so the phone line reads the same file + // for the voice, and a later import needs no URL. + if (given && options.handles && given !== persona?.profile) { + await options.handles.describe(who.id, fallbackHandle(who.id), { profile: given }); + } + await answer(); + return; + } + + if (path === "/api/v1/me/profile/photo") { + if (request.method === "PUT" || request.method === "POST") { + let bytes: Uint8Array; + try { + bytes = await readBytes(request, PHOTO_LIMIT); + } catch { + json(response, 413, { error: "a photo may be up to 1 MB" }); + return; + } + const kept = await profiles.setPhoto(who.id, bytes); + if (kept.error) { json(response, 422, { error: kept.error }); return; } + await answer(); + return; + } + if (request.method === "DELETE") { + await profiles.removePhoto(who.id); + await answer(); + return; + } + json(response, 405, { error: "PUT or DELETE" }); + return; + } + json(response, 404, { error: "no such profile route" }); + return; + } + + // The stored photo, public: it is on every class the host runs, and a + // page shows it to strangers by design. Cached by its etag. + if (path.startsWith("/api/v1/profiles/") && path.endsWith("/photo") && options.accountProfiles) { + if (request.method !== "GET" && request.method !== "HEAD") { json(response, 405, { error: "GET only" }); return; } + const userId = decodeURIComponent(path.slice("/api/v1/profiles/".length, -"/photo".length)); + const photo = userId === "" ? null : await options.accountProfiles.photo(userId); + if (photo === null) { json(response, 404, { error: "no photo" }); return; } + const etag = `"${photo.etag}"`; + if (request.headers["if-none-match"] === etag) { + response.writeHead(304, { etag, "cache-control": "public, max-age=86400" }); + response.end(); + return; + } + response.writeHead(200, { + "content-type": photo.type, + "content-length": photo.bytes.length, + etag, + "cache-control": "public, max-age=86400", + "x-content-type-options": "nosniff", + "access-control-allow-origin": "*", + }); + response.end(request.method === "HEAD" ? undefined : Buffer.from(photo.bytes)); + return; + } + // --- the servers this account runs ---------------------------------- // // Kept against the account rather than the machine, so the list reads the @@ -6004,7 +6153,10 @@ function sendFile(request: IncomingMessage, response: ServerResponse, file: stri export function createServer(engine: Engine, options: HandlerOptions): Server { const handle = createHandler(engine, options); const onRequest = (request: IncomingMessage, response: ServerResponse): void => { - handle(request, response).catch(() => { + handle(request, response).catch((error: unknown) => { + // Silent by default: a stack trace per bad request is a log nobody + // reads. Said aloud when asked, which is how a 500 in a test is found. + if (process.env["NIXAMP_DEBUG"]) console.error(error); if (!response.headersSent) json(response, 500, { error: "server error" }); else response.end(); }); @@ -6864,7 +7016,16 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { // The same pool the follows and reminders use: three small tables in // one database do not want three sets of connections. ...(pool - ? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) } + ? { + servers: new Servers(pool), + handles: new Handles(pool), + openDirs: new OpenDirs(pool), + accountProfiles: new AccountProfiles(pool), + // The school's side of "Connect nixamp": tokens from nixamp.com, + // kept against the school account. The issuer is this same + // program in production, reached over HTTP like any client. + links: new NixampLinks(pool, nixampExchange(nixampSite, BACKTOSCHOOL_CLIENT.id), BACKTOSCHOOL_CLIENT.id), + } : {}), signIn: new SignIn( providersFrom(process.env), diff --git a/test/nixamp-link.test.ts b/test/nixamp-link.test.ts new file mode 100644 index 0000000..d99734d --- /dev/null +++ b/test/nixamp-link.test.ts @@ -0,0 +1,273 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer as createHttp } from "node:http"; +import { randomUUID } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { PostgresAdapter } from "@profullstack/auth-system"; +import pg from "pg"; +import { Accounts, type AdapterLike } from "../src/accounts.ts"; +import type { Queryable } from "../src/follows.ts"; +import { Handles } from "../src/handles.ts"; +import { LINK_SCOPE, NixampLinks, nixampExchange, type OAuthExchange, type TokenGrant } from "../src/nixamp-link.ts"; +import { streamsFor } from "../src/nixamp-link-api.ts"; +import { AuthorizationServer, BACKTOSCHOOL_CLIENT, clientsFrom, SCOPES } from "../src/oauth-server.ts"; +import { createServer, EmptyEngine } from "../src/server.ts"; +import { Servers } from "../src/servers.ts"; + +/** One table, in a Map. */ +function fakeDb(): Queryable & { rows: Map> } { + const rows = new Map>(); + return { + rows, + async query(text: string, values: unknown[] = []) { + const sql = text.trim().replace(/\s+/g, " "); + if (sql.startsWith("CREATE TABLE")) return { rows: [] }; + if (sql.startsWith("INSERT INTO nixamp_links")) { + const [user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token] = values; + const had = rows.get(String(user_id)); + rows.set(String(user_id), { user_id, nixamp_user_id, handle, scope, access_token, access_expires_at, refresh_token, created_at: had?.["created_at"] ?? new Date() }); + return { rows: [] }; + } + if (sql.startsWith("UPDATE nixamp_links SET access_token")) { + const [user_id, access_token, access_expires_at, refresh_token] = values; + const row = rows.get(String(user_id)); + if (row) Object.assign(row, { access_token, access_expires_at, refresh_token }); + return { rows: [] }; + } + if (sql.startsWith("DELETE FROM nixamp_links")) { + rows.delete(String(values[0])); + return { rows: [] }; + } + if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_links WHERE user_id = $1")) { + const row = rows.get(String(values[0])); + return { rows: row ? [row] : [] }; + } + throw new Error(`unexpected SQL: ${sql}`); + }, + }; +} + +function fakeExchange(log: string[]): OAuthExchange & { fail: boolean } { + let n = 0; + const it = { + fail: false, + authorizeUrl(query: Record) { + return `https://nixamp.test/api/v1/oauth/authorize?${new URLSearchParams(query)}`; + }, + async token(form: URLSearchParams): Promise { + log.push(`token:${form.get("grant_type")}`); + if (it.fail) throw new Error("invalid_grant"); + n += 1; + return { access_token: `nxa_access_${n}`, refresh_token: `nxr_refresh_${n}`, expires_in: 3600, scope: LINK_SCOPE }; + }, + async userinfo(token: string) { + log.push(`userinfo:${token}`); + return { sub: "nixamp-user-9", handle: "chovy" }; + }, + async revoke(token: string) { + log.push(`revoke:${token}`); + }, + }; + return it; +} + +test("a connection is begun with PKCE, kept with its tokens, refreshed when stale, and withdrawn on both sides", async () => { + const log: string[] = []; + const exchange = fakeExchange(log); + let clock = 1_000_000; + const links = new NixampLinks(fakeDb(), exchange, "backtoschool", () => clock); + + const leg = links.begin("https://backtoschool.help/api/v1/nixamp/callback"); + const url = new URL(leg.url); + assert.equal(url.searchParams.get("client_id"), "backtoschool"); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.equal(url.searchParams.get("scope"), LINK_SCOPE); + assert.equal(url.searchParams.get("state"), leg.state); + assert.ok(leg.verifier.length >= 43); + assert.notEqual(url.searchParams.get("code_challenge"), leg.verifier, "the verifier itself never leaves"); + assert.deepEqual(await links.of("school-1"), { connected: false, handle: "", nixampUserId: "", scope: "", since: null }); + + const made = await links.finish("school-1", { code: "c0de", verifier: leg.verifier, redirectUri: "https://backtoschool.help/api/v1/nixamp/callback" }); + assert.equal(made.connected, true); + assert.equal(made.handle, "chovy"); + assert.equal(made.nixampUserId, "nixamp-user-9"); + assert.deepEqual(log, ["token:authorization_code", "userinfo:nxa_access_1"]); + + assert.equal(await links.accessToken("school-1"), "nxa_access_1", "fresh: served as kept"); + clock += 3600 * 1000; + assert.equal(await links.accessToken("school-1"), "nxa_access_2", "stale: refreshed"); + assert.equal(log.at(-1), "token:refresh_token"); + assert.equal(await links.accessToken("school-1"), "nxa_access_2", "and the new one is kept"); + + clock += 3600 * 1000; + exchange.fail = true; + assert.equal(await links.accessToken("school-1"), "", "a refused refresh is a withdrawn grant"); + assert.equal((await links.of("school-1")).connected, false); + assert.equal(await links.disconnect("school-1"), false); + + exchange.fail = false; + await links.finish("school-1", { code: "c0de2", verifier: leg.verifier, redirectUri: "https://backtoschool.help/api/v1/nixamp/callback" }); + assert.equal(await links.disconnect("school-1"), true); + assert.equal(log.at(-1), "revoke:nxr_refresh_3", "the refresh token is what is handed back"); + assert.equal((await links.of("school-1")).connected, false); +}); + +test("the school is a registered public client with a streams scope", () => { + const clients = clientsFrom({}); + assert.ok(clients.some((one) => one.id === "backtoschool" && !one.secretHash)); + assert.ok(BACKTOSCHOOL_CLIENT.redirectUris.includes("https://backtoschool.help/api/v1/nixamp/callback")); + assert.ok(!clientsFrom({ NIXAMP_OAUTH_BACKTOSCHOOL: "off" }).some((one) => one.id === "backtoschool")); + assert.match(SCOPES.streams, /servers you run/); +}); + +test("streams are read from every server the account remembers, one that is down is said to be", async () => { + const calls: string[] = []; + const fetcher: typeof fetch = async (input) => { + const url = String(input); + calls.push(url); + if (url === "https://nixamp.test/api/v1/servers") { + return Response.json({ servers: [ + { id: "s1", name: "study", url: "https://study.example", key: "k3y" }, + { id: "s2", name: "", url: "https://down.example", key: "" }, + ] }); + } + if (url.startsWith("https://study.example/api/streams")) { + assert.equal(new URL(url).searchParams.get("k"), "k3y", "the account's own key is used to ask"); + return Response.json({ + server: { name: "study hall", nowPlaying: "Lecture 3", playing: true, url: "https://study.example/view/l1st3n" }, + channels: [{ id: "algebra", name: "Algebra", kind: "video", listeners: 2 }], + }); + } + throw new Error("down"); + }; + const servers = await streamsFor("nxa_t", "https://nixamp.test", "https://nixamp.test", fetcher); + assert.equal(servers.length, 2); + const [study, down] = servers; + assert.equal(study!.reachable, true); + assert.equal(study!.name, "study hall"); + assert.equal(study!.live, "https://nixamp.test/?url=https%3A%2F%2Fstudy.example%2Fview%2Fl1st3n&play=live"); + assert.equal(study!.channels[0]!.link, "https://nixamp.test/?url=https%3A%2F%2Fstudy.example%2Fview%2Fl1st3n&play=channel%3Aalgebra"); + assert.equal(study!.channels[0]!.listeners, 2); + assert.equal(down!.reachable, false); + assert.equal(down!.name, "https://down.example"); + assert.deepEqual(down!.channels, []); +}); + +test("connect, consent, callback and streams, end to end over one server that is both sides", { + skip: !process.env["NIXAMP_TEST_DATABASE_URL"], +}, async () => { + const pool = new pg.Pool({ connectionString: process.env["NIXAMP_TEST_DATABASE_URL"] }); + const adapter = new PostgresAdapter({ pool }) as unknown as AdapterLike; + const accounts = new Accounts({ connectionString: "", secret: "test-secret", adapter }); + const email = `link-${randomUUID()}@example.com`; + const signed = await accounts.signUp(email, "Some-password9"); + assert.equal(signed.ok, true); + const account = signed.account!; + + // A nixamp somewhere, with one channel on it. + const machine = createHttp((request, response) => { + if (request.url?.startsWith("/api/streams")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ server: { name: "study", playing: false, url: "" }, channels: [{ id: "algebra", name: "Algebra", kind: "audio", listeners: 0 }] })); + return; + } + response.writeHead(404); response.end(); + }); + await new Promise((resolve) => machine.listen(0, "127.0.0.1", resolve)); + const machineUrl = `http://127.0.0.1:${(machine.address() as AddressInfo).port}`; + + let base = ""; + const authServer = new AuthorizationServer({ db: pool, tokens: accounts.tokens!, clients: clientsFrom({}), issuer: "http://placeholder.test" }); + const viaBase: typeof fetch = (input, init) => fetch(String(input).replace("http://placeholder.test", base), init); + const links = new NixampLinks(pool, nixampExchange("http://placeholder.test", BACKTOSCHOOL_CLIENT.id, viaBase), BACKTOSCHOOL_CLIENT.id, undefined, viaBase); + const server = createServer(new EmptyEngine(), { + web: null, media: false, version: "test", load: async () => [], accounts, + handles: new Handles(pool), servers: new Servers(pool), authServer, links, site: "http://placeholder.test", + webSites: new Map([["backtoschool.help", { site: "https://backtoschool.help", web: "/unused" }]]), + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const school = { authorization: `Bearer ${signed.token}`, host: "backtoschool.help" }; + const at = (path: string, init: RequestInit = {}) => fetch(`${base}${path}`, { redirect: "manual", ...init, headers: { ...school, ...(init.headers ?? {}) } }); + try { + const handle = `h${randomUUID().slice(0, 8)}`; + const claimed = await new Handles(pool).claim(account.id, handle); + assert.equal(claimed.error, ""); + // Remember the machine on the nixamp side (the same account, the same table). + const kept = await fetch(`${base}/api/v1/servers`, { method: "POST", headers: { authorization: `Bearer ${signed.token}`, "content-type": "application/json" }, body: JSON.stringify({ name: "study", url: machineUrl }) }); + assert.equal(kept.status, 201); + + const before = await (await at("/api/v1/nixamp/connection")).json() as { connected: boolean; available: boolean }; + assert.deepEqual(before, { connected: false, handle: "", nixampUserId: "", scope: "", since: null, available: true }); + assert.equal((await at("/api/v1/nixamp/streams")).status, 409, "no streams before a connection"); + assert.equal((await fetch(`${base}/api/v1/nixamp/connect`, { redirect: "manual", headers: { host: "backtoschool.help" } })).status, 401); + assert.equal((await fetch(`${base}/api/v1/nixamp/connect`, { redirect: "manual", headers: { authorization: school.authorization, host: "elsewhere.example" } })).status, 400, "only a registered site may connect"); + + // Leg one: sent to nixamp.com with PKCE, the secrets in a short cookie. + const go = await at("/api/v1/nixamp/connect"); + assert.equal(go.status, 302, await go.text()); + const consent = new URL(go.headers.get("location")!); + assert.equal(consent.pathname, "/api/v1/oauth/authorize"); + assert.equal(consent.searchParams.get("redirect_uri"), "https://backtoschool.help/api/v1/nixamp/callback"); + const cookie = go.headers.get("set-cookie")!; + assert.match(cookie, /^nixamp_connect=.+; Path=\/api\/v1\/nixamp\/callback; Max-Age=600; SameSite=Lax; HttpOnly$/); + const legCookie = cookie.split(";")[0]!; + + // The person, on nixamp.com, says yes. + const form = new URLSearchParams(consent.search); + form.set("decision", "allow"); + const allowed = await fetch(`${base}/api/v1/oauth/authorize`, { method: "POST", redirect: "manual", headers: { authorization: school.authorization, "content-type": "application/x-www-form-urlencoded" }, body: form.toString() }); + assert.equal(allowed.status, 302); + const back = new URL(allowed.headers.get("location")!); + assert.equal(back.origin + back.pathname, "https://backtoschool.help/api/v1/nixamp/callback"); + assert.equal(back.searchParams.get("state"), consent.searchParams.get("state")); + + // A callback with a state this browser never started is refused. + const forged = await at(`/api/v1/nixamp/callback?code=${back.searchParams.get("code")}&state=forged`, { headers: { cookie: legCookie } }); + assert.equal(forged.status, 302); + assert.match(forged.headers.get("location")!, /nixamp=failed/); + + // Leg two: the code becomes tokens on the school account. + const done = await at(`/api/v1/nixamp/callback${back.search}`, { headers: { cookie: legCookie } }); + assert.equal(done.status, 302); + assert.equal(done.headers.get("location"), "/?nixamp=connected#settings"); + assert.match(done.headers.get("set-cookie")!, /Max-Age=0/); + const after = await (await at("/api/v1/nixamp/connection")).json() as { connected: boolean; handle: string; nixampUserId: string; scope: string }; + assert.equal(after.connected, true); + assert.equal(after.handle, handle); + assert.equal(after.nixampUserId, account.id); + assert.equal(after.scope, LINK_SCOPE); + const stored = (await pool.query("SELECT * FROM nixamp_links WHERE user_id=$1", [account.id])).rows[0]; + assert.match(String(stored.access_token), /^nxa_/); + assert.match(String(stored.refresh_token), /^nxr_/); + // nixamp.com lists the school among the account's connections. + const grants = await authServer.grants(account.id); + assert.ok(grants.some((one) => one.clientId === "backtoschool" && one.scope.includes("streams"))); + + // What the connection is for. + const streamed = await at("/api/v1/nixamp/streams"); + assert.equal(streamed.status, 200, await streamed.clone().text()); + const streams = await streamed.json() as { servers: { name: string; reachable: boolean; channels: { id: string; link: string }[] }[] }; + assert.equal(streams.servers.length, 1); + assert.equal(streams.servers[0]!.reachable, true); + assert.equal(streams.servers[0]!.channels[0]!.id, "algebra"); + assert.equal(streams.servers[0]!.channels[0]!.link, `http://placeholder.test/?url=${encodeURIComponent(machineUrl)}&play=channel%3Aalgebra`); + + // Withdrawn here, gone on nixamp.com too. + const gone = await (await at("/api/v1/nixamp/connection", { method: "DELETE" })).json() as { withdrawn: boolean }; + assert.equal(gone.withdrawn, true); + assert.equal(((await (await at("/api/v1/nixamp/connection")).json()) as { connected: boolean }).connected, false); + assert.ok(!(await authServer.grants(account.id)).some((one) => one.clientId === "backtoschool")); + assert.equal(await accounts.whoIs(String(stored.access_token)), null, "the access token was revoked with the grant"); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + machine.closeAllConnections(); + await new Promise((resolve) => machine.close(() => resolve())); + for (const table of ["nixamp_links", "nixamp_oauth_refresh", "nixamp_oauth_codes", "nixamp_servers", "nixamp_handles", "nixamp_tokens"]) { + await pool.query(`DELETE FROM ${table} WHERE user_id=$1`, [account.id]).catch(() => undefined); + } + await pool.query("DELETE FROM users WHERE id=$1", [account.id]); + await pool.end(); + } +}); diff --git a/test/oauth-server.test.ts b/test/oauth-server.test.ts index da555a4..9aaed7f 100644 --- a/test/oauth-server.test.ts +++ b/test/oauth-server.test.ts @@ -350,14 +350,15 @@ test("PKCE accepts only a well-formed verifier that hashes to the challenge", () assert.equal(verifierMatches(undefined, challengeFor(verifier)), false); }); -test("bittorrented.com is registered out of the box, and the env may add more", () => { +test("bittorrented.com and the school are registered out of the box, and the env may add more", () => { assert.ok(clientsFrom({}).some((client) => client.id === "bittorrented")); + assert.ok(clientsFrom({}).some((client) => client.id === "backtoschool")); const extra = clientsFrom({ NIXAMP_OAUTH_CLIENTS: JSON.stringify([{ id: "other", name: "Other", redirectUris: ["https://other.test/cb"] }]), }); - assert.deepEqual(extra.map((client) => client.id).sort(), ["bittorrented", "other"]); + assert.deepEqual(extra.map((client) => client.id).sort(), ["backtoschool", "bittorrented", "other"]); // An entry with no redirect URI is not a client; it is a mistake. - assert.equal(clientsFrom({ NIXAMP_OAUTH_CLIENTS: '[{"id":"bad"}]' }).length, 1); + assert.equal(clientsFrom({ NIXAMP_OAUTH_CLIENTS: '[{"id":"bad"}]' }).length, 2); }); test("a party code is rubbed of the spacing people type, and a watch link must be the client's own site", () => { diff --git a/test/profiles.test.ts b/test/profiles.test.ts new file mode 100644 index 0000000..b1bf1e8 Binary files /dev/null and b/test/profiles.test.ts differ diff --git a/web/index.html b/web/index.html index 90186f5..6365f1c 100644 --- a/web/index.html +++ b/web/index.html @@ -121,6 +121,11 @@

Now Playing

+ + + + Forgot password? diff --git a/web/src/app.ts b/web/src/app.ts index 2962bec..0cb4627 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -157,6 +157,7 @@ export function start(): void { fullscreen: need("fullscreen"), copyNow: need("copy-now"), copyNixamp: need("copy-nixamp"), + schoolLive: need("school-live"), canvas: need("spectrum"), glyphs: need("glyphs"), levels: need("levels"), @@ -188,6 +189,7 @@ export function start(): void { accountPassword: need("account-password"), accountSubmit: need("account-submit"), accountToggle: need("account-toggle"), + accountForgot: need("account-forgot"), accountProviders: need("account-providers"), accountPanel: need("account-panel"), accountElsewhere: need("account-elsewhere"), @@ -1389,6 +1391,9 @@ export function start(): void { // none, and nothing loaded has nothing to copy. dom.copyNow.hidden = player.source === ""; dom.copyNixamp.hidden = liveRoomLinkNow() === ""; + // Teaching it needs a live room to point at and an account to own the + // class; the school and this site keep the same accounts. + dom.schoolLive.hidden = liveRoomLinkNow() === "" || !keepsAccounts || meId === ""; // Share sits with the transport, for anything with an address safe to hand out. dom.shareNow.hidden = shareLinkNow() === ""; // The trollbox follows whatever live is joined. @@ -4631,6 +4636,49 @@ export function start(): void { if (link !== "") void copyText(link, dom.copyNixamp, "✓"); }); + /** Where classes are. The school runs on this same server and keeps the same accounts. */ + const SCHOOL_SITE = "https://backtoschool.help"; + + /** + * One click: a class on backtoschool.help that plays this live room. The + * events API is the school's own, on this origin, so the session cookie + * carries it; the host card comes from the profile, so nothing is asked. + * The class is started at once and opened in a new tab. + */ + async function teachOnSchool(): Promise { + const link = liveRoomLinkNow(); + if (link === "" || dom.schoolLive.disabled) return; + const was = dom.schoolLive.textContent; + dom.schoolLive.disabled = true; + dom.schoolLive.textContent = "Opening a classroom…"; + const headers = { "content-type": "application/json" }; + try { + const made = await fetch("/api/v1/events", { + method: "POST", headers, + body: JSON.stringify({ kind: "class", title: currentShareTitle() || "Live class", broadcastUrl: link, visibility: "public" }), + }); + const body = (await made.json().catch(() => ({}))) as { event?: { id: string; slug: string; version: number }; error?: string }; + if (!made.ok || !body.event) throw new Error(body.error ?? "the class could not be made"); + const event = body.event; + const started = await fetch(`/api/v1/events/${encodeURIComponent(event.id)}/start`, { + method: "POST", headers, body: JSON.stringify({ version: event.version }), + }); + const where = `${SCHOOL_SITE}/live/${encodeURIComponent(event.slug)}`; + note = started.ok + ? `Your class is live on backtoschool.help: ${where}` + : `The class is made but not started yet. Open it to start: ${where}`; + draw(); + globalThis.open(where, "_blank", "noopener"); + } catch (error) { + note = `Could not open a classroom: ${(error as Error).message}`; + draw(); + } finally { + dom.schoolLive.disabled = false; + dom.schoolLive.textContent = was; + } + } + dom.schoolLive.addEventListener("click", () => { void teachOnSchool(); }); + dom.favHere.addEventListener("click", () => { // Kept as the view link, so opening a favourite later is watching it; // administering is what the directory's Admin button is for. @@ -5580,6 +5628,10 @@ export function start(): void { uiText(dom.accountSubmit, () => creating ? uiMessage("Create account") : uiMessage("Sign in")); uiText(dom.accountToggle, () => creating ? "I have one" : uiMessage("Create one")); dom.accountPassword.autocomplete = creating ? "new-password" : "current-password"; + // Nothing to forget while making an account. Inside a classroom embed + // the page is somebody else's iframe, so recovery opens in its own tab. + dom.accountForgot.hidden = creating; + if (classroomEmbed) { dom.accountForgot.target = "_blank"; dom.accountForgot.rel = "noopener"; } showWelcome(); }; diff --git a/web/src/styles.css b/web/src/styles.css index 154346c..b8bb044 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -938,11 +938,17 @@ button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid button.primary { color: var(--green); border-color: var(--green-dim); min-width: 64px; } button.ghost { background: transparent; color: var(--muted); } +/* A link dressed as a ghost button, so "Forgot password?" sits with "Create one". */ +a.ghost { color: var(--muted); font-size: 0.9em; text-decoration: none; padding: 6px 4px; } +a.ghost:hover { color: var(--green); text-decoration: underline; } /* Share, with the transport: the one button there that is for somebody else. */ button.share { color: var(--accent); border-color: var(--edge); letter-spacing: 0.04em; } button.share:hover { background: #16241c; } /* Go live: red, because that is what "on the air" has looked like since radio. */ button.golive { color: #ff5c5c; border-color: #7a2b2b; letter-spacing: 0.06em; } +/* Teach this: the school's purple, so it reads as another place. */ +button.school { color: #b9a6ff; border-color: #4a3a8a; } +button.school:hover { color: #d9cdff; border-color: #6d5bc4; } button.golive:hover { color: #fff; background: #7a2b2b; border-color: #ff5c5c; } button.golive:disabled { opacity: 0.5; cursor: progress; } .row-live:hover { color: #ff5c5c; border-color: #ff5c5c; }