feat(images): default frontend assets to image service

This commit is contained in:
2026-08-08 09:30:58 +00:00
parent bda76b7888
commit 4374c490ac
28 changed files with 228 additions and 61 deletions
+6 -3
View File
@@ -31,13 +31,15 @@ GATEWAY_DB_SCHEMA=public
GATEWAY_WORKSPACE_ROOT=/path/to/core2026 GATEWAY_WORKSPACE_ROOT=/path/to/core2026
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
GATEWAY_USER_ICON_DIR=uploads/user-icons GATEWAY_USER_ICON_DIR=uploads/user-icons
GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons GATEWAY_USER_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons 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_LOCAL_REGISTRATION_ENABLED=true GATEWAY_LOCAL_REGISTRATION_ENABLED=true
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7
# Optional. Keep the PEM private key outside Git; if omitted, a per-process RSA key is generated. # Optional. Keep the PEM private key outside Git; if omitted, a per-process RSA key is generated.
@@ -65,10 +67,11 @@ TRPC_PATH=/trpc
# Frontend public URLs # Frontend public URLs
# These values are public and become part of the browser bundle. # These values are public and become part of the browser bundle.
VITE_GATEWAY_WEB_URL=/gateway/ VITE_GATEWAY_WEB_URL=/gateway/
VITE_IMAGE_PUBLIC_URL=https://sam-image.hided.net
# Comma-separated public hostnames accepted by Vite preview. Use * only behind a trusted reverse proxy. # Comma-separated public hostnames accepted by Vite preview. Use * only behind a trusted reverse proxy.
VITE_PREVIEW_ALLOWED_HOSTS=localhost,dev-sam-e2e.hided.net VITE_PREVIEW_ALLOWED_HOSTS=localhost,dev-sam-e2e.hided.net
# Account-uploaded icons are served by the Gateway and remain a public browser URL. # Account-uploaded icons are public immutable files on the image service.
VITE_GATEWAY_USER_ICON_BASE_URL=/gateway/api/user-icons VITE_GATEWAY_USER_ICON_BASE_URL=https://sam-image.hided.net/icons
VITE_BOARD_COMMUNITY_URL=/xe/community VITE_BOARD_COMMUNITY_URL=/xe/community
# Set the remaining links only after their public routes are confirmed. # Set the remaining links only after their public routes are confirmed.
VITE_BOARD_REQUEST_URL= VITE_BOARD_REQUEST_URL=
+11
View File
@@ -95,11 +95,14 @@ mount하고 다음 서버 전용 변수를 설정합니다.
GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
GATEWAY_USER_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_URL=https://sam-image.hided.net
IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core2026_secret IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core2026_secret
VITE_IMAGE_PUBLIC_URL=https://sam-image.hided.net
VITE_GATEWAY_USER_ICON_BASE_URL=https://sam-image.hided.net/icons
``` ```
Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간 Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간
@@ -107,6 +110,14 @@ PUT을 수행합니다. game-api의 국방·외교·정찰 편집기 첨부 이
사용하되 `/uploads/core2026/` bind 경로에 저장합니다. 공유 비밀값은 `VITE_*`, 사용하되 `/uploads/core2026/` bind 경로에 저장합니다. 공유 비밀값은 `VITE_*`,
브라우저 응답 또는 Cloudflare로 전달하지 않습니다. 브라우저 응답 또는 Cloudflare로 전달하지 않습니다.
두 frontend는 별도 설정이 없으면 `https://sam-image.hided.net`을 공개 이미지
origin으로 사용합니다. 게임 자산은 `/game`, 공용 장수 아이콘은 `/icons`,
Core2026 사용자 아이콘은 서버가 발급한 `users/core2026/<파일>``/icons`
base 아래에 붙여 읽습니다. 로컬
이미지 fixture가 필요한 비교 환경만 `VITE_IMAGE_PUBLIC_URL=/image`와 기존
`VITE_GAME_ASSET_URL`을 명시적으로 덮어씁니다. 모든 `VITE_*` 값은 공개값이며,
업로드 HMAC secret은 반드시 위의 server-side secret 파일로만 주입합니다.
Gitea webhook을 놓친 경우에는 별도의 `image_sync_core2026_secret`을 mount한 Gitea webhook을 놓친 경우에는 별도의 `image_sync_core2026_secret`을 mount한
서버 컨테이너에서 `pnpm sync:image`를 실행합니다. 특정 이미지 저장소 commit을 서버 컨테이너에서 `pnpm sync:image`를 실행합니다. 특정 이미지 저장소 commit을
확인하면서 동기화하려면 `pnpm sync:image -- --commit <full-commit-sha>` 확인하면서 동기화하려면 `pnpm sync:image -- --commit <full-commit-sha>`
+3 -1
View File
@@ -7,6 +7,8 @@ const DEFAULT_NATION = {
color: '#000000', color: '#000000',
}; };
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
export const resolveNationInfo = async ( export const resolveNationInfo = async (
db: DatabaseClient, db: DatabaseClient,
nationId: number nationId: number
@@ -30,7 +32,7 @@ export const buildTargetFromGeneral = async (db: DatabaseClient, general: Genera
nationId: general.nationId, nationId: general.nationId,
nationName: nation.name, nationName: nation.name,
color: nation.color, color: nation.color,
icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`, icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`,
}; };
}; };
+9 -5
View File
@@ -85,7 +85,7 @@ const readImage = async (relative: string): Promise<Buffer> => {
const installImages = async (page: Page) => { const installImages = async (page: Page) => {
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
await page.route(`**/image/game/${filename}`, async (route) => { await page.route(`https://sam-image.hided.net/game/${filename}`, async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'image/jpeg', contentType: 'image/jpeg',
@@ -93,7 +93,7 @@ const installImages = async (page: Page) => {
}); });
}); });
} }
await page.route('**/image/icons/**', async (route) => { await page.route('https://sam-image.hided.net/icons/**', async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'image/jpeg', contentType: 'image/jpeg',
@@ -270,6 +270,10 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
await installApi(page, state); await installApi(page, state);
await page.setViewportSize({ width: 1000, height: 800 }); await page.setViewportSize({ width: 1000, height: 800 });
await gotoBoard(page); await gotoBoard(page);
await expect(page.locator('.general-icon').first()).toHaveAttribute(
'src',
'https://sam-image.hided.net/icons/22.jpg'
);
if (artifactRoot) { if (artifactRoot) {
await page.screenshot({ await page.screenshot({
path: resolve(artifactRoot, 'board-core-desktop.png'), path: resolve(artifactRoot, 'board-core-desktop.png'),
@@ -331,9 +335,9 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
expect(geometry.font.family).toContain('Pretendard'); expect(geometry.font.family).toContain('Pretendard');
expect(geometry.font).toMatchObject({ size: '14px', lineHeight: '18.2px' }); expect(geometry.font).toMatchObject({ size: '14px', lineHeight: '18.2px' });
expect(geometry.whiteSpace).toBe('pre'); expect(geometry.whiteSpace).toBe('pre');
expect(geometry.walnut).toContain('back_walnut.jpg'); expect(geometry.walnut).toContain('https://sam-image.hided.net/game/back_walnut.jpg');
expect(geometry.green).toContain('back_green.jpg'); expect(geometry.green).toContain('https://sam-image.hided.net/game/back_green.jpg');
expect(geometry.blue).toContain('back_blue.jpg'); expect(geometry.blue).toContain('https://sam-image.hided.net/game/back_blue.jpg');
expect(geometry.submitStyle).toEqual({ expect(geometry.submitStyle).toEqual({
backgroundColor: 'rgb(68, 68, 68)', backgroundColor: 'rgb(68, 68, 68)',
borderColor: 'rgb(61, 61, 61)', borderColor: 'rgb(61, 61, 61)',
@@ -20,7 +20,7 @@
--sammo-button-info-border: #2f89c5; --sammo-button-info-border: #2f89c5;
--sammo-button-navigation-bg: #00582c; --sammo-button-navigation-bg: #00582c;
--sammo-button-navigation-border: #004f28; --sammo-button-navigation-border: #004f28;
--sammo-texture-walnut: url('/image/game/back_walnut.jpg'); --sammo-texture-walnut: url('https://sam-image.hided.net/game/back_walnut.jpg');
--sammo-texture-green: url('/image/game/back_green.jpg'); --sammo-texture-green: url('https://sam-image.hided.net/game/back_green.jpg');
--sammo-texture-blue: url('/image/game/back_blue.jpg'); --sammo-texture-blue: url('https://sam-image.hided.net/game/back_blue.jpg');
} }
@@ -7,6 +7,7 @@ import MapCityBasic from './MapCityBasic.vue';
import MapCityDetail from './MapCityDetail.vue'; import MapCityDetail from './MapCityDetail.vue';
import { useMapViewerStore } from '../../stores/mapViewer'; import { useMapViewerStore } from '../../stores/mapViewer';
import { buildAssetUrl } from '../../utils/mapAssets'; import { buildAssetUrl } from '../../utils/mapAssets';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface MapSummary { interface MapSummary {
year: number; year: number;
@@ -102,7 +103,7 @@ const resolveStateClass = (state: number): CityStateClass => {
return 'wrong'; return 'wrong';
}; };
const assetBaseUrl = computed(() => import.meta.env.VITE_GAME_ASSET_URL?.trim() || '/image/game'); const assetBaseUrl = computed(configuredGameAssetUrl);
const resolveAsset = (path: string) => buildAssetUrl(assetBaseUrl.value, path); const resolveAsset = (path: string) => buildAssetUrl(assetBaseUrl.value, path);
const nationById = computed(() => { const nationById = computed(() => {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { MessageType } from '@sammo-ts/logic'; import type { MessageType } from '@sammo-ts/logic';
import { resolveMessageGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon'; import { DEFAULT_GENERAL_ICON_URL, resolveMessageGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
interface MessageTarget { interface MessageTarget {
generalId: number; generalId: number;
@@ -48,7 +48,7 @@ const destination = computed<MessageTarget>(
nationId: 0, nationId: 0,
nationName: '재야', nationName: '재야',
color: '#000000', color: '#000000',
icon: '/image/icons/default.jpg', icon: DEFAULT_GENERAL_ICON_URL,
} }
); );
+1
View File
@@ -12,6 +12,7 @@ interface ImportMetaEnv {
readonly VITE_GAME_API_URL?: string; readonly VITE_GAME_API_URL?: string;
readonly VITE_GAME_SSE_URL?: string; readonly VITE_GAME_SSE_URL?: string;
readonly VITE_GAME_ASSET_URL?: string; readonly VITE_GAME_ASSET_URL?: string;
readonly VITE_IMAGE_PUBLIC_URL?: string;
readonly VITE_GAME_PROFILE?: string; readonly VITE_GAME_PROFILE?: string;
readonly VITE_GATEWAY_WEB_URL?: string; readonly VITE_GATEWAY_WEB_URL?: string;
readonly VITE_GATEWAY_USER_ICON_BASE_URL?: string; readonly VITE_GATEWAY_USER_ICON_BASE_URL?: string;
+3
View File
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import './assets/main.css'; import './assets/main.css';
import { installImageAssetCssVariables } from './utils/imageAssets';
installImageAssetCssVariables();
const app = createApp(App); const app = createApp(App);
+18 -8
View File
@@ -1,5 +1,12 @@
export const DEFAULT_GENERAL_ICON_URL = '/image/icons/default.jpg'; import {
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = '/gateway/api/user-icons'; configuredSharedIconPublicUrl,
configuredUserIconPublicUrl,
DEFAULT_USER_ICON_PUBLIC_URL,
externalizeLegacyImageUrl,
} from './imageAssets.ts';
export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`;
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = DEFAULT_USER_ICON_PUBLIC_URL;
export type GeneralIconSource = { export type GeneralIconSource = {
picture?: string | null; picture?: string | null;
@@ -23,16 +30,16 @@ const encodeLegacyIconPath = (value: string): string =>
}) })
.join('/'); .join('/');
const configuredUserIconBaseUrl = (): string =>
import.meta.env?.VITE_GATEWAY_USER_ICON_BASE_URL?.trim() || DEFAULT_GATEWAY_USER_ICON_BASE_URL;
export const resolveGeneralIconUrl = ( export const resolveGeneralIconUrl = (
source: GeneralIconSource, source: GeneralIconSource,
{ legacyBaseUrl = '/image/icons', userIconBaseUrl = configuredUserIconBaseUrl() }: GeneralIconOptions = {} {
legacyBaseUrl = configuredSharedIconPublicUrl(),
userIconBaseUrl = configuredUserIconPublicUrl(),
}: GeneralIconOptions = {}
): string => { ): string => {
const picture = source.picture?.trim() || 'default.jpg'; const picture = source.picture?.trim() || 'default.jpg';
const baseUrl = source.imageServer ? userIconBaseUrl : legacyBaseUrl; const baseUrl = source.imageServer ? userIconBaseUrl : legacyBaseUrl;
const encodedPicture = source.imageServer ? encodeURIComponent(picture) : encodeLegacyIconPath(picture); const encodedPicture = encodeLegacyIconPath(picture);
return `${trimTrailingSlashes(baseUrl)}/${encodedPicture}`; return `${trimTrailingSlashes(baseUrl)}/${encodedPicture}`;
}; };
@@ -43,7 +50,7 @@ export const resolveGeneralIconBackgroundImage = (source: GeneralIconSource, opt
export const resolveMessageGeneralIconUrl = ( export const resolveMessageGeneralIconUrl = (
icon: string | null | undefined, icon: string | null | undefined,
userIconBaseUrl = configuredUserIconBaseUrl() userIconBaseUrl = configuredUserIconPublicUrl()
): string => { ): string => {
const normalized = icon?.trim(); const normalized = icon?.trim();
if (!normalized) { if (!normalized) {
@@ -55,6 +62,9 @@ export const resolveMessageGeneralIconUrl = (
return resolveGeneralIconUrl({ picture: userIconMatch[1], imageServer: 1 }, { userIconBaseUrl }); return resolveGeneralIconUrl({ picture: userIconMatch[1], imageServer: 1 }, { userIconBaseUrl });
} }
if (normalized.startsWith('/image/')) {
return externalizeLegacyImageUrl(normalized);
}
if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) { if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) {
return normalized; return normalized;
} }
@@ -0,0 +1,49 @@
export const DEFAULT_IMAGE_PUBLIC_URL = 'https://sam-image.hided.net';
export const DEFAULT_SHARED_ICON_PUBLIC_URL = `${DEFAULT_IMAGE_PUBLIC_URL}/icons`;
export const DEFAULT_USER_ICON_PUBLIC_URL = DEFAULT_SHARED_ICON_PUBLIC_URL;
const trimTrailingSlashes = (value: string): string => value.replace(/\/+$/u, '');
const configuredValue = (value: string | undefined, fallback: string): string => {
const normalized = value?.trim();
return trimTrailingSlashes(normalized || fallback);
};
export const configuredImagePublicUrl = (): string =>
configuredValue(import.meta.env?.VITE_IMAGE_PUBLIC_URL, DEFAULT_IMAGE_PUBLIC_URL);
export const configuredSharedIconPublicUrl = (): string =>
`${configuredImagePublicUrl()}/icons`;
export const configuredUserIconPublicUrl = (): string =>
configuredValue(
import.meta.env?.VITE_GATEWAY_USER_ICON_BASE_URL,
`${configuredImagePublicUrl()}/icons`
);
export const configuredGameAssetUrl = (): string => {
const explicit = import.meta.env?.VITE_GAME_ASSET_URL?.trim();
if (!explicit) {
return `${configuredImagePublicUrl()}/game`;
}
const normalized = trimTrailingSlashes(explicit);
return normalized.endsWith('/image') ? `${normalized}/game` : normalized;
};
export const externalizeLegacyImageUrl = (value: string): string => {
if (value.startsWith('/image/general/')) {
return `${configuredSharedIconPublicUrl()}/${value.slice('/image/general/'.length)}`;
}
if (value.startsWith('/image/')) {
return `${configuredImagePublicUrl()}/${value.slice('/image/'.length)}`;
}
return value;
};
export const installImageAssetCssVariables = (): void => {
const root = document.documentElement.style;
const gameAssetUrl = configuredGameAssetUrl();
root.setProperty('--sammo-texture-walnut', `url(${JSON.stringify(`${gameAssetUrl}/back_walnut.jpg`)})`);
root.setProperty('--sammo-texture-green', `url(${JSON.stringify(`${gameAssetUrl}/back_green.jpg`)})`);
root.setProperty('--sammo-texture-blue', `url(${JSON.stringify(`${gameAssetUrl}/back_blue.jpg`)})`);
};
@@ -229,10 +229,7 @@ const nationStyle = (color: string) => ({
const signerIcon = (signer: DiplomacyLetter['src'] | DiplomacyLetter['dest']): string | null => { const signerIcon = (signer: DiplomacyLetter['src'] | DiplomacyLetter['dest']): string | null => {
if (signer.generalIcon) return signer.generalIcon; if (signer.generalIcon) return signer.generalIcon;
if (!signer.generalPicture) return null; if (!signer.generalPicture) return null;
return resolveGeneralIconUrl( return resolveGeneralIconUrl({ picture: signer.generalPicture, imageServer: signer.generalImageServer });
{ picture: signer.generalPicture, imageServer: signer.generalImageServer },
{ legacyBaseUrl: '/image/general' }
);
}; };
const toggleHistory = (letterId: number) => { const toggleHistory = (letterId: number) => {
@@ -48,7 +48,7 @@ const loadDirectory = async () => {
} }
}; };
const imageUrl = (general: General): string => resolveGeneralIconUrl(general, { legacyBaseUrl: '/image/general' }); const imageUrl = (general: General): string => resolveGeneralIconUrl(general);
const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100);
onMounted(() => { onMounted(() => {
+12 -9
View File
@@ -9,13 +9,13 @@ import {
} from '../src/utils/generalIcon.ts'; } from '../src/utils/generalIcon.ts';
void describe('generalIcon', () => { void describe('generalIcon', () => {
void it('routes user icons through the gateway and encodes the complete filename', () => { void it('preserves server-issued user icon path segments while encoding traversal segments', () => {
assert.equal( assert.equal(
resolveGeneralIconUrl( resolveGeneralIconUrl(
{ picture: '계정 icon/../1.jpg', imageServer: 1 }, { picture: '계정 icon/../1.jpg', imageServer: 1 },
{ userIconBaseUrl: '/gateway/api/user-icons/' } { userIconBaseUrl: '/gateway/api/user-icons/' }
), ),
'/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon%2F..%2F1.jpg' '/gateway/api/user-icons/%EA%B3%84%EC%A0%95%20icon/%2E%2E/1.jpg'
); );
}); });
@@ -30,31 +30,34 @@ void describe('generalIcon', () => {
); );
assert.equal( assert.equal(
resolveGeneralIconUrl({ picture: '장수/관우 1.png', imageServer: 0 }), resolveGeneralIconUrl({ picture: '장수/관우 1.png', imageServer: 0 }),
'/image/icons/%EC%9E%A5%EC%88%98/%EA%B4%80%EC%9A%B0%201.png' 'https://sam-image.hided.net/icons/%EC%9E%A5%EC%88%98/%EA%B4%80%EC%9A%B0%201.png'
); );
assert.equal( assert.equal(
resolveGeneralIconUrl({ picture: '../secret.png', imageServer: 0 }), resolveGeneralIconUrl({ picture: '../secret.png', imageServer: 0 }),
'/image/icons/%2E%2E/secret.png' 'https://sam-image.hided.net/icons/%2E%2E/secret.png'
); );
}); });
void it('uses a deterministic default and safe layered fallback for CSS backgrounds', () => { void it('uses a deterministic default and safe layered fallback for CSS backgrounds', () => {
assert.equal(resolveGeneralIconUrl({ picture: null, imageServer: 0 }), '/image/icons/default.jpg'); assert.equal(
resolveGeneralIconUrl({ picture: null, imageServer: 0 }),
'https://sam-image.hided.net/icons/default.jpg'
);
assert.equal( assert.equal(
resolveGeneralIconBackgroundImage( resolveGeneralIconBackgroundImage(
{ picture: 'custom.jpg', imageServer: 1 }, { picture: 'custom.jpg', imageServer: 1 },
{ userIconBaseUrl: '/gateway/api/user-icons' } { userIconBaseUrl: '/gateway/api/user-icons' }
), ),
'url("/gateway/api/user-icons/custom.jpg"), url("/image/icons/default.jpg")' 'url("/gateway/api/user-icons/custom.jpg"), url("https://sam-image.hided.net/icons/default.jpg")'
); );
}); });
void it('translates legacy message d_pic references without changing absolute or external icons', () => { void it('translates legacy message d_pic references without changing absolute or external icons', () => {
assert.equal( assert.equal(
resolveMessageGeneralIconUrl('d_pic/user name.jpg', '/gateway/api/user-icons/'), resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'),
'/gateway/api/user-icons/user%20name.jpg' '/gateway/api/user-icons/users/core2026/user%20name.jpg'
); );
assert.equal(resolveMessageGeneralIconUrl('/image/icons/22.jpg'), '/image/icons/22.jpg'); assert.equal(resolveMessageGeneralIconUrl('/image/icons/22.jpg'), 'https://sam-image.hided.net/icons/22.jpg');
assert.equal(resolveMessageGeneralIconUrl('https://cdn.example/icon.jpg'), 'https://cdn.example/icon.jpg'); assert.equal(resolveMessageGeneralIconUrl('https://cdn.example/icon.jpg'), 'https://cdn.example/icon.jpg');
assert.equal(resolveMessageGeneralIconUrl(''), DEFAULT_GENERAL_ICON_URL); assert.equal(resolveMessageGeneralIconUrl(''), DEFAULT_GENERAL_ICON_URL);
}); });
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
configuredGameAssetUrl,
configuredImagePublicUrl,
configuredSharedIconPublicUrl,
configuredUserIconPublicUrl,
externalizeLegacyImageUrl,
} from '../src/utils/imageAssets.ts';
void describe('imageAssets', () => {
void it('uses the dedicated image host when no frontend override is provided', () => {
assert.equal(configuredImagePublicUrl(), 'https://sam-image.hided.net');
assert.equal(configuredGameAssetUrl(), 'https://sam-image.hided.net/game');
assert.equal(configuredSharedIconPublicUrl(), 'https://sam-image.hided.net/icons');
assert.equal(configuredUserIconPublicUrl(), 'https://sam-image.hided.net/icons');
});
void it('maps legacy local image paths to the dedicated image host', () => {
assert.equal(
externalizeLegacyImageUrl('/image/icons/default.jpg'),
'https://sam-image.hided.net/icons/default.jpg'
);
assert.equal(
externalizeLegacyImageUrl('/image/general/장수/유비.png'),
'https://sam-image.hided.net/icons/장수/유비.png'
);
assert.equal(externalizeLegacyImageUrl('/gateway/logo.png'), '/gateway/logo.png');
});
});
+1 -1
View File
@@ -77,7 +77,7 @@ const encodeIconPath = (picture: string): string => picture.split('/').map(encod
const buildPictureUrl = (ctx: GatewayApiContext, picture: string, imageServer: number): string => const buildPictureUrl = (ctx: GatewayApiContext, picture: string, imageServer: number): string =>
imageServer === 1 imageServer === 1
? `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}` ? `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeIconPath(picture)}`
: `${ctx.sharedIconPublicUrl.replace(/\/$/, '')}/${encodeIconPath(picture)}`; : `${ctx.sharedIconPublicUrl.replace(/\/$/, '')}/${encodeIconPath(picture)}`;
const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => { const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => {
+2 -3
View File
@@ -95,10 +95,9 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoRedirectUri, kakaoRedirectUri,
publicBaseUrl, publicBaseUrl,
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons', userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`, userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net', imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
imageUploadSecretFile: imageUploadSecretFile: env.GATEWAY_IMAGE_UPLOAD_SECRET_FILE ?? '/run/secrets/image_upload_core2026_secret',
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', sharedIconPublicUrl: env.GATEWAY_SHARED_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false), adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
localRegistrationEnabled: parseBooleanWithFallback(env.GATEWAY_LOCAL_REGISTRATION_ENABLED, true), localRegistrationEnabled: parseBooleanWithFallback(env.GATEWAY_LOCAL_REGISTRATION_ENABLED, true),
+1 -1
View File
@@ -77,7 +77,7 @@ export const createGatewayApiContext = (options: {
oauthSessions: options.oauthSessions, oauthSessions: options.oauthSessions,
publicBaseUrl: options.publicBaseUrl, publicBaseUrl: options.publicBaseUrl,
userIconDir: options.userIconDir ?? 'uploads/user-icons', userIconDir: options.userIconDir ?? 'uploads/user-icons',
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`, userIconPublicUrl: options.userIconPublicUrl ?? 'https://sam-image.hided.net/icons',
sharedIconPublicUrl: options.sharedIconPublicUrl ?? 'https://sam-image.hided.net/icons', sharedIconPublicUrl: options.sharedIconPublicUrl ?? 'https://sam-image.hided.net/icons',
userIconUpload: options.userIconUpload, userIconUpload: options.userIconUpload,
adminLocalAccountEnabled: options.adminLocalAccountEnabled, adminLocalAccountEnabled: options.adminLocalAccountEnabled,
+4
View File
@@ -979,11 +979,15 @@ describe('account self service', () => {
imageData: `data:image/png;base64,${png.toString('base64')}`, imageData: `data:image/png;base64,${png.toString('base64')}`,
}); });
const updated = await users.findById(user.id); const updated = await users.findById(user.id);
const account = await caller.account.get({ sessionToken: session.sessionToken });
expect(result.iconUrl).toMatch( expect(result.iconUrl).toMatch(
/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.png$/ /^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(account?.icons[0]?.url).toMatch(
/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.png$/
);
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');
expect(userIconUpload.upload).toHaveBeenCalledWith( expect(userIconUpload.upload).toHaveBeenCalledWith(
@@ -51,7 +51,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
canCreateGeneral = true, canCreateGeneral = true,
myGeneral = { myGeneral = {
name: '선택장수', name: '선택장수',
picture: 'account-hash.png', picture: 'users/core2026/account-hash.png',
imageServer: 1, imageServer: 1,
}, },
selectionPoolEnabled = true, selectionPoolEnabled = true,
@@ -168,7 +168,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
}); });
await fulfillTrpc(route, results); await fulfillTrpc(route, results);
}); });
await page.route('**/gateway/api/user-icons/account-hash.png', async (route) => { await page.route('https://sam-image.hided.net/icons/users/core2026/account-hash.png', async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'image/png', contentType: 'image/png',
@@ -189,7 +189,10 @@ test('exchanges the gateway token before loading authenticated lobby general dat
await expect(row).toContainText('선택장수'); await expect(row).toContainText('선택장수');
await expect(row.getByRole('button', { name: '입장' })).toBeVisible(); await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
const portrait = row.locator('img'); const portrait = row.locator('img');
await expect(portrait).toHaveAttribute('src', '/gateway/api/user-icons/account-hash.png'); await expect(portrait).toHaveAttribute(
'src',
'https://sam-image.hided.net/icons/users/core2026/account-hash.png'
);
await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1); await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({ expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { configuredGameAssetUrl } from '../utils/imageAssets';
interface MapSummary { interface MapSummary {
year: number; year: number;
@@ -40,7 +41,7 @@ const props = defineProps<{
const BASE_MAP_WIDTH = 700; const BASE_MAP_WIDTH = 700;
const BASE_MAP_HEIGHT = 500; const BASE_MAP_HEIGHT = 500;
const assetBase = computed(() => (import.meta.env.VITE_GAME_ASSET_URL ?? '/image').replace(/\/+$/, '')); const assetBase = computed(configuredGameAssetUrl);
const season = computed(() => { const season = computed(() => {
if (props.mapData.month <= 3) return 'spring'; if (props.mapData.month <= 3) return 'spring';
if (props.mapData.month <= 6) return 'summer'; if (props.mapData.month <= 6) return 'summer';
@@ -49,11 +50,11 @@ const season = computed(() => {
}); });
const mapBackground = computed(() => { const mapBackground = computed(() => {
const theme = props.mapLayout.mapName; const theme = props.mapLayout.mapName;
if (theme === 'ludo_rathowm') return `${assetBase.value}/game/map/ludo_rathowm/back.jpg`; if (theme === 'ludo_rathowm') return `${assetBase.value}/map/ludo_rathowm/back.jpg`;
if (theme === 'chess') return `${assetBase.value}/game/map/chess/chessboard.png`; if (theme === 'chess') return `${assetBase.value}/map/chess/chessboard.png`;
if (theme === 'pokemon_v1') return `${assetBase.value}/game/map/pokemon_v1/back_pal8.png`; if (theme === 'pokemon_v1') return `${assetBase.value}/map/pokemon_v1/back_pal8.png`;
if (theme === 'cr') return `${assetBase.value}/game/map/cr/bg-fs8.png`; if (theme === 'cr') return `${assetBase.value}/map/cr/bg-fs8.png`;
return `${assetBase.value}/game/map/che/bg_${season.value}.jpg`; return `${assetBase.value}/map/che/bg_${season.value}.jpg`;
}); });
const nationById = computed(() => { const nationById = computed(() => {
+2
View File
@@ -11,6 +11,8 @@ interface ImportMetaEnv {
readonly VITE_GATEWAY_API_URL?: string; readonly VITE_GATEWAY_API_URL?: string;
readonly VITE_GAME_API_URL_TEMPLATE?: string; readonly VITE_GAME_API_URL_TEMPLATE?: string;
readonly VITE_GAME_ASSET_URL?: string; readonly VITE_GAME_ASSET_URL?: string;
readonly VITE_IMAGE_PUBLIC_URL?: string;
readonly VITE_GATEWAY_USER_ICON_BASE_URL?: string;
readonly VITE_GAME_WEB_URL?: string; readonly VITE_GAME_WEB_URL?: string;
readonly VITE_GAME_WEB_URL_TEMPLATE?: string; readonly VITE_GAME_WEB_URL_TEMPLATE?: string;
} }
+3
View File
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import './assets/main.css'; import './assets/main.css';
import { installImageAssetCssVariables } from './utils/imageAssets';
installImageAssetCssVariables();
const app = createApp(App); const app = createApp(App);
@@ -0,0 +1,38 @@
export const DEFAULT_IMAGE_PUBLIC_URL = 'https://sam-image.hided.net';
export const DEFAULT_SHARED_ICON_PUBLIC_URL = `${DEFAULT_IMAGE_PUBLIC_URL}/icons`;
export const DEFAULT_USER_ICON_PUBLIC_URL = DEFAULT_SHARED_ICON_PUBLIC_URL;
const trimTrailingSlashes = (value: string): string => value.replace(/\/+$/u, '');
const configuredValue = (value: string | undefined, fallback: string): string => {
const normalized = value?.trim();
return trimTrailingSlashes(normalized || fallback);
};
export const configuredImagePublicUrl = (): string =>
configuredValue(import.meta.env?.VITE_IMAGE_PUBLIC_URL, DEFAULT_IMAGE_PUBLIC_URL);
export const configuredSharedIconPublicUrl = (): string => `${configuredImagePublicUrl()}/icons`;
export const configuredUserIconPublicUrl = (): string =>
configuredValue(
import.meta.env?.VITE_GATEWAY_USER_ICON_BASE_URL,
`${configuredImagePublicUrl()}/icons`
);
export const configuredGameAssetUrl = (): string => {
const explicit = import.meta.env?.VITE_GAME_ASSET_URL?.trim();
if (!explicit) {
return `${configuredImagePublicUrl()}/game`;
}
const normalized = trimTrailingSlashes(explicit);
return normalized.endsWith('/game') ? normalized : `${normalized}/game`;
};
export const installImageAssetCssVariables = (): void => {
const root = document.documentElement.style;
const gameAssetUrl = configuredGameAssetUrl();
root.setProperty('--sammo-texture-walnut', `url(${JSON.stringify(`${gameAssetUrl}/back_walnut.jpg`)})`);
root.setProperty('--sammo-texture-green', `url(${JSON.stringify(`${gameAssetUrl}/back_green.jpg`)})`);
root.setProperty('--sammo-texture-blue', `url(${JSON.stringify(`${gameAssetUrl}/back_blue.jpg`)})`);
};
@@ -713,17 +713,17 @@ onBeforeUnmount(() => {
.legacy-bg0 { .legacy-bg0 {
background-color: #302016; background-color: #302016;
background-image: url('/image/game/back_walnut.jpg'); background-image: var(--sammo-texture-walnut, url('https://sam-image.hided.net/game/back_walnut.jpg'));
} }
.legacy-bg1 { .legacy-bg1 {
background-color: #14241b; background-color: #14241b;
background-image: url('/image/game/back_green.jpg'); background-image: var(--sammo-texture-green, url('https://sam-image.hided.net/game/back_green.jpg'));
} }
.legacy-bg2 { .legacy-bg2 {
background-color: #172a52; background-color: #172a52;
background-image: url('/image/game/back_blue.jpg'); background-image: var(--sammo-texture-blue, url('https://sam-image.hided.net/game/back_blue.jpg'));
} }
#account-table caption { #account-table caption {
+6 -4
View File
@@ -9,6 +9,7 @@ import { trpc } from '../utils/trpc';
import { createGameTrpc } from '../utils/gameTrpc'; import { createGameTrpc } from '../utils/gameTrpc';
import type { GameRouter } from '../utils/gameTrpc'; import type { GameRouter } from '../utils/gameTrpc';
import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus'; import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus';
import { configuredSharedIconPublicUrl, configuredUserIconPublicUrl } from '../utils/imageAssets';
type GatewayRouterOutput = inferRouterOutputs<AppRouter>; type GatewayRouterOutput = inferRouterOutputs<AppRouter>;
type GameRouterOutput = inferRouterOutputs<GameRouter>; type GameRouterOutput = inferRouterOutputs<GameRouter>;
@@ -40,7 +41,8 @@ const canAccessAdmin = computed(
) ?? false ) ?? false
); );
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified); const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons'; const userIconBaseUrl = configuredUserIconPublicUrl();
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
const formatGraceEndsAt = (value: string | null | undefined): string => const formatGraceEndsAt = (value: string | null | undefined): string =>
value ? new Date(value).toLocaleString('ko-KR') : ''; value ? new Date(value).toLocaleString('ko-KR') : '';
@@ -57,8 +59,8 @@ const encodeLegacyIconPath = (value: string): string =>
const resolveGeneralPicture = (general: LobbyGeneral): string => { const resolveGeneralPicture = (general: LobbyGeneral): string => {
const picture = general.picture?.trim() || 'default.jpg'; const picture = general.picture?.trim() || 'default.jpg';
return general.imageServer return general.imageServer
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}` ? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeLegacyIconPath(picture)}`
: `/image/icons/${encodeLegacyIconPath(picture)}`; : `${sharedIconBaseUrl}/${encodeLegacyIconPath(picture)}`;
}; };
const handleGeneralPictureError = (event: Event): void => { const handleGeneralPictureError = (event: Event): void => {
const image = event.currentTarget as HTMLImageElement; const image = event.currentTarget as HTMLImageElement;
@@ -66,7 +68,7 @@ const handleGeneralPictureError = (event: Event): void => {
return; return;
} }
image.dataset.generalIconFallbackSource = image.currentSrc; image.dataset.generalIconFallbackSource = image.currentSrc;
image.src = '/image/icons/default.jpg'; image.src = `${sharedIconBaseUrl}/default.jpg`;
}; };
onMounted(async () => { onMounted(async () => {
@@ -74,7 +74,7 @@ const resolveGeneralIcon = (general: General): string => {
(typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number' (typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number'
? String(rawPicture) ? String(rawPicture)
: 'default.jpg'; : 'default.jpg';
return `/image/icons/${picture}`; return `https://sam-image.hided.net/icons/${picture}`;
}; };
export class ActionDefinition< export class ActionDefinition<
@@ -41,14 +41,14 @@ export default defineConfig({
}, },
webServer: [ webServer: [
{ {
command: `VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port ${gatewayPort}`, command: `VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_IMAGE_PUBLIC_URL=/image VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port ${gatewayPort}`,
cwd: repositoryRoot, cwd: repositoryRoot,
url: `http://127.0.0.1:${gatewayPort}/gateway/`, url: `http://127.0.0.1:${gatewayPort}/gateway/`,
reuseExistingServer, reuseExistingServer,
timeout: 120_000, timeout: 120_000,
}, },
{ {
command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${gamePort}`, command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_IMAGE_PUBLIC_URL=/image VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${gamePort}`,
cwd: repositoryRoot, cwd: repositoryRoot,
url: `http://127.0.0.1:${gamePort}/che/`, url: `http://127.0.0.1:${gamePort}/che/`,
reuseExistingServer, reuseExistingServer,