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
+7
View File
@@ -28,3 +28,10 @@ ALLOW_PRIVATE_SERVER_URLS=false
APPLE_TEAM_ID=
APPLE_MUSICKIT_KEY_ID=
APPLE_MUSICKIT_PRIVATE_KEY=
# Outgoing email (verification, password reset). SMTPS on 465.
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
MAIL_FROM=musicshare <app@musicshare.binarygnome.com>
@@ -0,0 +1,3 @@
ALTER TABLE "users" ADD COLUMN "onboarded_at" timestamp with time zone;--> statement-breakpoint
-- Accounts that existed before onboarding shipped have already set up; do not show it to them.
UPDATE "users" SET "onboarded_at" = "created_at" WHERE "onboarded_at" IS NULL;
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE "blocks" (
"blocker_id" uuid NOT NULL,
"blocked_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "blocks_blocker_id_blocked_id_pk" PRIMARY KEY("blocker_id","blocked_id")
);
--> statement-breakpoint
CREATE TABLE "reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"reporter_id" uuid,
"reported_id" uuid NOT NULL,
"reason" text NOT NULL,
"note" varchar(500),
"status" text DEFAULT 'open' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocker_id_users_id_fk" FOREIGN KEY ("blocker_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocked_id_users_id_fk" FOREIGN KEY ("blocked_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_reported_id_users_id_fk" FOREIGN KEY ("reported_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "blocks_blocked_idx" ON "blocks" USING btree ("blocked_id");--> statement-breakpoint
CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status");
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "suspended_at" timestamp with time zone;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -50,6 +50,27 @@
"when": 1791114009783,
"tag": "0006_grey_korvac",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1791116118996,
"tag": "0007_lowly_wilson_fisk",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1791239885734,
"tag": "0008_romantic_nomad",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1791240840729,
"tag": "0009_black_wild_pack",
"breakpoints": true
}
]
}
+3 -1
View File
@@ -14,7 +14,9 @@
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:watch": "vitest",
"start:migrate": "node dist/db/migrate.js"
"start:migrate": "node dist/db/migrate.js",
"admin:promote": "tsx src/scripts/set-role.ts admin",
"admin:demote": "tsx src/scripts/set-role.ts user"
},
"dependencies": {
"@fastify/cookie": "^11.1.2",
+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;
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { applyPollResult } from '../src/services/nowPlaying';
import { bearer, createUser, useTestApp } from './helpers';
const app = useTestApp();
const get = (url: string, token?: string) => app().inject({ url, headers: token ? bearer(token) : {} });
const post = (url: string, token: string, payload: object = {}) => app().inject({ method: 'POST', url, headers: bearer(token), payload });
describe('admin', () => {
it('makes the first account on a server the admin, and only the first', async () => {
const first = await createUser(app(), 'founder');
const second = await createUser(app(), 'second');
expect(first.user.role).toBe('admin');
expect(second.user.role).toBe('user');
});
it('keeps admin endpoints away from regular users', async () => {
await createUser(app(), 'founder');
const user = await createUser(app(), 'regular');
expect((await get('/admin/stats', user.token)).statusCode).toBe(403);
expect((await get('/admin/reports', user.token)).statusCode).toBe(403);
expect((await get('/admin/stats')).statusCode).toBe(401);
});
it('shows the report queue and resolves reports', async () => {
const admin = await createUser(app(), 'founder');
const alice = await createUser(app(), 'alice');
await createUser(app(), 'spammer');
await post('/users/spammer/report', alice.token, { reason: 'spam', note: 'links everywhere' });
const queue = (await get('/admin/reports', admin.token)).json();
expect(queue).toHaveLength(1);
expect(queue[0]).toMatchObject({ reason: 'spam', note: 'links everywhere', reporter: { username: 'alice' }, reported: { username: 'spammer', suspended: false } });
expect((await get('/admin/stats', admin.token)).json()).toMatchObject({ users: 3, openReports: 1 });
await post(`/admin/reports/${queue[0].id}/resolve`, admin.token);
expect((await get('/admin/reports', admin.token)).json()).toHaveLength(0);
expect((await get('/admin/reports?status=resolved', admin.token)).json()).toHaveLength(1);
});
it('suspension signs the user out, blocks login, hides them, and stops sharing', async () => {
const admin = await createUser(app(), 'founder');
const bad = await createUser(app(), 'troll');
const viewer = await createUser(app(), 'viewer');
await applyPollResult(bad.user.id, 'spotify', { track: { title: 'Loud', artist: 'X' }, isPlaying: true, progressMs: 0 });
expect((await post('/admin/users/troll/suspend', admin.token)).statusCode).toBe(200);
expect((await get('/me', bad.token)).statusCode).toBe(401);
const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'troll@example.com', password: 'password123' } });
expect(login.statusCode).toBe(403);
expect((await get('/users/troll', viewer.token)).statusCode).toBe(404);
expect((await get('/users/search?q=tro', viewer.token)).json()).toEqual([]);
// Admins can still look them up.
expect((await get('/users/troll', admin.token)).json().nowPlaying.isPlaying).toBe(false);
await app().inject({ method: 'DELETE', url: '/admin/users/troll/suspend', headers: bearer(admin.token) });
const again = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'troll@example.com', password: 'password123' } });
expect(again.statusCode).toBe(200);
});
it("can't suspend yourself or another admin", async () => {
const admin = await createUser(app(), 'founder');
expect((await post('/admin/users/founder/suspend', admin.token)).statusCode).toBe(400);
});
});
+11
View File
@@ -92,3 +92,14 @@ describe('auth', () => {
expect((await app().inject({ url: '/users/bob', headers: bearer(bob.token) })).json().followerCount).toBe(0);
});
});
describe('onboarding flag', () => {
it('new accounts start un-onboarded; finishing sets it once', async () => {
const { token, user } = await createUser(app(), 'newbie');
expect(user.onboardedAt).toBeNull();
const first = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json();
expect(first.onboardedAt).toEqual(expect.any(String));
const again = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json();
expect(again.onboardedAt).toBe(first.onboardedAt);
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ function assertTestTargets() {
export async function resetState() {
assertTestTargets();
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices restart identity cascade`;
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports restart identity cascade`;
await redis.flushdb();
}
-24
View File
@@ -61,27 +61,3 @@ describe('Last.fm sign-in', () => {
expect((await app().inject({ url: '/connections/lastfm/callback?state=forged&token=x' })).statusCode).toBe(400);
});
});
describe('Spotify sign-in during the limited beta', () => {
it('sends users who are not on the allowlist back with a friendly error instead of failing', async () => {
vi.stubGlobal(
'fetch',
routeFetch([
[/accounts\.spotify\.com\/api\/token/, () => json({ access_token: 'a', refresh_token: 'r', expires_in: 3600 })],
[/api\.spotify\.com\/v1\/me$/, () => json({ error: { status: 403, message: 'User not registered in the Developer Dashboard' } }, 403)],
]),
);
const { token } = await createUser(app(), 'alice');
const start = await app().inject({
method: 'POST',
url: '/connections/spotify/authorize',
headers: bearer(token),
payload: { client: 'mobile', returnTo: 'musicshare://settings' },
});
const state = new URL(start.json().url).searchParams.get('state');
const done = await app().inject({ url: `/connections/spotify/callback?state=${state}&code=abc` });
expect(done.statusCode).toBe(302);
expect(done.headers.location).toBe('musicshare://settings?error=spotify_limited');
expect(await db.select().from(schema.connections)).toHaveLength(0);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { db, schema } from '../src/db';
import { applyPollResult } from '../src/services/nowPlaying';
import { bearer, createUser, follow, useTestApp } from './helpers';
const app = useTestApp();
const get = (url: string, token?: string) => app().inject({ url, headers: token ? bearer(token) : {} });
const post = (url: string, token: string, payload: object = {}) => app().inject({ method: 'POST', url, headers: bearer(token), payload });
const del = (url: string, token: string) => app().inject({ method: 'DELETE', url, headers: bearer(token) });
const names = (list: { users: { username: string }[] }) => list.users.map((u) => u.username);
describe('followers and following lists', () => {
it('lists followers and following, newest first, with the viewer’s follow state', async () => {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
const carol = await createUser(app(), 'carol');
await follow(app(), bob.token, 'alice');
await follow(app(), carol.token, 'alice');
await follow(app(), alice.token, 'carol');
const followers = (await get('/users/alice/followers', alice.token)).json();
expect(names(followers)).toEqual(['carol', 'bob']);
expect(followers.users.find((u: { username: string }) => u.username === 'carol').isFollowing).toBe(true);
expect(followers.nextCursor).toBeNull();
expect(names((await get('/users/alice/following')).json())).toEqual(['carol']);
});
it('paginates', { timeout: 60_000 }, async () => {
const alice = await createUser(app(), 'alice');
for (let i = 0; i < 55; i++) {
const f = await createUser(app(), `fan${i}`);
await follow(app(), f.token, 'alice');
}
const first = (await get('/users/alice/followers', alice.token)).json();
expect(first.users).toHaveLength(50);
const second = (await get(`/users/alice/followers?cursor=${first.nextCursor}`, alice.token)).json();
expect(second.users).toHaveLength(5);
expect(second.nextCursor).toBeNull();
});
it('shows "follows you" on profiles', async () => {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), bob.token, 'alice');
expect((await get('/users/bob', alice.token)).json().followsYou).toBe(true);
expect((await get('/users/alice', bob.token)).json().followsYou).toBe(false);
});
it('removing a follower takes them off your list', async () => {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), bob.token, 'alice');
expect((await del('/me/followers/bob', alice.token)).statusCode).toBe(200);
expect(names((await get('/users/alice/followers', alice.token)).json())).toEqual([]);
expect((await get('/feed', bob.token)).json()).toEqual([]);
});
});
describe('blocking', () => {
async function blockedPair() {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), alice.token, 'bob');
await follow(app(), bob.token, 'alice');
await applyPollResult(alice.user.id, 'spotify', { track: { title: 'Secret', artist: 'X' }, isPlaying: true, progressMs: 0 });
expect((await post('/users/bob/block', alice.token)).statusCode).toBe(200);
return { alice, bob };
}
it('severs follows in both directions', async () => {
const { alice, bob } = await blockedPair();
expect(await db.select().from(schema.follows)).toHaveLength(0);
expect((await get('/feed', bob.token)).json()).toEqual([]);
expect((await get('/feed', alice.token)).json()).toEqual([]);
});
it('hides the blocker from the blocked person entirely', async () => {
const { bob } = await blockedPair();
expect((await get('/users/alice', bob.token)).statusCode).toBe(404);
expect((await get('/users/alice/followers', bob.token)).statusCode).toBe(404);
expect((await get('/users/search?q=ali', bob.token)).json()).toEqual([]);
expect((await post('/users/alice/follow', bob.token)).statusCode).toBe(404);
});
it('still shows them to everyone else', async () => {
await blockedPair();
const carol = await createUser(app(), 'carol');
expect((await get('/users/alice', carol.token)).json().nowPlaying.track.title).toBe('Secret');
});
it("the blocker sees a profile with activity hidden, and can unblock", async () => {
const { alice, bob } = await blockedPair();
await applyPollResult(bob.user.id, 'spotify', { track: { title: 'Bobs song', artist: 'Y' }, isPlaying: true, progressMs: 0 });
const profile = (await get('/users/bob', alice.token)).json();
expect(profile).toMatchObject({ isBlocked: true, nowPlaying: null, recent: [], featured: null });
expect((await post('/users/bob/follow', alice.token)).statusCode).toBe(400);
expect((await get('/me/blocks', alice.token)).json().map((u: { username: string }) => u.username)).toEqual(['bob']);
await del('/users/bob/block', alice.token);
expect((await get('/users/alice', bob.token)).statusCode).toBe(200);
expect((await post('/users/bob/follow', alice.token)).statusCode).toBe(200);
});
it("can't block yourself", async () => {
const alice = await createUser(app(), 'alice');
expect((await post('/users/alice/block', alice.token)).statusCode).toBe(400);
});
});
describe('reporting', () => {
it('stores a report for moderation', async () => {
const alice = await createUser(app(), 'alice');
await createUser(app(), 'spammer');
const res = await post('/users/spammer/report', alice.token, { reason: 'spam', note: 'posting links' });
expect(res.statusCode).toBe(200);
const [report] = await db.select().from(schema.reports);
expect(report).toMatchObject({ reason: 'spam', note: 'posting links', status: 'open', reporterId: alice.user.id });
});
it('validates the reason and refuses self-reports', async () => {
const alice = await createUser(app(), 'alice');
await createUser(app(), 'bob');
expect((await post('/users/bob/report', alice.token, { reason: 'boring' })).statusCode).toBe(400);
expect((await post('/users/alice/report', alice.token, { reason: 'spam' })).statusCode).toBe(400);
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/server.ts', 'src/worker.ts', 'src/db/migrate.ts'],
entry: ['src/server.ts', 'src/worker.ts', 'src/db/migrate.ts', 'src/scripts/set-role.ts'],
format: ['esm'],
target: 'node22',
platform: 'node',
+2
View File
@@ -43,6 +43,8 @@ export default defineConfig({
// one shared database: run files one at a time
fileParallelism: false,
hookTimeout: 30_000,
// Password hashing is deliberately slow; tests that create many accounts need headroom on busy machines.
testTimeout: 20_000,
},
},
],