- 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:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
Reference in New Issue
Block a user