feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
@@ -0,0 +1,73 @@
export interface AccountIconProjection {
revision: string;
picture: string;
imageServer: number;
}
export const isCanonicalIsoTimestamp = (value: string): boolean => {
const parsed = new Date(value);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
};
export const parseAccountIconProjection = (value: unknown): AccountIconProjection => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Account icon projection must be an object.');
}
const record = value as Record<string, unknown>;
if (
Object.keys(record).length !== 3 ||
!Object.hasOwn(record, 'revision') ||
!Object.hasOwn(record, 'picture') ||
!Object.hasOwn(record, 'imageServer') ||
typeof record.revision !== 'string' ||
!isCanonicalIsoTimestamp(record.revision) ||
typeof record.picture !== 'string' ||
record.picture.length === 0 ||
typeof record.imageServer !== 'number' ||
!Number.isSafeInteger(record.imageServer) ||
record.imageServer < 0
) {
throw new Error('Account icon projection is invalid.');
}
return {
revision: record.revision,
picture: record.picture,
imageServer: record.imageServer,
};
};
const canonicalRevision = (value: string | undefined): string | null =>
value && isCanonicalIsoTimestamp(value) ? value : null;
export const resolveAccountIconProjection = (input: {
createdAt: string;
picture: string;
imageServer: number;
iconUpdatedAt?: string;
iconRevision?: string;
profileIconResetAt?: string;
}): AccountIconProjection => {
const iconRevision =
canonicalRevision(input.iconRevision) ??
canonicalRevision(input.iconUpdatedAt) ??
canonicalRevision(input.createdAt);
if (!iconRevision) {
throw new Error('User account icon revision is invalid.');
}
const resetRevision = canonicalRevision(input.profileIconResetAt);
if (resetRevision && resetRevision >= iconRevision) {
return {
revision: resetRevision,
picture: 'default.jpg',
imageServer: 0,
};
}
if (!input.picture || !Number.isSafeInteger(input.imageServer) || input.imageServer < 0) {
throw new Error('User account icon is invalid.');
}
return {
revision: iconRevision,
picture: input.picture,
imageServer: input.imageServer,
};
};
+8 -1
View File
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { isCanonicalIsoTimestamp } from './accountIconProjection.js';
export interface UserSanctions {
bannedUntil?: string;
mutedUntil?: string;
@@ -7,7 +9,6 @@ export interface UserSanctions {
warningCount?: number;
flags?: string[];
notes?: string;
profileIconResetAt?: string;
serverRestrictions?: Record<string, UserServerRestriction>;
legacyPenalty?: Record<string, unknown>;
}
@@ -26,6 +27,8 @@ export interface GatewayUserInfo {
roles: string[];
picture?: string;
imageServer?: number;
iconUpdatedAt?: string;
profileIconResetAt?: string;
canUseGeneralPicture?: boolean;
createdAt?: string;
legacyMemberNo?: number;
@@ -90,6 +93,10 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
!Array.isArray(user.roles) ||
(user.picture !== undefined && typeof user.picture !== 'string') ||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
(user.iconUpdatedAt !== undefined &&
(typeof user.iconUpdatedAt !== 'string' || !isCanonicalIsoTimestamp(user.iconUpdatedAt))) ||
(user.profileIconResetAt !== undefined &&
(typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) ||
(user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') ||
(user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
) {