Add API and worker: accounts, follows, now-playing, cross-service links
- Fastify API with cookie (web) and bearer (mobile) sessions, follows, pinned friends, featured songs, account deletion, Redis-backed rate limits - Providers: Spotify (OAuth), Last.fm, ListenBrainz, Plex (PIN flow and server discovery), Jellyfin (token auth, revoked on disconnect) - Worker polls on an adaptive schedule in a Redis sorted set, so polling scales horizontally and API usage tracks whether anyone is watching - Track resolver links songs across services via ISRC (Deezer, Spotify), iTunes search and song.link - Realtime updates over WebSocket fan out through Redis pub/sub - User-supplied server URLs are restricted to public addresses, checked both up front and at connect time to defeat DNS rebinding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { loginSchema, registerSchema } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { SESSION_COOKIE, createSession } from '../lib/auth';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { toMeDTO } from './me';
|
||||
|
||||
const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/auth/register', authRateLimit, async (req, reply) => {
|
||||
const input = registerSchema.parse(req.body);
|
||||
const [taken] = await db
|
||||
.select({ username: schema.users.username, email: schema.users.email })
|
||||
.from(schema.users)
|
||||
.where(or(eq(schema.users.username, input.username), eq(schema.users.email, input.email)));
|
||||
if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken');
|
||||
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ username: input.username, email: input.email, passwordHash: await hash(input.password) })
|
||||
.returning();
|
||||
const token = await createSession(user!.id, reply);
|
||||
return { user: toMeDTO(user!), token };
|
||||
});
|
||||
|
||||
app.post('/auth/login', authRateLimit, async (req, reply) => {
|
||||
const input = loginSchema.parse(req.body);
|
||||
const [user] = await db.select().from(schema.users).where(eq(schema.users.email, input.email));
|
||||
if (!user || !(await verify(user.passwordHash, input.password))) {
|
||||
throw new HttpError(401, 'Invalid email or password');
|
||||
}
|
||||
const token = await createSession(user.id, reply);
|
||||
return { user: toMeDTO(user), token };
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (req, reply) => {
|
||||
if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId));
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { and, count, eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import type { User } from '../db/schema';
|
||||
import { env, spotifyConfigured } from '../env';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { encrypt, randomToken } from '../lib/crypto';
|
||||
import { entitlementsFor } from '../lib/entitlements';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { redis } from '../lib/redis';
|
||||
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
|
||||
import { jellyfinAuthenticate, jellyfinLogout, jellyfinPublicInfo, type JellyfinMeta } from '../providers/jellyfin';
|
||||
import { lastfmUserExists } from '../providers/lastfm';
|
||||
import { listenbrainzUserExists } from '../providers/listenbrainz';
|
||||
import * as plex from '../providers/plex';
|
||||
import * as spotify from '../providers/spotify';
|
||||
import { toConnectionDTO, upsertConnection } from '../services/connections';
|
||||
import { unschedulePoll } from '../services/scheduler';
|
||||
|
||||
const oauthStateKey = (state: string) => `oauth:state:${state}`;
|
||||
|
||||
const authorizeBodySchema = z.object({
|
||||
client: z.enum(['web', 'mobile']).default('web'),
|
||||
/** Mobile only: deep link to return to (the app scheme, or Expo Go's exp:// URL in development). */
|
||||
returnTo: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
/** Where to send the user after OAuth. Only our own web app or app scheme is allowed (no open redirects). */
|
||||
function resolveReturnTo(body: z.infer<typeof authorizeBodySchema>): string {
|
||||
if (body.client === 'web') return `${env.WEB_URL}/settings`;
|
||||
const allowed = body.returnTo?.startsWith('musicshare://') || (env.NODE_ENV !== 'production' && body.returnTo?.startsWith('exp://'));
|
||||
return allowed ? body.returnTo! : 'musicshare://settings';
|
||||
}
|
||||
|
||||
async function assertCanConnect(user: User, provider: NowPlayingProvider) {
|
||||
const [existing] = await db
|
||||
.select({ id: schema.connections.id })
|
||||
.from(schema.connections)
|
||||
.where(and(eq(schema.connections.userId, user.id), eq(schema.connections.provider, provider)));
|
||||
if (existing) return; // reconnecting doesn't count against the limit
|
||||
const [row] = await db.select({ n: count() }).from(schema.connections).where(eq(schema.connections.userId, user.id));
|
||||
if ((row?.n ?? 0) >= entitlementsFor(user).maxConnections) {
|
||||
throw new HttpError(402, 'Connection limit reached for your plan');
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectionRoutes(app: FastifyInstance) {
|
||||
app.get('/connections', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const rows = await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id));
|
||||
return rows.map(toConnectionDTO);
|
||||
});
|
||||
|
||||
// ---- Spotify (OAuth) ----
|
||||
|
||||
app.post('/connections/spotify/authorize', async (req) => {
|
||||
const user = requireUser(req);
|
||||
if (!spotifyConfigured) throw new HttpError(501, 'Spotify is not configured on this server');
|
||||
await assertCanConnect(user, 'spotify');
|
||||
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
|
||||
const state = randomToken(24);
|
||||
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 600);
|
||||
return { url: spotify.authorizeUrl(state) };
|
||||
});
|
||||
|
||||
// The callback is identified by `state`, not the session cookie: Spotify
|
||||
// requires a 127.0.0.1 redirect in dev, which won't carry localhost cookies.
|
||||
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/connections/spotify/callback',
|
||||
async (req, reply) => {
|
||||
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
|
||||
if (!raw) throw new HttpError(400, 'Invalid or expired OAuth state');
|
||||
const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string };
|
||||
const returnTo = new URL(state.returnTo);
|
||||
|
||||
if (req.query.error || !req.query.code) {
|
||||
returnTo.searchParams.set('error', req.query.error ?? 'spotify_denied');
|
||||
return reply.redirect(returnTo.toString());
|
||||
}
|
||||
const tok = await spotify.exchangeCode(req.query.code);
|
||||
const profile = await spotify.getProfile(tok.access_token);
|
||||
await upsertConnection({
|
||||
userId,
|
||||
provider: 'spotify',
|
||||
externalUserId: profile.id,
|
||||
displayName: profile.display_name,
|
||||
accessTokenEnc: encrypt(tok.access_token),
|
||||
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : null,
|
||||
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
|
||||
});
|
||||
returnTo.searchParams.set('connected', 'spotify');
|
||||
return reply.redirect(returnTo.toString());
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Plex (PIN flow) ----
|
||||
|
||||
app.post('/connections/plex/authorize', async (req) => {
|
||||
const user = requireUser(req);
|
||||
await assertCanConnect(user, 'plex');
|
||||
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
|
||||
const pin = await plex.createPin();
|
||||
const state = randomToken(24);
|
||||
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo, pinId: pin.id }), 'EX', 900);
|
||||
return { url: plex.authAppUrl(pin.code, `${env.API_PUBLIC_URL}/connections/plex/callback?state=${state}`) };
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { state?: string } }>('/connections/plex/callback', async (req, reply) => {
|
||||
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
|
||||
if (!raw) throw new HttpError(400, 'Invalid or expired Plex login');
|
||||
const { userId, pinId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string; pinId: number };
|
||||
const returnTo = new URL(state.returnTo);
|
||||
|
||||
// Plex forwards right after approval; the token can lag by a moment
|
||||
let token: string | null = null;
|
||||
for (let i = 0; i < 3 && !token; i++) {
|
||||
token = await plex.checkPin(pinId);
|
||||
if (!token) await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (!token) {
|
||||
returnTo.searchParams.set('error', 'Plex sign-in was cancelled');
|
||||
return reply.redirect(returnTo.toString());
|
||||
}
|
||||
const account = await plex.getPlexAccount(token);
|
||||
await upsertConnection({
|
||||
userId,
|
||||
provider: 'plex',
|
||||
externalUserId: String(account.id),
|
||||
displayName: account.username || account.title,
|
||||
accessTokenEnc: encrypt(token),
|
||||
meta: {},
|
||||
});
|
||||
returnTo.searchParams.set('connected', 'plex');
|
||||
return reply.redirect(returnTo.toString());
|
||||
});
|
||||
|
||||
// ---- Jellyfin (server URL + credentials) ----
|
||||
|
||||
app.post('/connections/jellyfin', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (req) => {
|
||||
const user = requireUser(req);
|
||||
await assertCanConnect(user, 'jellyfin');
|
||||
const input = jellyfinConnectSchema.parse(req.body);
|
||||
let serverUrl: string;
|
||||
try {
|
||||
serverUrl = await assertSafeServerUrl(input.serverUrl);
|
||||
} catch (e) {
|
||||
if (e instanceof UnsafeUrlError) throw new HttpError(400, e.message);
|
||||
throw e;
|
||||
}
|
||||
const info = await jellyfinPublicInfo(serverUrl).catch(() => {
|
||||
throw new HttpError(400, `Couldn't reach a Jellyfin server at ${serverUrl}`);
|
||||
});
|
||||
const deviceId = `musicshare-${randomToken(12)}`;
|
||||
const auth = await jellyfinAuthenticate(serverUrl, deviceId, input.username, input.password);
|
||||
if (!auth) throw new HttpError(401, 'Jellyfin rejected that username or password');
|
||||
|
||||
const meta: JellyfinMeta = { serverUrl, serverName: info.ServerName, deviceId };
|
||||
return toConnectionDTO(
|
||||
await upsertConnection({
|
||||
userId: user.id,
|
||||
provider: 'jellyfin',
|
||||
externalUserId: auth.User.Id,
|
||||
displayName: `${auth.User.Name} on ${info.ServerName}`,
|
||||
accessTokenEnc: encrypt(auth.AccessToken),
|
||||
meta: { ...meta },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ---- username-based scrobblers ----
|
||||
|
||||
app.post('/connections/lastfm', async (req) => {
|
||||
const user = requireUser(req);
|
||||
if (!env.LASTFM_API_KEY) throw new HttpError(501, 'Last.fm is not configured on this server');
|
||||
await assertCanConnect(user, 'lastfm');
|
||||
const { username } = connectUsernameSchema.parse(req.body);
|
||||
const lfm = await lastfmUserExists(username);
|
||||
if (!lfm) throw new HttpError(404, 'Last.fm user not found');
|
||||
return toConnectionDTO(
|
||||
await upsertConnection({ userId: user.id, provider: 'lastfm', externalUserId: lfm.name, displayName: lfm.name }),
|
||||
);
|
||||
});
|
||||
|
||||
app.post('/connections/listenbrainz', async (req) => {
|
||||
const user = requireUser(req);
|
||||
await assertCanConnect(user, 'listenbrainz');
|
||||
const { username } = connectUsernameSchema.parse(req.body);
|
||||
if (!(await listenbrainzUserExists(username))) throw new HttpError(404, 'ListenBrainz user not found');
|
||||
return toConnectionDTO(
|
||||
await upsertConnection({ userId: user.id, provider: 'listenbrainz', externalUserId: username, displayName: username }),
|
||||
);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { provider: string } }>('/connections/:provider', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const [deleted] = await db
|
||||
.delete(schema.connections)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.connections.userId, user.id),
|
||||
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (deleted) {
|
||||
await unschedulePoll(deleted.id);
|
||||
if (deleted.provider === 'jellyfin') await jellyfinLogout(deleted);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { setFeaturedSchema, updateMeSchema, type MeDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import type { User } from '../db/schema';
|
||||
import { SESSION_COOKIE, requireUser } from '../lib/auth';
|
||||
import { effectivePlan, entitlementsFor } from '../lib/entitlements';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { deezerTrack } from '../providers/catalog';
|
||||
import { enqueueResolve } from '../services/queues';
|
||||
import { unschedulePoll } from '../services/scheduler';
|
||||
import { upsertTrack } from '../services/tracks';
|
||||
|
||||
export const toMeDTO = (u: User): MeDTO => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
avatarUrl: u.avatarUrl,
|
||||
email: u.email,
|
||||
bio: u.bio,
|
||||
preferredService: u.preferredService,
|
||||
plan: effectivePlan(u),
|
||||
entitlements: entitlementsFor(u),
|
||||
});
|
||||
|
||||
export async function meRoutes(app: FastifyInstance) {
|
||||
app.get('/me', async (req) => toMeDTO(requireUser(req)));
|
||||
|
||||
app.patch('/me', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const input = updateMeSchema.parse(req.body);
|
||||
const [updated] = await db.update(schema.users).set(input).where(eq(schema.users.id, user.id)).returning();
|
||||
return toMeDTO(updated!);
|
||||
});
|
||||
|
||||
/** Required by the App Store: users must be able to delete their account in-app. */
|
||||
app.delete('/me', async (req, reply) => {
|
||||
const user = requireUser(req);
|
||||
const conns = await db
|
||||
.select({ id: schema.connections.id })
|
||||
.from(schema.connections)
|
||||
.where(eq(schema.connections.userId, user.id));
|
||||
await Promise.all(conns.map((c) => unschedulePoll(c.id)));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, user.id));
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.put('/me/featured', async (req) => {
|
||||
const user = requireUser(req);
|
||||
const input = setFeaturedSchema.parse(req.body);
|
||||
let trackId: string;
|
||||
if ('deezerId' in input) {
|
||||
const dz = await deezerTrack(input.deezerId);
|
||||
if (!dz) throw new HttpError(404, 'Track not found');
|
||||
const t = await upsertTrack({
|
||||
title: dz.title,
|
||||
artist: dz.artist.name,
|
||||
album: dz.album.title,
|
||||
durationMs: dz.duration * 1000,
|
||||
artworkUrl: dz.album.cover_xl ?? dz.album.cover_big ?? null,
|
||||
isrc: dz.isrc ?? null,
|
||||
links: { deezer: { id: String(dz.id), url: dz.link } },
|
||||
});
|
||||
trackId = t.id;
|
||||
} else {
|
||||
const [t] = await db.select({ id: schema.tracks.id }).from(schema.tracks).where(eq(schema.tracks.id, input.trackId));
|
||||
if (!t) throw new HttpError(404, 'Track not found');
|
||||
trackId = t.id;
|
||||
}
|
||||
const values = { trackId, comment: input.comment || null, updatedAt: new Date() };
|
||||
await db
|
||||
.insert(schema.featuredSongs)
|
||||
.values({ userId: user.id, ...values })
|
||||
.onConflictDoUpdate({ target: schema.featuredSongs.userId, set: values });
|
||||
await enqueueResolve(trackId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete('/me/featured', async (req) => {
|
||||
const user = requireUser(req);
|
||||
await db.delete(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id));
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Live now-playing updates over WebSocket.
|
||||
*
|
||||
* Workers publish to Redis channel `np:<userId>`; every API instance
|
||||
* pattern-subscribes and forwards to its own sockets that follow that user.
|
||||
* Instances share nothing else, so you can run as many as you like behind a
|
||||
* load balancer.
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { db, schema } from '../db';
|
||||
import { FOLLOWS_CHANGED_CHANNEL, createRedis } from '../lib/redis';
|
||||
import { watchUsers } from '../services/connections';
|
||||
|
||||
const watchers = new Map<string, Set<WebSocket>>(); // watched userId -> sockets
|
||||
const socketsByOwner = new Map<string, Set<WebSocket>>(); // viewer userId -> their sockets
|
||||
const watching = new Map<WebSocket, Set<string>>();
|
||||
|
||||
function addTo<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
|
||||
let set = map.get(key);
|
||||
if (!set) map.set(key, (set = new Set()));
|
||||
set.add(value);
|
||||
}
|
||||
function removeFrom<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
|
||||
const set = map.get(key);
|
||||
set?.delete(value);
|
||||
if (set?.size === 0) map.delete(key);
|
||||
}
|
||||
|
||||
async function subscribeSocket(socket: WebSocket, viewerId: string) {
|
||||
const rows = await db
|
||||
.select({ id: schema.follows.followeeId })
|
||||
.from(schema.follows)
|
||||
.where(eq(schema.follows.followerId, viewerId));
|
||||
const ids = new Set([viewerId, ...rows.map((r) => r.id)]);
|
||||
for (const old of watching.get(socket) ?? []) removeFrom(watchers, old, socket);
|
||||
for (const id of ids) addTo(watchers, id, socket);
|
||||
watching.set(socket, ids);
|
||||
await watchUsers([...ids]);
|
||||
}
|
||||
|
||||
export async function realtimeRoutes(app: FastifyInstance) {
|
||||
const sub = createRedis();
|
||||
await sub.psubscribe('np:*');
|
||||
await sub.subscribe(FOLLOWS_CHANGED_CHANNEL);
|
||||
|
||||
sub.on('pmessage', (_pattern, channel, message) => {
|
||||
const userId = channel.slice(3);
|
||||
const payload = `{"type":"now_playing","data":${message}}`;
|
||||
for (const socket of watchers.get(userId) ?? []) socket.send(payload);
|
||||
});
|
||||
sub.on('message', (channel, viewerId) => {
|
||||
if (channel !== FOLLOWS_CHANGED_CHANNEL) return;
|
||||
for (const socket of socketsByOwner.get(viewerId) ?? []) {
|
||||
subscribeSocket(socket, viewerId).catch((e) => app.log.error(e));
|
||||
}
|
||||
});
|
||||
|
||||
// Keep presence alive so workers keep polling watched users at the fast rate.
|
||||
const heartbeat = setInterval(() => {
|
||||
watchUsers([...watchers.keys()]).catch((e) => app.log.error(e));
|
||||
for (const socket of watching.keys()) socket.ping();
|
||||
}, 30_000);
|
||||
app.addHook('onClose', async () => {
|
||||
clearInterval(heartbeat);
|
||||
sub.disconnect();
|
||||
});
|
||||
|
||||
app.get('/ws', { websocket: true }, (socket, req) => {
|
||||
const viewer = req.user;
|
||||
if (!viewer) {
|
||||
socket.close(4401, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
addTo(socketsByOwner, viewer.id, socket);
|
||||
subscribeSocket(socket, viewer.id).catch((e) => app.log.error(e));
|
||||
|
||||
socket.on('close', () => {
|
||||
for (const id of watching.get(socket) ?? []) removeFrom(watchers, id, socket);
|
||||
watching.delete(socket);
|
||||
removeFrom(socketsByOwner, viewer.id, socket);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { STREAMING_SERVICES, openUrl, type StreamingService, type TrackSearchResultDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { deezerSearch } from '../providers/catalog';
|
||||
import { resolveTrackLinks } from '../services/resolver';
|
||||
import { getTrackDTOs } from '../services/tracks';
|
||||
|
||||
export async function trackRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { q?: string } }>(
|
||||
'/tracks/search',
|
||||
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||
async (req): Promise<TrackSearchResultDTO[]> => {
|
||||
const q = (req.query.q ?? '').trim();
|
||||
if (q.length < 2) return [];
|
||||
const results = await deezerSearch(q, 15);
|
||||
return results.map((r) => ({
|
||||
deezerId: String(r.id),
|
||||
title: r.title,
|
||||
artist: r.artist.name,
|
||||
album: r.album.title,
|
||||
artworkUrl: r.album.cover_big ?? null,
|
||||
durationMs: r.duration * 1000,
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>('/tracks/:id', async (req) => {
|
||||
const track = (await getTrackDTOs([req.params.id])).get(req.params.id);
|
||||
if (!track) throw new HttpError(404, 'Track not found');
|
||||
return track;
|
||||
});
|
||||
|
||||
/** Shareable redirect: /tracks/:id/open?service=spotify (defaults to the viewer's preferred service). */
|
||||
app.get<{ Params: { id: string }; Querystring: { service?: string } }>('/tracks/:id/open', async (req, reply) => {
|
||||
const service = (req.query.service ?? req.user?.preferredService ?? 'spotify') as StreamingService;
|
||||
if (!STREAMING_SERVICES.includes(service)) throw new HttpError(400, 'Unknown service');
|
||||
let track = (await getTrackDTOs([req.params.id])).get(req.params.id);
|
||||
if (!track) throw new HttpError(404, 'Track not found');
|
||||
if (!track.links[service]) {
|
||||
const [row] = await db
|
||||
.select({ resolved: schema.tracks.linksResolvedAt })
|
||||
.from(schema.tracks)
|
||||
.where(eq(schema.tracks.id, track.id));
|
||||
if (!row?.resolved) {
|
||||
await resolveTrackLinks(track.id).catch((e) => req.log.warn(e, 'inline resolve failed'));
|
||||
track = (await getTrackDTOs([track.id])).get(track.id)!;
|
||||
}
|
||||
}
|
||||
return reply.redirect(openUrl(track, service).url);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { and, count, eq, ilike, isNotNull, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { entitlementsFor } from '../lib/entitlements';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||
import { watchUsers } from '../services/connections';
|
||||
import { getNowPlaying, toUserSummary } from '../services/nowPlaying';
|
||||
import { getTrackDTOs } from '../services/tracks';
|
||||
|
||||
async function findByUsername(username: string) {
|
||||
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase()));
|
||||
if (!user) throw new HttpError(404, 'User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => {
|
||||
const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, '');
|
||||
if (q.length < 2) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)))
|
||||
.limit(20);
|
||||
return rows.map(toUserSummary);
|
||||
});
|
||||
|
||||
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
|
||||
const user = await findByUsername(req.params.username);
|
||||
const viewer = req.user;
|
||||
const [[followers], [following], [follow], [featured], nowPlaying] = await Promise.all([
|
||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
|
||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
|
||||
viewer
|
||||
? db
|
||||
.select({ pinnedAt: schema.follows.pinnedAt })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id)))
|
||||
: Promise.resolve([]),
|
||||
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
|
||||
getNowPlaying([user.id]),
|
||||
]);
|
||||
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
|
||||
await watchUsers([user.id]);
|
||||
|
||||
return {
|
||||
user: { ...toUserSummary(user), bio: user.bio },
|
||||
followerCount: followers?.n ?? 0,
|
||||
followingCount: following?.n ?? 0,
|
||||
isFollowing: Boolean(follow),
|
||||
isPinned: Boolean(follow?.pinnedAt),
|
||||
isSelf: viewer?.id === user.id,
|
||||
featured:
|
||||
featured && featuredTrack
|
||||
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
|
||||
: null,
|
||||
nowPlaying: nowPlaying[0] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself");
|
||||
await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing();
|
||||
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
await db
|
||||
.delete(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.put<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
const [follow] = await db
|
||||
.select({ pinnedAt: schema.follows.pinnedAt })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
if (!follow) throw new HttpError(400, 'Follow someone before pinning them');
|
||||
if (follow.pinnedAt) return { ok: true };
|
||||
const [pinned] = await db
|
||||
.select({ n: count() })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), isNotNull(schema.follows.pinnedAt)));
|
||||
const max = entitlementsFor(viewer).maxPinnedFriends;
|
||||
if ((pinned?.n ?? 0) >= max) throw new HttpError(402, `You can pin up to ${max} friends on your plan`);
|
||||
await db
|
||||
.update(schema.follows)
|
||||
.set({ pinnedAt: new Date() })
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
await db
|
||||
.update(schema.follows)
|
||||
.set({ pinnedAt: null })
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Now-playing state for everyone the viewer follows, pinned friends first. */
|
||||
app.get('/feed', async (req): Promise<FeedItemDTO[]> => {
|
||||
const viewer = requireUser(req);
|
||||
const followees = await db
|
||||
.select({ id: schema.follows.followeeId, pinnedAt: schema.follows.pinnedAt })
|
||||
.from(schema.follows)
|
||||
.where(eq(schema.follows.followerId, viewer.id));
|
||||
const pins = new Map(followees.map((f) => [f.id, f.pinnedAt?.toISOString() ?? null]));
|
||||
await watchUsers([...pins.keys()]);
|
||||
const items = await getNowPlaying([...pins.keys()]);
|
||||
return sortFeed(items.map((np) => ({ ...np, pinnedAt: pins.get(np.user.id) ?? null })));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Inbound webhooks from Plex (Plex Pass) and the Jellyfin Webhook plugin.
|
||||
*
|
||||
* We don't trust or parse the payload: a webhook only means "something
|
||||
* changed", so we poll that connection right away. That keeps one code path
|
||||
* for state, works with any Jellyfin template, and means a forged request can
|
||||
* at most cause an extra poll. The secret in the URL identifies the connection.
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { NowPlayingProvider } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { pollSoon } from '../services/scheduler';
|
||||
|
||||
export async function webhookRoutes(app: FastifyInstance) {
|
||||
// Plex posts multipart/form-data (sometimes with a thumbnail attached). We
|
||||
// ignore bodies entirely, so accept any content type without parsing.
|
||||
app.addContentTypeParser('*', { bodyLimit: 10 * 1024 * 1024 }, (_req, payload, done) => {
|
||||
payload.resume();
|
||||
payload.on('end', () => done(null, undefined));
|
||||
payload.on('error', done);
|
||||
});
|
||||
app.removeContentTypeParser('application/json');
|
||||
app.addContentTypeParser('application/json', (_req, payload, done) => {
|
||||
payload.resume();
|
||||
payload.on('end', () => done(null, undefined));
|
||||
});
|
||||
|
||||
app.post<{ Params: { provider: string; secret: string } }>(
|
||||
'/webhooks/:provider/:secret',
|
||||
{ config: { rateLimit: { max: 120, timeWindow: '1 minute' } } },
|
||||
async (req, reply) => {
|
||||
const [conn] = await db
|
||||
.select({ id: schema.connections.id })
|
||||
.from(schema.connections)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.connections.webhookSecret, req.params.secret),
|
||||
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
|
||||
eq(schema.connections.status, 'active'),
|
||||
),
|
||||
);
|
||||
if (conn) await pollSoon([conn.id]);
|
||||
// Same response either way, so secrets can't be probed.
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user