계정 아이콘 등록과 목록 내리기 제한을 조정한다

This commit is contained in:
2026-09-24 15:46:11 +00:00
parent 4bf812b601
commit 8823434610
9 changed files with 138 additions and 153 deletions
+8 -41
View File
@@ -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(
+3 -3
View File
@@ -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>;
@@ -156,7 +156,7 @@ integration('account icon daily PostgreSQL CAS', () => {
});
});
it('serializes the five-slot library and preserves retired rows', async () => {
it('serializes the five-slot library without an upload cooldown 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 } });
@@ -166,35 +166,29 @@ integration('account icon daily PostgreSQL CAS', () => {
});
for (let index = 0; index < 5; index += 1) {
const now = new Date(start.getTime() + index * 86_400_000);
const now = new Date(start.getTime() + index);
await expect(
users.addIconForWindow(
userId,
`postgres-library-${index}.png`,
1,
now,
new Date(now.getTime() - 86_400_000),
5
)
users.addIconForWindow(userId, `postgres-library-${index}.png`, 1, now, 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
)
users.addIconForWindow(userId, 'postgres-library-sixth.png', 1, new Date(start.getTime() + 5), 5)
).resolves.toEqual({ ok: false, reason: 'LIMIT' });
const icons = await users.listIcons(userId);
const retiredAt = new Date(start.getTime() + 6 * 86_400_000);
const retiredAt = new Date(start.getTime() + 6);
await expect(
users.retireIconForWindow(userId, icons[0]!.id, retiredAt, new Date(retiredAt.getTime() - 7 * 86_400_000))
users.retireIconForWindow(userId, icons[0]!.id, retiredAt, new Date(retiredAt.getTime() - 86_400_000))
).resolves.toMatchObject({ ok: true });
await expect(users.listIcons(userId)).resolves.toHaveLength(4);
const tooSoon = new Date(retiredAt.getTime() + 86_400_000 - 1);
await expect(
users.retireIconForWindow(userId, icons[1]!.id, tooSoon, new Date(tooSoon.getTime() - 86_400_000))
).resolves.toEqual({ ok: false, reason: 'COOLDOWN' });
const allowedAt = new Date(retiredAt.getTime() + 86_400_000);
await expect(
users.retireIconForWindow(userId, icons[1]!.id, allowedAt, new Date(allowedAt.getTime() - 86_400_000))
).resolves.toMatchObject({ ok: true });
await expect(users.listIcons(userId)).resolves.toHaveLength(3);
await expect(users.listIcons(userId, true)).resolves.toContainEqual(
expect.objectContaining({ picture: 'postgres-library-0.png', retiredAt: retiredAt.toISOString() })
);
+61 -21
View File
@@ -1434,8 +1434,9 @@ describe('account self service', () => {
expect(userIconUpload.upload).toHaveBeenCalledWith(
expect.objectContaining({ contentType: 'image/png', body: png })
);
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).resolves.toMatchObject({
ok: true,
iconUrl: null,
});
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
@@ -1473,7 +1474,7 @@ describe('account self service', () => {
}
});
it('atomically allows only one icon change per KST day and removes the losing file', async () => {
it('accepts concurrent uploads until the five-icon limit is reached', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try {
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
@@ -1501,14 +1502,64 @@ describe('account self service', () => {
)
);
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(await users.listIcons(user.id)).toHaveLength(1);
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(2);
expect(await users.listIcons(user.id)).toHaveLength(2);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('limits only owned library retirement to one icon per rolling 24 hours', async () => {
const { caller, users, sessions } = buildCaller();
const now = new Date('2026-08-01T00:00:00.000Z');
const png = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#556677' },
})
.png()
.toBuffer();
const imageData = `data:image/png;base64,${png.toString('base64')}`;
try {
vi.useFakeTimers();
vi.setSystemTime(now);
const owner = await users.createUser({ username: 'icon-retirement-owner', password: 'password' });
const other = await users.createUser({ username: 'icon-retirement-other', password: 'password' });
const ownerSession = await sessions.createSession(owner);
const otherSession = await sessions.createSession(other);
const first = await caller.account.changeIcon({ sessionToken: ownerSession.sessionToken, imageData });
const second = await caller.account.changeIcon({ sessionToken: ownerSession.sessionToken, imageData });
await expect(
caller.account.retireIcon({
sessionToken: otherSession.sessionToken,
iconId: first.icon.id,
})
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await caller.account.retireIcon({ sessionToken: ownerSession.sessionToken, iconId: first.icon.id });
expect(await caller.account.get({ sessionToken: ownerSession.sessionToken })).toMatchObject({
nextUploadAt: null,
nextRetireAt: new Date(now.getTime() + 86_400_000).toISOString(),
});
vi.setSystemTime(new Date(now.getTime() + 86_400_000 - 1));
const nextOwnerSession = await sessions.createSession(owner);
await expect(
caller.account.retireIcon({
sessionToken: nextOwnerSession.sessionToken,
iconId: second.icon.id,
})
).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
vi.setSystemTime(new Date(now.getTime() + 86_400_000));
await expect(
caller.account.retireIcon({
sessionToken: nextOwnerSession.sessionToken,
iconId: second.icon.id,
})
).resolves.toMatchObject({ ok: true });
} finally {
vi.useRealTimers();
}
});
it('flushes an account icon deletion with selectable running profiles', async () => {
const { caller, users, sessions, flushPublisher } = buildCaller();
const user = await users.createUser({
@@ -1530,7 +1581,7 @@ describe('account self service', () => {
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
});
it('uses a rolling 24-hour upload window and preserves delete-to-upload behavior', async () => {
it('allows a default-icon reset and another upload within the same day', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
const png = await sharp({
create: {
@@ -1553,22 +1604,11 @@ describe('account self service', () => {
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-31T00:00:00.000Z'));
const session = await sessions.createSession(user);
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
});
vi.setSystemTime(new Date('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 deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
expect(deleted.revision).toBe('2026-07-31T14:59:59.001Z');
const changed = await caller.account.changeIcon({
sessionToken: nextSession.sessionToken,
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
});
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
+14 -49
View File
@@ -5,33 +5,15 @@ import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository
const DAY_MS = 24 * 60 * 60 * 1000;
describe('user icon library', () => {
it('keeps five immutable active icons and enforces the rolling upload window', async () => {
it('allows uploads without a cooldown while keeping five immutable active icons', 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
);
const now = new Date(start.getTime() + index);
const stored = await users.addIconForWindow(user.id, `immutable-${index}.png`, 1, now, 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);
@@ -42,40 +24,19 @@ describe('user icon library', () => {
'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
);
const overLimit = await users.addIconForWindow(user.id, 'sixth.png', 1, new Date(start.getTime() + 5), 5);
expect(overLimit).toEqual({ ok: false, reason: 'LIMIT' });
});
it('retires without deleting the durable record and allows retirement only every seven days', async () => {
it('retires without deleting the durable record and allows one retirement per rolling 24 hours', 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
);
const first = await users.addIconForWindow(user.id, 'hall-of-fame.png', 1, firstAt, 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
);
const secondAt = new Date(firstAt.getTime() + 1);
const second = await users.addIconForWindow(user.id, 'next.png', 1, secondAt, 5);
expect(second.ok).toBe(true);
if (!second.ok) return;
@@ -83,7 +44,7 @@ describe('user icon library', () => {
user.id,
first.icon.id,
secondAt,
new Date(secondAt.getTime() - 7 * DAY_MS)
new Date(secondAt.getTime() - DAY_MS)
);
expect(retired.ok).toBe(true);
expect(await users.listIcons(user.id)).toHaveLength(1);
@@ -94,9 +55,13 @@ describe('user icon library', () => {
const blocked = await users.retireIconForWindow(
user.id,
second.icon.id,
new Date(secondAt.getTime() + 7 * DAY_MS - 1),
new Date(secondAt.getTime() + DAY_MS - 1),
new Date(secondAt.getTime() - 1)
);
expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' });
const allowedAt = new Date(secondAt.getTime() + DAY_MS);
await expect(
users.retireIconForWindow(user.id, second.icon.id, allowedAt, new Date(allowedAt.getTime() - DAY_MS))
).resolves.toMatchObject({ ok: true });
});
});
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
@@ -263,11 +264,34 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
};
};
test('chooses a preferred library icon and retires an icon only after confirmation', async ({ page }) => {
test('chooses a preferred library icon and retires an icon only after confirmation', async ({ page }, testInfo) => {
const fixture = await installFixture(page);
await page.goto('account');
await expect(page.locator('.account-icon-card')).toHaveCount(2);
await expect(page.getByText('2 / 5개')).toBeVisible();
await expect(page.getByText('목록에서 내리기는 24시간에 1개')).toBeVisible();
await expect(page.getByText('업로드 횟수 제한 없음')).toHaveCount(0);
for (const [name, size] of [
['desktop', { width: 1280, height: 900 }],
['mobile', { width: 390, height: 844 }],
] as const) {
await page.setViewportSize(size);
const policy = page.locator('.icon-policy');
await expect(policy).toBeVisible();
const measurement = await policy.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
};
});
await page.screenshot({ path: testInfo.outputPath(`account-icon-policy-${name}.png`) });
await writeFile(testInfo.outputPath(`account-icon-policy-${name}.json`), JSON.stringify(measurement, null, 2));
}
await page.getByRole('button', { name: '대표로 설정' }).click();
await expect.poll(fixture.preferredIconCount).toBe(1);
@@ -805,8 +805,8 @@ onBeforeUnmount(() => {
<span v-if="account.icons.length === 0">등록한 전용 아이콘이 없습니다.</span>
</div>
<p class="icon-policy">
{{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 업로드는 24시간에 1회 ·
목록에서 내리기는 7일에 1회
{{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 목록에서 내리기는 24시간에
1개
</p>
</td>
</tr>