Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`: 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
Expand Down
41 changes: 30 additions & 11 deletions src/oauth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code> one, with where playback is
* GET /api/v1/watch-parties/<code> one, with where playback is (open: the code is the invitation)
* POST /api/v1/watch-parties/<code>/playback the host moving everybody
* POST /api/v1/watch-parties/<code>/end the host ending it
*
Expand Down Expand Up @@ -189,12 +189,12 @@ async function callerFor(
return account ? { account, clientId: "", scope: SCOPE_NAMES } : null;
}

function partyBody(parties: WatchParties, view: PartyView, caller: Caller): Record<string, unknown> {
function partyBody(parties: WatchParties, view: PartyView, caller: Caller | null): Record<string, unknown> {
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,
};
}

Expand Down Expand Up @@ -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/<slug> 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({
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<string, unknown>;
try {
Expand Down
23 changes: 23 additions & 0 deletions src/watch-party.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PartyView | null> {
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<PartyView | null> {
await this.ensure();
Expand Down
38 changes: 38 additions & 0 deletions test/oauth-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ function fakeDb(): Queryable & { events: Map<string, Record<string, unknown>> }
}

// --- 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] : [] };
Expand Down Expand Up @@ -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/<slug> 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);
Expand Down
23 changes: 23 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,29 @@ <h2 class="panel-heading" data-i18n="Now Playing">Now Playing</h2>
<p class="hint"><a href="/" id="directory-back" hidden data-i18n="Back to the player">Back to the player</a></p>
</section>

<!-- A watch party's room: nixamp.com/live/<slug>. The film plays on the
site that has it (Join party); what is here is the room -- who
is hosting, where the film is, and the chat every nixamp client
reads. A page, like /directory, not a drawer under the player. -->
<section class="panel col-b party-room" data-title="Watch party" data-i18n-data-title="Watch party" id="party-room" hidden>
<h2 id="party-title" class="party-title"></h2>
<p class="hint" id="party-meta"></p>
<p class="hint" id="party-note" hidden></p>
<div class="onair-actions party-room-actions">
<a id="party-watch" class="button" href="#" rel="noopener" target="_blank" data-i18n="Join party">Join party</a>
<button id="party-copy" type="button" class="ghost" data-i18n="Copy room link">Copy room link</button>
<button id="party-end" type="button" class="ghost" hidden data-i18n="End party">End party</button>
</div>
<ol id="party-chat" class="party-chat" aria-live="polite" aria-label="Party chat" data-i18n-aria-label="Party chat"></ol>
<form id="party-chat-form" class="picker">
<label for="party-chat-input" data-i18n="Say something">Say something</label>
<input id="party-chat-input" type="text" maxlength="1000" autocomplete="off" placeholder="Everybody in the party reads this" data-i18n-placeholder="Everybody in the party reads this" />
<button id="party-chat-send" type="submit" class="button" data-i18n="Send">Send</button>
</form>
<p class="hint" id="party-chat-note" hidden></p>
<p class="hint"><a href="/" data-i18n="Back to the player">Back to the player</a></p>
</section>

<section class="panel player-only col-a" data-title="Favourites" data-i18n-data-title="Favourites" id="favorites-panel" hidden>
<p class="hint" id="favorites-note">Servers you hearted. Connect to one, or let it go.</p>
<ul id="favorites-list" class="directory-list"></ul>
Expand Down
Loading
Loading