/** * 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 { removeNotificationsBetween } from '../services/notifications'; import { toUserSummary } from '../services/nowPlaying'; import { removeReactionsBetween } from './reactions'; 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 removeReactionsBetween(me.id, them.id); await removeNotificationsBetween(me.id, them.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 }; }, ); }