feat: complete user-facing message and account APIs
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const zPassword = z.string().min(6).max(128);
|
||||
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<UserRecord> => {
|
||||
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,
|
||||
currentPassword: zPassword,
|
||||
newPassword: zPassword,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
if (!(await ctx.users.verifyPassword(user, input.currentPassword))) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '현재 비밀번호가 일치하지 않습니다.' });
|
||||
}
|
||||
await ctx.users.updatePassword(user.id, input.newPassword);
|
||||
await ctx.flushPublisher.publishUserFlush(user.id, 'password-changed');
|
||||
return { ok: true };
|
||||
}),
|
||||
scheduleDeletion: procedure
|
||||
.input(z.object({ sessionToken: zSessionToken, currentPassword: zPassword }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
if (!(await ctx.users.verifyPassword(user, input.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 };
|
||||
}),
|
||||
});
|
||||
@@ -43,6 +43,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
oauthId: input.oauth?.id,
|
||||
email: input.oauth?.email,
|
||||
oauthInfo: input.oauth?.info,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
thirdPartyUse: true,
|
||||
passwordSalt: salt,
|
||||
passwordHash: hasher.hash(input.password, salt),
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -97,6 +100,35 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
user.picture = picture;
|
||||
user.imageServer = imageServer;
|
||||
user.iconUpdatedAt = updatedAt.toISOString();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
user.thirdPartyUse = allowed;
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
user.deleteAfter = deleteAfter.toISOString();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
for (const [username, user] of usersByName.entries()) {
|
||||
if (user.id === userId) {
|
||||
|
||||
@@ -29,6 +29,11 @@ const mapUser = (row: {
|
||||
oauthId: string | null;
|
||||
email: string | null;
|
||||
oauthInfo: GatewayPrisma.JsonValue;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt: Date | null;
|
||||
thirdPartyUse: boolean;
|
||||
deleteAfter: Date | null;
|
||||
createdAt: Date;
|
||||
}): UserRecord => ({
|
||||
id: row.id,
|
||||
@@ -40,6 +45,11 @@ const mapUser = (row: {
|
||||
oauthId: row.oauthId ?? undefined,
|
||||
email: row.email ?? undefined,
|
||||
oauthInfo: readObject<UserOAuthInfo>(row.oauthInfo, {}),
|
||||
picture: row.picture,
|
||||
imageServer: row.imageServer,
|
||||
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
|
||||
thirdPartyUse: row.thirdPartyUse,
|
||||
deleteAfter: row.deleteAfter?.toISOString(),
|
||||
passwordHash: row.passwordHash,
|
||||
passwordSalt: row.passwordSalt,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -139,6 +149,28 @@ export const createPostgresUserRepository = (
|
||||
},
|
||||
});
|
||||
},
|
||||
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
picture,
|
||||
imageServer,
|
||||
iconUpdatedAt: updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
data: { thirdPartyUse: allowed },
|
||||
});
|
||||
},
|
||||
async scheduleDeletion(userId: string, deleteAfter: Date): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
data: { deleteAfter },
|
||||
});
|
||||
},
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
await prisma.appUser.delete({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface UserRecord {
|
||||
oauthId?: string;
|
||||
email?: string;
|
||||
oauthInfo?: UserOAuthInfo;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt?: string;
|
||||
thirdPartyUse: boolean;
|
||||
deleteAfter?: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
createdAt: string;
|
||||
@@ -18,6 +23,7 @@ export interface PublicUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
picture: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -44,6 +50,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
picture: user.picture,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
|
||||
@@ -70,6 +77,9 @@ export interface UserRepository {
|
||||
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
|
||||
updateRoles(userId: string, roles: string[]): Promise<void>;
|
||||
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
|
||||
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
|
||||
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
|
||||
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
|
||||
deleteUser(userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface GatewayApiConfig {
|
||||
kakaoAdminKey?: string;
|
||||
kakaoRedirectUri: string;
|
||||
publicBaseUrl: string;
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
orchestratorEnabled: boolean;
|
||||
orchestratorReconcileIntervalMs: number;
|
||||
@@ -81,6 +83,8 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
|
||||
kakaoRedirectUri,
|
||||
publicBaseUrl,
|
||||
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`,
|
||||
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
|
||||
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
|
||||
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface GatewayApiContext {
|
||||
kakaoClient: KakaoOAuthClient;
|
||||
oauthSessions: OAuthSessionStore;
|
||||
publicBaseUrl: string;
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
profiles: GatewayProfileRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
@@ -36,6 +38,8 @@ export const createGatewayApiContext = (options: {
|
||||
kakaoClient: KakaoOAuthClient;
|
||||
oauthSessions: OAuthSessionStore;
|
||||
publicBaseUrl: string;
|
||||
userIconDir?: string;
|
||||
userIconPublicUrl?: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
profiles: GatewayProfileRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
@@ -51,6 +55,8 @@ export const createGatewayApiContext = (options: {
|
||||
kakaoClient: options.kakaoClient,
|
||||
oauthSessions: options.oauthSessions,
|
||||
publicBaseUrl: options.publicBaseUrl,
|
||||
userIconDir: options.userIconDir ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
|
||||
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
|
||||
profiles: options.profiles,
|
||||
orchestrator: options.orchestrator,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { procedure, router } from './trpc.js';
|
||||
import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { UserOAuthInfo } from './auth/userRepository.js';
|
||||
import { adminRouter } from './adminRouter.js';
|
||||
import { accountRouter } from './account/router.js';
|
||||
|
||||
const zUsername = z.string().min(2).max(32);
|
||||
const zPassword = z.string().min(6).max(128);
|
||||
@@ -61,6 +62,7 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
admin: adminRouter,
|
||||
account: accountRouter,
|
||||
auth: router({
|
||||
bootstrapLocal: procedure
|
||||
.input(
|
||||
@@ -330,6 +332,12 @@ export const appRouter = router({
|
||||
message: 'Invalid username or password.',
|
||||
});
|
||||
}
|
||||
if (user.deleteAfter) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account deletion is pending.',
|
||||
});
|
||||
}
|
||||
const ok = await ctx.users.verifyPassword(user, input.password);
|
||||
if (!ok) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import {
|
||||
createGatewayPostgresConnector,
|
||||
@@ -60,6 +63,12 @@ export const createGatewayApiServer = async () => {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
|
||||
await app.register(fastifyStatic, {
|
||||
root: path.resolve(process.cwd(), config.userIconDir),
|
||||
prefix: '/user-icons/',
|
||||
decorateReply: false,
|
||||
});
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: config.trpcPath,
|
||||
@@ -75,6 +84,8 @@ export const createGatewayApiServer = async () => {
|
||||
kakaoClient,
|
||||
oauthSessions,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
userIconDir: path.resolve(process.cwd(), config.userIconDir),
|
||||
userIconPublicUrl: config.userIconPublicUrl,
|
||||
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
|
||||
profiles,
|
||||
orchestrator,
|
||||
|
||||
Reference in New Issue
Block a user