feat: synchronize account icons across game profiles
This commit is contained in:
@@ -10,6 +10,7 @@ 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';
|
||||
import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const MAX_ICON_BYTES = 50 * 1024;
|
||||
@@ -37,13 +38,15 @@ const decodeImage = (input: string): Buffer => {
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const sameUtcDate = (left: Date, right: Date): boolean =>
|
||||
left.getUTCFullYear() === right.getUTCFullYear() &&
|
||||
left.getUTCMonth() === right.getUTCMonth() &&
|
||||
left.getUTCDate() === right.getUTCDate();
|
||||
const SEOUL_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
export const kstDayStart = (value: Date): Date => {
|
||||
const shifted = new Date(value.getTime() + SEOUL_OFFSET_MS);
|
||||
return new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - SEOUL_OFFSET_MS);
|
||||
};
|
||||
|
||||
const assertIconChangeAvailable = (user: UserRecord, now: Date): void => {
|
||||
if (user.iconUpdatedAt && sameUtcDate(new Date(user.iconUpdatedAt), now)) {
|
||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt && new Date(user.iconUpdatedAt) >= kstDayStart(now)) {
|
||||
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘은 하루에 한 번만 변경할 수 있습니다.' });
|
||||
}
|
||||
};
|
||||
@@ -59,8 +62,34 @@ const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
|
||||
};
|
||||
|
||||
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)}`;
|
||||
const icon = resolveEffectiveAccountIcon(user);
|
||||
if (icon.imageServer !== 1 || icon.picture === 'default.jpg') return null;
|
||||
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
|
||||
};
|
||||
|
||||
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
|
||||
(await ctx.profileStatus.listLobbyProfiles({ userId }))
|
||||
.filter(
|
||||
(profile) => (profile.status === 'RUNNING' || profile.status === 'PREOPEN') && profile.runtime.apiRunning
|
||||
)
|
||||
.map(({ profileName, profile, apiPort, korName }) => ({
|
||||
profileName,
|
||||
profile,
|
||||
apiPort,
|
||||
korName,
|
||||
}));
|
||||
|
||||
const publishIconFlush = async (
|
||||
ctx: GatewayApiContext,
|
||||
userId: string,
|
||||
reason: 'account-icon-changed' | 'account-icon-deleted'
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await ctx.flushPublisher.publishUserFlush(userId, reason);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const accountRouter = router({
|
||||
@@ -136,6 +165,7 @@ export const accountRouter = router({
|
||||
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();
|
||||
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) {
|
||||
@@ -154,17 +184,56 @@ 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' });
|
||||
await ctx.users.updateIcon(user.id, filename, 1, now);
|
||||
let revision: string | null;
|
||||
try {
|
||||
revision = await ctx.users.updateIconForDay(user.id, filename, 1, now, kstDayStart(now), true);
|
||||
} catch (error) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
throw error;
|
||||
}
|
||||
if (!revision) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
|
||||
revision,
|
||||
profiles,
|
||||
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);
|
||||
await ctx.users.updateIcon(user.id, 'default.jpg', 0, now);
|
||||
return { ok: true, iconUrl: null };
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, kstDayStart(now), false);
|
||||
if (!revision) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '아이콘은 하루에 한 번만 변경할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: null,
|
||||
revision,
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
}),
|
||||
prepareIconSync: procedure.input(z.object({ sessionToken: zSessionToken })).query(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
return {
|
||||
iconUrl: buildIconUrl(ctx, user),
|
||||
projection: resolveEffectiveAccountIcon(user),
|
||||
profiles: await listIconSyncProfiles(ctx, user.id),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -260,16 +260,17 @@ const zServerRestriction = z.object({
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
const zSanctionsPatch = z.object({
|
||||
bannedUntil: z.string().datetime().nullable().optional(),
|
||||
mutedUntil: z.string().datetime().nullable().optional(),
|
||||
suspendedUntil: z.string().datetime().nullable().optional(),
|
||||
warningCount: z.number().int().min(0).nullable().optional(),
|
||||
flags: z.array(z.string().min(1)).nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
profileIconResetAt: z.string().datetime().nullable().optional(),
|
||||
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
||||
});
|
||||
const zSanctionsPatch = z
|
||||
.object({
|
||||
bannedUntil: z.string().datetime().nullable().optional(),
|
||||
mutedUntil: z.string().datetime().nullable().optional(),
|
||||
suspendedUntil: z.string().datetime().nullable().optional(),
|
||||
warningCount: z.number().int().min(0).nullable().optional(),
|
||||
flags: z.array(z.string().min(1)).nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zLocalAccountInput = z.object({
|
||||
username: z.string().min(2).max(32),
|
||||
@@ -332,8 +333,6 @@ const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): Use
|
||||
applyField('warningCount', patch.warningCount);
|
||||
applyField('flags', patch.flags);
|
||||
applyField('notes', patch.notes);
|
||||
applyField('profileIconResetAt', patch.profileIconResetAt);
|
||||
|
||||
if (patch.serverRestrictions !== undefined) {
|
||||
if (patch.serverRestrictions === null) {
|
||||
delete next.serverRestrictions;
|
||||
@@ -495,6 +494,7 @@ export const adminRouter = router({
|
||||
oauthType: user.oauthType,
|
||||
oauthId: user.oauthId,
|
||||
email: user.email,
|
||||
profileIconResetAt: user.profileIconResetAt,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}),
|
||||
@@ -611,12 +611,22 @@ export const adminRouter = router({
|
||||
message: 'User not found.',
|
||||
});
|
||||
}
|
||||
const next = applySanctionsPatch(user.sanctions, {
|
||||
profileIconResetAt: new Date().toISOString(),
|
||||
});
|
||||
await ctx.users.updateSanctions(input.userId, next);
|
||||
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset');
|
||||
return { profileIconResetAt: next.profileIconResetAt };
|
||||
const profileIconResetAt = await ctx.users.resetProfileIcon(input.userId, new Date());
|
||||
if (!profileIconResetAt) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'User not found.',
|
||||
});
|
||||
}
|
||||
let flushPublished = true;
|
||||
try {
|
||||
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-profile-icon-reset', {
|
||||
iconRevision: profileIconResetAt,
|
||||
});
|
||||
} catch {
|
||||
flushPublished = false;
|
||||
}
|
||||
return { profileIconResetAt, flushPublished };
|
||||
}),
|
||||
forceDelete: userAdminProcedure
|
||||
.input(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { isCanonicalIsoTimestamp } from '@sammo-ts/common';
|
||||
|
||||
import type { UserRepository } from './userRepository.js';
|
||||
import { resolveEffectiveAccountIcon } from './accountIconProjection.js';
|
||||
|
||||
const INTERNAL_TOKEN_HEADER = 'x-sammo-internal-token';
|
||||
const INTERNAL_TOKEN_CONTEXT = 'sammo:account-icon-source:v1';
|
||||
|
||||
const deriveInternalToken = (secret: string): string =>
|
||||
createHmac('sha256', secret).update(INTERNAL_TOKEN_CONTEXT).digest('hex');
|
||||
|
||||
const matchesSecret = (provided: string | string[] | undefined, expected: string): boolean => {
|
||||
const candidate = Array.isArray(provided) ? provided[0] : provided;
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const candidateBuffer = Buffer.from(candidate);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
|
||||
};
|
||||
|
||||
const parseUserIds = (body: unknown): string[] | null => {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return null;
|
||||
}
|
||||
const record = body as Record<string, unknown>;
|
||||
if (Object.keys(record).length !== 1 || !Array.isArray(record.userIds)) {
|
||||
return null;
|
||||
}
|
||||
if (record.userIds.length === 0 || record.userIds.length > 500) {
|
||||
return null;
|
||||
}
|
||||
const userIds = record.userIds.filter(
|
||||
(value): value is string => typeof value === 'string' && value.length > 0 && value.length <= 128
|
||||
);
|
||||
return userIds.length === record.userIds.length && new Set(userIds).size === userIds.length ? userIds : null;
|
||||
};
|
||||
|
||||
export const registerAccountIconInternalRoute = (
|
||||
app: FastifyInstance,
|
||||
options: {
|
||||
users: UserRepository;
|
||||
secret: string;
|
||||
}
|
||||
): void => {
|
||||
app.get<{ Params: { userId: string } }>('/internal/account-icons/:userId', async (request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const user = await options.users.findById(request.params.userId);
|
||||
if (!user) {
|
||||
await reply.status(404).send({ ok: false, error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
await reply.send(resolveEffectiveAccountIcon(user));
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>('/internal/account-icon-resets', async (request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
if (!matchesSecret(request.headers[INTERNAL_TOKEN_HEADER], deriveInternalToken(options.secret))) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
const userIds = parseUserIds(request.body);
|
||||
if (!userIds) {
|
||||
await reply.status(400).send({ ok: false, error: 'invalid_request' });
|
||||
return;
|
||||
}
|
||||
const users = await options.users.findByIds(userIds);
|
||||
const byId = new Map(users.map((user) => [user.id, user]));
|
||||
const resets = userIds.flatMap((userId) => {
|
||||
const user = byId.get(userId);
|
||||
const resetRevision = user?.profileIconResetAt;
|
||||
if (!user || !resetRevision || !isCanonicalIsoTimestamp(resetRevision)) {
|
||||
return [];
|
||||
}
|
||||
return [{ userId, resetRevision, current: resolveEffectiveAccountIcon(user) }];
|
||||
});
|
||||
await reply.send({ resets });
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { resolveAccountIconProjection, type AccountIconProjection } from '@sammo-ts/common';
|
||||
|
||||
import type { UserRecord } from './userRepository.js';
|
||||
|
||||
export const resolveEffectiveAccountIcon = (
|
||||
user: Pick<
|
||||
UserRecord,
|
||||
'createdAt' | 'picture' | 'imageServer' | 'iconUpdatedAt' | 'iconRevision' | 'profileIconResetAt'
|
||||
>
|
||||
): AccountIconProjection =>
|
||||
resolveAccountIconProjection({
|
||||
createdAt: user.createdAt,
|
||||
picture: user.picture,
|
||||
imageServer: user.imageServer,
|
||||
...(user.iconUpdatedAt ? { iconUpdatedAt: user.iconUpdatedAt } : {}),
|
||||
...(user.iconRevision ? { iconRevision: user.iconRevision } : {}),
|
||||
...(user.profileIconResetAt ? { profileIconResetAt: user.profileIconResetAt } : {}),
|
||||
});
|
||||
@@ -2,10 +2,11 @@ export interface GatewayUserFlushEvent {
|
||||
userId: string;
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
iconRevision?: string;
|
||||
}
|
||||
|
||||
export interface GatewayFlushPublisher {
|
||||
publishUserFlush(userId: string, reason?: string): Promise<void>;
|
||||
publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
@@ -17,11 +18,12 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
async publishUserFlush(userId: string, reason?: string): Promise<void> {
|
||||
async publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise<void> {
|
||||
const payload: GatewayUserFlushEvent = {
|
||||
userId,
|
||||
flushedAt: new Date().toISOString(),
|
||||
reason,
|
||||
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
|
||||
};
|
||||
await this.client.publish(this.channel, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async findByIds(ids: string[]): Promise<UserRecord[]> {
|
||||
const requested = new Set(ids);
|
||||
return [...usersByName.values()].filter((user) => requested.has(user.id));
|
||||
},
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
return usersByName.get(username) ?? null;
|
||||
},
|
||||
@@ -149,11 +153,62 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
user.picture = picture;
|
||||
user.imageServer = imageServer;
|
||||
user.iconUpdatedAt = updatedAt.toISOString();
|
||||
user.iconRevision = updatedAt.toISOString();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async updateIconForDay(
|
||||
userId: string,
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
): 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;
|
||||
}
|
||||
const previousRevision = Math.max(
|
||||
new Date(user.createdAt).getTime(),
|
||||
user.iconUpdatedAt ? new Date(user.iconUpdatedAt).getTime() : 0,
|
||||
user.iconRevision ? new Date(user.iconRevision).getTime() : 0,
|
||||
user.profileIconResetAt ? new Date(user.profileIconResetAt).getTime() : 0
|
||||
);
|
||||
const revision = new Date(Math.max(updatedAt.getTime(), previousRevision + 1)).toISOString();
|
||||
user.picture = picture;
|
||||
user.imageServer = imageServer;
|
||||
if (consumeDailyQuota) {
|
||||
user.iconUpdatedAt = updatedAt.toISOString();
|
||||
}
|
||||
user.iconRevision = revision;
|
||||
return revision;
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
continue;
|
||||
}
|
||||
const previousRevision = Math.max(
|
||||
new Date(user.createdAt).getTime(),
|
||||
user.iconUpdatedAt ? new Date(user.iconUpdatedAt).getTime() : 0,
|
||||
user.iconRevision ? new Date(user.iconRevision).getTime() : 0,
|
||||
user.profileIconResetAt ? new Date(user.profileIconResetAt).getTime() : 0
|
||||
);
|
||||
const revision = new Date(Math.max(requestedAt.getTime(), previousRevision + 1)).toISOString();
|
||||
user.iconRevision = revision;
|
||||
user.profileIconResetAt = revision;
|
||||
return revision;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
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';
|
||||
@@ -44,6 +44,8 @@ const mapUser = (row: {
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt: Date | null;
|
||||
iconRevision: Date | null;
|
||||
profileIconResetAt: Date | null;
|
||||
thirdPartyUse: boolean;
|
||||
termsAcceptedAt: Date | null;
|
||||
privacyAcceptedAt: Date | null;
|
||||
@@ -65,6 +67,8 @@ const mapUser = (row: {
|
||||
picture: row.picture,
|
||||
imageServer: row.imageServer,
|
||||
iconUpdatedAt: row.iconUpdatedAt?.toISOString(),
|
||||
iconRevision: row.iconRevision?.toISOString(),
|
||||
profileIconResetAt: row.profileIconResetAt?.toISOString(),
|
||||
thirdPartyUse: row.thirdPartyUse,
|
||||
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
|
||||
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
|
||||
@@ -91,6 +95,15 @@ export const createPostgresUserRepository = (
|
||||
});
|
||||
return row ? mapUser(row) : null;
|
||||
},
|
||||
async findByIds(ids: string[]): Promise<UserRecord[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rows = await prisma.appUser.findMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
return rows.map(mapUser);
|
||||
},
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
const row = await prisma.appUser.findUnique({
|
||||
where: {
|
||||
@@ -219,9 +232,81 @@ export const createPostgresUserRepository = (
|
||||
picture,
|
||||
imageServer,
|
||||
iconUpdatedAt: updatedAt,
|
||||
iconRevision: updatedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
async updateIconForDay(
|
||||
userId: string,
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
): Promise<string | null> {
|
||||
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
||||
UPDATE "app_user"
|
||||
SET
|
||||
"picture" = ${picture},
|
||||
"image_server" = ${imageServer},
|
||||
"icon_updated_at" = CASE
|
||||
WHEN ${consumeDailyQuota} THEN ${updatedAt}
|
||||
ELSE "icon_updated_at"
|
||||
END,
|
||||
"icon_revision" = GREATEST(
|
||||
${updatedAt},
|
||||
COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond'
|
||||
)
|
||||
WHERE "id" = ${userId}
|
||||
AND (
|
||||
"picture" = 'default.jpg'
|
||||
OR "icon_updated_at" IS NULL
|
||||
OR "icon_updated_at" < ${dayStart}
|
||||
)
|
||||
RETURNING "icon_revision" AS "iconRevision"
|
||||
`);
|
||||
return rows[0]?.iconRevision.toISOString() ?? null;
|
||||
},
|
||||
async resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{
|
||||
iconRevision: Date | null;
|
||||
iconUpdatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
profileIconResetAt: Date | null;
|
||||
}>
|
||||
>(GatewayPrisma.sql`
|
||||
SELECT
|
||||
"icon_revision" AS "iconRevision",
|
||||
"icon_updated_at" AS "iconUpdatedAt",
|
||||
"created_at" AS "createdAt",
|
||||
"profile_icon_reset_at" AS "profileIconResetAt"
|
||||
FROM "app_user"
|
||||
WHERE "id" = ${userId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const previous = Math.max(
|
||||
row.createdAt.getTime(),
|
||||
row.iconUpdatedAt?.getTime() ?? 0,
|
||||
row.iconRevision?.getTime() ?? 0,
|
||||
row.profileIconResetAt?.getTime() ?? 0
|
||||
);
|
||||
const revision = new Date(Math.max(requestedAt.getTime(), previous + 1));
|
||||
await tx.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
iconRevision: revision,
|
||||
profileIconResetAt: revision,
|
||||
},
|
||||
});
|
||||
return revision.toISOString();
|
||||
});
|
||||
},
|
||||
async setThirdPartyUse(userId: string, allowed: boolean): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UserRecord {
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt?: string;
|
||||
iconRevision?: string;
|
||||
profileIconResetAt?: string;
|
||||
thirdPartyUse: boolean;
|
||||
termsAcceptedAt?: string;
|
||||
privacyAcceptedAt?: string;
|
||||
@@ -42,7 +44,6 @@ export interface UserSanctions {
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||
legacyPenalty?: Record<string, unknown>;
|
||||
}
|
||||
@@ -82,6 +83,7 @@ export interface CreateUserInput {
|
||||
|
||||
export interface UserRepository {
|
||||
findById(id: string): Promise<UserRecord | null>;
|
||||
findByIds(ids: string[]): Promise<UserRecord[]>;
|
||||
findByUsername(username: string): Promise<UserRecord | null>;
|
||||
findByDisplayName(displayName: string): Promise<UserRecord | null>;
|
||||
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
|
||||
@@ -102,6 +104,15 @@ export interface UserRepository {
|
||||
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>;
|
||||
updateIconForDay(
|
||||
userId: string,
|
||||
picture: string,
|
||||
imageServer: number,
|
||||
updatedAt: Date,
|
||||
dayStart: Date,
|
||||
consumeDailyQuota: boolean
|
||||
): Promise<string | null>;
|
||||
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
|
||||
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
|
||||
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
|
||||
deleteUser(userId: string): Promise<void>;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface GatewayApiConfig {
|
||||
sessionTtlSeconds: number;
|
||||
gameSessionTtlSeconds: number;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
oauthSessionTtlSeconds: number;
|
||||
kakaoRestKey: string;
|
||||
kakaoAdminKey?: string;
|
||||
@@ -36,6 +37,7 @@ export interface GatewayOrchestratorConfig {
|
||||
dbSchema: string;
|
||||
redisKeyPrefix: string;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
orchestratorReconcileIntervalMs: number;
|
||||
orchestratorScheduleIntervalMs: number;
|
||||
orchestratorBuildIntervalMs: number;
|
||||
@@ -64,9 +66,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
}
|
||||
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
|
||||
return {
|
||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||
port: parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'),
|
||||
port,
|
||||
trpcPath: env.GATEWAY_TRPC_PATH ?? env.TRPC_PATH ?? '/trpc',
|
||||
dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA),
|
||||
redisKeyPrefix,
|
||||
@@ -78,6 +81,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
'GAME_SESSION_TTL_SECONDS'
|
||||
),
|
||||
gameTokenSecret: secret,
|
||||
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? `http://127.0.0.1:${port}`,
|
||||
oauthSessionTtlSeconds: parseNumberWithFallback(
|
||||
env.OAUTH_SESSION_TTL_SECONDS,
|
||||
10 * 60,
|
||||
@@ -133,10 +137,12 @@ export const resolveGatewayOrchestratorConfigFromEnv = (
|
||||
throw new Error('GAME_TOKEN_SECRET is required for game server processes.');
|
||||
}
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
const gatewayPort = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
|
||||
return {
|
||||
dbSchema: resolveSchemaName(env.GATEWAY_DB_SCHEMA),
|
||||
redisKeyPrefix,
|
||||
gameTokenSecret: secret,
|
||||
gatewayInternalApiUrl: env.GATEWAY_INTERNAL_API_URL ?? `http://127.0.0.1:${gatewayPort}`,
|
||||
orchestratorReconcileIntervalMs: parseNumberWithFallback(
|
||||
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
|
||||
15000,
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface GatewayProcessConfig {
|
||||
workspaceRoot: string;
|
||||
redisKeyPrefix: string;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
baseEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -350,6 +351,7 @@ export const buildProcessDefinitions = (
|
||||
GAME_UPLOAD_PATH: `/${profile.profile}/api/uploads`,
|
||||
GATEWAY_REDIS_PREFIX: config.redisKeyPrefix,
|
||||
GAME_TOKEN_SECRET: config.gameTokenSecret,
|
||||
GATEWAY_INTERNAL_API_URL: config.gatewayInternalApiUrl,
|
||||
};
|
||||
const daemonEnv = {
|
||||
...baseEnv,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const createGatewayOrchestrator = (
|
||||
workspaceRoot,
|
||||
redisKeyPrefix: config.redisKeyPrefix,
|
||||
gameTokenSecret: config.gameTokenSecret,
|
||||
gatewayInternalApiUrl: config.gatewayInternalApiUrl,
|
||||
baseEnv,
|
||||
},
|
||||
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { adminRouter } from './adminRouter.js';
|
||||
import { accountRouter } from './account/router.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
||||
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
|
||||
|
||||
const zUsername = z
|
||||
.string()
|
||||
@@ -655,6 +656,7 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
const now = new Date();
|
||||
const accountIcon = resolveEffectiveAccountIcon(user);
|
||||
const payload = {
|
||||
version: 1,
|
||||
profile: gameSession.profile,
|
||||
@@ -665,8 +667,10 @@ export const appRouter = router({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
picture: user.picture,
|
||||
imageServer: user.imageServer,
|
||||
picture: accountIcon.picture,
|
||||
imageServer: accountIcon.imageServer,
|
||||
iconUpdatedAt: accountIcon.revision,
|
||||
...(user.profileIconResetAt ? { profileIconResetAt: user.profileIconResetAt } : {}),
|
||||
canUseGeneralPicture: user.legacyGrade === undefined || user.legacyGrade >= 1,
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
||||
import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
|
||||
export const createGatewayApiServer = async () => {
|
||||
const config = resolveGatewayApiConfigFromEnv();
|
||||
@@ -78,6 +79,10 @@ export const createGatewayApiServer = async () => {
|
||||
prefix: '/user-icons/',
|
||||
decorateReply: false,
|
||||
});
|
||||
registerAccountIconInternalRoute(app, {
|
||||
users,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: config.trpcPath,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
|
||||
|
||||
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const userId = 'e72680fd-0aed-4fdd-80d9-24f78d55676c';
|
||||
|
||||
const assertDedicatedSchema = (): void => {
|
||||
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
|
||||
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
|
||||
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
|
||||
}
|
||||
};
|
||||
|
||||
integration('account icon daily PostgreSQL CAS', () => {
|
||||
let db: GatewayPrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let initialized = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedSchema();
|
||||
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
initialized = true;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
await db.appUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
loginId: 'icon-cas-integration',
|
||||
displayName: '아이콘 CAS 통합',
|
||||
passwordHash: 'not-used',
|
||||
passwordSalt: 'not-used',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
picture: 'old.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: new Date('2026-07-30T09:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (initialized) {
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('allows exactly one concurrent update in the same KST day', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const updatedAt = new Date('2026-07-31T15:00:00.000Z');
|
||||
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
|
||||
const results = await Promise.all([
|
||||
users.updateIconForDay(userId, 'first.png', 1, updatedAt, kstDayStart, true),
|
||||
users.updateIconForDay(userId, 'second.png', 1, updatedAt, kstDayStart, true),
|
||||
]);
|
||||
|
||||
expect(results.filter(Boolean)).toHaveLength(1);
|
||||
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
|
||||
picture: expect.stringMatching(/^(first|second)\.png$/),
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a default icon to upload again while revisions stay strictly increasing', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const frozenNow = new Date('2026-08-01T03:00:00.000Z');
|
||||
const kstDayStart = new Date('2026-07-31T15:00:00.000Z');
|
||||
await db.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
picture: 'old.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: new Date('2026-07-30T14:59:59.000Z'),
|
||||
iconRevision: new Date('2026-08-01T03:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const deletedRevision = await users.updateIconForDay(userId, 'default.jpg', 0, frozenNow, kstDayStart, false);
|
||||
const uploadedRevision = await users.updateIconForDay(userId, 'again.png', 1, frozenNow, kstDayStart, true);
|
||||
|
||||
expect(deletedRevision).toBe('2026-08-01T03:00:00.001Z');
|
||||
expect(uploadedRevision).toBe('2026-08-01T03:00:00.002Z');
|
||||
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
|
||||
picture: 'again.png',
|
||||
iconUpdatedAt: frozenNow,
|
||||
iconRevision: new Date('2026-08-01T03:00:00.002Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses UTC 15:00 as the KST date boundary', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
await db.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
picture: 'same-day.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: new Date('2026-07-31T14:59:59.000Z'),
|
||||
iconRevision: new Date('2026-07-31T14:59:59.000Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
users.updateIconForDay(
|
||||
userId,
|
||||
'blocked.png',
|
||||
1,
|
||||
new Date('2026-07-31T14:59:59.999Z'),
|
||||
new Date('2026-07-30T15:00:00.000Z'),
|
||||
true
|
||||
)
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
users.updateIconForDay(
|
||||
userId,
|
||||
'allowed.png',
|
||||
1,
|
||||
new Date('2026-07-31T15:00:00.000Z'),
|
||||
new Date('2026-07-31T15:00:00.000Z'),
|
||||
true
|
||||
)
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('serializes administrator reset revisions in a dedicated column', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const frozenNow = new Date('2026-08-02T00:00:00.000Z');
|
||||
await db.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
picture: 'custom.png',
|
||||
imageServer: 1,
|
||||
iconRevision: frozenNow,
|
||||
profileIconResetAt: null,
|
||||
sanctions: { warningCount: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const first = await users.resetProfileIcon(userId, frozenNow);
|
||||
const second = await users.resetProfileIcon(userId, frozenNow);
|
||||
|
||||
expect(first).toBe('2026-08-02T00:00:00.001Z');
|
||||
expect(second).toBe('2026-08-02T00:00:00.002Z');
|
||||
await expect(db.appUser.findUniqueOrThrow({ where: { id: userId } })).resolves.toMatchObject({
|
||||
profileIconResetAt: new Date('2026-08-02T00:00:00.002Z'),
|
||||
iconRevision: new Date('2026-08-02T00:00:00.002Z'),
|
||||
sanctions: { warningCount: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
|
||||
import fastify from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerAccountIconInternalRoute } from '../src/auth/accountIconInternalRoute.js';
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
|
||||
const secret = 'gateway-test-secret';
|
||||
const token = createHmac('sha256', secret).update('sammo:account-icon-source:v1').digest('hex');
|
||||
|
||||
describe('account icon internal route', () => {
|
||||
const app = fastify();
|
||||
const users = createInMemoryUserRepository();
|
||||
let userId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!app.hasRoute({ method: 'GET', url: '/internal/account-icons/:userId' })) {
|
||||
registerAccountIconInternalRoute(app, { users, secret });
|
||||
}
|
||||
const user = await users.createUser({
|
||||
username: `internal-${randomUUID()}`,
|
||||
password: 'password',
|
||||
displayName: `내부-${randomUUID()}`,
|
||||
});
|
||||
userId = user.id;
|
||||
await users.updateIcon(userId, 'latest.png', 1, new Date('2026-07-31T09:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (userId) {
|
||||
await users.deleteUser(userId);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires the purpose-derived token and exposes only the projection', async () => {
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/internal/account-icons/${userId}`,
|
||||
headers: { 'x-sammo-internal-token': secret },
|
||||
});
|
||||
expect(unauthorized.statusCode).toBe(401);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/internal/account-icons/${userId}`,
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
});
|
||||
expect(Object.keys(response.json()).sort()).toEqual(['imageServer', 'picture', 'revision']);
|
||||
});
|
||||
|
||||
it('returns 404 for a missing account without leaking account fields', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/internal/account-icons/${randomUUID()}`,
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ ok: false, error: 'not_found' });
|
||||
});
|
||||
|
||||
it('returns the durable reset marker even after a newer ordinary icon change', async () => {
|
||||
const resetRevision = await users.resetProfileIcon(userId, new Date('2099-07-31T09:00:00.001Z'));
|
||||
await users.updateIcon(userId, 'newer.png', 1, new Date('2099-07-31T09:00:00.002Z'));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/account-icon-resets',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
payload: { userIds: [userId, randomUUID()] },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.json()).toEqual({
|
||||
resets: [
|
||||
{
|
||||
userId,
|
||||
resetRevision,
|
||||
current: {
|
||||
revision: '2099-07-31T09:00:00.002Z',
|
||||
picture: 'newer.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/account-icon-resets',
|
||||
headers: { 'x-sammo-internal-token': token },
|
||||
payload: { userIds: [userId], extra: true },
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ const buildCaller = async (
|
||||
const session = await sessions.createSession({ ...admin, roles: adminRoles });
|
||||
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
||||
const flushes: Array<{ userId: string; reason?: string }> = [];
|
||||
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
||||
const profile = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
@@ -79,8 +79,12 @@ const buildCaller = async (
|
||||
users,
|
||||
sessions,
|
||||
flushPublisher: {
|
||||
publishUserFlush: async (userId, reason) => {
|
||||
flushes.push({ userId, reason });
|
||||
publishUserFlush: async (userId, reason, metadata) => {
|
||||
flushes.push({
|
||||
userId,
|
||||
reason,
|
||||
...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
gameTokenSecret: 'test-secret',
|
||||
@@ -397,4 +401,37 @@ describe('admin role non-escalation', () => {
|
||||
{ userId: target.id, reason: 'admin-server-restriction' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps profile icon reset revisions monotonic and outside generic sanction patches', async () => {
|
||||
const harness = await buildCaller(unusedCreateOperation);
|
||||
const target = await harness.users.createUser({
|
||||
username: 'icon-reset-target',
|
||||
password: 'secretpass',
|
||||
displayName: 'Icon Reset Target',
|
||||
});
|
||||
const frozenNow = new Date(target.createdAt);
|
||||
await harness.users.updateIcon(target.id, 'custom.png', 1, frozenNow);
|
||||
const first = await harness.users.resetProfileIcon(target.id, frozenNow);
|
||||
const second = await harness.users.resetProfileIcon(target.id, frozenNow);
|
||||
|
||||
expect(new Date(first!).getTime()).toBe(new Date(target.createdAt).getTime() + 1);
|
||||
expect(new Date(second!).getTime()).toBe(new Date(first!).getTime() + 1);
|
||||
await expect(
|
||||
harness.caller.admin.users.updateSanctions({
|
||||
userId: target.id,
|
||||
patch: {
|
||||
profileIconResetAt: null,
|
||||
},
|
||||
} as never)
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
|
||||
const result = await harness.caller.admin.users.resetProfileIcon({ userId: target.id });
|
||||
expect(new Date(result.profileIconResetAt).getTime()).toBeGreaterThan(new Date(second!).getTime());
|
||||
expect((await harness.users.findById(target.id))?.profileIconResetAt).toBe(result.profileIconResetAt);
|
||||
expect(harness.flushes.at(-1)).toEqual({
|
||||
userId: target.id,
|
||||
reason: 'admin-profile-icon-reset',
|
||||
iconRevision: result.profileIconResetAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -16,14 +16,25 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => {
|
||||
const buildCaller = (
|
||||
options: {
|
||||
userIconDir?: string;
|
||||
localAccountGraceDays?: number;
|
||||
flushError?: Error;
|
||||
profileListError?: Error;
|
||||
} = {}
|
||||
) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const sessions = new InMemoryGatewaySessionService({
|
||||
sessionTtlSeconds: 3600,
|
||||
gameSessionTtlSeconds: 600,
|
||||
});
|
||||
const flushPublisher = {
|
||||
publishUserFlush: async () => {},
|
||||
publishUserFlush: vi.fn(async () => {
|
||||
if (options.flushError) {
|
||||
throw options.flushError;
|
||||
}
|
||||
}),
|
||||
};
|
||||
const oauthSessions = new InMemoryOAuthSessionStore();
|
||||
const kakaoClient = {
|
||||
@@ -139,6 +150,11 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
|
||||
color: '#fff',
|
||||
}))
|
||||
);
|
||||
if (options.profileListError) {
|
||||
profileStatus.listLobbyProfiles = async () => {
|
||||
throw options.profileListError;
|
||||
};
|
||||
}
|
||||
const passwordEnvelope = createPasswordEnvelopeService();
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
const sealPassword = (password: string) => {
|
||||
@@ -187,6 +203,7 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
|
||||
oauthSessions,
|
||||
users,
|
||||
sessions,
|
||||
flushPublisher,
|
||||
sealPassword,
|
||||
setSessionHeader: (sessionToken: string) => {
|
||||
requestHeaders['x-session-token'] = sessionToken;
|
||||
@@ -576,6 +593,7 @@ describe('gateway auth flow', () => {
|
||||
roles: ['user', 'latest-role'],
|
||||
picture: 'latest-owner.webp',
|
||||
imageServer: 3,
|
||||
iconUpdatedAt: '2026-07-30T12:00:00.000Z',
|
||||
canUseGeneralPicture: false,
|
||||
});
|
||||
expect(payload?.sanctions).toMatchObject({
|
||||
@@ -668,7 +686,9 @@ describe('account self service', () => {
|
||||
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
|
||||
const { caller, users, sessions, flushPublisher } = buildCaller({
|
||||
userIconDir: iconDir,
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'icon-self',
|
||||
password: 'current-password',
|
||||
@@ -692,7 +712,9 @@ describe('account self service', () => {
|
||||
const updated = await users.findById(user.id);
|
||||
|
||||
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
|
||||
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
|
||||
expect(updated?.imageServer).toBe(1);
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
|
||||
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
|
||||
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
@@ -701,4 +723,177 @@ describe('account self service', () => {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('atomically allows only one icon change per KST day and removes the losing file', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
|
||||
const user = await users.createUser({
|
||||
username: 'icon-race',
|
||||
password: 'current-password',
|
||||
});
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 4,
|
||||
background: '#556677',
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const attempts = await Promise.allSettled(
|
||||
[1, 2].map(() =>
|
||||
caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
|
||||
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
|
||||
expect(await fs.readdir(iconDir)).toHaveLength(1);
|
||||
} finally {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('flushes an account icon deletion with selectable running profiles', async () => {
|
||||
const { caller, users, sessions, flushPublisher } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'icon-delete',
|
||||
password: 'current-password',
|
||||
});
|
||||
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-30T12:00:00.000Z'));
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
const result = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
|
||||
const updated = await users.findById(user.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
iconUrl: null,
|
||||
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
|
||||
});
|
||||
expect(updated).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
|
||||
});
|
||||
|
||||
it('uses the Asia/Seoul day boundary and preserves Ref delete-to-upload behavior', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 4,
|
||||
background: '#667788',
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
try {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-31T14:59:59.000Z'));
|
||||
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
|
||||
const user = await users.createUser({
|
||||
username: 'icon-kst',
|
||||
password: 'current-password',
|
||||
});
|
||||
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'));
|
||||
const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
|
||||
expect(deleted.revision).toBe('2026-07-31T15:00:00.000Z');
|
||||
|
||||
const changed = await caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
});
|
||||
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
|
||||
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not commit an icon when profile discovery fails before mutation', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-profile-failure-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({
|
||||
userIconDir: iconDir,
|
||||
profileListError: new Error('profile unavailable'),
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'icon-profile-failure',
|
||||
password: 'current-password',
|
||||
});
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 64, channels: 4, background: '#778899' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await expect(
|
||||
caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
})
|
||||
).rejects.toThrow('profile unavailable');
|
||||
expect(await fs.readdir(iconDir)).toEqual([]);
|
||||
expect(await users.findById(user.id)).toMatchObject({
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a recoverable success when flush publication fails after commit', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-flush-failure-'));
|
||||
try {
|
||||
const { caller, users, sessions } = buildCaller({
|
||||
userIconDir: iconDir,
|
||||
flushError: new Error('redis unavailable'),
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'icon-flush-failure',
|
||||
password: 'current-password',
|
||||
});
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 64, channels: 4, background: '#8899aa' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const changed = await caller.account.changeIcon({
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
});
|
||||
expect(changed.flushPublished).toBe(false);
|
||||
expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
|
||||
|
||||
await expect(caller.account.prepareIconSync({ sessionToken: session.sessionToken })).resolves.toMatchObject(
|
||||
{
|
||||
projection: {
|
||||
revision: changed.revision,
|
||||
imageServer: 1,
|
||||
},
|
||||
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,7 @@ const createHarness = (
|
||||
workspaceRoot: '/srv/sammo',
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
scheduleIntervalMs: 60_000,
|
||||
|
||||
@@ -102,6 +102,7 @@ describe('buildProcessDefinitions', () => {
|
||||
workspaceRoot: '/srv/sammo/main',
|
||||
redisKeyPrefix: 'sammo:gateway',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
};
|
||||
|
||||
it('runs a built profile from its commit worktree', () => {
|
||||
@@ -114,6 +115,7 @@ describe('buildProcessDefinitions', () => {
|
||||
GAME_PROFILE_NAME: 'che:2',
|
||||
GAME_TRPC_PATH: '/che/api/trpc',
|
||||
GAME_API_EVENTS_PATH: '/che/api/events',
|
||||
GATEWAY_INTERNAL_API_URL: 'http://127.0.0.1:13000',
|
||||
GAME_UPLOAD_PATH: '/che/api/uploads',
|
||||
});
|
||||
expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||
|
||||
@@ -62,6 +62,7 @@ const createHarness = (
|
||||
workspaceRoot: '/srv/sammo',
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
scheduleIntervalMs: 60_000,
|
||||
|
||||
@@ -49,9 +49,7 @@ describe('password credential compatibility', () => {
|
||||
password: 'current-password',
|
||||
});
|
||||
const userSalt = 'ref-user-salt';
|
||||
const browserHash = createHash('sha512')
|
||||
.update(`${globalSalt}current-password${globalSalt}`)
|
||||
.digest('hex');
|
||||
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
|
||||
user.passwordSalt = userSalt;
|
||||
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user