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
+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)}`),
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 }),
connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) =>
request<ConnectionDTO>(`/connections/${provider}`, { body: { username } }),
+36
View File
@@ -23,6 +23,9 @@ export interface MeDTO extends UserSummaryDTO {
email: string;
bio: string | 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". */
sharingPausedUntil: string | null;
plan: Plan;
@@ -77,6 +80,10 @@ export interface ProfileDTO {
isFollowing: boolean;
isPinned: 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;
nowPlaying: NowPlayingDTO | null;
/** Most recent first, not including the track playing right now. */
@@ -85,6 +92,35 @@ export interface ProfileDTO {
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 {
provider: NowPlayingProvider;
externalUserId: string;
+2
View File
@@ -5,3 +5,5 @@ export * from './plans';
export * from './client';
export * from './time';
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(),
avatarUrl: z.url().max(500).nullable().optional(),
preferredService: z.enum(STREAMING_SERVICES).nullable().optional(),
/** Mark onboarding as finished. */
onboarded: z.literal(true).optional(),
});
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). */
export const PAUSE_OPTIONS = ['1h', '8h', 'indefinitely'] as const;
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(),
});