feat(gateway): upload user icons to image service

This commit is contained in:
2026-08-06 15:40:55 +00:00
parent 3be3e3b307
commit 57800d8574
9 changed files with 225 additions and 18 deletions
@@ -0,0 +1,75 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
export interface UserIconUploadResult {
picture: string;
publicUrl: string;
}
export interface UserIconUploadStore {
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>;
}
const signature = (
secret: string,
expires: string,
requestId: string,
pathname: string,
contentType: string,
body: Buffer
): string => {
const digest = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret)
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
.digest('hex');
};
export class RemoteUserIconStore implements UserIconUploadStore {
constructor(
private readonly baseUrl: string,
private readonly publicBaseUrl: string,
private readonly secret: string,
private readonly fetchImpl: typeof fetch = fetch,
private readonly now: () => number = Date.now
) {}
async upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult> {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid user icon filename.');
}
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`;
const expires = String(Math.floor(this.now() / 1000) + 60);
const requestId = randomUUID();
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature(
this.secret,
expires,
requestId,
pathname,
input.contentType,
input.body
),
},
body: input.body,
});
if (!response.ok) {
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
}
const picture = `users/core2026/${input.filename}`;
const payload: unknown = await response.json();
if (
!payload ||
typeof payload !== 'object' ||
!('path' in payload) ||
payload.path !== `icons/${picture}`
) {
throw new Error('Image repository returned an unexpected upload path.');
}
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
}
}
+28 -12
View File
@@ -1,6 +1,4 @@
import { randomBytes } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { TRPCError } from '@trpc/server';
import sharp from 'sharp';
@@ -18,6 +16,13 @@ const MAX_ACTIVE_ICONS = 5;
const ICON_UPLOAD_COOLDOWN_MS = 24 * 60 * 60 * 1000;
const ICON_RETIRE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
const ICON_CONTENT_TYPES: Record<string, string> = {
avif: 'image/avif',
webp: 'image/webp',
jpg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
};
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
const session = await ctx.sessions.getSession(sessionToken);
@@ -68,10 +73,17 @@ const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
);
};
const encodeIconPath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
const buildPictureUrl = (ctx: GatewayApiContext, picture: string, imageServer: number): string =>
imageServer === 1
? `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
: `${ctx.sharedIconPublicUrl.replace(/\/$/, '')}/${encodeIconPath(picture)}`;
const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => {
const icon = resolveEffectiveAccountIcon(user);
if (icon.imageServer !== 1 || icon.picture === 'default.jpg') return null;
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
if (icon.picture === 'default.jpg') return null;
return buildPictureUrl(ctx, icon.picture, icon.imageServer);
};
const buildLibraryIcon = (
@@ -83,7 +95,7 @@ const buildLibraryIcon = (
imageServer: icon.imageServer,
createdAt: icon.createdAt,
retiredAt: icon.retiredAt ?? null,
url: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`,
url: buildPictureUrl(ctx, icon.picture, icon.imageServer),
});
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
@@ -211,24 +223,28 @@ export const accountRouter = router({
}
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
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' });
if (!ctx.userIconUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
}
const uploaded = await ctx.userIconUpload.upload({
filename,
contentType: ICON_CONTENT_TYPES[extension]!,
body: buffer,
});
let stored;
try {
stored = await ctx.users.addIconForWindow(
user.id,
filename,
1,
uploaded.picture,
0,
now,
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
MAX_ACTIVE_ICONS
);
} catch (error) {
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
throw error;
}
if (!stored.ok) {
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
if (stored.reason === 'LIMIT') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
@@ -243,7 +259,7 @@ export const accountRouter = router({
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
iconUrl: uploaded.publicUrl,
revision: stored.revision,
icon: buildLibraryIcon(ctx, stored.icon),
profiles,
+7
View File
@@ -19,6 +19,9 @@ export interface GatewayApiConfig {
publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
imageUploadBaseUrl: string;
imageUploadSecretFile: string;
sharedIconPublicUrl: string;
adminLocalAccountEnabled: boolean;
localRegistrationEnabled: boolean;
localAccountGraceDays: number;
@@ -93,6 +96,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
publicBaseUrl,
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`,
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
imageUploadSecretFile:
env.GATEWAY_IMAGE_UPLOAD_SECRET_FILE ?? '/run/secrets/image_upload_core2026_secret',
sharedIconPublicUrl: env.GATEWAY_SHARED_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
localRegistrationEnabled: parseBooleanWithFallback(env.GATEWAY_LOCAL_REGISTRATION_ENABLED, true),
localAccountGraceDays: parseNumberWithFallback(
+7
View File
@@ -14,6 +14,7 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra';
import type { AdminAuthContext } from './adminAuth.js';
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
export interface GatewayApiContext {
users: UserRepository;
@@ -26,6 +27,8 @@ export interface GatewayApiContext {
publicBaseUrl: string;
userIconDir: string;
userIconPublicUrl: string;
sharedIconPublicUrl: string;
userIconUpload?: UserIconUploadStore;
adminLocalAccountEnabled: boolean;
localRegistrationEnabled: boolean;
localAccountGraceDays: number;
@@ -51,6 +54,8 @@ export const createGatewayApiContext = (options: {
publicBaseUrl: string;
userIconDir?: string;
userIconPublicUrl?: string;
sharedIconPublicUrl?: string;
userIconUpload?: UserIconUploadStore;
adminLocalAccountEnabled: boolean;
localRegistrationEnabled: boolean;
localAccountGraceDays: number;
@@ -73,6 +78,8 @@ export const createGatewayApiContext = (options: {
publicBaseUrl: options.publicBaseUrl,
userIconDir: options.userIconDir ?? 'uploads/user-icons',
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
sharedIconPublicUrl: options.sharedIconPublicUrl ?? 'https://sam-image.hided.net/icons',
userIconUpload: options.userIconUpload,
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
localRegistrationEnabled: options.localRegistrationEnabled,
localAccountGraceDays: options.localAccountGraceDays,
+12
View File
@@ -27,6 +27,7 @@ import { appRouter } from './router.js';
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv();
@@ -39,6 +40,15 @@ export const createGatewayApiServer = async () => {
? await fs.readFile(config.passwordEncryptionPrivateKeyFile, 'utf8')
: undefined;
const passwordEnvelope = createPasswordEnvelopeService(privateKeyPem);
const imageUploadSecret = (await fs.readFile(config.imageUploadSecretFile, 'utf8')).trim();
if (imageUploadSecret.length < 32) {
throw new Error('GATEWAY_IMAGE_UPLOAD_SECRET_FILE must contain at least 32 characters.');
}
const userIconUpload = new RemoteUserIconStore(
config.imageUploadBaseUrl,
config.sharedIconPublicUrl,
imageUploadSecret
);
const users = createPostgresUserRepository(
postgres.prisma as GatewayPrismaClient,
createPasswordHasher({ legacyGlobalSalt: config.legacyPasswordGlobalSalt })
@@ -103,6 +113,8 @@ export const createGatewayApiServer = async () => {
publicBaseUrl: config.publicBaseUrl,
userIconDir: path.resolve(process.cwd(), config.userIconDir),
userIconPublicUrl: config.userIconPublicUrl,
sharedIconPublicUrl: config.sharedIconPublicUrl,
userIconUpload,
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
localRegistrationEnabled: config.localRegistrationEnabled,
localAccountGraceDays: config.localAccountGraceDays,