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
+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);