From 57800d8574a567e581e0902a181475dc088c48ab Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 15:40:55 +0000 Subject: [PATCH] feat(gateway): upload user icons to image service --- .env.example | 3 + README.md | 15 ++++ .../src/account/remoteUserIconStore.ts | 75 +++++++++++++++++++ app/gateway-api/src/account/router.ts | 40 +++++++--- app/gateway-api/src/config.ts | 7 ++ app/gateway-api/src/context.ts | 7 ++ app/gateway-api/src/server.ts | 12 +++ app/gateway-api/test/authFlow.test.ts | 23 ++++-- .../test/remoteUserIconStore.test.ts | 61 +++++++++++++++ 9 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 app/gateway-api/src/account/remoteUserIconStore.ts create mode 100644 app/gateway-api/test/remoteUserIconStore.test.ts diff --git a/.env.example b/.env.example index 0653a1c..4a5a655 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 67751ca..7d7072f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/gateway-api/src/account/remoteUserIconStore.ts b/app/gateway-api/src/account/remoteUserIconStore.ts new file mode 100644 index 0000000..fa9756a --- /dev/null +++ b/app/gateway-api/src/account/remoteUserIconStore.ts @@ -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; +} + +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 { + 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}` }; + } +} diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index c45c654..e82e8b6 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -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 = { + avif: 'image/avif', + webp: 'image/webp', + jpg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', +}; const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise => { 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, diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index c751e67..7d9dfb6 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -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( diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 7fce418..4136856 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -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, diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 5ac6944..25357d9 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -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, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 0c373f4..0e3d3c9 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -157,6 +157,12 @@ const buildCaller = ( } const passwordEnvelope = createPasswordEnvelopeService(); const requestHeaders: Record = {}; + 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' }], } diff --git a/app/gateway-api/test/remoteUserIconStore.test.ts b/app/gateway-api/test/remoteUserIconStore.test.ts new file mode 100644 index 0000000..bcb9b6e --- /dev/null +++ b/app/gateway-api/test/remoteUserIconStore.test.ts @@ -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; + 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'); + }); +});