/** * Phones that report playback themselves (the Android app reads system media * sessions). Each phone registers once and gets a device token that can only * call POST /devices/now-playing, so a leaked token can't read or change * anything else, and the user can revoke a single phone. */ import { and, desc, eq, notInArray } from 'drizzle-orm'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import { ANDROID_MUSIC_APPS, devicePlaybackSchema, registerDeviceSchema, type DeviceDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import { requireUser } from '../lib/auth'; import { randomToken, sha256 } from '../lib/crypto'; import { HttpError } from '../lib/http'; import { applyPollResult, isSharingPaused } from '../services/nowPlaying'; const MAX_DEVICES_PER_USER = 10; export const DEVICE_TOKEN_PREFIX = 'dev_'; type Device = typeof schema.devices.$inferSelect; const toDeviceDTO = (d: Device): DeviceDTO => ({ id: d.id, platform: d.platform, name: d.name, lastSeenAt: d.lastSeenAt?.toISOString() ?? null, createdAt: d.createdAt.toISOString(), }); function deviceToken(req: FastifyRequest): string | null { const header = req.headers.authorization; const token = header?.startsWith('Bearer ') ? header.slice(7) : null; return token?.startsWith(DEVICE_TOKEN_PREFIX) ? token : null; } export async function deviceRoutes(app: FastifyInstance) { app.get('/me/devices', async (req) => { const user = requireUser(req); const rows = await db .select() .from(schema.devices) .where(eq(schema.devices.userId, user.id)) .orderBy(desc(schema.devices.createdAt)); return rows.map(toDeviceDTO); }); app.post('/me/devices', async (req) => { const user = requireUser(req); const input = registerDeviceSchema.parse(req.body); const token = DEVICE_TOKEN_PREFIX + randomToken(32); const [device] = await db .insert(schema.devices) .values({ userId: user.id, tokenHash: sha256(token), platform: input.platform, name: input.name }) .returning(); // Reinstalls register a new device; keep only the newest few. const keep = await db .select({ id: schema.devices.id }) .from(schema.devices) .where(eq(schema.devices.userId, user.id)) .orderBy(desc(schema.devices.createdAt)) .limit(MAX_DEVICES_PER_USER); await db.delete(schema.devices).where( and( eq(schema.devices.userId, user.id), notInArray( schema.devices.id, keep.map((k) => k.id), ), ), ); return { device: toDeviceDTO(device!), token }; }); app.delete<{ Params: { id: string } }>('/me/devices/:id', async (req) => { const user = requireUser(req); if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) throw new HttpError(404, 'Device not found'); await db.delete(schema.devices).where(and(eq(schema.devices.id, req.params.id), eq(schema.devices.userId, user.id))); return { ok: true }; }); app.post( '/devices/now-playing', { config: { rateLimit: { max: 120, timeWindow: '1 minute', keyGenerator: (req: FastifyRequest) => `device:${sha256(deviceToken(req) ?? req.ip)}`, }, }, }, async (req, reply) => { const token = deviceToken(req); const [device] = token ? await db.select().from(schema.devices).where(eq(schema.devices.tokenHash, sha256(token))) : []; if (!device) throw new HttpError(401, 'Unknown device'); const input = devicePlaybackSchema.parse(req.body); await db.update(schema.devices).set({ lastSeenAt: new Date() }).where(eq(schema.devices.id, device.id)); const [owner] = await db.select().from(schema.users).where(eq(schema.users.id, device.userId)); if (!owner || isSharingPaused(owner)) return reply.status(204).send(); // accepted, not shared const sourceApp = ANDROID_MUSIC_APPS[input.app] ?? input.appName ?? null; const track = input.track ? { title: input.track.title, artist: input.track.artist || 'Unknown artist', album: input.track.album ?? null, durationMs: input.track.durationMs ?? null, } : null; await applyPollResult( device.userId, 'android_media_session', track ? { track, isPlaying: input.state === 'playing', progressMs: input.positionMs ?? null } : null, { sourceApp }, ); return reply.status(204).send(); }, ); }