계정 아이콘 등록과 목록 내리기 제한을 조정한다
This commit is contained in:
@@ -15,8 +15,7 @@ import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
|
||||
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 ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
|
||||
const ICON_CONTENT_TYPES: Record<string, string> = {
|
||||
avif: 'image/avif',
|
||||
@@ -48,16 +47,6 @@ const decodeImage = (input: string): Buffer => {
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
|
||||
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시간에 한 번만 가능합니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
|
||||
const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil];
|
||||
for (const value of dates) {
|
||||
@@ -212,9 +201,7 @@ export const accountRouter = router({
|
||||
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,
|
||||
nextUploadAt: null,
|
||||
nextRetireAt: user.iconRetiredAt
|
||||
? new Date(new Date(user.iconRetiredAt).getTime() + ICON_RETIRE_COOLDOWN_MS).toISOString()
|
||||
: null,
|
||||
@@ -279,7 +266,6 @@ export const accountRouter = router({
|
||||
.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 buffer = decodeImage(input.imageData);
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
@@ -306,14 +292,7 @@ export const accountRouter = router({
|
||||
contentType: ICON_CONTENT_TYPES[extension]!,
|
||||
body: buffer,
|
||||
});
|
||||
const stored = await ctx.users.addIconForWindow(
|
||||
user.id,
|
||||
uploaded.picture,
|
||||
0,
|
||||
now,
|
||||
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
|
||||
MAX_ACTIVE_ICONS
|
||||
);
|
||||
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
|
||||
if (!stored.ok) {
|
||||
if (stored.reason === 'LIMIT') {
|
||||
throw new TRPCError({
|
||||
@@ -321,10 +300,7 @@ export const accountRouter = router({
|
||||
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘 업로드는 24시간에 한 번만 가능합니다.',
|
||||
});
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
@@ -364,7 +340,7 @@ export const accountRouter = router({
|
||||
if (result.reason === 'COOLDOWN') {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '전용 아이콘은 7일에 한 번만 목록에서 내릴 수 있습니다.',
|
||||
message: '전용 아이콘은 24시간에 한 개만 목록에서 내릴 수 있습니다.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
@@ -382,21 +358,12 @@ export const accountRouter = router({
|
||||
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,
|
||||
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
|
||||
false,
|
||||
true
|
||||
);
|
||||
const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, now, false, true, false);
|
||||
if (!revision) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘 변경은 24시간에 한 번만 가능합니다.',
|
||||
code: 'NOT_FOUND',
|
||||
message: '아이콘을 제거하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
||||
|
||||
@@ -434,13 +434,14 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean,
|
||||
allowCutoffEquality = false
|
||||
allowCutoffEquality = false,
|
||||
enforceCooldown = true
|
||||
): Promise<string | null> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
continue;
|
||||
}
|
||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt) {
|
||||
if (enforceCooldown && user.picture !== 'default.jpg' && user.iconUpdatedAt) {
|
||||
const previousUpdate = new Date(user.iconUpdatedAt);
|
||||
if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) {
|
||||
return null;
|
||||
@@ -468,12 +469,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
.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) {
|
||||
async addIconForWindow(userId, picture, imageServer, now, 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);
|
||||
|
||||
@@ -607,7 +607,8 @@ export const createPostgresUserRepository = (
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean,
|
||||
allowCutoffEquality = false
|
||||
allowCutoffEquality = false,
|
||||
enforceCooldown = true
|
||||
): Promise<string | null> {
|
||||
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
||||
UPDATE "app_user"
|
||||
@@ -623,7 +624,7 @@ export const createPostgresUserRepository = (
|
||||
COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond'
|
||||
)
|
||||
WHERE "id" = ${userId}
|
||||
AND (
|
||||
AND (NOT ${enforceCooldown} OR
|
||||
"picture" = 'default.jpg'
|
||||
OR "icon_updated_at" IS NULL
|
||||
OR "icon_updated_at" < ${dayStart}
|
||||
@@ -640,20 +641,16 @@ export const createPostgresUserRepository = (
|
||||
});
|
||||
return rows.map(mapIcon);
|
||||
},
|
||||
async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) {
|
||||
async addIconForWindow(userId, picture, imageServer, now, maxActive) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const users = await tx.$queryRaw<
|
||||
Array<{ createdAt: Date; iconUpdatedAt: Date | null; iconRevision: Date | null }>
|
||||
Array<{ createdAt: Date; iconRevision: Date | null }>
|
||||
>(GatewayPrisma.sql`
|
||||
SELECT "created_at" AS "createdAt", "icon_updated_at" AS "iconUpdatedAt",
|
||||
"icon_revision" AS "iconRevision"
|
||||
SELECT "created_at" AS "createdAt", "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(
|
||||
|
||||
@@ -63,7 +63,7 @@ export interface SpecialAccountAccessGrantRecord {
|
||||
}
|
||||
|
||||
export type AddUserIconResult =
|
||||
{ ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' };
|
||||
{ ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'LIMIT' | 'NOT_FOUND' };
|
||||
|
||||
export type RetireUserIconResult =
|
||||
| { ok: true; icon: UserIconRecord; revision: string; preferredChanged: boolean }
|
||||
@@ -233,7 +233,8 @@ export interface UserRepository {
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean,
|
||||
allowCutoffEquality?: boolean
|
||||
allowCutoffEquality?: boolean,
|
||||
enforceCooldown?: boolean
|
||||
): Promise<string | null>;
|
||||
listIcons(userId: string, includeRetired?: boolean): Promise<UserIconRecord[]>;
|
||||
addIconForWindow(
|
||||
@@ -241,7 +242,6 @@ export interface UserRepository {
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
now: Date,
|
||||
uploadCutoff: Date,
|
||||
maxActive: number
|
||||
): Promise<AddUserIconResult>;
|
||||
setPreferredIcon(userId: string, iconId: string, now: Date): Promise<string | null>;
|
||||
|
||||
Reference in New Issue
Block a user