Profile pictures, display name default, coloured service names
CI / check (push) Canceled after 0s

- Upload a profile picture on web and mobile. The API square-crops it to
  512px WebP, strips metadata (phone photos carry GPS), stores it in
  Postgres and serves it with immutable caching. Avatar URLs are stored as
  paths, so they survive moving the API. Arbitrary avatar URLs are no
  longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
  accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.

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 23:38:00 -04:00
co-authored by Claude Opus 5.5
parent 9873cc1710
commit 2b02aa8e89
25 changed files with 1746 additions and 22 deletions
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE "avatars" (
"user_id" uuid PRIMARY KEY NOT NULL,
"image" "bytea" NOT NULL,
"content_type" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "avatars" ADD CONSTRAINT "avatars_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
UPDATE "users" SET "display_name" = "username" WHERE "display_name" IS NULL OR "display_name" = '';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1791240840729,
"tag": "0009_black_wild_pack",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1791257078479,
"tag": "0010_avatars",
"breakpoints": true
}
]
}
+2
View File
@@ -21,6 +21,7 @@
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0",
"@fastify/multipart": "^10.1.2",
"@fastify/rate-limit": "^11.2.0",
"@fastify/websocket": "^11.3.2",
"@musicshare/shared": "*",
@@ -31,6 +32,7 @@
"fastify": "^5.12.5",
"ioredis": "^6.0.0",
"postgres": "^3.4.9",
"sharp": "^0.35.5",
"undici": "^6.29.0",
"zod": "^4.6.5"
},
+4
View File
@@ -1,6 +1,7 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import multipart from '@fastify/multipart';
import websocket from '@fastify/websocket';
import Fastify from 'fastify';
import { ZodError } from 'zod';
@@ -11,6 +12,7 @@ import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin';
import { authRoutes } from './routes/auth';
import { avatarRoutes } from './routes/avatars';
import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me';
@@ -41,6 +43,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
// Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances.
if (rateLimits) await app.register(rateLimit, { global: false, redis });
await app.register(websocket);
await app.register(multipart);
app.decorateRequest('user', null);
app.decorateRequest('sessionId', null);
@@ -64,6 +67,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(authRoutes);
await app.register(meRoutes);
await app.register(avatarRoutes);
await app.register(userRoutes);
await app.register(safetyRoutes);
await app.register(adminRoutes);
+14
View File
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm';
import {
boolean,
customType,
index,
integer,
jsonb,
@@ -23,6 +24,7 @@ export const users = pgTable('users', {
passwordHash: text('password_hash').notNull(),
displayName: varchar('display_name', { length: 64 }),
bio: varchar('bio', { length: 280 }),
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
@@ -236,6 +238,18 @@ export const featuredSongs = pgTable('featured_songs', {
.default(sql`now()`),
});
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */
export const avatars = pgTable('avatars', {
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
image: bytea('image').notNull(),
contentType: text('content_type').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export type User = typeof users.$inferSelect;
export type Connection = typeof connections.$inferSelect;
export type Track = typeof tracks.$inferSelect;
+29
View File
@@ -0,0 +1,29 @@
import sharp from 'sharp';
import { env } from '../env';
import { HttpError } from './http';
export const AVATAR_SIZE = 512;
export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
/**
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
* working if the API moves; older rows may hold a full external URL.
*/
export const publicAvatarUrl = (stored: string | null): string | null =>
stored?.startsWith('/') ? env.API_PUBLIC_URL.replace(/\/$/, '') + stored : stored;
/**
* Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF
* (phone photos carry GPS location) and anything that isn't image data.
*/
export async function processAvatar(input: Buffer): Promise<Buffer> {
try {
return await sharp(input, { limitInputPixels: 50_000_000, animated: false })
.rotate() // apply the EXIF orientation before it's stripped
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
.webp({ quality: 82 })
.toBuffer();
} catch {
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
}
}
+4 -1
View File
@@ -12,6 +12,9 @@ const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' }
export async function authRoutes(app: FastifyInstance) {
app.post('/auth/register', authRateLimit, async (req, reply) => {
const input = registerSchema.parse(req.body);
// Usernames are stored lowercase; keep the capitalization they typed as the display name.
const typed = String((req.body as { username?: unknown }).username ?? '').trim();
const displayName = typed.toLowerCase() === input.username ? typed : input.username;
const [taken] = await db
.select({ username: schema.users.username, email: schema.users.email })
.from(schema.users)
@@ -25,7 +28,7 @@ export async function authRoutes(app: FastifyInstance) {
const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }];
const [created] = await tx
.insert(schema.users)
.values({ username: input.username, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' })
.values({ username: input.username, displayName, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' })
.returning();
return created;
});
+56
View File
@@ -0,0 +1,56 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { AVATAR_MAX_UPLOAD_BYTES, processAvatar } from '../lib/avatars';
import { HttpError } from '../lib/http';
import { toMeDTO } from './me';
export async function avatarRoutes(app: FastifyInstance) {
/** Multipart upload with one file field. Returns the updated profile. */
app.post('/me/avatar', { config: { rateLimit: { max: 20, timeWindow: '1 hour' } } }, async (req) => {
const user = requireUser(req);
const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined);
if (!file) throw new HttpError(400, 'Choose a picture to upload');
const input = await file.toBuffer().catch(() => {
throw new HttpError(413, 'That picture is too big (10 MB max)');
});
const image = await processAvatar(input);
const values = { image, contentType: 'image/webp', updatedAt: new Date() };
await db
.insert(schema.avatars)
.values({ userId: user.id, ...values })
.onConflictDoUpdate({ target: schema.avatars.userId, set: values });
// The version busts caches, since the image itself is served as immutable.
const [updated] = await db
.update(schema.users)
.set({ avatarUrl: `/avatars/${user.id}?v=${values.updatedAt.getTime()}` })
.where(eq(schema.users.id, user.id))
.returning();
return toMeDTO(updated!);
});
app.delete('/me/avatar', async (req) => {
const user = requireUser(req);
await db.delete(schema.avatars).where(eq(schema.avatars.userId, user.id));
const [updated] = await db.update(schema.users).set({ avatarUrl: null }).where(eq(schema.users.id, user.id)).returning();
return toMeDTO(updated!);
});
app.get('/avatars/:userId', async (req, reply) => {
const { userId } = z.object({ userId: z.uuid() }).parse(req.params);
const [row] = await db
.select({ image: schema.avatars.image, contentType: schema.avatars.contentType })
.from(schema.avatars)
.where(eq(schema.avatars.userId, userId));
if (!row) throw new HttpError(404, 'No picture');
return reply
.header('Content-Type', row.contentType)
.header('Cache-Control', 'public, max-age=31536000, immutable')
.header('Content-Security-Policy', "default-src 'none'")
.header('Cross-Origin-Resource-Policy', 'cross-origin')
.send(row.image);
});
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { SESSION_COOKIE, requireUser } from '../lib/auth';
import { publicAvatarUrl } from '../lib/avatars';
import { effectivePlan, entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http';
import { deezerTrack } from '../providers/catalog';
@@ -17,7 +18,7 @@ export const toMeDTO = (u: User): MeDTO => ({
id: u.id,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
avatarUrl: publicAvatarUrl(u.avatarUrl),
email: u.email,
bio: u.bio,
preferredService: u.preferredService,
@@ -34,6 +35,8 @@ export async function meRoutes(app: FastifyInstance) {
app.patch('/me', async (req) => {
const user = requireUser(req);
const { onboarded, ...input } = updateMeSchema.parse(req.body);
// Clearing the display name falls back to the username.
if ('displayName' in input && !input.displayName) input.displayName = user.username;
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();
+2 -1
View File
@@ -2,6 +2,7 @@ import { and, desc, eq, inArray, lt } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { publicAvatarUrl } from '../lib/avatars';
import { nowPlayingChannel, redis } from '../lib/redis';
import type { PollResult } from '../providers/types';
import { enqueueResolve } from './queues';
@@ -11,7 +12,7 @@ export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' |
id: u.id,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
avatarUrl: publicAvatarUrl(u.avatarUrl),
});
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
+81
View File
@@ -0,0 +1,81 @@
import sharp from 'sharp';
import { describe, expect, it } from 'vitest';
import { bearer, createUser, useTestApp } from './helpers';
const app = useTestApp();
/** Builds a multipart body by hand, the way a browser or phone would send it. */
function multipart(file: Buffer, type = 'image/jpeg', name = 'photo.jpg') {
const boundary = '----musicshare-test';
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`),
file,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return { payload, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
}
const photo = () =>
sharp({ create: { width: 1200, height: 800, channels: 3, background: '#7b2ff7' } })
.jpeg()
.withExif({ IFD0: { Make: 'TestPhone', Model: 'GPS-Leaker' } })
.toBuffer();
describe('avatars', () => {
it('resizes, re-encodes and serves an uploaded picture', async () => {
const { token, user } = await createUser(app(), 'alice');
const { payload, headers } = multipart(await photo());
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
expect(res.statusCode).toBe(200);
const url: string = res.json().avatarUrl;
expect(url).toMatch(new RegExp(`^https://api\\.test/avatars/${user.id}\\?v=\\d+$`));
const img = await app().inject({ url: url.replace('https://api.test', '') });
expect(img.statusCode).toBe(200);
expect(img.headers['content-type']).toBe('image/webp');
expect(img.headers['cache-control']).toContain('immutable');
const meta = await sharp(img.rawPayload).metadata();
expect(meta).toMatchObject({ format: 'webp', width: 512, height: 512 });
expect(meta.exif).toBeUndefined(); // camera/GPS metadata stripped
// Shows up wherever the user does, e.g. their profile.
const profile = await app().inject({ url: '/users/alice', headers: bearer(token) });
expect(profile.json().user.avatarUrl).toBe(url);
});
it('rejects files that are not images', async () => {
const { token } = await createUser(app(), 'alice');
const { payload, headers } = multipart(Buffer.from('<script>alert(1)</script>'), 'image/png', 'x.png');
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/isn't an image/);
});
it('removes the picture', async () => {
const { token, user } = await createUser(app(), 'alice');
const { payload, headers } = multipart(await photo());
await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
const res = await app().inject({ method: 'DELETE', url: '/me/avatar', headers: bearer(token) });
expect(res.json().avatarUrl).toBeNull();
expect((await app().inject({ url: `/avatars/${user.id}` })).statusCode).toBe(404);
});
it('requires sign-in to upload', async () => {
const { payload, headers } = multipart(await photo());
expect((await app().inject({ method: 'POST', url: '/me/avatar', payload, headers })).statusCode).toBe(401);
});
});
describe('display name', () => {
it('defaults to the username as typed, and falls back to it when cleared', async () => {
const res = await app().inject({
method: 'POST',
url: '/auth/register',
payload: { username: 'BinaryGnome', email: 'g@example.com', password: 'password123' },
});
expect(res.json().user).toMatchObject({ username: 'binarygnome', displayName: 'BinaryGnome' });
const cleared = await app().inject({ method: 'PATCH', url: '/me', headers: bearer(res.json().token), payload: { displayName: '' } });
expect(cleared.json().displayName).toBe('binarygnome');
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ function assertTestTargets() {
export async function resetState() {
assertTestTargets();
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports restart identity cascade`;
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars restart identity cascade`;
await redis.flushdb();
}