diff --git a/src/app/api/v1/nixamp/oauth/callback/route.ts b/src/app/api/v1/nixamp/oauth/callback/route.ts index 76b0881a..5f65bbd9 100644 --- a/src/app/api/v1/nixamp/oauth/callback/route.ts +++ b/src/app/api/v1/nixamp/oauth/callback/route.ts @@ -1,7 +1,11 @@ /** * GET /api/v1/nixamp/oauth/callback * - * Where nixamp sends the browser back. This is the exact path registered with + * Where nixamp sends the browser back, to connect an account or to sign in + * as one ("Sign in with nixamp": no session here yet, the identity is the + * one nixamp vouches for, and the account with that email is theirs). + * + * This is the exact path registered with * nixamp, which matches it byte for byte, so it is not something to rename * lightly: the OAuth 2.1 rule is exact matching, with only a loopback port * allowed to vary. @@ -14,24 +18,35 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCurrentUser } from '@/lib/auth'; import { + AUTH_COOKIE_MAX_AGE, + AUTH_COOKIE_NAME, discover, exchangeCodeForTokens, fetchUserInfo, getNixampOAuthConfig, NIXAMP_OAUTH_STATE_COOKIE, + NixampSignInError, + safeRedirect, + sessionCookieValue, + sessionForNixampIdentity, upsertNixampAccount, } from '@/lib/nixamp'; const SETTINGS = '/settings?tab=connections'; -function back(origin: string, params: Record): NextResponse { - const url = new URL(SETTINGS, origin); +function back(origin: string, params: Record, to = SETTINGS): NextResponse { + const url = new URL(to, origin); for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value); const res = NextResponse.redirect(url); res.cookies.delete(NIXAMP_OAUTH_STATE_COOKIE); return res; } +/** A refusal on the way in goes to the login page, which knows how to say it. */ +function refuse(origin: string, reason: string, redirect: string): NextResponse { + return back(origin, { nixamp_error: reason, redirect }, '/login'); +} + export async function GET(request: NextRequest): Promise { // The public origin, from the configured redirect URI rather than // request.url, which behind a proxy can be the internal bind address. @@ -44,28 +59,35 @@ export async function GET(request: NextRequest): Promise { return back(new URL(request.url).origin, { nixamp_error: 'server_misconfigured' }); } + const cookie = request.cookies.get(NIXAMP_OAUTH_STATE_COOKIE)?.value; + let kept: { state?: string; verifier?: string; redirect?: string; signin?: boolean } = {}; + try { + kept = cookie ? (JSON.parse(cookie) as typeof kept) : {}; + } catch { + kept = {}; + } + const redirect = safeRedirect(kept.redirect); + + // Signed in: connecting nixamp to this account. Signed out: this IS the + // sign-in, and the cookie set on the way out says so -- a callback that + // arrives with no session and no such cookie is not a flow we started. const user = await getCurrentUser(); - if (!user) return back(origin, { nixamp_error: 'not_authenticated' }); + const signin = !user && kept.signin === true; + if (!user && !signin) return back(origin, { nixamp_error: 'not_authenticated' }); + const fail = (reason: string): NextResponse => (signin ? refuse(origin, reason, redirect) : back(origin, { nixamp_error: reason })); const { searchParams } = new URL(request.url); const refused = searchParams.get('error'); - if (refused) return back(origin, { nixamp_error: refused }); + if (refused) return fail(refused); const code = searchParams.get('code'); const state = searchParams.get('state'); - if (!code || !state) return back(origin, { nixamp_error: 'missing_code_or_state' }); + if (!code || !state) return fail('missing_code_or_state'); - const cookie = request.cookies.get(NIXAMP_OAUTH_STATE_COOKIE)?.value; - let kept: { state?: string; verifier?: string } = {}; - try { - kept = cookie ? (JSON.parse(cookie) as typeof kept) : {}; - } catch { - kept = {}; - } // Both halves, and the state compared rather than merely present: a // callback whose state we did not issue is somebody replaying a URL. if (!kept.state || !kept.verifier || kept.state !== state) { - return back(origin, { nixamp_error: 'state_mismatch' }); + return fail('state_mismatch'); } try { @@ -73,8 +95,20 @@ export async function GET(request: NextRequest): Promise { const tokens = await exchangeCodeForTokens(config, metadata, code, kept.verifier); const who = await fetchUserInfo(metadata, tokens.access_token); + let userId = user?.id ?? ''; + let session: { accessToken: string; refreshToken: string } | null = null; + if (signin) { + const minted = await sessionForNixampIdentity({ + email: who.email ?? '', + sub: who.sub, + ...(who.handle ? { handle: who.handle } : {}), + }); + userId = minted.userId; + session = minted; + } + await upsertNixampAccount({ - userId: user.id, + userId, nixampSub: who.sub, nixampSite: config.site, // The handle, never the address: on nixamp the account email is the @@ -88,9 +122,22 @@ export async function GET(request: NextRequest): Promise { scopes: tokens.scope ? tokens.scope.split(' ').filter(Boolean) : [], }); - return back(origin, { nixamp: 'connected' }); + // Home is wherever they were going: the party they were sent to, or the + // connections tab, which is what a plain Connect started from. + const res = back(origin, signin ? {} : { nixamp: 'connected' }, redirect); + if (session) { + res.cookies.set(AUTH_COOKIE_NAME, sessionCookieValue(session), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: AUTH_COOKIE_MAX_AGE, + }); + } + return res; } catch (err) { console.error('[nixamp OAuth] callback failed:', err); - return back(origin, { nixamp_error: 'exchange_failed' }); + if (err instanceof NixampSignInError) return fail(err.reason); + return fail('exchange_failed'); } } diff --git a/src/app/api/v1/nixamp/oauth/start/route.ts b/src/app/api/v1/nixamp/oauth/start/route.ts index 3b34f3c3..bf294fa0 100644 --- a/src/app/api/v1/nixamp/oauth/start/route.ts +++ b/src/app/api/v1/nixamp/oauth/start/route.ts @@ -1,9 +1,12 @@ /** * GET /api/v1/nixamp/oauth/start * - * Begins the OAuth 2.1 flow that connects a nixamp account to this one. + * Begins the OAuth 2.1 flow that connects a nixamp account to this one, or, + * for somebody signed out, signs them in here as their nixamp self. * nixamp is the authorization server; we are the client. * + * ?redirect=/watch-party?code=ABC123 where to land afterwards (this site only) + * * Two secrets go out in one httpOnly cookie and neither ever reaches the * browser's JavaScript: the CSRF state, which proves the callback belongs to * a flow we started, and the PKCE verifier, which proves the code exchange is @@ -22,17 +25,18 @@ import { getNixampOAuthConfig, NIXAMP_OAUTH_STATE_COOKIE, NIXAMP_OAUTH_STATE_MAX_AGE_SECONDS, + safeRedirect, } from '@/lib/nixamp'; export async function GET(request: NextRequest): Promise { - const origin = new URL(request.url).origin; + const url = new URL(request.url); + const origin = url.origin; + const redirect = safeRedirect(url.searchParams.get('redirect')); const user = await getCurrentUser(); - if (!user) { - // Connecting is an act of an account: there has to be one to connect TO. - const back = new URL('/login', origin); - back.searchParams.set('redirect', '/settings?tab=connections'); - return NextResponse.redirect(back); - } + // Signed out, this is "Sign in with nixamp": the same round trip, and the + // callback makes the account here from the identity there. Signed in, it + // connects nixamp to the account that is already here. + const signin = !user; let config; try { @@ -48,7 +52,7 @@ export async function GET(request: NextRequest): Promise { const authUrl = buildAuthUrl(config, metadata, state, await codeChallenge(verifier)); const response = NextResponse.redirect(authUrl); - response.cookies.set(NIXAMP_OAUTH_STATE_COOKIE, JSON.stringify({ state, verifier }), { + response.cookies.set(NIXAMP_OAUTH_STATE_COOKIE, JSON.stringify({ state, verifier, redirect, signin }), { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', diff --git a/src/app/api/watch-party/_view.ts b/src/app/api/watch-party/_view.ts new file mode 100644 index 00000000..d35a3bf0 --- /dev/null +++ b/src/app/api/watch-party/_view.ts @@ -0,0 +1,54 @@ +/** + * What a party looks like to a client, and who counts as its host. + * + * One shape for every route, so the page can poll GET and get exactly what + * create, join and the playback update answered. + */ + +import type { WatchParty } from '@/lib/watch-party'; + +export interface PartyView { + id: string; + code: string; + hostId: string; + hostName: string; + mediaUrl: string; + mediaTitle: string; + state: WatchParty['state']; + memberCount: number; + members: { id: string; name: string; isHost: boolean }[]; + playback: WatchParty['playback']; + settings: WatchParty['settings']; + createdAt: string; +} + +export function partyView(party: WatchParty): PartyView { + return { + id: party.id, + code: party.code, + hostId: party.hostId, + hostName: party.hostName, + mediaUrl: party.mediaUrl, + mediaTitle: party.mediaTitle, + state: party.state, + memberCount: party.members.length, + members: party.members.map((m) => ({ id: m.id, name: m.name, isHost: m.isHost })), + playback: party.playback, + settings: party.settings, + createdAt: party.createdAt.toISOString(), + }; +} + +/** + * Is this request the host's? + * + * A signed-in host is proven by the session: the party's hostId is their user + * id, and the cookie is the only thing that says so. A guest-hosted party has + * only its guest string, which is the best such a party can do -- and it is + * accepted only for guest hosts, so a member who saw a signed-in host's id in + * the member list cannot present it as their own. + */ +export function isHostOf(party: WatchParty, sessionUserId: string | undefined, claimedHostId: string | undefined): boolean { + if (sessionUserId && sessionUserId === party.hostId) return true; + return party.hostId.startsWith('guest_') && claimedHostId === party.hostId; +} diff --git a/src/app/api/watch-party/nixamp/route.test.ts b/src/app/api/watch-party/nixamp/route.test.ts index 4e97afa1..390550cc 100644 --- a/src/app/api/watch-party/nixamp/route.test.ts +++ b/src/app/api/watch-party/nixamp/route.test.ts @@ -13,6 +13,8 @@ const nixamp = vi.hoisted(() => ({ endBridgedParty: vi.fn(), getBridgedRoom: vi.fn(), pushPlayback: vi.fn(), + readRoomChat: vi.fn(), + postRoomChat: vi.fn(), NixampNotConnected: class NixampNotConnected extends Error {}, NixampConnectionLost: class NixampConnectionLost extends Error {}, })); @@ -129,7 +131,7 @@ describe('POST', () => { nixamp.bridgeParty.mockRejectedValue(new nixamp.NixampNotConnected()); const res = await POST(post({ code: 'ABC123' })); expect(res.status).toBe(409); - expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start'); + expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start?redirect=%2Fwatch-party%3Fcode%3DABC123'); }); it('says to connect again when the grant has been withdrawn', async () => { @@ -150,3 +152,38 @@ describe('POST', () => { expect(res.status).toBe(400); }); }); + +describe('chat', () => { + it('reads the room to anybody, since the code was the invitation', async () => { + auth.getCurrentUser.mockResolvedValue(null); + nixamp.getBridgedRoom.mockResolvedValue(room); + nixamp.readRoomChat.mockResolvedValue([{ id: 'm1', authorName: 'chovy', body: 'hi', createdAt: '2026-09-17T00:00:00.000Z' }]); + const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=ABC123&chat=1&after=2026-09-16T00:00:00.000Z')); + expect(res.status).toBe(200); + expect((await res.json()).messages).toHaveLength(1); + expect(nixamp.readRoomChat).toHaveBeenCalledWith(room, '2026-09-16T00:00:00.000Z'); + }); + + it('lets any member post, as their own nixamp self', async () => { + auth.getCurrentUser.mockResolvedValue({ id: 'user-2', email: 'b@b.test' }); + nixamp.getBridgedRoom.mockResolvedValue(room); + nixamp.postRoomChat.mockResolvedValue({ id: 'm2', authorName: 'bob', body: 'hello', createdAt: 'now' }); + const res = await POST(post({ code: 'ABC123', action: 'chat', body: 'hello' })); + expect(res.status).toBe(200); + expect(nixamp.postRoomChat).toHaveBeenCalledWith('user-2', room, 'hello'); + }); + + it('sends a member without nixamp to connect it, and back to this party', async () => { + auth.getCurrentUser.mockResolvedValue({ id: 'user-2', email: 'b@b.test' }); + nixamp.getBridgedRoom.mockResolvedValue(room); + nixamp.postRoomChat.mockRejectedValue(new nixamp.NixampNotConnected()); + const res = await POST(post({ code: 'ABC123', action: 'chat', body: 'hello' })); + expect(res.status).toBe(409); + expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start?redirect=%2Fwatch-party%3Fcode%3DABC123'); + }); + + it('has nothing to say in a party that is not on nixamp', async () => { + const res = await POST(post({ code: 'ABC123', action: 'chat', body: 'hello' })); + expect(res.status).toBe(409); + }); +}); diff --git a/src/app/api/watch-party/nixamp/route.ts b/src/app/api/watch-party/nixamp/route.ts index 692fe621..f6e6be05 100644 --- a/src/app/api/watch-party/nixamp/route.ts +++ b/src/app/api/watch-party/nixamp/route.ts @@ -1,8 +1,10 @@ /** * The bridge between a watch party here and a room on nixamp. * - * GET /api/watch-party/nixamp?code=ABC123 where the room is (public) - * POST /api/watch-party/nixamp bridge, sync or end (host only) + * GET /api/watch-party/nixamp?code=ABC123 where the room is (public) + * GET /api/watch-party/nixamp?code=ABC123&chat=1 the room's chat (public) + * POST /api/watch-party/nixamp bridge, sync or end (host only) + * chat (any member with nixamp connected) * * The film stays here; the room goes there. Once a party is bridged, anybody * on nixamp -- the web app, the terminal, the desktop app, a television, an @@ -29,13 +31,17 @@ import { bridgeParty, endBridgedParty, getBridgedRoom, + postRoomChat, pushPlayback, + readRoomChat, } from '@/lib/nixamp'; import { getParty } from '../_store'; interface BridgeBody { code?: string; - action?: 'bridge' | 'sync' | 'end'; + action?: 'bridge' | 'sync' | 'end' | 'chat'; + /** For `chat`: the line. */ + body?: string; title?: string; mediaTitle?: string; positionSeconds?: number; @@ -51,12 +57,22 @@ export async function GET(request: NextRequest): Promise { const code = cleanCode(new URL(request.url).searchParams.get('code')); if (!code) return NextResponse.json({ error: 'A valid party code is required' }, { status: 400 }); + const params = new URL(request.url).searchParams; const room = await getBridgedRoom(code); if (!room) { // Not an error: most parties are never bridged, and the page asks about // every one it shows. return NextResponse.json({ success: true, bridged: false }); } + if (params.get('chat')) { + try { + const messages = await readRoomChat(room, params.get('after') ?? undefined); + return NextResponse.json({ success: true, bridged: true, messages }); + } catch (error) { + console.error('[WatchParty] nixamp chat read failed:', error); + return NextResponse.json({ error: 'Could not read the room' }, { status: 502 }); + } + } return NextResponse.json({ success: true, bridged: true, @@ -85,14 +101,26 @@ export async function POST(request: NextRequest): Promise { const party = getParty(code); if (!party) return NextResponse.json({ error: 'Party not found' }, { status: 404 }); - if (party.hostId !== user.id) { - return NextResponse.json({ error: 'Only the host can put this party on nixamp' }, { status: 403 }); - } const origin = new URL(request.url).origin; const action = body.action ?? 'bridge'; try { + if (action === 'chat') { + // Any member, not only the host: the line is signed with their own + // nixamp handle, which is the whole reason it needs their own account. + const text = typeof body.body === 'string' ? body.body.trim() : ''; + if (!text) return NextResponse.json({ error: 'Say something' }, { status: 400 }); + const room = await getBridgedRoom(code); + if (!room) return NextResponse.json({ error: 'This party is not on nixamp yet' }, { status: 409 }); + const message = await postRoomChat(user.id, room, text); + return NextResponse.json({ success: true, message }); + } + + if (party.hostId !== user.id) { + return NextResponse.json({ error: 'Only the host can put this party on nixamp' }, { status: 403 }); + } + if (action === 'bridge') { const room = await bridgeParty({ userId: user.id, @@ -132,17 +160,13 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ error: 'action must be bridge, sync or end' }, { status: 400 }); } catch (error) { + // Back to this party once nixamp is connected, not to the settings page. + const connect = `/api/v1/nixamp/oauth/start?redirect=${encodeURIComponent(`/watch-party?code=${code}`)}`; if (error instanceof NixampNotConnected) { - return NextResponse.json( - { error: 'Connect your nixamp account first.', connect: '/api/v1/nixamp/oauth/start' }, - { status: 409 } - ); + return NextResponse.json({ error: 'Connect your nixamp account first.', connect }, { status: 409 }); } if (error instanceof NixampConnectionLost) { - return NextResponse.json( - { error: 'That nixamp connection has ended. Connect it again.', connect: '/api/v1/nixamp/oauth/start' }, - { status: 409 } - ); + return NextResponse.json({ error: 'That nixamp connection has ended. Connect it again.', connect }, { status: 409 }); } console.error('[WatchParty] nixamp bridge failed:', error); return NextResponse.json({ error: 'Could not reach nixamp' }, { status: 502 }); diff --git a/src/app/api/watch-party/playback/route.test.ts b/src/app/api/watch-party/playback/route.test.ts new file mode 100644 index 00000000..83ff7c48 --- /dev/null +++ b/src/app/api/watch-party/playback/route.test.ts @@ -0,0 +1,89 @@ +/** + * The host's player is everybody's clock, and only the host's. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +const auth = vi.hoisted(() => ({ getCurrentUser: vi.fn() })); +const store = vi.hoisted(() => ({ getParty: vi.fn(), setParty: vi.fn() })); + +vi.mock('@/lib/auth', () => auth); +vi.mock('../_store', () => store); + +const { PATCH } = await import('./route'); + +function party(hostId = 'user-1') { + return { + id: 'p1', + code: 'ABC123', + hostId, + hostName: 'Ann', + mediaUrl: '/api/stream?torrent=t&file=f', + mediaTitle: 'Dune', + createdAt: new Date(), + state: 'waiting' as const, + members: [{ id: hostId, name: 'Ann', isHost: true, joinedAt: new Date() }], + playback: { isPlaying: false, currentTime: 0, duration: 0, lastUpdate: 0 }, + settings: { allowGuestControl: false, maxMembers: 50, chatEnabled: true }, + }; +} + +function patch(body: unknown): NextRequest { + return new NextRequest('https://bittorrented.test/api/watch-party/playback', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + auth.getCurrentUser.mockResolvedValue({ id: 'user-1', email: 'a@b.test' }); + store.getParty.mockReturnValue(party()); +}); + +describe('PATCH /api/watch-party/playback', () => { + it('moves the party to where the host is, and marks it playing', async () => { + const res = await PATCH(patch({ code: 'abc123', currentTime: 930.5, isPlaying: true, duration: 9000 })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.party.playback.currentTime).toBe(930.5); + expect(body.party.playback.isPlaying).toBe(true); + expect(body.party.state).toBe('playing'); + expect(body.party.playback.lastUpdate).toBeGreaterThan(0); + expect(store.setParty).toHaveBeenCalledWith('ABC123', expect.objectContaining({ state: 'playing' })); + }); + + it('a pause is a paused party', async () => { + store.getParty.mockReturnValue({ ...party(), state: 'playing' }); + const res = await PATCH(patch({ code: 'ABC123', isPlaying: false })); + expect((await res.json()).party.state).toBe('paused'); + }); + + it('refuses a member who presents the host id they saw in the member list', async () => { + auth.getCurrentUser.mockResolvedValue({ id: 'user-2', email: 'b@b.test' }); + const res = await PATCH(patch({ code: 'ABC123', hostId: 'user-1', currentTime: 10 })); + expect(res.status).toBe(403); + expect(store.setParty).not.toHaveBeenCalled(); + }); + + it('accepts the guest id for a guest-hosted party, which is all it has', async () => { + auth.getCurrentUser.mockResolvedValue(null); + store.getParty.mockReturnValue(party('guest_1_abc')); + const res = await PATCH(patch({ code: 'ABC123', hostId: 'guest_1_abc', currentTime: 10 })); + expect(res.status).toBe(200); + }); + + it('will not take a position that is not one', async () => { + const res = await PATCH(patch({ code: 'ABC123', currentTime: -5 })); + expect(res.status).toBe(400); + }); + + it('says when the party is gone', async () => { + store.getParty.mockReturnValue(undefined); + expect((await PATCH(patch({ code: 'ABC123', currentTime: 1 }))).status).toBe(404); + store.getParty.mockReturnValue({ ...party(), state: 'ended' }); + expect((await PATCH(patch({ code: 'ABC123', currentTime: 1 }))).status).toBe(410); + }); +}); diff --git a/src/app/api/watch-party/playback/route.ts b/src/app/api/watch-party/playback/route.ts new file mode 100644 index 00000000..db581d3b --- /dev/null +++ b/src/app/api/watch-party/playback/route.ts @@ -0,0 +1,72 @@ +/** + * PATCH /api/watch-party/playback — the host saying where the film is. + * + * A watch party is everybody at the same second, and this is the second. + * The host's player reports it on play, pause and seek and then every few + * seconds while playing; every other member polls the party and follows. + * + * Who is the host is proven by the session first: a signed-in host's id is + * their user id, which nobody else's cookie carries. A guest-hosted party + * (one made before the site went members-only) is proven by the guest id, + * because that is all such a party has. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { updatePlaybackState, validatePartyCode } from '@/lib/watch-party'; +import { getParty, setParty } from '../_store'; +import { isHostOf, partyView } from '../_view'; + +interface PlaybackBody { + code?: string; + hostId?: string; + currentTime?: number; + isPlaying?: boolean; + duration?: number; +} + +function finiteSeconds(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +export async function PATCH(request: NextRequest): Promise { + let body: PlaybackBody; + try { + body = (await request.json()) as PlaybackBody; + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const code = (body.code ?? '').toUpperCase(); + if (!code || !validatePartyCode(code)) { + return NextResponse.json({ error: 'A valid party code is required' }, { status: 400 }); + } + const party = getParty(code); + if (!party) return NextResponse.json({ error: 'Party not found' }, { status: 404 }); + if (party.state === 'ended') return NextResponse.json({ error: 'Party has ended' }, { status: 410 }); + + const user = await getCurrentUser(); + if (!isHostOf(party, user?.id, body.hostId)) { + return NextResponse.json({ error: 'Only the host can move playback' }, { status: 403 }); + } + + const currentTime = finiteSeconds(body.currentTime); + const duration = finiteSeconds(body.duration); + const isPlaying = typeof body.isPlaying === 'boolean' ? body.isPlaying : undefined; + if (currentTime === undefined && isPlaying === undefined && duration === undefined) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }); + } + + const updated = updatePlaybackState(party, { + ...(currentTime !== undefined ? { currentTime } : {}), + ...(duration !== undefined ? { duration } : {}), + ...(isPlaying !== undefined ? { isPlaying } : {}), + }); + // The party's own state follows the player: a film that is playing is a + // party that is playing, which is what the list of parties reads. + if (updated.playback.isPlaying) updated.state = 'playing'; + else if (party.state === 'playing' || party.state === 'paused') updated.state = 'paused'; + setParty(code, updated); + + return NextResponse.json({ success: true, party: partyView(updated) }); +} diff --git a/src/app/api/watch-party/route.ts b/src/app/api/watch-party/route.ts index 319ab4aa..c3e2577d 100644 --- a/src/app/api/watch-party/route.ts +++ b/src/app/api/watch-party/route.ts @@ -18,6 +18,7 @@ import { type PartySettings, } from '@/lib/watch-party'; import { getParty, setParty, cleanupOldParties } from './_store'; +import { isHostOf } from './_view'; interface CreatePartyBody { hostId?: string; @@ -173,7 +174,7 @@ export async function GET(request: NextRequest): Promise { interface UpdatePartyBody { code: string; - hostId: string; + hostId?: string; mediaUrl?: string; mediaTitle?: string; torrentId?: string; @@ -196,13 +197,6 @@ export async function PATCH(request: NextRequest): Promise { ); } - if (!body.hostId || typeof body.hostId !== 'string') { - return NextResponse.json( - { error: 'Host ID is required' }, - { status: 400 } - ); - } - const code = body.code.toUpperCase(); if (!validatePartyCode(code)) { @@ -221,8 +215,10 @@ export async function PATCH(request: NextRequest): Promise { ); } - // Verify the requester is the host - if (party.hostId !== body.hostId) { + // Verify the requester is the host: the session for a signed-in host, + // the guest id only for a guest-hosted party. + const user = await getCurrentUser(); + if (!isHostOf(party, user?.id, typeof body.hostId === 'string' ? body.hostId : undefined)) { return NextResponse.json( { error: 'Only the host can update party media' }, { status: 403 } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2254a8a5..4048b4d0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -13,17 +13,35 @@ import { MainLayout } from '@/components/layout'; import { cn } from '@/lib/utils'; import { LoadingSpinner } from '@/components/ui/icons'; +/** What went wrong on the way back from nixamp, in words. */ +const NIXAMP_REFUSALS: Record = { + access_denied: 'You did not allow it on nixamp, so nothing was signed in.', + no_email: 'nixamp did not share an email address, and one is needed to know which account here is yours.', + state_mismatch: 'That nixamp sign-in took too long or was reused. Try it again.', + exchange_failed: 'nixamp did not finish the sign-in. Try it again.', +}; + +/** Sign in with nixamp, landing wherever the visit was going. */ +function nixampSignInHref(redirect: string): string { + return `/api/v1/nixamp/oauth/start?redirect=${encodeURIComponent(redirect)}`; +} + export default function LoginPage(): React.ReactElement { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [reason, setReason] = useState(null); + const [redirect, setRedirect] = useState('/'); + const [error, setError] = useState(null); + useEffect(() => { const params = new URLSearchParams(window.location.search); setReason(params.get('reason')); + setRedirect(params.get('redirect') || '/'); + const refused = params.get('nixamp_error'); + if (refused) setError(NIXAMP_REFUSALS[refused] ?? `nixamp sign-in did not finish (${refused}).`); }, []); - const [error, setError] = useState(null); const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault(); @@ -171,6 +189,28 @@ export default function LoginPage(): React.ReactElement { + {/* Sign in with nixamp: the same person, as nixamp.com knows them */} +
+
+ or +
+ + + Sign in with nixamp + +

+ OAuth 2.1 with nixamp.com. A watch party here is a room there. +

+
+ {/* Sign up link */}

Don't have an account?{' '} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 96f7dfac..e1f6b039 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -219,6 +219,25 @@ export default function SignupPage(): React.ReactElement { + {/* Sign up with nixamp: an account here from the one there, no password */} +

+ {/* Sign in link */}

Already have an account?{' '} diff --git a/src/app/watch-party/page.tsx b/src/app/watch-party/page.tsx index 93228095..cf5b65f4 100644 --- a/src/app/watch-party/page.tsx +++ b/src/app/watch-party/page.tsx @@ -1,15 +1,24 @@ 'use client'; /** - * Watch Party Page + * Watch Party * - * Synchronized streaming with real-time chat. - * Free for anyone without requiring login. + * Everybody at the same second. The host picks a file from the catalog and + * their player is the clock: play, pause and seek go to the party, and every + * other member's player follows within a couple of seconds. The party is also + * a room on nixamp when the host has connected one, which is where the chat + * lives and what lets somebody join from the nixamp app, a terminal, the + * desktop app or a television. + * + * `/watch-party?code=ABC123` is the link the party is handed around by, and + * the one nixamp sends its members to: it lands on the join form with the + * code filled in, and joins on its own once there is a name to join as. */ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { MainLayout } from '@/components/layout'; -import { NixampPanel } from '@/components/watch-party'; +import { MediaSelectionModal, NixampPanel, PartyChat } from '@/components/watch-party'; +import { Focusable } from '@/components/ui/focusable'; import { cn } from '@/lib/utils'; import { PartyIcon, PlusIcon, UsersIcon } from '@/components/ui/icons'; @@ -29,10 +38,11 @@ interface PartyData { state: 'waiting' | 'playing' | 'paused' | 'ended'; memberCount: number; members?: PartyMember[]; + playback?: { isPlaying: boolean; currentTime: number; duration: number; lastUpdate: number }; settings: { maxMembers: number; - allowChat: boolean; - hostOnlyControl: boolean; + allowGuestControl: boolean; + chatEnabled: boolean; }; } @@ -56,6 +66,22 @@ interface FileItem { type ViewState = 'home' | 'create-form' | 'party-room'; +/** Members poll the party this often; the host does too, to see who is in. */ +const POLL_EVERY_MS = 3_000; +/** The host's player reports its position this often while playing. */ +const HEARTBEAT_MS = 5_000; +/** A member further than this from the host is seeked. */ +const DRIFT_SECONDS = 2; + +const CODE_RE = /^[A-Z0-9]{6}$/; + +/** Where the host's player is now, given what they last said and when. */ +function expectedPosition(playback: NonNullable): number { + if (!playback.isPlaying) return playback.currentTime; + const since = (Date.now() - playback.lastUpdate) / 1000; + return playback.currentTime + (Number.isFinite(since) && since > 0 ? since : 0); +} + export default function WatchPartyPage(): React.ReactElement { const [partyCode, setPartyCode] = useState(''); const [hostName, setHostName] = useState(''); @@ -65,99 +91,232 @@ export default function WatchPartyPage(): React.ReactElement { const [userId, setUserId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [, setIsMediaModalOpen] = useState(false); - // The host's own player, so its position can be told to nixamp. Everybody - // else follows; only the host states where the film is. + const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); + const [nixampConnected, setNixampConnected] = useState(false); + const [bridged, setBridged] = useState(null); + const [syncSignal, setSyncSignal] = useState(0); + const [needsTap, setNeedsTap] = useState(false); + // Only the host's own player is a clock; everybody else's follows it. const videoRef = useRef(null); + // A seek we asked for must not be reported back as one the host made. + const followingRef = useRef(false); + const nameInputRef = useRef(null); + + // Who is here, so a name does not have to be typed: the nixamp handle when + // one is connected (it is what the room shows anyway), else the account. + useEffect(() => { + let alive = true; + void (async () => { + let name = ''; + try { + const res = await fetch('/api/v1/nixamp/connection', { cache: 'no-store' }); + if (res.ok) { + const body = (await res.json()) as { connected?: boolean; account?: { handle?: string } }; + if (body.connected) { + if (alive) setNixampConnected(true); + name = body.account?.handle ?? ''; + } + } + } catch { + // Not connected, as far as this page is concerned. + } + if (!name) { + try { + const res = await fetch('/api/auth/me', { cache: 'no-store' }); + if (res.ok) { + const body = (await res.json()) as { user?: { displayName?: string; email?: string } }; + name = body.user?.displayName || (body.user?.email ?? '').split('@')[0] || ''; + } + } catch { + // Signed out: the form asks. + } + } + if (alive && name) { + setHostName((was) => was || name); + setUserName((was) => was || name); + } + })(); + return () => { + alive = false; + }; + }, []); - const handleCreateParty = useCallback(async () => { - if (!hostName.trim()) { + // ?code=ABC123: the link a party is handed around by. + const [linkedCode, setLinkedCode] = useState(null); + useEffect(() => { + const code = (new URLSearchParams(window.location.search).get('code') ?? '').trim().toUpperCase(); + if (CODE_RE.test(code)) { + setPartyCode(code); + setLinkedCode(code); + } + }, []); + + const handleJoinParty = useCallback(async (code = partyCode, name = userName) => { + if (!name.trim()) { setError('Please enter your name'); + nameInputRef.current?.focus(); + return; + } + if (!CODE_RE.test(code)) { + setError('Please enter a valid 6-character party code'); return; } - setIsLoading(true); setError(null); - try { - const response = await fetch('/api/watch-party', { + const response = await fetch('/api/watch-party/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - hostName: hostName.trim(), - mediaTitle: 'Watch Party', - }), + body: JSON.stringify({ code, userName: name.trim() }), }); - - const data = await response.json() as { success?: boolean; party?: PartyData; error?: string }; - - if (!response.ok || !data.success) { - throw new Error(data.error ?? 'Failed to create party'); - } - + const data = (await response.json()) as { success?: boolean; party?: PartyData; userId?: string; error?: string }; + if (!response.ok || !data.success) throw new Error(data.error ?? 'Failed to join party'); setParty(data.party ?? null); - setUserId(data.party?.hostId ?? null); + setUserId(data.userId ?? null); setViewState('party-room'); + window.history.replaceState(null, '', `/watch-party?code=${code}`); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create party'); + setError(err instanceof Error ? err.message : 'Failed to join party'); } finally { setIsLoading(false); } - }, [hostName]); + }, [partyCode, userName]); - const handleJoinParty = useCallback(async () => { + // A linked code joins on its own once there is a name to join as. + const autoJoined = useRef(false); + useEffect(() => { + if (!linkedCode || autoJoined.current || viewState !== 'home') return; if (!userName.trim()) { - setError('Please enter your name'); + nameInputRef.current?.focus(); return; } + autoJoined.current = true; + void handleJoinParty(linkedCode, userName); + }, [linkedCode, userName, viewState, handleJoinParty]); - if (partyCode.length !== 6) { - setError('Please enter a valid 6-character party code'); + const handleCreateParty = useCallback(async () => { + if (!hostName.trim()) { + setError('Please enter your name'); return; } - setIsLoading(true); setError(null); - try { - const response = await fetch('/api/watch-party/join', { + const response = await fetch('/api/watch-party', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - code: partyCode, - userName: userName.trim(), - }), + body: JSON.stringify({ hostName: hostName.trim(), mediaTitle: 'Watch Party' }), }); - - const data = await response.json() as { success?: boolean; party?: PartyData; userId?: string; error?: string }; - - if (!response.ok || !data.success) { - throw new Error(data.error ?? 'Failed to join party'); - } - + const data = (await response.json()) as { success?: boolean; party?: PartyData; error?: string }; + if (!response.ok || !data.success) throw new Error(data.error ?? 'Failed to create party'); setParty(data.party ?? null); - setUserId(data.userId ?? null); + setUserId(data.party?.hostId ?? null); setViewState('party-room'); + if (data.party?.code) window.history.replaceState(null, '', `/watch-party?code=${data.party.code}`); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to join party'); + setError(err instanceof Error ? err.message : 'Failed to create party'); } finally { setIsLoading(false); } - }, [partyCode, userName]); + }, [hostName]); const handleLeaveParty = useCallback(() => { setParty(null); setUserId(null); setViewState('home'); setPartyCode(''); - setHostName(''); - setUserName(''); + setLinkedCode(null); + setBridged(null); setError(null); + window.history.replaceState(null, '', '/watch-party'); }, []); - const _handleMediaSelect = useCallback(async (file: FileItem, _torrent: TorrentItem) => { - if (!party || !userId) return; + const isHost = party !== null && userId !== null && userId === party.hostId; + + // The host's player says where the film is. + const report = useCallback(async (extra: { isPlaying?: boolean } = {}) => { + const video = videoRef.current; + if (!party || !isHost || !video) return; + try { + await fetch('/api/watch-party/playback', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: party.code, + hostId: userId, + currentTime: video.currentTime, + duration: Number.isFinite(video.duration) ? video.duration : undefined, + isPlaying: extra.isPlaying ?? !video.paused, + }), + }); + } catch { + // The next heartbeat says it again. + } + }, [party, isHost, userId]); + + const onHostEvent = useCallback((isPlaying?: boolean) => { + void report(isPlaying === undefined ? {} : { isPlaying }); + setSyncSignal((n) => n + 1); + }, [report]); + + useEffect(() => { + if (!isHost || viewState !== 'party-room') return; + const timer = setInterval(() => { + const video = videoRef.current; + if (video && !video.paused) void report(); + }, HEARTBEAT_MS); + return () => clearInterval(timer); + }, [isHost, viewState, report]); + + // Everybody polls the party: the member list for all, the film and the + // second for the members. A party that is gone sends everybody home. + useEffect(() => { + if (viewState !== 'party-room' || !party) return; + const code = party.code; + let alive = true; + const tick = async (): Promise => { + try { + const res = await fetch(`/api/watch-party?code=${code}`, { cache: 'no-store' }); + if (!alive) return; + if (res.status === 404 || res.status === 410) { + handleLeaveParty(); + setError('That party has ended.'); + return; + } + if (!res.ok) return; + const data = (await res.json()) as { party?: PartyData }; + if (data.party && alive) setParty(data.party); + } catch { + // Try again next tick. + } + }; + const timer = setInterval(() => void tick(), POLL_EVERY_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [viewState, party?.code, handleLeaveParty]); // eslint-disable-line react-hooks/exhaustive-deps + + // A member's player follows the host's. + useEffect(() => { + if (isHost || !party?.playback || !party.mediaUrl) return; + const video = videoRef.current; + if (!video) return; + const playback = party.playback; + const target = expectedPosition(playback); + if (Math.abs(video.currentTime - target) > DRIFT_SECONDS && Number.isFinite(target)) { + followingRef.current = true; + video.currentTime = target; + } + if (playback.isPlaying && video.paused) { + video.play().then(() => setNeedsTap(false)).catch(() => setNeedsTap(true)); + } else if (!playback.isPlaying && !video.paused) { + video.pause(); + } + }, [party, isHost]); + const handleMediaSelect = useCallback(async (file: FileItem, _torrent: TorrentItem) => { + if (!party || !userId) return; try { const response = await fetch('/api/watch-party', { method: 'PATCH', @@ -170,104 +329,131 @@ export default function WatchPartyPage(): React.ReactElement { mediaTitle: file.name, }), }); - - const data = await response.json() as { success?: boolean; party?: PartyData; error?: string }; - - if (!response.ok || !data.success) { - throw new Error(data.error ?? 'Failed to update party media'); - } - - // Update local party state + const data = (await response.json()) as { success?: boolean; party?: PartyData; error?: string }; + if (!response.ok || !data.success) throw new Error(data.error ?? 'Failed to update party media'); setParty(data.party ?? null); setIsMediaModalOpen(false); + setSyncSignal((n) => n + 1); } catch (err) { console.error('Failed to update party media:', err); setError(err instanceof Error ? err.message : 'Failed to update party media'); } }, [party, userId]); + const input = cn( + 'w-full rounded-lg border border-border-default bg-bg-tertiary px-4 py-3', + 'text-text-primary placeholder:text-text-muted', + 'focus:border-accent-primary focus:outline-hidden focus:ring-1 focus:ring-accent-primary' + ); + const primary = cn( + 'w-full flex items-center justify-center gap-2 rounded-lg px-4 py-3', + 'font-medium transition-colors bg-accent-primary text-white hover:bg-accent-primary/90' + ); + // Party Room View if (viewState === 'party-room' && party) { - const isHost = userId === party.hostId; - return (

- {/* Party Header */} -
-
-

- {party.mediaTitle} -

+
+
+

{party.mediaTitle}

Party Code: {party.code} {' · '} {party.memberCount} {party.memberCount === 1 ? 'member' : 'members'} + {' · '} + {isHost ? 'you are the host' : `hosted by ${party.hostName}`}

- +
- {/* Main Content */} + {error ? ( +
{error}
+ ) : null} +
- {/* Video/Media Area */}
-
+
{party.mediaUrl ? (
- {/* Playback Controls (Host Only) */} - {isHost ?
-

Host Controls

-
- - +
-
: null} +
+ ) : null}
- {/* Chat & Members Sidebar */}
- {/* The same party, as a nixamp room */} ({ @@ -278,64 +464,42 @@ export default function WatchPartyPage(): React.ReactElement { } /> - {/* Members List */}
-

- Members ({party.memberCount}) -

+

Members ({party.memberCount})

{party.members?.map((member) => ( -
-
+
+
{member.name} - {member.isHost ? - Host - : null} + {member.isHost ? ( + Host + ) : null}
))}
- {/* Chat */} - {party.settings.allowChat ?
-

Chat

-
-

- No messages yet. Say hi! 👋 -

-
-
- - -
-
: null} + {party.settings.chatEnabled !== false ? ( + + ) : null}
- {/* Share Code */}

Share this code with friends:

-

- {party.code} +

{party.code}

+

+ or the link {typeof window !== 'undefined' ? `${window.location.origin}/watch-party?code=${party.code}` : ''}

+ + {isHost ? ( + setIsMediaModalOpen(false)} + onSelect={(file, torrent) => void handleMediaSelect(file, torrent)} + /> + ) : null}
); @@ -346,53 +510,37 @@ export default function WatchPartyPage(): React.ReactElement { return (
- +

Create Watch Party

-

- Enter your name to create a party -

+

Enter your name to create a party

- {error ?
- {error} -
: null} + {error ? ( +
{error}
+ ) : null} -
+
{ + e.preventDefault(); + void handleCreateParty(); + }} + >
-
- -
+ +
); @@ -416,26 +564,20 @@ export default function WatchPartyPage(): React.ReactElement { return (
- {/* Header */}

Watch Party

Watch together with friends in perfect sync. Create a party or join an existing one. - No login required! + Also a room on nixamp: join from the app, a terminal or a TV.

- {error ?
- {error} -
: null} + {error ? ( +
{error}
+ ) : null} - {/* Actions */}
- {/* Create Party */} -
+
@@ -448,74 +590,65 @@ export default function WatchPartyPage(): React.ReactElement {

Create a party and share the code with friends. You control playback, everyone watches in sync.

- +
- {/* Join Party */} -
+

Join Party

-

Enter a party code

+

{linkedCode ? `You were invited to ${linkedCode}` : 'Enter a party code'}

- Got a party code? Enter it below to join your friends and watch together. + {linkedCode ? 'Tell the party your name and you are in.' : 'Got a party code? Enter it below to join your friends and watch together.'}

-
+
{ + e.preventDefault(); + void handleJoinParty(); + }} + > setUserName(e.target.value)} placeholder="Your name" - className={cn( - 'w-full rounded-lg border border-border-default bg-bg-tertiary px-4 py-2', - 'text-sm text-text-primary', - 'placeholder:text-text-muted', - 'focus:border-accent-primary focus:outline-hidden focus:ring-1 focus:ring-accent-primary' - )} + aria-label="Your name" + className={cn(input, 'py-2 text-sm')} /> setPartyCode(e.target.value.toUpperCase())} placeholder="Enter party code" + aria-label="Party code" maxLength={6} - className={cn( - 'w-full rounded-lg border border-border-default bg-bg-tertiary px-4 py-3', - 'text-center text-lg font-mono tracking-widest text-text-primary', - 'placeholder:text-text-muted placeholder:tracking-normal placeholder:font-sans', - 'focus:border-accent-primary focus:outline-hidden focus:ring-1 focus:ring-accent-primary' - )} + className={cn(input, 'text-center text-lg font-mono tracking-widest', 'placeholder:tracking-normal placeholder:font-sans')} /> - -
+ +
- {/* Features */}

Features

🎬

Synced Playback

-

Everyone watches at the same time

+

Everyone follows the host's player

💬
-

Live Chat

-

React and chat in real-time

+

One Chat Everywhere

+

The nixamp room, in every nixamp app

👥
diff --git a/src/components/watch-party/index.ts b/src/components/watch-party/index.ts index e7ee9acc..0a97adfe 100644 --- a/src/components/watch-party/index.ts +++ b/src/components/watch-party/index.ts @@ -6,3 +6,4 @@ export { MediaSelectionModal } from './media-selection-modal'; export { NixampPanel, type NixampPanelProps } from './nixamp-panel'; +export { PartyChat, type PartyChatProps } from './party-chat'; diff --git a/src/components/watch-party/nixamp-panel.tsx b/src/components/watch-party/nixamp-panel.tsx index 7758486f..b198ef18 100644 --- a/src/components/watch-party/nixamp-panel.tsx +++ b/src/components/watch-party/nixamp-panel.tsx @@ -22,6 +22,15 @@ export interface NixampPanelProps { /** Where the host's own player is, asked for at the moment of a sync. */ positionSeconds?: () => { positionSeconds: number; playing: boolean }; mediaTitle?: string; + /** + * Bridge without being asked, once. A host who has already connected nixamp + * has said what they want; the button is for the one who has not. + */ + autoBridge?: boolean; + /** Bumped by the host's player on play, pause and seek: sync now, not in 15 s. */ + syncSignal?: number; + /** Told whether the party is on nixamp, so the page can open the chat. */ + onRoom?: (bridged: boolean) => void; } interface Room { @@ -43,7 +52,15 @@ interface BridgeAnswer { /** How often the host's position is pushed while a bridged party is playing. */ const SYNC_EVERY_MS = 15_000; -export function NixampPanel({ partyCode, isHost, positionSeconds, mediaTitle }: NixampPanelProps): React.ReactElement | null { +export function NixampPanel({ + partyCode, + isHost, + positionSeconds, + mediaTitle, + autoBridge = false, + syncSignal = 0, + onRoom, +}: NixampPanelProps): React.ReactElement | null { const [room, setRoom] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -57,7 +74,15 @@ export function NixampPanel({ partyCode, isHost, positionSeconds, mediaTitle }: useEffect(() => { positionRef.current = positionSeconds; }, [positionSeconds]); + const onRoomRef = useRef(onRoom); + useEffect(() => { + onRoomRef.current = onRoom; + }, [onRoom]); + useEffect(() => { + onRoomRef.current?.(room !== null); + }, [room]); + const [asked, setAsked] = useState(false); useEffect(() => { let alive = true; void (async () => { @@ -67,6 +92,8 @@ export function NixampPanel({ partyCode, isHost, positionSeconds, mediaTitle }: if (alive && body.bridged && body.room) setRoom(body.room); } catch { // Not bridged, as far as anybody here is concerned. + } finally { + if (alive) setAsked(true); } })(); return () => { @@ -109,6 +136,24 @@ export function NixampPanel({ partyCode, isHost, positionSeconds, mediaTitle }: await post({ action: 'sync', ...(where ?? {}) }); }, [post]); + // A connected host's party goes on nixamp the moment they open it. The + // answer is idempotent on both sides, so a reload asks for the same room. + const autoTried = useRef(false); + useEffect(() => { + if (!autoBridge || !isHost || !asked || room || autoTried.current) return; + autoTried.current = true; + void bridge(); + }, [autoBridge, isHost, asked, room, bridge]); + + // The host pressed play, paused or seeked: everybody on nixamp should know + // now, not at the next quarter-minute. + const lastSignal = useRef(syncSignal); + useEffect(() => { + if (syncSignal === lastSignal.current) return; + lastSignal.current = syncSignal; + if (room && isHost) void sync(); + }, [syncSignal, room, isHost, sync]); + // While a host holds a bridged party, the position goes over on its own. // Somebody opening the room from a terminal an hour in should land an hour // in, and asking the host to press a button for that would not happen. diff --git a/src/components/watch-party/party-chat.tsx b/src/components/watch-party/party-chat.tsx new file mode 100644 index 00000000..5a1a9330 --- /dev/null +++ b/src/components/watch-party/party-chat.tsx @@ -0,0 +1,186 @@ +'use client'; + +/** + * The party's chat, which is the nixamp room's chat. + * + * One conversation for everybody in the party wherever they are: this page, + * the nixamp app, a terminal, the desktop app, a television. Reading needs + * nothing; the party code was the invitation. A line is signed with a nixamp + * handle, so sending one takes the member's own nixamp connection, and the + * box says so instead of pretending to send. + * + * Until the host puts the party on nixamp there is no room, so there is no + * chat -- the panel says that too, rather than showing an input that goes + * nowhere. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Focusable } from '@/components/ui/focusable'; + +interface ChatLine { + id: string; + authorName: string; + body: string; + createdAt: string; +} + +export interface PartyChatProps { + partyCode: string; + /** Is the party on nixamp? Null while that is still being asked. */ + bridged: boolean | null; + isHost: boolean; +} + +const POLL_EVERY_MS = 4_000; + +export function PartyChat({ partyCode, bridged, isHost }: PartyChatProps): React.ReactElement { + const [lines, setLines] = useState([]); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [connectAt, setConnectAt] = useState(null); + const [loaded, setLoaded] = useState(false); + const lastAt = useRef(''); + const scroller = useRef(null); + + // Poll for what is new. `after` is the newest line's time, so a quiet room + // costs one small request every few seconds and nothing is re-read. + useEffect(() => { + if (!bridged) return; + let alive = true; + const read = async (): Promise => { + try { + const url = new URL('/api/watch-party/nixamp', window.location.origin); + url.searchParams.set('code', partyCode); + url.searchParams.set('chat', '1'); + if (lastAt.current) url.searchParams.set('after', lastAt.current); + const res = await fetch(url.toString(), { cache: 'no-store' }); + if (!res.ok) return; + const body = (await res.json()) as { messages?: ChatLine[] }; + const fresh = body.messages ?? []; + if (!alive) return; + setLoaded(true); + if (fresh.length === 0) return; + lastAt.current = fresh[fresh.length - 1]!.createdAt; + setLines((known) => { + const seen = new Set(known.map((l) => l.id)); + return [...known, ...fresh.filter((l) => !seen.has(l.id))].slice(-200); + }); + } catch { + // The room is slow; the next poll will say. + } + }; + void read(); + const timer = setInterval(() => void read(), POLL_EVERY_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [bridged, partyCode]); + + // Keep the newest line in view unless the reader has scrolled up to read. + useEffect(() => { + const box = scroller.current; + if (!box) return; + const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80; + if (nearBottom) box.scrollTop = box.scrollHeight; + }, [lines]); + + const send = useCallback(async () => { + const text = draft.trim(); + if (!text || sending) return; + setSending(true); + setError(null); + try { + const res = await fetch('/api/watch-party/nixamp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: partyCode, action: 'chat', body: text }), + }); + const body = (await res.json().catch(() => ({}))) as { message?: ChatLine; error?: string; connect?: string }; + if (!res.ok) { + setError(body.error ?? 'That did not send'); + setConnectAt(body.connect ?? null); + return; + } + setDraft(''); + setConnectAt(null); + if (body.message) { + const line = body.message; + lastAt.current = line.createdAt; + setLines((known) => (known.some((l) => l.id === line.id) ? known : [...known, line])); + } + } catch { + setError('That did not send'); + } finally { + setSending(false); + } + }, [draft, sending, partyCode]); + + return ( +
+

Chat

+

+ {bridged ? 'The nixamp room. Everybody in the party reads this, in every nixamp app.' : ''} +

+
+ {bridged === false ? ( +

+ {isHost + ? 'Put this party on nixamp and the chat opens here and in every nixamp app.' + : 'Chat opens once the host puts this party on nixamp.'} +

+ ) : null} + {bridged && loaded && lines.length === 0 ? ( +

No messages yet. Say hi! 👋

+ ) : null} + {lines.map((line) => ( +
+ {line.authorName} + {line.body} +
+ ))} +
+
{ + e.preventDefault(); + void send(); + }} + > + setDraft(e.target.value)} + disabled={!bridged || sending} + maxLength={1000} + placeholder={bridged ? 'Type a message...' : 'No room yet'} + aria-label="Chat message" + className={cn( + 'flex-1 rounded-lg border border-border-default bg-bg-tertiary px-3 py-2', + 'text-sm text-text-primary placeholder:text-text-muted', + 'focus:border-accent-primary focus:outline-hidden', + 'disabled:opacity-50' + )} + /> + void send()} className={cn('px-4 py-2 rounded-lg bg-accent-primary text-white text-sm', (!bridged || sending) && 'opacity-50')}> + Send + +
+ {error ? ( +

+ {error} + {connectAt ? ( + <> + {' '} + + Connect nixamp to chat + + + ) : null} +

+ ) : null} +
+ ); +} diff --git a/src/lib/nixamp/config.ts b/src/lib/nixamp/config.ts index d42611d1..e0fb25ad 100644 --- a/src/lib/nixamp/config.ts +++ b/src/lib/nixamp/config.ts @@ -27,11 +27,13 @@ export const NIXAMP_DEFAULT_CLIENT_ID = 'bittorrented'; * * `profile` is the handle, which is what a party shows other people -- * never the account email, which on nixamp is a credential rather than a - * name. `parties` is the whole point. `offline_access` keeps the connection - * alive across the hour an access token lives, so somebody who connected on - * Monday can still host on Friday without approving again. + * name. `email` is that address, asked for because "Sign in with nixamp" has + * to know which account here is theirs, and is used for nothing else. + * `parties` is the whole point. `offline_access` keeps the connection alive + * across the hour an access token lives, so somebody who connected on Monday + * can still host on Friday without approving again. */ -export const NIXAMP_SCOPES = ['profile', 'parties', 'offline_access'] as const; +export const NIXAMP_SCOPES = ['profile', 'email', 'parties', 'offline_access'] as const; /** Cookie holding the PKCE verifier and CSRF state during the round trip. */ export const NIXAMP_OAUTH_STATE_COOKIE = 'nixamp_oauth'; diff --git a/src/lib/nixamp/index.ts b/src/lib/nixamp/index.ts index 7937636f..62ba1721 100644 --- a/src/lib/nixamp/index.ts +++ b/src/lib/nixamp/index.ts @@ -49,7 +49,21 @@ export { bridgeParty, endBridgedParty, getBridgedRoom, + postRoomChat, pushPlayback, + readRoomChat, watchPartyUrl, type BridgedRoom, + type RoomChatMessage, } from './rooms'; + +export { + AUTH_COOKIE_MAX_AGE, + AUTH_COOKIE_NAME, + NixampSignInError, + safeRedirect, + sessionCookieValue, + sessionForNixampIdentity, + type MintedSession, + type NixampIdentity, +} from './signin'; diff --git a/src/lib/nixamp/oauth.test.ts b/src/lib/nixamp/oauth.test.ts index 067b8226..7d421528 100644 --- a/src/lib/nixamp/oauth.test.ts +++ b/src/lib/nixamp/oauth.test.ts @@ -78,7 +78,7 @@ describe('the authorization URL', () => { expect(url.searchParams.get('code_challenge_method')).toBe('S256'); expect(url.searchParams.get('code_challenge')).toHaveLength(43); expect(url.searchParams.get('redirect_uri')).toBe(config.redirectUri); - expect(url.searchParams.get('scope')).toBe('profile parties offline_access'); + expect(url.searchParams.get('scope')).toBe('profile email parties offline_access'); expect(url.searchParams.get('state')).toBe('st4te'); }); }); @@ -126,7 +126,7 @@ describe('the token endpoint', () => { // A public client sends no secret, and PKCE is what replaces it. expect(body.get('client_secret')).toBeNull(); return new Response( - JSON.stringify({ access_token: 'nxa_1_a', token_type: 'Bearer', expires_in: 3600, refresh_token: 'nxr_1_b', scope: 'profile parties offline_access' }), + JSON.stringify({ access_token: 'nxa_1_a', token_type: 'Bearer', expires_in: 3600, refresh_token: 'nxr_1_b', scope: 'profile email parties offline_access' }), { status: 200, headers: { 'content-type': 'application/json' } } ); }) as unknown as typeof fetch; @@ -174,7 +174,7 @@ describe('refresh rotation', () => { access_token: 'nxa_2_a', refresh_token: 'nxr_2_b', expires_in: 3600, - scope: 'profile parties offline_access', + scope: 'profile email parties offline_access', }); const next = await refreshTokens(config, metadata, 'nxr_1_b', fetcher); expect(next.refresh_token).toBe('nxr_2_b'); diff --git a/src/lib/nixamp/rooms.ts b/src/lib/nixamp/rooms.ts index a6096927..17537d63 100644 --- a/src/lib/nixamp/rooms.ts +++ b/src/lib/nixamp/rooms.ts @@ -195,3 +195,54 @@ export async function endBridgedParty(userId: string, partyCode: string, fetcher return false; } } + +// ---- the room's chat -------------------------------------------------------- +// +// A bridged party's chat is the nixamp room's chat, and nothing else: the +// same lines are read in the nixamp app, the terminal, the desktop app and +// on a television, so a member here is in the same conversation as a member +// there. Reading needs no account -- the party is unlisted, and the code is +// the invitation -- but a line is signed with a nixamp handle, so posting +// takes the member's own nixamp connection. + +export interface RoomChatMessage { + id: string; + authorId?: string; + authorName: string; + body: string; + createdAt: string; +} + +/** The lines since `after` (an ISO time), oldest first. */ +export async function readRoomChat( + room: BridgedRoom, + after?: string, + fetcher: typeof fetch = fetch +): Promise { + const url = new URL(`${room.nixampSite}/api/v1/events/${encodeURIComponent(room.eventId)}/chat`); + if (after) url.searchParams.set('after', after); + const res = await fetcher(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error(`nixamp would not read the room (${res.status})`); + const body = (await res.json().catch(() => ({}))) as { messages?: RoomChatMessage[] }; + return body.messages ?? []; +} + +/** Say something in the room, as the member's own nixamp account. */ +export async function postRoomChat( + userId: string, + room: BridgedRoom, + text: string, + fetcher: typeof fetch = fetch +): Promise { + const account = await getNixampAccount(userId); + if (!account) throw new NixampNotConnected(); + const { accessToken } = await usableAccessToken(account, fetcher); + const res = await fetcher(`${room.nixampSite}/api/v1/events/${encodeURIComponent(room.eventId)}/chat`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: text }), + }); + const body = (await res.json().catch(() => ({}))) as { message?: RoomChatMessage; error?: string }; + if (!res.ok || !body.message) throw new Error(body.error ?? `nixamp would not take that line (${res.status})`); + return body.message; +} diff --git a/src/lib/nixamp/signin.test.ts b/src/lib/nixamp/signin.test.ts new file mode 100644 index 00000000..b838aa83 --- /dev/null +++ b/src/lib/nixamp/signin.test.ts @@ -0,0 +1,97 @@ +/** + * Sign in with nixamp: the account with that email is theirs, made if absent, + * and a session is minted for it without a password. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +vi.mock('@/lib/supabase', () => ({ createServerClient: () => { throw new Error('not in this test'); } })); + +const { sessionForNixampIdentity, safeRedirect, sessionCookieValue } = await import('./signin'); + +function fakeClient(overrides: { create?: unknown; link?: unknown; verify?: unknown } = {}) { + const calls: Record = { createUser: [], generateLink: [], verifyOtp: [] }; + const client = { + auth: { + admin: { + createUser: vi.fn(async (input: unknown) => { + calls.createUser!.push(input); + return overrides.create ?? { data: { user: { id: 'u-new' } }, error: null }; + }), + generateLink: vi.fn(async (input: unknown) => { + calls.generateLink!.push(input); + return overrides.link ?? { data: { properties: { hashed_token: 'hash-1' } }, error: null }; + }), + }, + verifyOtp: vi.fn(async (input: unknown) => { + calls.verifyOtp!.push(input); + return ( + overrides.verify ?? { + data: { user: { id: 'u-1' }, session: { access_token: 'at', refresh_token: 'rt' } }, + error: null, + } + ); + }), + }, + }; + return { client: client as unknown as SupabaseClient, calls }; +} + +describe('sessionForNixampIdentity', () => { + it('makes a confirmed account with the handle as its name, then a session', async () => { + const { client, calls } = fakeClient(); + const minted = await sessionForNixampIdentity({ email: 'Chovy@Example.com', handle: 'chovy', sub: 'nx-1' }, client); + expect(minted).toEqual({ userId: 'u-1', accessToken: 'at', refreshToken: 'rt', created: true }); + expect(calls.createUser![0]).toMatchObject({ + email: 'chovy@example.com', + email_confirm: true, + user_metadata: { display_name: 'chovy', nixamp_handle: 'chovy', nixamp_sub: 'nx-1' }, + }); + expect(calls.generateLink![0]).toEqual({ type: 'magiclink', email: 'chovy@example.com' }); + expect(calls.verifyOtp![0]).toEqual({ type: 'magiclink', token_hash: 'hash-1' }); + }); + + it('an account that already exists is the one signed in', async () => { + const { client } = fakeClient({ create: { data: { user: null }, error: { message: 'A user with this email address has already been registered' } } }); + const minted = await sessionForNixampIdentity({ email: 'a@b.test', sub: 'nx-1' }, client); + expect(minted.created).toBe(false); + expect(minted.userId).toBe('u-1'); + }); + + it('needs an email, because that is what names the account here', async () => { + const { client, calls } = fakeClient(); + await expect(sessionForNixampIdentity({ email: '', sub: 'nx-1' }, client)).rejects.toMatchObject({ reason: 'no_email' }); + expect(calls.createUser).toHaveLength(0); + }); + + it('any other refusal to create is a failure, not a silent sign-in', async () => { + const { client } = fakeClient({ create: { data: { user: null }, error: { message: 'Database error' } } }); + await expect(sessionForNixampIdentity({ email: 'a@b.test', sub: 'nx-1' }, client)).rejects.toMatchObject({ reason: 'create_failed' }); + }); + + it('a link that will not verify is a failure', async () => { + const { client } = fakeClient({ verify: { data: { user: null, session: null }, error: { message: 'expired' } } }); + await expect(sessionForNixampIdentity({ email: 'a@b.test', sub: 'nx-1' }, client)).rejects.toMatchObject({ reason: 'verify_failed' }); + }); +}); + +describe('safeRedirect', () => { + it('keeps a path on this site', () => { + expect(safeRedirect('/watch-party?code=ABC123')).toBe('/watch-party?code=ABC123'); + }); + it('refuses another origin however it is spelled', () => { + expect(safeRedirect('https://evil.example/')).toBe('/settings?tab=connections'); + expect(safeRedirect('//evil.example')).toBe('/settings?tab=connections'); + expect(safeRedirect('/\\evil.example')).toBe('/settings?tab=connections'); + expect(safeRedirect('')).toBe('/settings?tab=connections'); + expect(safeRedirect(null, '/')).toBe('/'); + }); +}); + +describe('sessionCookieValue', () => { + it('is the same shape a password login sets', () => { + const value = sessionCookieValue({ accessToken: 'a', refreshToken: 'r' }); + expect(JSON.parse(decodeURIComponent(value))).toEqual({ access_token: 'a', refresh_token: 'r' }); + }); +}); diff --git a/src/lib/nixamp/signin.ts b/src/lib/nixamp/signin.ts new file mode 100644 index 00000000..5fc420aa --- /dev/null +++ b/src/lib/nixamp/signin.ts @@ -0,0 +1,118 @@ +/** + * Sign in with nixamp. + * + * nixamp.com is the authorization server and, for somebody who arrives from + * it -- a party link in the nixamp app, a terminal, a television -- it is + * also who they are. The OAuth 2.1 round trip proves an email address they + * hold on nixamp; the account here with that address is theirs, made on the + * spot if there is none, and a session for it is minted without a password + * ever existing on this side. + * + * The session is a real Supabase session, the same as a password login + * makes, so nothing downstream -- the members gate, the profile check, every + * API route -- knows the difference. It is minted the only way the admin API + * allows without a password: a magic link generated server-side and spent + * server-side, in the same request, with the token never leaving this + * process. + */ + +import type { SupabaseClient } from '@supabase/supabase-js'; +import { createServerClient } from '@/lib/supabase'; + +/** The identity nixamp vouched for. */ +export interface NixampIdentity { + email: string; + handle?: string; + sub: string; +} + +export interface MintedSession { + userId: string; + accessToken: string; + refreshToken: string; + /** True when the account here was made by this sign-in. */ + created: boolean; +} + +export class NixampSignInError extends Error { + constructor(message: string, readonly reason: string) { + super(message); + this.name = 'NixampSignInError'; + } +} + +/** + * Where to land afterwards: a path on this site, never another origin. + * + * `//evil.example` and `/\evil.example` are both origins to a browser, so + * anything that does not start with exactly one slash and a path character + * becomes the fallback. + */ +export function safeRedirect(value: string | null | undefined, fallback = '/settings?tab=connections'): string { + if (!value || !/^\/(?![/\\])/.test(value)) return fallback; + return value; +} + +function sb(): SupabaseClient { + return createServerClient() as unknown as SupabaseClient; +} + +/** + * The account for a nixamp identity, and a session for it. + * + * Existing account: the one with that email, which is why `email` is a scope + * this sign-in cannot do without. New account: made confirmed, because nixamp + * already confirmed the address, with the handle as its display name so the + * party shows the same name on both sides. + */ +export async function sessionForNixampIdentity( + who: NixampIdentity, + client: SupabaseClient = sb() +): Promise { + const email = who.email.trim().toLowerCase(); + if (!email || !email.includes('@')) { + throw new NixampSignInError('nixamp did not say which email address this account has', 'no_email'); + } + + let created = false; + const made = await client.auth.admin.createUser({ + email, + email_confirm: true, + user_metadata: { + ...(who.handle ? { display_name: who.handle, nixamp_handle: who.handle } : {}), + nixamp_sub: who.sub, + signed_up_with: 'nixamp', + }, + }); + if (!made.error) { + created = true; + } else if (!/already|exists|registered/i.test(made.error.message)) { + throw new NixampSignInError(`could not make an account here: ${made.error.message}`, 'create_failed'); + } + + // A magic link, generated and spent here. The hashed token is what the + // link would have carried; verifying it is what clicking it would do. + const link = await client.auth.admin.generateLink({ type: 'magiclink', email }); + if (link.error || !link.data.properties?.hashed_token) { + throw new NixampSignInError(`could not sign that account in: ${link.error?.message ?? 'no token'}`, 'link_failed'); + } + const verified = await client.auth.verifyOtp({ type: 'magiclink', token_hash: link.data.properties.hashed_token }); + if (verified.error || !verified.data.session || !verified.data.user) { + throw new NixampSignInError(`could not sign that account in: ${verified.error?.message ?? 'no session'}`, 'verify_failed'); + } + + return { + userId: verified.data.user.id, + accessToken: verified.data.session.access_token, + refreshToken: verified.data.session.refresh_token, + created, + }; +} + +/** The same cookie a password login sets, so nothing downstream can tell. */ +export const AUTH_COOKIE_NAME = 'sb-auth-token'; +export const AUTH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60; + +export function sessionCookieValue(session: Pick): string { + return encodeURIComponent(JSON.stringify({ access_token: session.accessToken, refresh_token: session.refreshToken })); +} diff --git a/src/proxy.ts b/src/proxy.ts index 44bb38f5..5cfc6540 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -443,6 +443,9 @@ const PUBLIC_PATHS = [ '/sw.js', '/.well-known', '/api/auth', + // Sign in with nixamp: the OAuth round trip starts and ends without a + // session here, because getting one is what it is for. + '/api/v1/nixamp/oauth', '/api/webhooks', '/api/cron', '/api/health',