From ced09ec08d85d8770b294006159ea5e5a0218ba8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 16 Sep 2026 14:08:23 +0000 Subject: [PATCH 1/2] Account panel: a "Forgot password?" link to the reset page nixamp.com already serves PR #181 built password recovery (hashed single-use links, Resend mail, session revocation) and put a "Forgot password?" link on the BackToSchool sign-in, but nixamp.com's own account panel never linked to it. A member whose password no longer matched read "that email and password do not match an account" with nowhere to go, although /reset-password was live on the same origin. The link sits next to "Create one", hides while creating an account, and opens in its own tab inside a classroom embed so the host page's iframe is not navigated away. Co-Authored-By: Claude Fable 5.1 --- web/index.html | 3 +++ web/src/app.ts | 5 +++++ web/src/styles.css | 3 +++ 3 files changed, 11 insertions(+) diff --git a/web/index.html b/web/index.html index 90186f5..d1ceedd 100644 --- a/web/index.html +++ b/web/index.html @@ -288,6 +288,9 @@

Now Playing

placeholder="password" data-i18n-placeholder="password" aria-label="Password" data-i18n-aria-label="Password" /> + + Forgot password? diff --git a/web/src/app.ts b/web/src/app.ts index 2962bec..e0fd1d9 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -188,6 +188,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"), @@ -5580,6 +5581,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..e6af70b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -938,6 +938,9 @@ 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; } From 9f86b7e093e18490eac15c8304f0fb66db899dba Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 16 Sep 2026 16:23:46 +0000 Subject: [PATCH 2/2] BackToSchool is its own account; nixamp is an optional backend; the host card is said once backtoschool.help runs in the nixamp process over the same users table, and its sign-in dialog said so ("your BackToSchool identity is your NixAmp account"), which filed the nixamp password under the school in a password manager and made nixamp a thing every teacher had to have. Neither is wanted. The account card, shared by both apps: src/profiles.ts keeps a name, a homepage, a line, a linked avatar and an uploaded photo (bytes in Postgres, type sniffed, served with an etag) against the account. An OpenProfile.md fills it in one move and its address is remembered on the persona. A class made without host fields takes the card, so the school form no longer asks for a name and photo every time; it shows the card and points at settings. Connect nixamp, optional: the school is a built-in public OAuth 2.1 client of nixamp.com (PKCE, consent page, refresh rotation, a new `streams` scope). src/nixamp-link.ts keeps the tokens against the school account and refreshes them; src/nixamp-link-api.ts is connect, callback, connection and streams. What it buys is a pick list of the servers you run and what is live on them, instead of a link pasted from a terminal. Withdrawing it on either side ends it on both. The school gets a settings dialog (profile, photo, OpenProfile, the connection, sign out) and loses every mention of nixamp on the way in. nixamp.com gets "Teach this on backtoschool.help": one click makes the class from the live room and the card, starts it, and opens it. Tests: unit tests for the card parser, the link module and the streams reader; two real-Postgres runs (card round trip with a photo, an import and a class; connect through consent to callback, streams and disconnect over one server that is both sides). The server's catch-all now logs under NIXAMP_DEBUG, which is how the second of those was made to pass. Co-Authored-By: Claude Fable 5.1 --- backtoschool/index.html | 63 ++++++++- backtoschool/src/main.ts | 263 +++++++++++++++++++++++++++++++--- backtoschool/src/styles.css | 16 +++ src/live-api.ts | 13 ++ src/nixamp-link-api.ts | 220 +++++++++++++++++++++++++++++ src/nixamp-link-types.ts | 36 +++++ src/nixamp-link.ts | 265 ++++++++++++++++++++++++++++++++++ src/oauth-server.ts | 26 +++- src/profiles.ts | Bin 0 -> 11793 bytes src/server.ts | 167 +++++++++++++++++++++- test/nixamp-link.test.ts | 273 ++++++++++++++++++++++++++++++++++++ test/oauth-server.test.ts | 7 +- test/profiles.test.ts | Bin 0 -> 9919 bytes web/index.html | 5 + web/src/app.ts | 47 +++++++ web/src/styles.css | 3 + 16 files changed, 1368 insertions(+), 36 deletions(-) create mode 100644 src/nixamp-link-api.ts create mode 100644 src/nixamp-link-types.ts create mode 100644 src/nixamp-link.ts create mode 100644 src/profiles.ts create mode 100644 test/nixamp-link.test.ts create mode 100644 test/profiles.test.ts 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 0000000000000000000000000000000000000000..925824045daa26b6ab808adb021d19f975fd145a GIT binary patch literal 11793 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 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 0000000000000000000000000000000000000000..b1bf1e8bad609f5305eb0f4386f084de776fd06d GIT binary patch literal 9919 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 Playing + +