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 });
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user