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:
@@ -12,6 +12,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from './plans';
|
||||
export * from './client';
|
||||
export * from './time';
|
||||
export * from './devices';
|
||||
export * from './links';
|
||||
export * from './qr';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user