diff --git a/README.md b/README.md index f2a987b..12d0f83 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,12 @@ NIXAMP_OAUTH_CLIENTS='[{"id":"example","name":"Example","redirectUris":["https:/ ``` Once a party is bridged it is an ordinary live event with a room, so every -surface already knows what to do with it: +surface already knows what to do with it. Its room is a page, +`nixamp.com/live/`: the host, where the film is (a clock that keeps +counting), **Join party** to the site that plays it, and the chat, which is +the same chat on the party's own page, in the terminal, in the desktop app +and on a television. Reading one party needs no account, because the code or +the link is the invitation; saying something does. ``` nixamp party list the ones you could join right now diff --git a/src/oauth-api.ts b/src/oauth-api.ts index 2b63761..255b260 100644 --- a/src/oauth-api.ts +++ b/src/oauth-api.ts @@ -13,7 +13,7 @@ * * POST /api/v1/watch-parties bridge one, idempotently * GET /api/v1/watch-parties the ones you could join - * GET /api/v1/watch-parties/ one, with where playback is + * GET /api/v1/watch-parties/ one, with where playback is (open: the code is the invitation) * POST /api/v1/watch-parties//playback the host moving everybody * POST /api/v1/watch-parties//end the host ending it * @@ -189,12 +189,12 @@ async function callerFor( return account ? { account, clientId: "", scope: SCOPE_NAMES } : null; } -function partyBody(parties: WatchParties, view: PartyView, caller: Caller): Record { +function partyBody(parties: WatchParties, view: PartyView, caller: Caller | null): Record { return { party: { ...view.party, positionNow: parties.positionNow(view.party) }, event: view.event, links: parties.links(view.party), - host: view.event.ownerId === caller.account.id, + host: caller !== null && view.event.ownerId === caller.account.id, }; } @@ -381,16 +381,29 @@ export async function handleOAuthApi( return true; } const caller = await callerFor(request, options, "parties"); - if (caller === null) { + const rest = path.slice("/api/v1/watch-parties/".length).split("/"); + const reference = decodeURIComponent(rest[0] ?? ""); + const action = rest[1]; + // Reading one party is open, the way its /live/ page is: the code + // or the room link IS the invitation, and a person who was handed one + // on a television or in a terminal has no session on nixamp.com yet. + // Everything else -- the list, bridging, moving playback, ending -- + // still needs a session or a token with the parties scope. + const openRead = path !== "/api/v1/watch-parties" && !action && request.method === "GET"; + if (caller === null && !openRead) { json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" }); return true; } // A client bridges parties under its own origin, so two sites cannot // collide on a six-character code; a person acting directly is filed // under nixamp itself. - const origin = caller.clientId || "nixamp"; + const origin = caller?.clientId || "nixamp"; if (path === "/api/v1/watch-parties") { + if (caller === null) { + json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" }); + return true; + } if (request.method === "GET") { const wanted = url.searchParams.get("origin"); const found = await parties.list({ @@ -424,14 +437,16 @@ export async function handleOAuthApi( return true; } - const rest = path.slice("/api/v1/watch-parties/".length).split("/"); - const reference = decodeURIComponent(rest[0] ?? ""); - const action = rest[1]; // A party is findable by its code on the origin that bridged it, or by // the nixamp slug or room a client was handed, because a nixamp client - // arriving from a share link has only the latter. - const view = (await parties.byCode(origin, reference).catch(() => null)) ?? (await parties.byEvent(reference)); - if (!view) { + // arriving from a share link has only the latter. A person on + // nixamp.com with only a code gets the party that code names on + // whichever site bridged it. + const view = + (await parties.byCode(origin, reference).catch(() => null)) ?? + (await parties.byEvent(reference)) ?? + (caller === null || caller.clientId === "" ? await parties.byAnyCode(reference).catch(() => null) : null); + if (!view || (caller === null && view.event.visibility === "private")) { json(response, 404, { error: "watch party not found" }); return true; } @@ -440,6 +455,10 @@ export async function handleOAuthApi( json(response, 200, partyBody(parties, view, caller)); return true; } + if (caller === null) { + json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" }); + return true; + } if (action === "playback" && request.method === "POST") { let input: Record; try { diff --git a/src/watch-party.ts b/src/watch-party.ts index fc01d00..2c9c9c1 100644 --- a/src/watch-party.ts +++ b/src/watch-party.ts @@ -284,6 +284,29 @@ export class WatchParties { return { party: partyFrom(row, event), event }; } + /** + * A party by its code alone, for somebody who has only the code. + * + * A code is unique per origin, not across them; a person typing one into + * nixamp.com is not asked which site it came from. The one still live, or + * the most recently touched, is the one they mean. + */ + async byAnyCode(partyCode: string): Promise { + await this.ensure(); + const code = cleanPartyCode(partyCode); + const { rows } = await this.options.db.query( + `SELECT p.* FROM ${TABLE} p JOIN live_events e ON e.id = p.event_id + WHERE p.party_code = $1 + ORDER BY (e.status = 'live') DESC, p.updated_at DESC LIMIT 1`, + [code], + ); + const row = rows[0]; + if (!row) return null; + const event = await this.options.events.get(String(row["event_id"] ?? "")); + if (!event) return null; + return { party: partyFrom(row, event), event }; + } + /** The party behind a nixamp room or slug, for a client that has only that. */ async byEvent(reference: string): Promise { await this.ensure(); diff --git a/test/oauth-server.test.ts b/test/oauth-server.test.ts index 9aaed7f..952bbc3 100644 --- a/test/oauth-server.test.ts +++ b/test/oauth-server.test.ts @@ -117,6 +117,10 @@ function fakeDb(): Queryable & { events: Map> } } // --- nixamp_watch_parties ---------------------------------------------- + if (sql.startsWith("SELECT") && sql.includes("WHERE p.party_code = $1")) { + const row = [...parties.values()].find((one) => one["party_code"] === values[0]); + return { rows: row ? [row] : [] }; + } if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_watch_parties WHERE origin = $1")) { const row = [...parties.values()].find((one) => one["origin"] === values[0] && one["party_code"] === values[1]); return { rows: row ? [row] : [] }; @@ -596,6 +600,40 @@ test("a watch party bridges to a nixamp room, once, however many times it is ask }); }); +test("one party is readable with no session at all: the code or the room link is the invitation", async () => { + await withServer(async (harness) => { + const token = await connected(harness); + const made = (await ( + await fetch(`${harness.base}/api/v1/watch-parties`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ partyCode: "OPEN01", title: "Open door", partyUrl: "https://bittorrented.com/watch-party?code=OPEN01" }), + }) + ).json()) as { party: { slug: string; partyCode: string }; event: { id: string } }; + + // By slug, which is what nixamp.com/live/ has, and by room id. + for (const reference of [made.party.slug, made.event.id]) { + const open = await fetch(`${harness.base}/api/v1/watch-parties/${encodeURIComponent(reference)}`); + assert.equal(open.status, 200, reference); + const body = (await open.json()) as { party: { partyCode: string }; links: { partyUrl: string }; host: boolean }; + assert.equal(body.party.partyCode, "OPEN01"); + assert.equal(body.links.partyUrl, "https://bittorrented.com/watch-party?code=OPEN01"); + // Nobody is the host of a party they are not signed in to. + assert.equal(body.host, false); + } + + // Reading is open; the list, and every write, still are not. + assert.equal((await fetch(`${harness.base}/api/v1/watch-parties`)).status, 401); + assert.equal( + (await fetch(`${harness.base}/api/v1/watch-parties/${made.party.partyCode}/playback`, { method: "POST", body: "{}" })).status, + 401, + ); + assert.equal((await fetch(`${harness.base}/api/v1/watch-parties/${made.party.partyCode}/end`, { method: "POST" })).status, 401); + const missing = await fetch(`${harness.base}/api/v1/watch-parties/nothing-here`); + assert.equal(missing.status, 404, await missing.text()); + }); +}); + test("a watch link must be on the client's own site", async () => { await withServer(async (harness) => { const token = await connected(harness); diff --git a/web/index.html b/web/index.html index 6365f1c..da9f767 100644 --- a/web/index.html +++ b/web/index.html @@ -276,6 +276,29 @@

Now Playing

+ + +