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
+3
View File
@@ -32,6 +32,9 @@ GATEWAY_WORKSPACE_ROOT=/path/to/core2026
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
GATEWAY_USER_ICON_DIR=uploads/user-icons
GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons
GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
GATEWAY_LOCAL_REGISTRATION_ENABLED=true
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7
# Optional. Keep the PEM private key outside Git; if omitted, a per-process RSA key is generated.
+15
View File
@@ -76,6 +76,21 @@ CI=1 pnpm typecheck
`../docker_compose_files/development/README.md`의 PostgreSQL·Redis stack을
worktree별로 준비할 수 있습니다.
Gateway 사용자 아이콘은 이미지 서비스의 Git checkout이 아니라 별도 bind
저장소로 직접 업로드합니다. `gateway-api`에는 이미지 서비스와 같은
`image_upload_core2026_secret``/run/secrets/image_upload_core2026_secret`
mount하고 다음 서버 전용 변수를 설정합니다.
```text
GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
```
Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간
PUT을 수행합니다. 공유 비밀값은 `VITE_*`, 브라우저 응답 또는 Cloudflare로
전달하지 않습니다.
```sh
cd ../docker_compose_files/development
./scripts/prepare-instance.sh main 15433 16379 ../../core2026
@@ -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,
+17 -6
View File
@@ -157,6 +157,12 @@ const buildCaller = (
}
const passwordEnvelope = createPasswordEnvelopeService();
const requestHeaders: Record<string, string> = {};
const userIconUpload = {
upload: vi.fn(async ({ filename }: { filename: string }) => ({
picture: `users/core2026/${filename}`,
publicUrl: `https://sam-image.hided.net/icons/users/core2026/${filename}`,
})),
};
const sealPassword = (password: string) => {
const key = passwordEnvelope.getPublicKey();
return {
@@ -183,6 +189,8 @@ const buildCaller = (
publicBaseUrl: 'http://localhost',
userIconDir: options.userIconDir,
userIconPublicUrl: 'http://localhost/user-icons',
sharedIconPublicUrl: 'https://sam-image.hided.net/icons',
userIconUpload,
adminLocalAccountEnabled: false,
localRegistrationEnabled: true,
localAccountGraceDays: options.localAccountGraceDays ?? 7,
@@ -204,6 +212,7 @@ const buildCaller = (
users,
sessions,
flushPublisher,
userIconUpload,
sealPassword,
setSessionHeader: (sessionToken: string) => {
requestHeaders['x-session-token'] = sessionToken;
@@ -686,7 +695,7 @@ describe('account self service', () => {
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
try {
const { caller, users, sessions, flushPublisher } = buildCaller({
const { caller, users, sessions, flushPublisher, userIconUpload } = buildCaller({
userIconDir: iconDir,
});
const user = await users.createUser({
@@ -711,11 +720,13 @@ describe('account self service', () => {
});
const updated = await users.findById(user.id);
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
expect(result.iconUrl).toMatch(/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{16}\.png$/);
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
expect(updated?.imageServer).toBe(1);
expect(updated?.imageServer).toBe(0);
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
expect(userIconUpload.upload).toHaveBeenCalledWith(
expect.objectContaining({ contentType: 'image/png', body: png })
);
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
});
@@ -754,7 +765,7 @@ describe('account self service', () => {
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(await fs.readdir(iconDir)).toHaveLength(1);
expect(await users.listIcons(user.id)).toHaveLength(1);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
@@ -893,7 +904,7 @@ describe('account self service', () => {
{
projection: {
revision: changed.revision,
imageServer: 1,
imageServer: 0,
},
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
}
@@ -0,0 +1,61 @@
import { createHash, createHmac } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { RemoteUserIconStore } from '../src/account/remoteUserIconStore.js';
describe('remote user icon store', () => {
it('uses a short-lived path and body-bound HMAC without sending the shared secret', async () => {
const body = Buffer.from('icon-body');
const secret = 'u'.repeat(32);
let captured: { input: string | URL | Request; init?: RequestInit } | undefined;
const fetchImpl: typeof fetch = async (input, init) => {
captured = { input, init };
return new Response(JSON.stringify({ path: `icons/users/core2026/${'a'.repeat(32)}.png` }), {
status: 201,
});
};
const store = new RemoteUserIconStore(
'https://sam-image.hided.net/',
'https://sam-image.hided.net/icons/',
secret,
fetchImpl,
() => Date.parse('2026-08-06T00:00:00.000Z')
);
const result = await store.upload({
filename: `${'a'.repeat(32)}.png`,
contentType: 'image/png',
body,
});
expect(result).toEqual({
picture: `users/core2026/${'a'.repeat(32)}.png`,
publicUrl: `https://sam-image.hided.net/icons/users/core2026/${'a'.repeat(32)}.png`,
});
expect(String(captured?.input)).toBe(
`https://sam-image.hided.net/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`
);
const headers = captured?.init?.headers as Record<string, string>;
expect(headers['x-image-expires']).toBe(String(Date.parse('2026-08-06T00:01:00.000Z') / 1000));
expect(Object.values(headers)).not.toContain(secret);
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
const digest = createHash('sha256').update(body).digest('hex');
const expected = createHmac('sha256', secret)
.update(
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`
)
.digest('hex');
expect(headers['x-image-signature']).toBe(expected);
});
it('does not return a picture when the image service rejects the grant', async () => {
const store = new RemoteUserIconStore(
'https://sam-image.hided.net',
'https://sam-image.hided.net/icons',
'u'.repeat(32),
async () => new Response('{}', { status: 401 })
);
await expect(
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
).rejects.toThrow('HTTP 401');
});
});