feat: synchronize account icons across game profiles
This commit is contained in:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user