fix: complete image service integration
This commit is contained in:
@@ -91,6 +91,8 @@ GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
|
|||||||
GAME_IMAGE_UPLOAD_URL=https://sam-image.hided.net
|
GAME_IMAGE_UPLOAD_URL=https://sam-image.hided.net
|
||||||
GAME_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
GAME_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
||||||
GAME_CONTENT_IMAGE_PUBLIC_URL=https://sam-image.hided.net/uploads/core2026
|
GAME_CONTENT_IMAGE_PUBLIC_URL=https://sam-image.hided.net/uploads/core2026
|
||||||
|
IMAGE_SYNC_URL=https://sam-image.hided.net
|
||||||
|
IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core2026_secret
|
||||||
```
|
```
|
||||||
|
|
||||||
Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간
|
Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간
|
||||||
@@ -98,6 +100,12 @@ PUT을 수행합니다. game-api의 국방·외교·정찰 편집기 첨부 이
|
|||||||
사용하되 `/uploads/core2026/` bind 경로에 저장합니다. 공유 비밀값은 `VITE_*`,
|
사용하되 `/uploads/core2026/` bind 경로에 저장합니다. 공유 비밀값은 `VITE_*`,
|
||||||
브라우저 응답 또는 Cloudflare로 전달하지 않습니다.
|
브라우저 응답 또는 Cloudflare로 전달하지 않습니다.
|
||||||
|
|
||||||
|
Gitea webhook을 놓친 경우에는 별도의 `image_sync_core2026_secret`을 mount한
|
||||||
|
서버 컨테이너에서 `pnpm sync:image`를 실행합니다. 특정 이미지 저장소 commit을
|
||||||
|
확인하면서 동기화하려면 `pnpm sync:image -- --commit <full-commit-sha>`를
|
||||||
|
사용합니다. 이 호출은 현재 활성 branch의 fast-forward만 요청하며 branch를
|
||||||
|
변경할 권한은 없습니다. 업로드 secret과 sync secret은 서로 바꾸어 쓰지 않습니다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd ../docker_compose_files/development
|
cd ../docker_compose_files/development
|
||||||
./scripts/prepare-instance.sh main 15433 16379 ../../core2026
|
./scripts/prepare-instance.sh main 15433 16379 ../../core2026
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export const accountRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||||
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
|
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
||||||
if (!ctx.userIconUpload) {
|
if (!ctx.userIconUpload) {
|
||||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -720,7 +720,9 @@ describe('account self service', () => {
|
|||||||
});
|
});
|
||||||
const updated = await users.findById(user.id);
|
const updated = await users.findById(user.id);
|
||||||
|
|
||||||
expect(result.iconUrl).toMatch(/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{16}\.png$/);
|
expect(result.iconUrl).toMatch(
|
||||||
|
/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.png$/
|
||||||
|
);
|
||||||
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
|
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
|
||||||
expect(updated?.imageServer).toBe(0);
|
expect(updated?.imageServer).toBe(0);
|
||||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
|
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
"generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas",
|
"generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas",
|
||||||
"validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources",
|
"validate:resources": "pnpm --filter @sammo-ts/tools-scripts validate:resources",
|
||||||
"manage:general-icons": "node tools/manage-general-icons.mjs",
|
"manage:general-icons": "node tools/manage-general-icons.mjs",
|
||||||
|
"sync:image": "node tools/sync-image-repository.mjs",
|
||||||
"check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check",
|
"check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check",
|
||||||
"check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check",
|
"check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check",
|
||||||
"check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs",
|
"check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs",
|
||||||
"check:architecture": "node tools/check-package-boundaries.mjs",
|
"check:architecture": "node tools/check-package-boundaries.mjs",
|
||||||
"test:architecture": "node --test tools/check-package-boundaries.test.mjs",
|
"test:architecture": "node --test tools/check-package-boundaries.test.mjs",
|
||||||
|
"test:image-sync": "node --test tools/sync-image-repository.test.mjs",
|
||||||
"test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json",
|
"test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json",
|
||||||
"typecheck:e2e:frontend-legacy": "tsc -p tools/frontend-legacy-parity/tsconfig.json --noEmit",
|
"typecheck:e2e:frontend-legacy": "tsc -p tools/frontend-legacy-parity/tsconfig.json --noEmit",
|
||||||
"test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs",
|
"test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { createHmac, randomUUID } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'https://sam-image.hided.net';
|
||||||
|
const DEFAULT_SECRET_FILE = '/run/secrets/image_sync_core2026_secret';
|
||||||
|
|
||||||
|
const parseArgs = (argv) => {
|
||||||
|
const result = {};
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index];
|
||||||
|
if (argument === '--commit' || argument === '--url' || argument === '--secret-file') {
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${argument} requires a value.`);
|
||||||
|
}
|
||||||
|
result[argument.slice(2)] = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown argument: ${argument}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeEndpoint = (baseUrl) => {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
throw new Error('Image sync URL must use HTTP or HTTPS.');
|
||||||
|
}
|
||||||
|
url.pathname = `${url.pathname.replace(/\/$/, '')}/v1/sync`;
|
||||||
|
url.search = '';
|
||||||
|
url.hash = '';
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncImageRepository = async ({
|
||||||
|
baseUrl = DEFAULT_BASE_URL,
|
||||||
|
secretFile = DEFAULT_SECRET_FILE,
|
||||||
|
commit,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
now = Date.now,
|
||||||
|
requestIdFactory = randomUUID,
|
||||||
|
} = {}) => {
|
||||||
|
if (commit !== undefined && !/^[0-9a-f]{40,64}$/i.test(commit)) {
|
||||||
|
throw new Error('Commit must be a full 40-64 character hexadecimal object ID.');
|
||||||
|
}
|
||||||
|
const secret = (await readFile(secretFile, 'utf8')).trim();
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error('IMAGE_SYNC_SECRET_FILE must contain at least 32 characters.');
|
||||||
|
}
|
||||||
|
const body = commit ? JSON.stringify({ commit }) : '{}';
|
||||||
|
const timestamp = String(Math.floor(now() / 1000));
|
||||||
|
const requestId = requestIdFactory();
|
||||||
|
const signature = createHmac('sha256', secret).update(`${timestamp}.${requestId}.${body}`).digest('hex');
|
||||||
|
const response = await fetchImpl(normalizeEndpoint(baseUrl), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-image-client': 'core2026',
|
||||||
|
'x-image-timestamp': timestamp,
|
||||||
|
'x-image-request-id': requestId,
|
||||||
|
'x-image-signature': signature,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Image repository sync failed with HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload || typeof payload !== 'object' || payload.ok !== true) {
|
||||||
|
throw new Error('Image repository returned an unexpected sync response.');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const result = await syncImageRepository({
|
||||||
|
baseUrl: args.url ?? process.env.IMAGE_SYNC_URL ?? DEFAULT_BASE_URL,
|
||||||
|
secretFile: args['secret-file'] ?? process.env.IMAGE_SYNC_SECRET_FILE ?? DEFAULT_SECRET_FILE,
|
||||||
|
commit: args.commit,
|
||||||
|
});
|
||||||
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
run().catch((error) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { syncImageRepository } from './sync-image-repository.mjs';
|
||||||
|
|
||||||
|
test('signs a scoped Core2026 fallback sync without exposing the secret', async (t) => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'sammo-image-sync-'));
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const secretFile = path.join(directory, 'secret');
|
||||||
|
const secret = 's'.repeat(32);
|
||||||
|
await writeFile(secretFile, `${secret}\n`, { mode: 0o600 });
|
||||||
|
let captured;
|
||||||
|
const result = await syncImageRepository({
|
||||||
|
baseUrl: 'https://sam-image.hided.net/',
|
||||||
|
secretFile,
|
||||||
|
commit: 'a'.repeat(40),
|
||||||
|
now: () => Date.parse('2026-08-08T00:00:00Z'),
|
||||||
|
requestIdFactory: () => 'request-1',
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
captured = { url, init };
|
||||||
|
return new Response(JSON.stringify({ ok: true, changed: false }), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: true, changed: false });
|
||||||
|
assert.equal(captured.url, 'https://sam-image.hided.net/v1/sync');
|
||||||
|
assert.equal(captured.init.body, JSON.stringify({ commit: 'a'.repeat(40) }));
|
||||||
|
assert.equal(captured.init.headers['x-image-client'], 'core2026');
|
||||||
|
assert.equal(
|
||||||
|
captured.init.headers['x-image-signature'],
|
||||||
|
createHmac('sha256', secret)
|
||||||
|
.update(`${captured.init.headers['x-image-timestamp']}.request-1.${captured.init.body}`)
|
||||||
|
.digest('hex')
|
||||||
|
);
|
||||||
|
assert.equal(Object.values(captured.init.headers).includes(secret), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid commits before reading the secret or making a request', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
syncImageRepository({
|
||||||
|
secretFile: '/does/not/exist',
|
||||||
|
commit: 'main',
|
||||||
|
fetchImpl: async () => {
|
||||||
|
throw new Error('must not fetch');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/Commit must be a full/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports authentication failures without returning a response body', async (t) => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), 'sammo-image-sync-'));
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const secretFile = path.join(directory, 'secret');
|
||||||
|
await writeFile(secretFile, 's'.repeat(32), { mode: 0o600 });
|
||||||
|
await assert.rejects(
|
||||||
|
syncImageRepository({
|
||||||
|
secretFile,
|
||||||
|
fetchImpl: async () => new Response('{"reason":"sensitive detail"}', { status: 401 }),
|
||||||
|
}),
|
||||||
|
/HTTP 401/
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user