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:
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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!);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user