feat: add user icon library and in-game selection
This commit is contained in:
@@ -14,6 +14,9 @@ import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const MAX_ICON_BYTES = 50 * 1024;
|
||||
const MAX_ACTIVE_ICONS = 5;
|
||||
const ICON_UPLOAD_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
const ICON_RETIRE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
|
||||
|
||||
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
|
||||
@@ -46,8 +49,12 @@ export const kstDayStart = (value: Date): Date => {
|
||||
};
|
||||
|
||||
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
|
||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= kstDayStart(now)) {
|
||||
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
|
||||
if (
|
||||
user.picture !== 'default.jpg' &&
|
||||
user.iconUpdatedAt &&
|
||||
new Date(user.iconUpdatedAt).getTime() > now.getTime() - ICON_UPLOAD_COOLDOWN_MS
|
||||
) {
|
||||
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘 업로드는 24시간에 한 번만 가능합니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,6 +74,18 @@ const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null =
|
||||
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
|
||||
};
|
||||
|
||||
const buildLibraryIcon = (
|
||||
ctx: GatewayApiContext,
|
||||
icon: Awaited<ReturnType<GatewayApiContext['users']['listIcons']>>[number]
|
||||
) => ({
|
||||
id: icon.id,
|
||||
picture: icon.picture,
|
||||
imageServer: icon.imageServer,
|
||||
createdAt: icon.createdAt,
|
||||
retiredAt: icon.retiredAt ?? null,
|
||||
url: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`,
|
||||
});
|
||||
|
||||
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
|
||||
(await ctx.profileStatus.listLobbyProfiles({ userId }))
|
||||
.filter(
|
||||
@@ -95,6 +114,7 @@ const publishIconFlush = async (
|
||||
export const accountRouter = router({
|
||||
get: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const icons = await ctx.users.listIcons(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -103,6 +123,15 @@ export const accountRouter = router({
|
||||
oauthType: user.oauthType,
|
||||
createdAt: user.createdAt,
|
||||
iconUrl: buildIconUrl(ctx, user),
|
||||
icons: icons.map((icon) => buildLibraryIcon(ctx, icon)),
|
||||
preferredPicture: resolveEffectiveAccountIcon(user).picture,
|
||||
maxActiveIcons: MAX_ACTIVE_ICONS,
|
||||
nextUploadAt: user.iconUpdatedAt
|
||||
? new Date(new Date(user.iconUpdatedAt).getTime() + ICON_UPLOAD_COOLDOWN_MS).toISOString()
|
||||
: null,
|
||||
nextRetireAt: user.iconRetiredAt
|
||||
? new Date(new Date(user.iconRetiredAt).getTime() + ICON_RETIRE_COOLDOWN_MS).toISOString()
|
||||
: null,
|
||||
thirdPartyUse: user.thirdPartyUse,
|
||||
deleteAfter: user.deleteAfter ?? null,
|
||||
};
|
||||
@@ -184,39 +213,104 @@ export const accountRouter = router({
|
||||
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' });
|
||||
let revision: string | null;
|
||||
let stored;
|
||||
try {
|
||||
revision = await ctx.users.updateIconForDay(user.id, filename, 1, now, kstDayStart(now), true);
|
||||
stored = await ctx.users.addIconForWindow(
|
||||
user.id,
|
||||
filename,
|
||||
1,
|
||||
now,
|
||||
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
|
||||
MAX_ACTIVE_ICONS
|
||||
);
|
||||
} catch (error) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
throw error;
|
||||
}
|
||||
if (!revision) {
|
||||
if (!stored.ok) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
if (stored.reason === 'LIMIT') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
||||
message: '아이콘 업로드는 24시간에 한 번만 가능합니다.',
|
||||
});
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
|
||||
revision,
|
||||
revision: stored.revision,
|
||||
icon: buildLibraryIcon(ctx, stored.icon),
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
}),
|
||||
setPreferredIcon: procedure
|
||||
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const revision = await ctx.users.setPreferredIcon(user.id, input.iconId, new Date());
|
||||
if (!revision) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
}
|
||||
const updated = await ctx.users.findById(user.id);
|
||||
if (!updated) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return { ok: true, revision, iconUrl: buildIconUrl(ctx, updated), flushPublished };
|
||||
}),
|
||||
retireIcon: procedure
|
||||
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const now = new Date();
|
||||
const result = await ctx.users.retireIconForWindow(
|
||||
user.id,
|
||||
input.iconId,
|
||||
now,
|
||||
new Date(now.getTime() - ICON_RETIRE_COOLDOWN_MS)
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'COOLDOWN') {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '전용 아이콘은 7일에 한 번만 목록에서 내릴 수 있습니다.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
}
|
||||
const updated = await ctx.users.findById(user.id);
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
revision: result.revision,
|
||||
preferredChanged: result.preferredChanged,
|
||||
iconUrl: updated ? buildIconUrl(ctx, updated) : null,
|
||||
flushPublished,
|
||||
};
|
||||
}),
|
||||
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);
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, kstDayStart(now), false);
|
||||
const revision = await ctx.users.updateIconForDay(
|
||||
user.id,
|
||||
'default.jpg',
|
||||
0,
|
||||
now,
|
||||
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
|
||||
false,
|
||||
true
|
||||
);
|
||||
if (!revision) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
||||
message: '아이콘 변경은 24시간에 한 번만 가능합니다.',
|
||||
});
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||
import type { CreateUserInput, UserRecord, UserRepository } from './userRepository.js';
|
||||
import type { CreateUserInput, UserIconRecord, UserRecord, UserRepository } from './userRepository.js';
|
||||
|
||||
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
|
||||
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
|
||||
const usersByName = new Map<string, UserRecord>();
|
||||
const usersByOauthId = new Map<string, UserRecord>();
|
||||
const usersByEmail = new Map<string, UserRecord>();
|
||||
const iconsById = new Map<string, UserIconRecord>();
|
||||
|
||||
const nextRevision = (user: UserRecord, now: Date): string =>
|
||||
new Date(
|
||||
Math.max(
|
||||
now.getTime(),
|
||||
new Date(user.createdAt).getTime() + 1,
|
||||
(user.iconRevision ? new Date(user.iconRevision).getTime() : 0) + 1
|
||||
)
|
||||
).toISOString();
|
||||
|
||||
return {
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
@@ -165,14 +175,18 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
consumeDailyQuota: boolean,
|
||||
allowCutoffEquality = false
|
||||
): Promise<string | null> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
continue;
|
||||
}
|
||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= dayStart) {
|
||||
return null;
|
||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt) {
|
||||
const previousUpdate = new Date(user.iconUpdatedAt);
|
||||
if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const previousRevision = Math.max(
|
||||
new Date(user.createdAt).getTime(),
|
||||
@@ -191,6 +205,67 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async listIcons(userId: string, includeRetired = false): Promise<UserIconRecord[]> {
|
||||
return [...iconsById.values()]
|
||||
.filter((icon) => icon.userId === userId && (includeRetired || !icon.retiredAt))
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
},
|
||||
async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) {
|
||||
const user = [...usersByName.values()].find((candidate) => candidate.id === userId);
|
||||
if (!user) return { ok: false, reason: 'NOT_FOUND' };
|
||||
if (user.iconUpdatedAt && new Date(user.iconUpdatedAt) > uploadCutoff) {
|
||||
return { ok: false, reason: 'COOLDOWN' };
|
||||
}
|
||||
const active = [...iconsById.values()].filter((icon) => icon.userId === userId && !icon.retiredAt);
|
||||
if (active.length >= maxActive) return { ok: false, reason: 'LIMIT' };
|
||||
const revision = nextRevision(user, now);
|
||||
const icon: UserIconRecord = {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
picture,
|
||||
imageServer,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
iconsById.set(icon.id, icon);
|
||||
user.picture = picture;
|
||||
user.imageServer = imageServer;
|
||||
user.iconUpdatedAt = now.toISOString();
|
||||
user.iconRevision = revision;
|
||||
return { ok: true, icon, revision };
|
||||
},
|
||||
async setPreferredIcon(userId, iconId, now) {
|
||||
const user = [...usersByName.values()].find((candidate) => candidate.id === userId);
|
||||
const icon = iconsById.get(iconId);
|
||||
if (!user || !icon || icon.userId !== userId || icon.retiredAt) return null;
|
||||
const revision = nextRevision(user, now);
|
||||
user.picture = icon.picture;
|
||||
user.imageServer = icon.imageServer;
|
||||
user.iconRevision = revision;
|
||||
return revision;
|
||||
},
|
||||
async retireIconForWindow(userId, iconId, now, retireCutoff) {
|
||||
const user = [...usersByName.values()].find((candidate) => candidate.id === userId);
|
||||
if (!user) return { ok: false, reason: 'NOT_FOUND' };
|
||||
if (user.iconRetiredAt && new Date(user.iconRetiredAt) > retireCutoff) {
|
||||
return { ok: false, reason: 'COOLDOWN' };
|
||||
}
|
||||
const icon = iconsById.get(iconId);
|
||||
if (!icon || icon.userId !== userId) return { ok: false, reason: 'NOT_FOUND' };
|
||||
if (icon.retiredAt) return { ok: false, reason: 'ALREADY_RETIRED' };
|
||||
icon.retiredAt = now.toISOString();
|
||||
const preferredChanged = user.picture === icon.picture;
|
||||
if (preferredChanged) {
|
||||
const fallback = [...iconsById.values()]
|
||||
.filter((candidate) => candidate.userId === userId && !candidate.retiredAt)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))[0];
|
||||
user.picture = fallback?.picture ?? 'default.jpg';
|
||||
user.imageServer = fallback?.imageServer ?? 0;
|
||||
}
|
||||
const revision = nextRevision(user, now);
|
||||
user.iconRevision = revision;
|
||||
user.iconRetiredAt = now.toISOString();
|
||||
return { ok: true, icon, revision, preferredChanged };
|
||||
},
|
||||
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
|
||||
import type { CreateUserInput, UserOAuthInfo, UserRecord, UserRepository, UserSanctions } from './userRepository.js';
|
||||
import type {
|
||||
CreateUserInput,
|
||||
UserIconRecord,
|
||||
UserOAuthInfo,
|
||||
UserRecord,
|
||||
UserRepository,
|
||||
UserSanctions,
|
||||
} from './userRepository.js';
|
||||
|
||||
const readStringArray = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
@@ -46,6 +53,7 @@ const mapUser = (row: {
|
||||
iconUpdatedAt: Date | null;
|
||||
iconRevision: Date | null;
|
||||
profileIconResetAt: Date | null;
|
||||
iconRetiredAt: Date | null;
|
||||
thirdPartyUse: boolean;
|
||||
termsAcceptedAt: Date | null;
|
||||
privacyAcceptedAt: Date | null;
|
||||
@@ -69,6 +77,7 @@ const mapUser = (row: {
|
||||
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
|
||||
iconRevision: row.iconRevision?.toISOString(),
|
||||
profileIconResetAt: row.profileIconResetAt?.toISOString(),
|
||||
iconRetiredAt: row.iconRetiredAt?.toISOString(),
|
||||
thirdPartyUse: row.thirdPartyUse,
|
||||
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
|
||||
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
|
||||
@@ -82,6 +91,22 @@ const mapUser = (row: {
|
||||
legacyGrade: readLegacyGrade(row.legacyData),
|
||||
});
|
||||
|
||||
const mapIcon = (row: {
|
||||
id: string;
|
||||
userId: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
createdAt: Date;
|
||||
retiredAt: Date | null;
|
||||
}): UserIconRecord => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
picture: row.picture,
|
||||
imageServer: row.imageServer,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
retiredAt: row.retiredAt?.toISOString(),
|
||||
});
|
||||
|
||||
export const createPostgresUserRepository = (
|
||||
prisma: GatewayPrismaClient,
|
||||
hasher: PasswordHasher = createSimplePasswordHasher()
|
||||
@@ -242,7 +267,8 @@ export const createPostgresUserRepository = (
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
consumeDailyQuota: boolean,
|
||||
allowCutoffEquality = false
|
||||
): Promise<string | null> {
|
||||
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
||||
UPDATE "app_user"
|
||||
@@ -262,11 +288,119 @@ export const createPostgresUserRepository = (
|
||||
"picture" = 'default.jpg'
|
||||
OR "icon_updated_at" IS NULL
|
||||
OR "icon_updated_at" < ${dayStart}
|
||||
OR (${allowCutoffEquality} AND "icon_updated_at" = ${dayStart})
|
||||
)
|
||||
RETURNING "icon_revision" AS "iconRevision"
|
||||
`);
|
||||
return rows[0]?.iconRevision.toISOString() ?? null;
|
||||
},
|
||||
async listIcons(userId: string, includeRetired = false): Promise<UserIconRecord[]> {
|
||||
const rows = await prisma.userIcon.findMany({
|
||||
where: { userId, ...(includeRetired ? {} : { retiredAt: null }) },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.map(mapIcon);
|
||||
},
|
||||
async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const users = await tx.$queryRaw<
|
||||
Array<{ createdAt: Date; iconUpdatedAt: Date | null; iconRevision: Date | null }>
|
||||
>(GatewayPrisma.sql`
|
||||
SELECT "created_at" AS "createdAt", "icon_updated_at" AS "iconUpdatedAt",
|
||||
"icon_revision" AS "iconRevision"
|
||||
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE
|
||||
`);
|
||||
const user = users[0];
|
||||
if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const };
|
||||
if (user.iconUpdatedAt && user.iconUpdatedAt > uploadCutoff) {
|
||||
return { ok: false as const, reason: 'COOLDOWN' as const };
|
||||
}
|
||||
const activeCount = await tx.userIcon.count({ where: { userId, retiredAt: null } });
|
||||
if (activeCount >= maxActive) return { ok: false as const, reason: 'LIMIT' as const };
|
||||
const revision = new Date(
|
||||
Math.max(now.getTime(), user.iconRevision?.getTime() ?? 0, user.createdAt.getTime()) +
|
||||
(now.getTime() <= (user.iconRevision?.getTime() ?? 0) ? 1 : 0)
|
||||
);
|
||||
const icon = await tx.userIcon.create({ data: { userId, picture, imageServer, createdAt: now } });
|
||||
await tx.appUser.update({
|
||||
where: { id: userId },
|
||||
data: { picture, imageServer, iconUpdatedAt: now, iconRevision: revision },
|
||||
});
|
||||
return { ok: true as const, icon: mapIcon(icon), revision: revision.toISOString() };
|
||||
});
|
||||
},
|
||||
async setPreferredIcon(userId, iconId, now) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const users = await tx.$queryRaw<Array<{ createdAt: Date; iconRevision: Date | null }>>(
|
||||
GatewayPrisma.sql`SELECT "created_at" AS "createdAt", "icon_revision" AS "iconRevision"
|
||||
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE`
|
||||
);
|
||||
const user = users[0];
|
||||
if (!user) return null;
|
||||
const icon = await tx.userIcon.findFirst({ where: { id: iconId, userId, retiredAt: null } });
|
||||
if (!icon) return null;
|
||||
const previous = Math.max(user.createdAt.getTime(), user.iconRevision?.getTime() ?? 0);
|
||||
const revision = new Date(Math.max(now.getTime(), previous + 1));
|
||||
await tx.appUser.update({
|
||||
where: { id: userId },
|
||||
data: { picture: icon.picture, imageServer: icon.imageServer, iconRevision: revision },
|
||||
});
|
||||
return revision.toISOString();
|
||||
});
|
||||
},
|
||||
async retireIconForWindow(userId, iconId, now, retireCutoff) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const users = await tx.$queryRaw<
|
||||
Array<{
|
||||
picture: string;
|
||||
createdAt: Date;
|
||||
iconRevision: Date | null;
|
||||
iconRetiredAt: Date | null;
|
||||
}>
|
||||
>(GatewayPrisma.sql`
|
||||
SELECT "picture", "created_at" AS "createdAt", "icon_revision" AS "iconRevision",
|
||||
"icon_retired_at" AS "iconRetiredAt"
|
||||
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE
|
||||
`);
|
||||
const user = users[0];
|
||||
if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const };
|
||||
if (user.iconRetiredAt && user.iconRetiredAt > retireCutoff) {
|
||||
return { ok: false as const, reason: 'COOLDOWN' as const };
|
||||
}
|
||||
const icon = await tx.userIcon.findFirst({ where: { id: iconId, userId } });
|
||||
if (!icon) return { ok: false as const, reason: 'NOT_FOUND' as const };
|
||||
if (icon.retiredAt) return { ok: false as const, reason: 'ALREADY_RETIRED' as const };
|
||||
const retired = await tx.userIcon.update({ where: { id: icon.id }, data: { retiredAt: now } });
|
||||
const preferredChanged = user.picture === icon.picture;
|
||||
const fallback = preferredChanged
|
||||
? await tx.userIcon.findFirst({
|
||||
where: { userId, retiredAt: null, id: { not: icon.id } },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
})
|
||||
: null;
|
||||
const previous = Math.max(user.createdAt.getTime(), user.iconRevision?.getTime() ?? 0);
|
||||
const revision = new Date(Math.max(now.getTime(), previous + 1));
|
||||
await tx.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
iconRetiredAt: now,
|
||||
iconRevision: revision,
|
||||
...(preferredChanged
|
||||
? {
|
||||
picture: fallback?.picture ?? 'default.jpg',
|
||||
imageServer: fallback?.imageServer ?? 0,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
icon: mapIcon(retired),
|
||||
revision: revision.toISOString(),
|
||||
preferredChanged,
|
||||
};
|
||||
});
|
||||
},
|
||||
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface UserRecord {
|
||||
iconUpdatedAt?: string;
|
||||
iconRevision?: string;
|
||||
profileIconResetAt?: string;
|
||||
iconRetiredAt?: string;
|
||||
thirdPartyUse: boolean;
|
||||
termsAcceptedAt?: string;
|
||||
privacyAcceptedAt?: string;
|
||||
@@ -26,6 +27,22 @@ export interface UserRecord {
|
||||
legacyGrade?: number;
|
||||
}
|
||||
|
||||
export interface UserIconRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
createdAt: string;
|
||||
retiredAt?: string;
|
||||
}
|
||||
|
||||
export type AddUserIconResult =
|
||||
{ ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' };
|
||||
|
||||
export type RetireUserIconResult =
|
||||
| { ok: true; icon: UserIconRecord; revision: string; preferredChanged: boolean }
|
||||
| { ok: false; reason: 'COOLDOWN' | 'NOT_FOUND' | 'ALREADY_RETIRED' };
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -110,8 +127,20 @@ export interface UserRepository {
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
consumeDailyQuota: boolean,
|
||||
allowCutoffEquality?: boolean
|
||||
): Promise<string | null>;
|
||||
listIcons(userId: string, includeRetired?: boolean): Promise<UserIconRecord[]>;
|
||||
addIconForWindow(
|
||||
userId: string,
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
now: Date,
|
||||
uploadCutoff: Date,
|
||||
maxActive: number
|
||||
): Promise<AddUserIconResult>;
|
||||
setPreferredIcon(userId: string, iconId: string, now: Date): Promise<string | null>;
|
||||
retireIconForWindow(userId: string, iconId: string, now: Date, retireCutoff: Date): Promise<RetireUserIconResult>;
|
||||
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
|
||||
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
|
||||
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
|
||||
|
||||
@@ -658,6 +658,7 @@ export const appRouter = router({
|
||||
}
|
||||
const now = new Date();
|
||||
const accountIcon = resolveEffectiveAccountIcon(user);
|
||||
const accountIcons = await ctx.users.listIcons(user.id);
|
||||
const payload = {
|
||||
version: 1,
|
||||
profile: gameSession.profile,
|
||||
@@ -676,6 +677,12 @@ export const appRouter = router({
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt,
|
||||
legacyMemberNo: user.legacyMemberNo,
|
||||
icons: accountIcons.map((icon) => ({
|
||||
id: icon.id,
|
||||
picture: icon.picture,
|
||||
imageServer: icon.imageServer,
|
||||
createdAt: icon.createdAt,
|
||||
})),
|
||||
},
|
||||
sanctions: user.sanctions,
|
||||
identity: {
|
||||
|
||||
@@ -154,4 +154,48 @@ integration('account icon daily PostgreSQL CAS', () => {
|
||||
sanctions: { warningCount: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes the five-slot library and preserves retired rows', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const start = new Date('2026-08-03T00:00:00.000Z');
|
||||
await db.userIcon.deleteMany({ where: { userId } });
|
||||
await db.appUser.update({
|
||||
where: { id: userId },
|
||||
data: { picture: 'default.jpg', imageServer: 0, iconUpdatedAt: null, iconRetiredAt: null },
|
||||
});
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const now = new Date(start.getTime() + index * 86_400_000);
|
||||
await expect(
|
||||
users.addIconForWindow(
|
||||
userId,
|
||||
`postgres-library-${index}.png`,
|
||||
1,
|
||||
now,
|
||||
new Date(now.getTime() - 86_400_000),
|
||||
5
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
}
|
||||
await expect(
|
||||
users.addIconForWindow(
|
||||
userId,
|
||||
'postgres-library-sixth.png',
|
||||
1,
|
||||
new Date(start.getTime() + 5 * 86_400_000),
|
||||
new Date(start.getTime() + 4 * 86_400_000),
|
||||
5
|
||||
)
|
||||
).resolves.toEqual({ ok: false, reason: 'LIMIT' });
|
||||
|
||||
const icons = await users.listIcons(userId);
|
||||
const retiredAt = new Date(start.getTime() + 6 * 86_400_000);
|
||||
await expect(
|
||||
users.retireIconForWindow(userId, icons[0]!.id, retiredAt, new Date(retiredAt.getTime() - 7 * 86_400_000))
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(users.listIcons(userId)).resolves.toHaveLength(4);
|
||||
await expect(users.listIcons(userId, true)).resolves.toContainEqual(
|
||||
expect.objectContaining({ picture: 'postgres-library-0.png', retiredAt: retiredAt.toISOString() })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -781,7 +781,7 @@ describe('account self service', () => {
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
|
||||
});
|
||||
|
||||
it('uses the Asia/Seoul day boundary and preserves Ref delete-to-upload behavior', async () => {
|
||||
it('uses a rolling 24-hour upload window and preserves delete-to-upload behavior', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
|
||||
const png = await sharp({
|
||||
create: {
|
||||
@@ -809,11 +809,17 @@ describe('account self service', () => {
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z'));
|
||||
const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
|
||||
expect(deleted.revision).toBe('2026-07-31T15:00:00.000Z');
|
||||
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-08-01T00:00:00.000Z'));
|
||||
const nextSession = await sessions.createSession(user);
|
||||
const deleted = await caller.account.deleteIcon({ sessionToken: nextSession.sessionToken });
|
||||
expect(deleted.revision).toBe('2026-08-01T00:00:00.000Z');
|
||||
|
||||
const changed = await caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
sessionToken: nextSession.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
});
|
||||
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('user icon library', () => {
|
||||
it('keeps five immutable active icons and enforces the rolling upload window', async () => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const user = await users.createUser({ username: 'five-icons', password: 'password' });
|
||||
const start = new Date('2026-08-01T00:00:00.000Z');
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const now = new Date(start.getTime() + index * DAY_MS);
|
||||
const stored = await users.addIconForWindow(
|
||||
user.id,
|
||||
`immutable-${index}.png`,
|
||||
1,
|
||||
now,
|
||||
new Date(now.getTime() - DAY_MS),
|
||||
5
|
||||
);
|
||||
expect(stored.ok).toBe(true);
|
||||
if (index === 0) {
|
||||
const blocked = await users.addIconForWindow(
|
||||
user.id,
|
||||
'too-soon.png',
|
||||
1,
|
||||
new Date(now.getTime() + DAY_MS - 1),
|
||||
new Date(now.getTime() - 1),
|
||||
5
|
||||
);
|
||||
expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' });
|
||||
}
|
||||
}
|
||||
|
||||
const icons = await users.listIcons(user.id);
|
||||
expect(icons.map((icon) => icon.picture)).toEqual([
|
||||
'immutable-0.png',
|
||||
'immutable-1.png',
|
||||
'immutable-2.png',
|
||||
'immutable-3.png',
|
||||
'immutable-4.png',
|
||||
]);
|
||||
const overLimit = await users.addIconForWindow(
|
||||
user.id,
|
||||
'sixth.png',
|
||||
1,
|
||||
new Date(start.getTime() + 5 * DAY_MS),
|
||||
new Date(start.getTime() + 4 * DAY_MS),
|
||||
5
|
||||
);
|
||||
expect(overLimit).toEqual({ ok: false, reason: 'LIMIT' });
|
||||
});
|
||||
|
||||
it('retires without deleting the durable record and allows retirement only every seven days', async () => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const user = await users.createUser({ username: 'retire-icons', password: 'password' });
|
||||
const firstAt = new Date('2026-08-01T00:00:00.000Z');
|
||||
const first = await users.addIconForWindow(
|
||||
user.id,
|
||||
'hall-of-fame.png',
|
||||
1,
|
||||
firstAt,
|
||||
new Date(firstAt.getTime() - DAY_MS),
|
||||
5
|
||||
);
|
||||
expect(first.ok).toBe(true);
|
||||
if (!first.ok) return;
|
||||
const secondAt = new Date(firstAt.getTime() + DAY_MS);
|
||||
const second = await users.addIconForWindow(
|
||||
user.id,
|
||||
'next.png',
|
||||
1,
|
||||
secondAt,
|
||||
new Date(secondAt.getTime() - DAY_MS),
|
||||
5
|
||||
);
|
||||
expect(second.ok).toBe(true);
|
||||
if (!second.ok) return;
|
||||
|
||||
const retired = await users.retireIconForWindow(
|
||||
user.id,
|
||||
first.icon.id,
|
||||
secondAt,
|
||||
new Date(secondAt.getTime() - 7 * DAY_MS)
|
||||
);
|
||||
expect(retired.ok).toBe(true);
|
||||
expect(await users.listIcons(user.id)).toHaveLength(1);
|
||||
expect(await users.listIcons(user.id, true)).toContainEqual(
|
||||
expect.objectContaining({ picture: 'hall-of-fame.png', retiredAt: secondAt.toISOString() })
|
||||
);
|
||||
|
||||
const blocked = await users.retireIconForWindow(
|
||||
user.id,
|
||||
second.icon.id,
|
||||
new Date(secondAt.getTime() + 7 * DAY_MS - 1),
|
||||
new Date(secondAt.getTime() - 1)
|
||||
);
|
||||
expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user