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_TEAM_ID=
APPLE_MUSICKIT_KEY_ID= APPLE_MUSICKIT_KEY_ID=
APPLE_MUSICKIT_PRIVATE_KEY= 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, "when": 1791114009783,
"tag": "0006_grey_korvac", "tag": "0006_grey_korvac",
"breakpoints": true "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": "vitest run",
"test:unit": "vitest run --project unit", "test:unit": "vitest run --project unit",
"test:watch": "vitest", "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": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
+4
View File
@@ -9,11 +9,13 @@ import { env } from './env';
import { loadUser } from './lib/auth'; import { loadUser } from './lib/auth';
import { HttpError } from './lib/http'; import { HttpError } from './lib/http';
import { redis } from './lib/redis'; import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin';
import { authRoutes } from './routes/auth'; import { authRoutes } from './routes/auth';
import { connectionRoutes } from './routes/connections'; import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices'; import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me'; import { meRoutes } from './routes/me';
import { realtimeRoutes } from './routes/realtime'; import { realtimeRoutes } from './routes/realtime';
import { safetyRoutes } from './routes/safety';
import { trackRoutes } from './routes/tracks'; import { trackRoutes } from './routes/tracks';
import { userRoutes } from './routes/users'; import { userRoutes } from './routes/users';
import { webhookRoutes } from './routes/webhooks'; 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(authRoutes);
await app.register(meRoutes); await app.register(meRoutes);
await app.register(userRoutes); await app.register(userRoutes);
await app.register(safetyRoutes);
await app.register(adminRoutes);
await app.register(trackRoutes); await app.register(trackRoutes);
await app.register(connectionRoutes); await app.register(connectionRoutes);
await app.register(deviceRoutes); await app.register(deviceRoutes);
+39 -1
View File
@@ -12,7 +12,7 @@ import {
uuid, uuid,
varchar, varchar,
} from 'drizzle-orm/pg-core'; } 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(); const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
@@ -26,6 +26,12 @@ export const users = pgTable('users', {
avatarUrl: varchar('avatar_url', { length: 500 }), avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(), preferredService: text('preferred_service').$type<StreamingService>(),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ /** 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. */ /** While in the future, nothing new is shared: services aren't polled and phone reports are dropped. */
sharingPausedUntil: timestamp('sharing_paused_until', { withTimezone: true }), sharingPausedUntil: timestamp('sharing_paused_until', { withTimezone: true }),
plan: text('plan').$type<Plan>().notNull().default('free'), 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)], (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( export const connections = pgTable(
'connections', 'connections',
{ {
+7 -1
View File
@@ -43,12 +43,18 @@ export async function loadUser(req: FastifyRequest) {
.from(schema.sessions) .from(schema.sessions)
.innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId)) .innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId))
.where(and(eq(schema.sessions.id, id), gt(schema.sessions.expiresAt, new Date()))); .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.user = row.user;
req.sessionId = id; 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 { export function requireUser(req: FastifyRequest): User {
if (!req.user) throw new HttpError(401, 'Not signed in'); if (!req.user) throw new HttpError(401, 'Not signed in');
return req.user; return req.user;
-2
View File
@@ -4,12 +4,10 @@ import { jellyfinProvider } from './jellyfin';
import { lastfmProvider } from './lastfm'; import { lastfmProvider } from './lastfm';
import { listenbrainzProvider } from './listenbrainz'; import { listenbrainzProvider } from './listenbrainz';
import { plexProvider } from './plex'; import { plexProvider } from './plex';
import { spotifyProvider } from './spotify';
import type { PollingProvider } from './types'; import type { PollingProvider } from './types';
/** Providers the server polls. Push-only sources (the Android app) don't appear here. */ /** Providers the server polls. Push-only sources (the Android app) don't appear here. */
export const pollingProviders: Partial<Record<NowPlayingProvider, PollingProvider>> = { export const pollingProviders: Partial<Record<NowPlayingProvider, PollingProvider>> = {
spotify: spotifyProvider,
lastfm: lastfmProvider, lastfm: lastfmProvider,
listenbrainz: listenbrainzProvider, listenbrainz: listenbrainzProvider,
plex: plexProvider, plex: plexProvider,
+8 -65
View File
@@ -1,15 +1,14 @@
import { eq } from 'drizzle-orm'; /**
import { db, schema } from '../db'; * Spotify catalog lookups only (app-level client credentials, not users' accounts),
import type { Connection } from '../db/schema'; * 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 { env } from '../env';
import { decrypt, encrypt } from '../lib/crypto'; import { HttpError } from '../lib/http';
import { HttpError, fetchJson } from '../lib/http';
import { redis } from '../lib/redis'; import { redis } from '../lib/redis';
import { findBestMatch } from './match'; import { findBestMatch } from './match';
import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack, type PollingProvider } from './types'; import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack } from './types';
const SCOPES = ['user-read-currently-playing', 'user-read-playback-state'];
export const redirectUri = () => `${env.API_PUBLIC_URL}/connections/spotify/callback`;
interface TokenResponse { interface TokenResponse {
access_token: string; access_token: string;
@@ -30,17 +29,6 @@ interface SpotifyTrack {
const basicAuth = () => const basicAuth = () =>
'Basic ' + Buffer.from(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`).toString('base64'); '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> { async function tokenRequest(body: Record<string, string>): Promise<TokenResponse> {
const res = await fetch('https://accounts.spotify.com/api/token', { const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST', method: 'POST',
@@ -53,33 +41,6 @@ async function tokenRequest(body: Record<string, string>): Promise<TokenResponse
return (await res.json()) as 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> { async function spotifyGet<T>(path: string, token: string): Promise<T | null> {
const res = await fetch(`https://api.spotify.com/v1${path}`, { const res = await fetch(`https://api.spotify.com/v1${path}`, {
headers: { Authorization: `Bearer ${token}` }, 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> { async function appAccessToken(): Promise<string> {
const cached = await redis.get('spotify:app-token'); const cached = await redis.get('spotify:app-token');
if (cached) return cached; 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 };
});
}
+11 -3
View File
@@ -1,5 +1,5 @@
import { hash, verify } from '@node-rs/argon2'; 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 type { FastifyInstance } from 'fastify';
import { loginSchema, registerSchema } from '@musicshare/shared'; import { loginSchema, registerSchema } from '@musicshare/shared';
import { db, schema } from '../db'; 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))); .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'); if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken');
const [user] = await db 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) .insert(schema.users)
.values({ username: input.username, email: input.email, passwordHash: await hash(input.password) }) .values({ username: input.username, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' })
.returning(); .returning();
return created;
});
const token = await createSession(user!.id, reply); const token = await createSession(user!.id, reply);
return { user: toMeDTO(user!), token }; return { user: toMeDTO(user!), token };
}); });
@@ -32,6 +39,7 @@ export async function authRoutes(app: FastifyInstance) {
if (!user || !(await verify(user.passwordHash, input.password))) { if (!user || !(await verify(user.passwordHash, input.password))) {
throw new HttpError(401, 'Invalid email or 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); const token = await createSession(user.id, reply);
return { user: toMeDTO(user), token }; 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 { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
import { db, schema } from '../db'; import { db, schema } from '../db';
import type { User } from '../db/schema'; import type { User } from '../db/schema';
import { appleMusicConfigured, env, spotifyConfigured } from '../env'; import { appleMusicConfigured, env } from '../env';
import { requireUser } from '../lib/auth'; import { requireUser } from '../lib/auth';
import { encrypt, randomToken } from '../lib/crypto'; import { encrypt, randomToken } from '../lib/crypto';
import { entitlementsFor } from '../lib/entitlements'; import { entitlementsFor } from '../lib/entitlements';
@@ -17,7 +17,6 @@ import { lastfmAuthUrl, lastfmSession, lastfmUserExists } from '../providers/las
import { ProviderAuthError } from '../providers/types'; import { ProviderAuthError } from '../providers/types';
import { listenbrainzUserExists } from '../providers/listenbrainz'; import { listenbrainzUserExists } from '../providers/listenbrainz';
import * as plex from '../providers/plex'; import * as plex from '../providers/plex';
import * as spotify from '../providers/spotify';
import { toConnectionDTO, upsertConnection } from '../services/connections'; import { toConnectionDTO, upsertConnection } from '../services/connections';
import { unschedulePoll } from '../services/scheduler'; import { unschedulePoll } from '../services/scheduler';
@@ -55,58 +54,6 @@ export async function connectionRoutes(app: FastifyInstance) {
return rows.map(toConnectionDTO); 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) ---- // ---- Plex (PIN flow) ----
app.post('/connections/plex/authorize', async (req) => { app.post('/connections/plex/authorize', async (req) => {
+6 -2
View File
@@ -21,6 +21,8 @@ export const toMeDTO = (u: User): MeDTO => ({
email: u.email, email: u.email,
bio: u.bio, bio: u.bio,
preferredService: u.preferredService, preferredService: u.preferredService,
role: u.role,
onboardedAt: u.onboardedAt?.toISOString() ?? null,
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null, sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
plan: effectivePlan(u), plan: effectivePlan(u),
entitlements: entitlementsFor(u), entitlements: entitlementsFor(u),
@@ -31,8 +33,10 @@ export async function meRoutes(app: FastifyInstance) {
app.patch('/me', async (req) => { app.patch('/me', async (req) => {
const user = requireUser(req); const user = requireUser(req);
const input = updateMeSchema.parse(req.body); const { onboarded, ...input } = updateMeSchema.parse(req.body);
const [updated] = await db.update(schema.users).set(input).where(eq(schema.users.id, user.id)).returning(); 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!); 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 };
},
);
}
+83 -13
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 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 { db, schema } from '../db';
import { requireUser } from '../lib/auth'; import { requireUser } from '../lib/auth';
import { entitlementsFor } from '../lib/entitlements'; import { entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http'; import { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis'; import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { blockStatus, hiddenUserIds } from '../services/blocks';
import { watchUsers } from '../services/connections'; import { watchUsers } from '../services/connections';
import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying'; import { getNowPlaying, getRecentPlays, toUserSummary } from '../services/nowPlaying';
import { getTrackDTOs } from '../services/tracks'; import { getTrackDTOs } from '../services/tracks';
@@ -16,22 +17,41 @@ async function findByUsername(username: string) {
return user; 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) { export async function userRoutes(app: FastifyInstance) {
app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => { app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => {
const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, ''); const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, '');
if (q.length < 2) return []; if (q.length < 2) return [];
const rows = await db const [rows, hidden] = await Promise.all([
db
.select() .select()
.from(schema.users) .from(schema.users)
.where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`))) .where(
.limit(20); and(
return rows.map(toUserSummary); 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> => { app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
const user = await findByUsername(req.params.username);
const viewer = req.user; 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.followeeId, user.id)),
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)), db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
viewer viewer
@@ -40,6 +60,12 @@ export async function userRoutes(app: FastifyInstance) {
.from(schema.follows) .from(schema.follows)
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id))) .where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id)))
: Promise.resolve([]), : 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)), db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
getNowPlaying([user.id]), getNowPlaying([user.id]),
getRecentPlays(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; if (url && (c.provider === 'lastfm' || c.provider === 'listenbrainz')) attribution[c.provider] = url;
} }
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined; const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
await watchUsers([user.id]); if (!viewerBlocked) await watchUsers([user.id]);
return { return {
user: { ...toUserSummary(user), bio: user.bio }, user: { ...toUserSummary(user), bio: user.bio },
@@ -63,20 +89,24 @@ export async function userRoutes(app: FastifyInstance) {
isFollowing: Boolean(follow), isFollowing: Boolean(follow),
isPinned: Boolean(follow?.pinnedAt), isPinned: Boolean(follow?.pinnedAt),
isSelf: viewer?.id === user.id, isSelf: viewer?.id === user.id,
followsYou: Boolean(followsYou),
isBlocked: viewerBlocked,
// You blocked them: don't show their activity (until you unblock).
featured: featured:
featured && featuredTrack !viewerBlocked && featured && featuredTrack
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() } ? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
: null, : null,
nowPlaying: nowPlaying[0] ?? null, nowPlaying: viewerBlocked ? null : (nowPlaying[0] ?? null),
recent, recent: viewerBlocked ? [] : recent,
attribution, attribution,
}; };
}); });
app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => { app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
const viewer = requireUser(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 (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 db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing();
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id); await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
return { ok: true }; return { ok: true };
@@ -124,6 +154,46 @@ export async function userRoutes(app: FastifyInstance) {
return { ok: true }; 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. */ /** Now-playing state for everyone the viewer follows, pinned friends first. */
app.get('/feed', async (req): Promise<FeedItemDTO[]> => { app.get('/feed', async (req): Promise<FeedItemDTO[]> => {
const viewer = requireUser(req); 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() })); return plays.map((p) => ({ track: tracks.get(p.trackId)!, source: p.source, playedAt: p.startedAt.toISOString() }));
} }
export const isSharingPaused = (user: Pick<User, 'sharingPausedUntil'>) => /** Paused by the user, or suspended by a moderator: nothing new is shared either way. */
Boolean(user.sharingPausedUntil && user.sharingPausedUntil > new Date()); 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. */ /** Starting a pause: whatever was playing becomes "played … ago" for friends. */
export async function pauseSharing(userId: string) { export async function pauseSharing(userId: string) {
+1 -1
View File
@@ -37,7 +37,7 @@ async function pollConnection(connectionId: string) {
if (isSharingPaused(user)) { if (isSharingPaused(user)) {
// Don't even ask the provider while paused; check back when the pause ends. // 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)); await schedulePoll(conn.id, Math.min(resumeIn, IDLE_POLL_SECONDS * 1000));
return; 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); 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() { export async function resetState() {
assertTestTargets(); 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(); 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); 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'; import { defineConfig } from 'tsup';
export default defineConfig({ 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'], format: ['esm'],
target: 'node22', target: 'node22',
platform: 'node', platform: 'node',
+2
View File
@@ -43,6 +43,8 @@ export default defineConfig({
// one shared database: run files one at a time // one shared database: run files one at a time
fileParallelism: false, fileParallelism: false,
hookTimeout: 30_000, hookTimeout: 30_000,
// Password hashing is deliberately slow; tests that create many accounts need headroom on busy machines.
testTimeout: 20_000,
}, },
}, },
], ],
@@ -1,3 +0,0 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

-40
View File
@@ -1,40 +0,0 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+337 -1
View File
@@ -14,7 +14,7 @@
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22.13"
} }
}, },
"apps/api": { "apps/api": {
@@ -803,14 +803,19 @@
"name": "@musicshare/mobile", "name": "@musicshare/mobile",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo-google-fonts/space-grotesk": "^0.4.1",
"@expo/ui": "~57.0.21", "@expo/ui": "~57.0.21",
"@musicshare/shared": "*", "@musicshare/shared": "*",
"expo": "~57.0.26", "expo": "~57.0.26",
"expo-build-properties": "~57.0.22",
"expo-camera": "~57.0.6",
"expo-constants": "~57.0.20", "expo-constants": "~57.0.20",
"expo-device": "~57.0.2", "expo-device": "~57.0.2",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-glass-effect": "~57.0.4", "expo-glass-effect": "~57.0.4",
"expo-haptics": "~57.0.3",
"expo-image": "~57.0.5", "expo-image": "~57.0.5",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11", "expo-linking": "~57.0.11",
"expo-router": "~57.0.24", "expo-router": "~57.0.24",
"expo-secure-store": "~57.0.4", "expo-secure-store": "~57.0.4",
@@ -826,6 +831,7 @@
"react-native-reanimated": "4.5.1", "react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0", "react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1", "react-native-worklets": "0.10.1",
"swr": "^2.5.1" "swr": "^2.5.1"
@@ -844,6 +850,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@musicshare/shared": "*", "@musicshare/shared": "*",
"motion": "^14.0.0",
"next": "^16.3.8", "next": "^16.3.8",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@@ -3169,6 +3176,12 @@
"integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==", "integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==",
"license": "MIT AND Apache-2.0" "license": "MIT AND Apache-2.0"
}, },
"node_modules/@expo-google-fonts/space-grotesk": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@expo-google-fonts/space-grotesk/-/space-grotesk-0.4.1.tgz",
"integrity": "sha512-ZVQYw4Ok/pgcSJiufP8oRZE3AVxS9xtmKEUfsurbHkHNdMc/GA1gDXP9G4Cr7KL4KqSc0haexR2TuMigotCn4Q==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo/cli": { "node_modules/@expo/cli": {
"version": "57.0.27", "version": "57.0.27",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.27.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.27.tgz",
@@ -7368,6 +7381,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/emscripten": {
"version": "1.41.6",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
"integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -8587,6 +8606,15 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/barcode-detector": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz",
"integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==",
"license": "MIT",
"dependencies": {
"zxing-wasm": "3.1.3"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -8628,6 +8656,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": { "node_modules/bplist-creator": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -9313,6 +9347,47 @@
"hyphenate-style-name": "^1.0.3" "hyphenate-style-name": "^1.0.3"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -9534,6 +9609,61 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "18.0.5", "version": "18.0.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.5.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.5.tgz",
@@ -9758,6 +9888,18 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-stack-parser": { "node_modules/error-stack-parser": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -10904,6 +11046,40 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-build-properties": {
"version": "57.0.22",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz",
"integrity": "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^57.0.2",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-camera": {
"version": "57.0.6",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.6.tgz",
"integrity": "sha512-eswB77bOzQ2OEvll/hV5VnzywaICFnYF21HIwcyokebFcSTSMfGQk8+VW06DRec9JnWuRExs0QXlfxyMPjS6KQ==",
"license": "MIT",
"dependencies": {
"barcode-detector": "^3.0.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": { "node_modules/expo-constants": {
"version": "57.0.20", "version": "57.0.20",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.20.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.20.tgz",
@@ -10964,6 +11140,15 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-haptics": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz",
"integrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image": { "node_modules/expo-image": {
"version": "57.0.5", "version": "57.0.5",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz", "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz",
@@ -10994,6 +11179,17 @@
"react": "*" "react": "*"
} }
}, },
"node_modules/expo-linear-gradient": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-57.0.2.tgz",
"integrity": "sha512-eGNSfcbdXYvw0B/TQ3nmXKQgj0jLUi3EgsXGlvI79rlXg5RR5dQTkY2yqkf7BQDM3PMd3tqBMZdsLhvBqSUaMg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-linking": { "node_modules/expo-linking": {
"version": "57.0.11", "version": "57.0.11",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.11.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.11.tgz",
@@ -11664,6 +11860,29 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/framer-motion": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.0.0.tgz",
"integrity": "sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==",
"license": "MIT",
"dependencies": {
"motion-dom": "14.0.0",
"motion-utils": "14.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -13634,6 +13853,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -14780,6 +15005,43 @@
"ufo": "^1.6.3" "ufo": "^1.6.3"
} }
}, },
"node_modules/motion": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-14.0.0.tgz",
"integrity": "sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==",
"license": "MIT",
"dependencies": {
"framer-motion": "14.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.0.0.tgz",
"integrity": "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==",
"license": "MIT",
"dependencies": {
"motion-utils": "14.0.0"
}
},
"node_modules/motion-utils": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.0.0.tgz",
"integrity": "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -15047,6 +15309,18 @@
"node": "^16.14.0 || >=18.0.0" "node": "^16.14.0 || >=18.0.0"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": { "node_modules/nullthrows": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -15873,6 +16147,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/query-string": { "node_modules/query-string": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
@@ -16122,6 +16402,21 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-svg": {
"version": "15.15.4",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
"integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": { "node_modules/react-native-web": {
"version": "0.21.3", "version": "0.21.3",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.3.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.3.tgz",
@@ -17760,6 +18055,18 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/terminal-link": { "node_modules/terminal-link": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@@ -19869,10 +20176,39 @@
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
}, },
"node_modules/zxing-wasm": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz",
"integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.41.5",
"type-fest": "^5.8.0"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
},
"node_modules/zxing-wasm/node_modules/type-fest": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
"integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/shared": { "packages/shared": {
"name": "@musicshare/shared", "name": "@musicshare/shared",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"qrcode-generator": "^2.0.4",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
+1
View File
@@ -12,6 +12,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"qrcode-generator": "^2.0.4",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -75,7 +75,7 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`), searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
connections: () => request<ConnectionDTO[]>('/connections'), connections: () => request<ConnectionDTO[]>('/connections'),
authorizeUrl: (provider: 'spotify' | 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) =>
request<{ url: string }>(`/connections/${provider}/authorize`, { body }), request<{ url: string }>(`/connections/${provider}/authorize`, { body }),
connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) => connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) =>
request<ConnectionDTO>(`/connections/${provider}`, { body: { username } }), request<ConnectionDTO>(`/connections/${provider}`, { body: { username } }),
+36
View File
@@ -23,6 +23,9 @@ export interface MeDTO extends UserSummaryDTO {
email: string; email: string;
bio: string | null; bio: string | null;
preferredService: StreamingService | null; preferredService: StreamingService | null;
role: 'user' | 'admin';
/** When onboarding was finished; null means show it. */
onboardedAt: string | null;
/** ISO time sharing resumes, or null when sharing. Far future means "until turned back on". */ /** ISO time sharing resumes, or null when sharing. Far future means "until turned back on". */
sharingPausedUntil: string | null; sharingPausedUntil: string | null;
plan: Plan; plan: Plan;
@@ -77,6 +80,10 @@ export interface ProfileDTO {
isFollowing: boolean; isFollowing: boolean;
isPinned: boolean; isPinned: boolean;
isSelf: boolean; isSelf: boolean;
/** They follow the viewer. */
followsYou: boolean;
/** The viewer has blocked them (activity is hidden; they can be unblocked). */
isBlocked: boolean;
featured: FeaturedSongDTO | null; featured: FeaturedSongDTO | null;
nowPlaying: NowPlayingDTO | null; nowPlaying: NowPlayingDTO | null;
/** Most recent first, not including the track playing right now. */ /** Most recent first, not including the track playing right now. */
@@ -85,6 +92,35 @@ export interface ProfileDTO {
attribution: Partial<Record<'lastfm' | 'listenbrainz', string>>; attribution: Partial<Record<'lastfm' | 'listenbrainz', string>>;
} }
export interface AdminReportDTO {
id: string;
reason: string;
note: string | null;
status: 'open' | 'resolved';
createdAt: string;
reporter: UserSummaryDTO | null;
reported: UserSummaryDTO & { suspended: boolean };
}
export interface AdminStatsDTO {
users: number;
listeningNow: number;
openReports: number;
suspended: number;
}
/** A row in a followers/following list. */
export interface FollowListItemDTO extends UserSummaryDTO {
/** Whether the viewer follows this person (for Follow/Following buttons). */
isFollowing: boolean;
}
export interface FollowListDTO {
users: FollowListItemDTO[];
/** Pass back as ?cursor= for the next page; null when there are no more. */
nextCursor: string | null;
}
export interface ConnectionDTO { export interface ConnectionDTO {
provider: NowPlayingProvider; provider: NowPlayingProvider;
externalUserId: string; externalUserId: string;
+2
View File
@@ -5,3 +5,5 @@ export * from './plans';
export * from './client'; export * from './client';
export * from './time'; export * from './time';
export * from './devices'; export * from './devices';
export * from './links';
export * from './qr';
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { parseProfileLink, profileUrl } from './links';
describe('profileUrl', () => {
it('builds a website profile link, tolerating a trailing slash', () => {
expect(profileUrl('https://musicshare.app/', 'alice')).toBe('https://musicshare.app/u/alice');
});
});
describe('parseProfileLink', () => {
it.each([
['https://musicshare.app/u/alice', 'alice'],
['http://192.168.1.215:3737/u/Bob_99', 'bob_99'],
['https://musicshare.app/u/alice/', 'alice'],
['https://musicshare.app/u/alice?ref=qr', 'alice'],
['musicshare://u/alice', 'alice'],
['exp://192.168.1.20:8081/--/u/alice', 'alice'],
['@alice', 'alice'],
[' https://musicshare.app/u/alice ', 'alice'],
])('reads %s', (text, expected) => {
expect(parseProfileLink(text)).toBe(expected);
});
it.each([
'https://example.com/',
'https://musicshare.app/u/',
'https://musicshare.app/u/alice/extra',
'https://musicshare.app/u/a', // too short to be a username
'https://musicshare.app/u/al%20ice',
'WIFI:S:home;T:WPA;P:secret;;',
'just some text',
'@x',
])('rejects %s', (text) => {
expect(parseProfileLink(text)).toBeNull();
});
});
+35
View File
@@ -0,0 +1,35 @@
/** Profile links: what QR codes and invites contain, and how scans are read back. */
const USERNAME = /^[a-z0-9_]{3,32}$/;
/** Shareable profile URL on the website, e.g. https://musicshare.app/u/alice */
export function profileUrl(webBase: string, username: string): string {
return `${webBase.replace(/\/+$/, '')}/u/${encodeURIComponent(username)}`;
}
/**
* Pull a username out of anything a QR scan might contain: a website profile
* URL (any host), the app's deep link (musicshare://u/alice), an Expo Go dev
* link (exp://…/--/u/alice), or a bare "@alice". Returns null if it isn't one.
*/
export function parseProfileLink(text: string): string | null {
const raw = text.trim();
const at = raw.match(/^@([A-Za-z0-9_]+)$/);
if (at) return valid(at[1]!);
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
// musicshare://u/alice parses with host "u" and path "/alice"
const path = url.protocol === 'musicshare:' ? `/${url.host}${url.pathname}` : url.pathname.replace(/^\/--/, '');
const match = path.match(/^\/u\/([^/?#]+)\/?$/);
return match ? valid(decodeURIComponent(match[1]!)) : null;
}
function valid(name: string): string | null {
const username = name.toLowerCase();
return USERNAME.test(username) ? username : null;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { qrMatrix, qrShape } from './qr';
describe('qr', () => {
const url = 'https://musicshare.app/u/alice';
it('produces a square grid with the three finder patterns', () => {
const m = qrMatrix(url);
const n = m.length;
expect(n).toBeGreaterThanOrEqual(21);
expect(m.every((row) => row.length === n)).toBe(true);
// Finder pattern corners: dark outer ring, light inner ring, dark 3x3 centre.
for (const [r, c] of [
[0, 0],
[0, n - 7],
[n - 7, 0],
] as const) {
expect(m[r]![c]).toBe(true);
expect(m[r + 1]![c + 1]).toBe(false);
expect(m[r + 3]![c + 3]).toBe(true);
}
});
it('is deterministic and draws every dark module', () => {
const a = qrShape(url);
expect(qrShape(url)).toEqual(a);
const darkCount = qrMatrix(url).flat().filter(Boolean).length;
const drawn = [...a.path.matchAll(/h(\d+)v1/g)].reduce((sum, m) => sum + Number(m[1]), 0);
expect(drawn).toBe(darkCount);
});
it('grows for longer text', () => {
expect(qrShape(`${url}?${'x'.repeat(200)}`).size).toBeGreaterThan(qrShape(url).size);
});
});
+36
View File
@@ -0,0 +1,36 @@
import qrcode from 'qrcode-generator';
export interface QrShape {
/** Width/height in modules (no quiet zone; renderers add margin). */
size: number;
/** SVG path in module units: draw it in a `0 0 size size` viewBox. */
path: string;
}
/** Dark/light grid for `text`. Medium error correction survives a scratched or dim screen. */
export function qrMatrix(text: string): boolean[][] {
const qr = qrcode(0, 'M'); // 0 = smallest version that fits
qr.addData(text);
qr.make();
const n = qr.getModuleCount();
return Array.from({ length: n }, (_, row) => Array.from({ length: n }, (_, col) => qr.isDark(row, col)));
}
/** One SVG path for the whole code, merging horizontal runs so it stays small to render. */
export function qrShape(text: string): QrShape {
const matrix = qrMatrix(text);
let path = '';
matrix.forEach((cells, y) => {
let x = 0;
while (x < cells.length) {
if (!cells[x]) {
x++;
continue;
}
const start = x;
while (x < cells.length && cells[x]) x++;
path += `M${start} ${y}h${x - start}v1h${start - x}z`;
}
});
return { size: matrix.length, path };
}
+9
View File
@@ -26,6 +26,8 @@ export const updateMeSchema = z.object({
bio: z.string().trim().max(280).nullable().optional(), bio: z.string().trim().max(280).nullable().optional(),
avatarUrl: z.url().max(500).nullable().optional(), avatarUrl: z.url().max(500).nullable().optional(),
preferredService: z.enum(STREAMING_SERVICES).nullable().optional(), preferredService: z.enum(STREAMING_SERVICES).nullable().optional(),
/** Mark onboarding as finished. */
onboarded: z.literal(true).optional(),
}); });
export const setFeaturedSchema = z.union([ export const setFeaturedSchema = z.union([
@@ -51,3 +53,10 @@ export const jellyfinConnectSchema = z.object({
/** Pause sharing: for an hour, until tomorrow, until turned back on, or resume (null). */ /** Pause sharing: for an hour, until tomorrow, until turned back on, or resume (null). */
export const PAUSE_OPTIONS = ['1h', '8h', 'indefinitely'] as const; export const PAUSE_OPTIONS = ['1h', '8h', 'indefinitely'] as const;
export const setSharingSchema = z.object({ pause: z.enum(PAUSE_OPTIONS).nullable() }); export const setSharingSchema = z.object({ pause: z.enum(PAUSE_OPTIONS).nullable() });
export const REPORT_REASONS = ['spam', 'harassment', 'inappropriate', 'impersonation', 'other'] as const;
export type ReportReason = (typeof REPORT_REASONS)[number];
export const reportSchema = z.object({
reason: z.enum(REPORT_REASONS),
note: z.string().trim().max(500).nullish(),
});