import { randomBytes } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { TRPCError } from '@trpc/server'; import sharp from 'sharp'; import { z } from 'zod'; import type { GatewayApiContext } from '../context.js'; import { procedure, router } from '../trpc.js'; import type { UserRecord, UserSanctions } from '../auth/userRepository.js'; import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js'; const zSessionToken = z.string().min(1); const MAX_ICON_BYTES = 50 * 1024; const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']); const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise => { const session = await ctx.sessions.getSession(sessionToken); if (!session) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' }); } const user = await ctx.users.findById(session.userId); if (!user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists.' }); } return user; }; const decodeImage = (input: string): Buffer => { const match = input.match(/^data:[^;]+;base64,(.+)$/); const encoded = match?.[1] ?? input; const buffer = Buffer.from(encoded, 'base64'); if (buffer.length === 0 || buffer.length > MAX_ICON_BYTES) { throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘은 50KB 이하여야 합니다.' }); } return buffer; }; const sameUtcDate = (left: Date, right: Date): boolean => left.getUTCFullYear() === right.getUTCFullYear() && left.getUTCMonth() === right.getUTCMonth() && left.getUTCDate() === right.getUTCDate(); const assertIconChangeAvailable = (user: UserRecord, now: Date): void => { if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' }); } }; const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => { const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil]; for (const value of dates) { if (value && new Date(value) > now) return true; } return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) => Boolean(restriction.until && new Date(restriction.until) > now) ); }; const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => { if (user.imageServer !== 1 || user.picture === 'default.jpg') return null; return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(user.picture)}`; }; export const accountRouter = router({ get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); return { id: user.id, username: user.username, displayName: user.displayName, roles: user.roles, oauthType: user.oauthType, createdAt: user.createdAt, iconUrl: buildIconUrl(ctx, user), thirdPartyUse: user.thirdPartyUse, deleteAfter: user.deleteAfter ?? null, }; }), changePassword: procedure .input( z.object({ sessionToken: zSessionToken, currentCredential: zPasswordEnvelope, newCredential: zPasswordEnvelope, }) ) .mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); const currentPassword = openPassword(ctx.passwordEnvelope, input.currentCredential); if (!(await ctx.users.verifyPassword(user, currentPassword))) { throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' }); } const newPassword = openPassword(ctx.passwordEnvelope, input.newCredential); await ctx.users.updatePassword(user.id, newPassword); await ctx.flushPublisher.publishUserFlush(user.id, 'password-changed'); return { ok: true }; }), scheduleDeletion: procedure .input(z.object({ sessionToken: zSessionToken, currentCredential: zPasswordEnvelope })) .mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); const currentPassword = openPassword(ctx.passwordEnvelope, input.currentCredential); if (!(await ctx.users.verifyPassword(user, currentPassword))) { throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' }); } if (user.deleteAfter) { throw new TRPCError({ code: 'CONFLICT', message: '이미 탈퇴 처리되어 있습니다.' }); } const now = new Date(); if (hasActiveSanction(user.sanctions, now)) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '징계가 남아 있어 탈퇴할 수 없습니다.' }); } const deleteAfter = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); await ctx.users.scheduleDeletion(user.id, deleteAfter); await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true }); await ctx.flushPublisher.publishUserFlush(user.id, 'account-deletion-scheduled'); return { ok: true, deleteAfter: deleteAfter.toISOString() }; }), disallowThirdPartyUse: procedure .input(z.object({ sessionToken: zSessionToken })) .mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); await ctx.users.setThirdPartyUse(user.id, false); return { ok: true }; }), changeIcon: procedure .input( z.object({ sessionToken: zSessionToken, imageData: z.string().min(1).max(100_000), }) ) .mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); const now = new Date(); assertIconChangeAvailable(user, now); const buffer = decodeImage(input.imageData); const metadata = await sharp(buffer, { animated: true }).metadata(); if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.', }); } if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) { throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.', }); } const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format; const filename = `${randomBytes(8).toString('hex')}.${extension}`; await fs.mkdir(ctx.userIconDir, { recursive: true }); await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' }); await ctx.users.updateIcon(user.id, filename, 1, now); return { ok: true, iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`, }; }), deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); const now = new Date(); assertIconChangeAvailable(user, now); await ctx.users.updateIcon(user.id, 'default.jpg', 0, now); return { ok: true, iconUrl: null }; }), });