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,
|
||||
|
||||
Reference in New Issue
Block a user