API: follower lists, blocking, reports, admin; drop Spotify sign-in

- Followers/following lists (paginated), 'follows you', remove a follower
- Blocking severs follows both ways and hides each user from the other
  (profile 404s, search, follows); the blocker sees activity hidden
- Reports for moderation (App Store guideline 1.2)
- First account on a server becomes admin; admins see the report queue,
  resolve reports, and suspend accounts (signs out, blocks login, hides
  the profile, stops sharing). set-role script to promote/demote
- users.onboardedAt drives first-run onboarding; existing accounts are
  backfilled as already onboarded. Empty PATCH /me no longer 500s
- Remove Spotify account sign-in and polling: Spotify caps new apps at 5
  accounts, so Spotify arrives via its Last.fm/ListenBrainz link or the
  Android reader. App-level catalog search stays for exact links
- Shared: QR code generation and profile-link parsing (verified with jsQR)
- Integration test timeout raised; password hashing is slow by design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-05 19:16:40 -04:00
co-authored by Claude Opus 5.5
parent 63d618f0e3
commit 586acdbeab
44 changed files with 4559 additions and 220 deletions
+4
View File
@@ -9,11 +9,13 @@ import { env } from './env';
import { loadUser } from './lib/auth';
import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin';
import { authRoutes } from './routes/auth';
import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me';
import { realtimeRoutes } from './routes/realtime';
import { safetyRoutes } from './routes/safety';
import { trackRoutes } from './routes/tracks';
import { userRoutes } from './routes/users';
import { webhookRoutes } from './routes/webhooks';
@@ -55,6 +57,8 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(authRoutes);
await app.register(meRoutes);
await app.register(userRoutes);
await app.register(safetyRoutes);
await app.register(adminRoutes);
await app.register(trackRoutes);
await app.register(connectionRoutes);
await app.register(deviceRoutes);
+39 -1
View File
@@ -12,7 +12,7 @@ import {
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import type { NowPlayingProvider, Plan, StreamingService } from '@musicshare/shared';
import type { NowPlayingProvider, Plan, ReportReason, StreamingService } from '@musicshare/shared';
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
@@ -26,6 +26,12 @@ export const users = pgTable('users', {
avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
/** The first account on a server becomes admin (moderation). */
role: text('role').$type<'user' | 'admin'>().notNull().default('user'),
/** Suspended by a moderator: can't sign in, profile hidden, sharing stopped. */
suspendedAt: timestamp('suspended_at', { withTimezone: true }),
/** Set when the user finishes (or skips through) onboarding; null shows it on next launch. */
onboardedAt: timestamp('onboarded_at', { withTimezone: true }),
/** While in the future, nothing new is shared: services aren't polled and phone reports are dropped. */
sharingPausedUntil: timestamp('sharing_paused_until', { withTimezone: true }),
plan: text('plan').$type<Plan>().notNull().default('free'),
@@ -63,6 +69,38 @@ export const follows = pgTable(
(t) => [primaryKey({ columns: [t.followerId, t.followeeId] }), index('follows_followee_idx').on(t.followeeId)],
);
/** Blocking removes follows both ways and hides each user from the other. */
export const blocks = pgTable(
'blocks',
{
blockerId: uuid('blocker_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
blockedId: uuid('blocked_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.blockerId, t.blockedId] }), index('blocks_blocked_idx').on(t.blockedId)],
);
/** User reports for moderation (App Store guideline 1.2 requires a way to report users). */
export const reports = pgTable(
'reports',
{
id: uuid('id').primaryKey().defaultRandom(),
reporterId: uuid('reporter_id').references(() => users.id, { onDelete: 'set null' }),
reportedId: uuid('reported_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
reason: text('reason').$type<ReportReason>().notNull(),
note: varchar('note', { length: 500 }),
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
createdAt: createdAt(),
},
(t) => [index('reports_status_idx').on(t.status)],
);
export const connections = pgTable(
'connections',
{
+7 -1
View File
@@ -43,12 +43,18 @@ export async function loadUser(req: FastifyRequest) {
.from(schema.sessions)
.innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId))
.where(and(eq(schema.sessions.id, id), gt(schema.sessions.expiresAt, new Date())));
if (row) {
if (row && !row.user.suspendedAt) {
req.user = row.user;
req.sessionId = id;
}
}
export function requireAdmin(req: FastifyRequest): User {
const user = requireUser(req);
if (user.role !== 'admin') throw new HttpError(403, 'Admins only');
return user;
}
export function requireUser(req: FastifyRequest): User {
if (!req.user) throw new HttpError(401, 'Not signed in');
return req.user;
-2
View File
@@ -4,12 +4,10 @@ import { jellyfinProvider } from './jellyfin';
import { lastfmProvider } from './lastfm';
import { listenbrainzProvider } from './listenbrainz';
import { plexProvider } from './plex';
import { spotifyProvider } from './spotify';
import type { PollingProvider } from './types';
/** Providers the server polls. Push-only sources (the Android app) don't appear here. */
export const pollingProviders: Partial<Record<NowPlayingProvider, PollingProvider>> = {
spotify: spotifyProvider,
lastfm: lastfmProvider,
listenbrainz: listenbrainzProvider,
plex: plexProvider,
+8 -65
View File
@@ -1,15 +1,14 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db';
import type { Connection } from '../db/schema';
/**
* Spotify catalog lookups only (app-level client credentials, not users' accounts),
* used to give "Open in Spotify" an exact link. Spotify listening itself comes in
* through Spotify's own Last.fm / ListenBrainz links, or the Android reader:
* Spotify caps new apps at 5 user accounts, so we don't ask users to sign in here.
*/
import { env } from '../env';
import { decrypt, encrypt } from '../lib/crypto';
import { HttpError, fetchJson } from '../lib/http';
import { HttpError } from '../lib/http';
import { redis } from '../lib/redis';
import { findBestMatch } from './match';
import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack, type PollingProvider } from './types';
const SCOPES = ['user-read-currently-playing', 'user-read-playback-state'];
export const redirectUri = () => `${env.API_PUBLIC_URL}/connections/spotify/callback`;
import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack } from './types';
interface TokenResponse {
access_token: string;
@@ -30,17 +29,6 @@ interface SpotifyTrack {
const basicAuth = () =>
'Basic ' + Buffer.from(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`).toString('base64');
export function authorizeUrl(state: string): string {
const params = new URLSearchParams({
client_id: env.SPOTIFY_CLIENT_ID!,
response_type: 'code',
redirect_uri: redirectUri(),
scope: SCOPES.join(' '),
state,
});
return `https://accounts.spotify.com/authorize?${params}`;
}
async function tokenRequest(body: Record<string, string>): Promise<TokenResponse> {
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
@@ -53,33 +41,6 @@ async function tokenRequest(body: Record<string, string>): Promise<TokenResponse
return (await res.json()) as TokenResponse;
}
export const exchangeCode = (code: string) =>
tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() });
export async function getProfile(accessToken: string) {
return (await fetchJson<{ id: string; display_name: string | null }>('https://api.spotify.com/v1/me', {
headers: { Authorization: `Bearer ${accessToken}` },
}))!;
}
/** Returns a valid access token for the connection, refreshing and persisting if needed. */
async function userAccessToken(conn: Connection): Promise<string> {
if (conn.accessTokenEnc && conn.tokenExpiresAt && conn.tokenExpiresAt.getTime() > Date.now() + 60_000) {
return decrypt(conn.accessTokenEnc);
}
if (!conn.refreshTokenEnc) throw new ProviderAuthError('no refresh token');
const tok = await tokenRequest({ grant_type: 'refresh_token', refresh_token: decrypt(conn.refreshTokenEnc) });
await db
.update(schema.connections)
.set({
accessTokenEnc: encrypt(tok.access_token),
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : conn.refreshTokenEnc,
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
})
.where(eq(schema.connections.id, conn.id));
return tok.access_token;
}
async function spotifyGet<T>(path: string, token: string): Promise<T | null> {
const res = await fetch(`https://api.spotify.com/v1${path}`, {
headers: { Authorization: `Bearer ${token}` },
@@ -104,24 +65,6 @@ function toIncoming(t: SpotifyTrack): IncomingTrack {
};
}
export const spotifyProvider: PollingProvider = {
id: 'spotify',
async poll(conn) {
const token = await userAccessToken(conn);
const data = await spotifyGet<{
is_playing: boolean;
progress_ms: number | null;
currently_playing_type: string;
item: SpotifyTrack | null;
}>('/me/player/currently-playing', token);
// Podcasts/ads come back with a null or non-track item.
if (!data?.item || data.currently_playing_type !== 'track') return null;
return { track: toIncoming(data.item), isPlaying: data.is_playing, progressMs: data.progress_ms };
},
};
// ---- catalog lookups (client credentials), used for cross-service linking ----
async function appAccessToken(): Promise<string> {
const cached = await redis.get('spotify:app-token');
if (cached) return cached;
+75
View File
@@ -0,0 +1,75 @@
/** Moderation for admins: the report queue, suspensions, and a few numbers. */
import { and, count, desc, eq, isNotNull } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { FastifyInstance } from 'fastify';
import type { AdminReportDTO, AdminStatsDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireAdmin } from '../lib/auth';
import { HttpError } from '../lib/http';
import { pauseSharing, toUserSummary } from '../services/nowPlaying';
export async function adminRoutes(app: FastifyInstance) {
app.get('/admin/stats', async (req): Promise<AdminStatsDTO> => {
requireAdmin(req);
const [[users], [listening], [open], [suspended]] = await Promise.all([
db.select({ n: count() }).from(schema.users),
db.select({ n: count() }).from(schema.nowPlaying).where(eq(schema.nowPlaying.isPlaying, true)),
db.select({ n: count() }).from(schema.reports).where(eq(schema.reports.status, 'open')),
db.select({ n: count() }).from(schema.users).where(isNotNull(schema.users.suspendedAt)),
]);
return { users: users!.n, listeningNow: listening!.n, openReports: open!.n, suspended: suspended!.n };
});
app.get<{ Querystring: { status?: 'open' | 'resolved' } }>('/admin/reports', async (req): Promise<AdminReportDTO[]> => {
requireAdmin(req);
const reporter = alias(schema.users, 'reporter');
const reported = alias(schema.users, 'reported');
const rows = await db
.select({ report: schema.reports, reporter, reported })
.from(schema.reports)
.leftJoin(reporter, eq(reporter.id, schema.reports.reporterId))
.innerJoin(reported, eq(reported.id, schema.reports.reportedId))
.where(eq(schema.reports.status, req.query.status === 'resolved' ? 'resolved' : 'open'))
.orderBy(desc(schema.reports.createdAt))
.limit(200);
return rows.map(({ report, reporter: by, reported: who }) => ({
id: report.id,
reason: report.reason,
note: report.note,
status: report.status,
createdAt: report.createdAt.toISOString(),
reporter: by ? toUserSummary(by) : null,
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
}));
});
app.post<{ Params: { id: string } }>('/admin/reports/:id/resolve', async (req) => {
requireAdmin(req);
if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) throw new HttpError(404, 'Report not found');
await db.update(schema.reports).set({ status: 'resolved' }).where(eq(schema.reports.id, req.params.id));
return { ok: true };
});
/** Suspend: signs them out everywhere, blocks login, hides their profile, stops sharing. */
app.post<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => {
const admin = requireAdmin(req);
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, req.params.username.toLowerCase()));
if (!user) throw new HttpError(404, 'User not found');
if (user.id === admin.id) throw new HttpError(400, "You can't suspend yourself");
if (user.role === 'admin') throw new HttpError(400, "Admins can't be suspended");
await db.update(schema.users).set({ suspendedAt: new Date() }).where(eq(schema.users.id, user.id));
await db.delete(schema.sessions).where(eq(schema.sessions.userId, user.id));
await pauseSharing(user.id);
req.log.warn({ by: admin.username, user: user.username }, 'user suspended');
return { ok: true };
});
app.delete<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => {
requireAdmin(req);
await db
.update(schema.users)
.set({ suspendedAt: null })
.where(and(eq(schema.users.username, req.params.username.toLowerCase()), isNotNull(schema.users.suspendedAt)));
return { ok: true };
});
}
+13 -5
View File
@@ -1,5 +1,5 @@
import { hash, verify } from '@node-rs/argon2';
import { eq, or } from 'drizzle-orm';
import { count, eq, or, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { loginSchema, registerSchema } from '@musicshare/shared';
import { db, schema } from '../db';
@@ -18,10 +18,17 @@ export async function authRoutes(app: FastifyInstance) {
.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 passwordHash = await hash(input.password);
const user = await db.transaction(async (tx) => {
// Serialize sign-ups so two simultaneous "first" accounts can't both become admin.
await tx.execute(sql`select pg_advisory_xact_lock(4242)`);
const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }];
const [created] = await tx
.insert(schema.users)
.values({ username: input.username, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' })
.returning();
return created;
});
const token = await createSession(user!.id, reply);
return { user: toMeDTO(user!), token };
});
@@ -32,6 +39,7 @@ export async function authRoutes(app: FastifyInstance) {
if (!user || !(await verify(user.passwordHash, input.password))) {
throw new HttpError(401, 'Invalid email or password');
}
if (user.suspendedAt) throw new HttpError(403, 'This account has been suspended');
const token = await createSession(user.id, reply);
return { user: toMeDTO(user), token };
});
+1 -54
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { appleMusicConfigured, env, spotifyConfigured } from '../env';
import { appleMusicConfigured, env } from '../env';
import { requireUser } from '../lib/auth';
import { encrypt, randomToken } from '../lib/crypto';
import { entitlementsFor } from '../lib/entitlements';
@@ -17,7 +17,6 @@ import { lastfmAuthUrl, lastfmSession, lastfmUserExists } from '../providers/las
import { ProviderAuthError } from '../providers/types';
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';
@@ -55,58 +54,6 @@ export async function connectionRoutes(app: FastifyInstance) {
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);
let profile;
try {
profile = await spotify.getProfile(tok.access_token);
} catch (err) {
// Development-mode apps reject accounts not on the allowlist with 403.
if (err instanceof HttpError && err.status === 403) {
returnTo.searchParams.set('error', 'spotify_limited');
return reply.redirect(returnTo.toString());
}
throw err;
}
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) => {
+6 -2
View File
@@ -21,6 +21,8 @@ export const toMeDTO = (u: User): MeDTO => ({
email: u.email,
bio: u.bio,
preferredService: u.preferredService,
role: u.role,
onboardedAt: u.onboardedAt?.toISOString() ?? null,
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
plan: effectivePlan(u),
entitlements: entitlementsFor(u),
@@ -31,8 +33,10 @@ export async function meRoutes(app: FastifyInstance) {
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();
const { onboarded, ...input } = updateMeSchema.parse(req.body);
const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) };
if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update
const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning();
return toMeDTO(updated!);
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Blocking, removing followers, and reporting. App Store guideline 1.2 requires
* apps with user content to let people block and report abusive users.
*/
import { and, desc, eq, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { reportSchema } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { toUserSummary } from '../services/nowPlaying';
async function findUser(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;
}
/** Live websocket subscriptions are built from follows; refresh them for both people. */
const followsChanged = (...userIds: string[]) => Promise.all(userIds.map((id) => redis.publish(FOLLOWS_CHANGED_CHANNEL, id)));
export async function safetyRoutes(app: FastifyInstance) {
/** Remove someone who follows you. They aren't notified, and can follow again (block to prevent that). */
app.delete<{ Params: { username: string } }>('/me/followers/:username', async (req) => {
const me = requireUser(req);
const them = await findUser(req.params.username);
await db.delete(schema.follows).where(and(eq(schema.follows.followerId, them.id), eq(schema.follows.followeeId, me.id)));
await followsChanged(them.id);
return { ok: true };
});
app.post<{ Params: { username: string } }>('/users/:username/block', async (req) => {
const me = requireUser(req);
const them = await findUser(req.params.username);
if (them.id === me.id) throw new HttpError(400, "You can't block yourself");
await db.transaction(async (tx) => {
await tx.insert(schema.blocks).values({ blockerId: me.id, blockedId: them.id }).onConflictDoNothing();
// Sever follows in both directions (pins go with them).
await tx
.delete(schema.follows)
.where(
or(
and(eq(schema.follows.followerId, me.id), eq(schema.follows.followeeId, them.id)),
and(eq(schema.follows.followerId, them.id), eq(schema.follows.followeeId, me.id)),
),
);
});
await followsChanged(me.id, them.id);
return { ok: true };
});
app.delete<{ Params: { username: string } }>('/users/:username/block', async (req) => {
const me = requireUser(req);
const them = await findUser(req.params.username);
await db.delete(schema.blocks).where(and(eq(schema.blocks.blockerId, me.id), eq(schema.blocks.blockedId, them.id)));
return { ok: true };
});
app.get('/me/blocks', async (req) => {
const me = requireUser(req);
const rows = await db
.select({ user: schema.users })
.from(schema.blocks)
.innerJoin(schema.users, eq(schema.users.id, schema.blocks.blockedId))
.where(eq(schema.blocks.blockerId, me.id))
.orderBy(desc(schema.blocks.createdAt));
return rows.map((r) => toUserSummary(r.user));
});
app.post<{ Params: { username: string } }>(
'/users/:username/report',
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
async (req) => {
const me = requireUser(req);
const them = await findUser(req.params.username);
if (them.id === me.id) throw new HttpError(400, "You can't report yourself");
const input = reportSchema.parse(req.body);
await db.insert(schema.reports).values({ reporterId: me.id, reportedId: them.id, reason: input.reason, note: input.note || null });
req.log.warn({ reported: them.username, reason: input.reason }, 'user reported');
return { ok: true };
},
);
}
+85 -15
View File
@@ -1,11 +1,12 @@
import { and, count, eq, ilike, inArray, isNotNull, or } from 'drizzle-orm';
import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { sortFeed, sourceProfileUrl, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { sortFeed, sourceProfileUrl, type FeedItemDTO, type FollowListDTO, 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 { blockStatus, hiddenUserIds } from '../services/blocks';
import { watchUsers } from '../services/connections';
import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying';
import { getTrackDTOs } from '../services/tracks';
@@ -16,22 +17,41 @@ async function findByUsername(username: string) {
return user;
}
/** Someone who blocked you looks like they don't exist. */
async function findVisible(username: string, viewerId: string | undefined, viewerIsAdmin = false) {
const user = await findByUsername(username);
if (user.suspendedAt && !viewerIsAdmin) throw new HttpError(404, 'User not found');
const status = await blockStatus(viewerId, user.id);
if (status.blockedByThem) throw new HttpError(404, 'User not found');
return { user, ...status };
}
const PAGE = 50;
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);
const [rows, hidden] = await Promise.all([
db
.select()
.from(schema.users)
.where(
and(
isNull(schema.users.suspendedAt),
or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)),
),
)
.limit(30),
hiddenUserIds(req.user?.id),
]);
return rows.filter((u) => !hidden.has(u.id)).slice(0, 20).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, recent, scrobblers] = await Promise.all([
const { user, viewerBlocked } = await findVisible(req.params.username, viewer?.id, viewer?.role === 'admin');
const [[followers], [following], [follow], [followsYou], [featured], nowPlaying, recent, scrobblers] = 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
@@ -40,6 +60,12 @@ export async function userRoutes(app: FastifyInstance) {
.from(schema.follows)
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id)))
: Promise.resolve([]),
viewer
? db
.select({ x: schema.follows.followerId })
.from(schema.follows)
.where(and(eq(schema.follows.followerId, user.id), eq(schema.follows.followeeId, viewer.id)))
: Promise.resolve([]),
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
getNowPlaying([user.id]),
getRecentPlays(user.id),
@@ -54,7 +80,7 @@ export async function userRoutes(app: FastifyInstance) {
if (url && (c.provider === 'lastfm' || c.provider === 'listenbrainz')) attribution[c.provider] = url;
}
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
await watchUsers([user.id]);
if (!viewerBlocked) await watchUsers([user.id]);
return {
user: { ...toUserSummary(user), bio: user.bio },
@@ -63,20 +89,24 @@ export async function userRoutes(app: FastifyInstance) {
isFollowing: Boolean(follow),
isPinned: Boolean(follow?.pinnedAt),
isSelf: viewer?.id === user.id,
followsYou: Boolean(followsYou),
isBlocked: viewerBlocked,
// You blocked them: don't show their activity (until you unblock).
featured:
featured && featuredTrack
!viewerBlocked && featured && featuredTrack
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
: null,
nowPlaying: nowPlaying[0] ?? null,
recent,
nowPlaying: viewerBlocked ? null : (nowPlaying[0] ?? null),
recent: viewerBlocked ? [] : recent,
attribution,
};
});
app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
const viewer = requireUser(req);
const target = await findByUsername(req.params.username);
const { user: target, viewerBlocked } = await findVisible(req.params.username, viewer.id);
if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself");
if (viewerBlocked) throw new HttpError(400, 'Unblock them first');
await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing();
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
return { ok: true };
@@ -124,6 +154,46 @@ export async function userRoutes(app: FastifyInstance) {
return { ok: true };
});
/** Who follows someone, or who they follow. Newest first, 50 per page. */
for (const direction of ['followers', 'following'] as const) {
app.get<{ Params: { username: string }; Querystring: { cursor?: string } }>(
`/users/:username/${direction}`,
async (req): Promise<FollowListDTO> => {
const viewer = req.user;
const { user } = await findVisible(req.params.username, viewer?.id);
const offset = Math.max(0, Number.parseInt(req.query.cursor ?? '0', 10) || 0);
const [listUser, matchCol] =
direction === 'followers'
? [schema.follows.followerId, schema.follows.followeeId]
: [schema.follows.followeeId, schema.follows.followerId];
const rows = await db
.select({ user: schema.users })
.from(schema.follows)
.innerJoin(schema.users, eq(schema.users.id, listUser))
.where(eq(matchCol, user.id))
.orderBy(desc(schema.follows.createdAt))
.limit(PAGE + 1)
.offset(offset);
const hidden = await hiddenUserIds(viewer?.id);
const page = rows.slice(0, PAGE).map((r) => r.user);
const viewerFollows = viewer && page.length
? new Set(
(
await db
.select({ id: schema.follows.followeeId })
.from(schema.follows)
.where(and(eq(schema.follows.followerId, viewer.id), inArray(schema.follows.followeeId, page.map((u) => u.id))))
).map((r) => r.id),
)
: new Set<string>();
return {
users: page.filter((u) => !hidden.has(u.id)).map((u) => ({ ...toUserSummary(u), isFollowing: viewerFollows.has(u.id) })),
nextCursor: rows.length > PAGE ? String(offset + PAGE) : null,
};
},
);
}
/** Now-playing state for everyone the viewer follows, pinned friends first. */
app.get('/feed', async (req): Promise<FeedItemDTO[]> => {
const viewer = requireUser(req);
+22
View File
@@ -0,0 +1,22 @@
/**
* Promote or demote an account:
* npm run admin:promote -w @musicshare/api -- <username>
* npm run admin:demote -w @musicshare/api -- <username>
* In Docker: docker compose exec api node dist/scripts/set-role.js admin <username>
*/
import { eq } from 'drizzle-orm';
import { db, schema, sql } from '../db';
const [role, username] = process.argv.slice(2);
if ((role !== 'admin' && role !== 'user') || !username) {
console.error('usage: set-role <admin|user> <username>');
process.exit(1);
}
const updated = await db
.update(schema.users)
.set({ role })
.where(eq(schema.users.username, username.toLowerCase()))
.returning({ username: schema.users.username });
console.log(updated.length ? `${updated[0]!.username} is now ${role}` : `no user named ${username}`);
await sql.end();
process.exit(updated.length ? 0 : 1);
+30
View File
@@ -0,0 +1,30 @@
import { and, eq, or } from 'drizzle-orm';
import { db, schema } from '../db';
/** Block relationship between a viewer and another user, in both directions. */
export async function blockStatus(viewerId: string | undefined, otherId: string) {
if (!viewerId || viewerId === otherId) return { viewerBlocked: false, blockedByThem: false };
const rows = await db
.select()
.from(schema.blocks)
.where(
or(
and(eq(schema.blocks.blockerId, viewerId), eq(schema.blocks.blockedId, otherId)),
and(eq(schema.blocks.blockerId, otherId), eq(schema.blocks.blockedId, viewerId)),
),
);
return {
viewerBlocked: rows.some((r) => r.blockerId === viewerId),
blockedByThem: rows.some((r) => r.blockerId === otherId),
};
}
/** Everyone the viewer shouldn't see in lists or search: people they blocked and people who blocked them. */
export async function hiddenUserIds(viewerId: string | undefined): Promise<Set<string>> {
if (!viewerId) return new Set();
const rows = await db
.select()
.from(schema.blocks)
.where(or(eq(schema.blocks.blockerId, viewerId), eq(schema.blocks.blockedId, viewerId)));
return new Set(rows.map((r) => (r.blockerId === viewerId ? r.blockedId : r.blockerId)));
}
+3 -2
View File
@@ -74,8 +74,9 @@ export async function getRecentPlays(userId: string, limit = 10): Promise<Recent
return plays.map((p) => ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() }));
}
export const isSharingPaused = (user: Pick<User, 'sharingPausedUntil'>) =>
Boolean(user.sharingPausedUntil && user.sharingPausedUntil > new Date());
/** Paused by the user, or suspended by a moderator: nothing new is shared either way. */
export const isSharingPaused = (user: Pick<User, 'sharingPausedUntil'> & Partial<Pick<User, 'suspendedAt'>>) =>
Boolean(user.suspendedAt) || Boolean(user.sharingPausedUntil && user.sharingPausedUntil > new Date());
/** Starting a pause: whatever was playing becomes "played … ago" for friends. */
export async function pauseSharing(userId: string) {
+1 -1
View File
@@ -37,7 +37,7 @@ async function pollConnection(connectionId: string) {
if (isSharingPaused(user)) {
// Don't even ask the provider while paused; check back when the pause ends.
const resumeIn = user.sharingPausedUntil!.getTime() - Date.now();
const resumeIn = user.sharingPausedUntil ? user.sharingPausedUntil.getTime() - Date.now() : IDLE_POLL_SECONDS * 1000;
await schedulePoll(conn.id, Math.min(resumeIn, IDLE_POLL_SECONDS * 1000));
return;
}