feat(images): default frontend assets to image service
This commit is contained in:
@@ -85,7 +85,7 @@ const readImage = async (relative: string): Promise<Buffer> => {
|
||||
|
||||
const installImages = async (page: Page) => {
|
||||
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({
|
||||
status: 200,
|
||||
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({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
@@ -270,6 +270,10 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
|
||||
await installApi(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await gotoBoard(page);
|
||||
await expect(page.locator('.general-icon').first()).toHaveAttribute(
|
||||
'src',
|
||||
'https://sam-image.hided.net/icons/22.jpg'
|
||||
);
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
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).toMatchObject({ size: '14px', lineHeight: '18.2px' });
|
||||
expect(geometry.whiteSpace).toBe('pre');
|
||||
expect(geometry.walnut).toContain('back_walnut.jpg');
|
||||
expect(geometry.green).toContain('back_green.jpg');
|
||||
expect(geometry.blue).toContain('back_blue.jpg');
|
||||
expect(geometry.walnut).toContain('https://sam-image.hided.net/game/back_walnut.jpg');
|
||||
expect(geometry.green).toContain('https://sam-image.hided.net/game/back_green.jpg');
|
||||
expect(geometry.blue).toContain('https://sam-image.hided.net/game/back_blue.jpg');
|
||||
expect(geometry.submitStyle).toEqual({
|
||||
backgroundColor: 'rgb(68, 68, 68)',
|
||||
borderColor: 'rgb(61, 61, 61)',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
--sammo-button-info-border: #2f89c5;
|
||||
--sammo-button-navigation-bg: #00582c;
|
||||
--sammo-button-navigation-border: #004f28;
|
||||
--sammo-texture-walnut: url('/image/game/back_walnut.jpg');
|
||||
--sammo-texture-green: url('/image/game/back_green.jpg');
|
||||
--sammo-texture-blue: url('/image/game/back_blue.jpg');
|
||||
--sammo-texture-walnut: url('https://sam-image.hided.net/game/back_walnut.jpg');
|
||||
--sammo-texture-green: url('https://sam-image.hided.net/game/back_green.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 { useMapViewerStore } from '../../stores/mapViewer';
|
||||
import { buildAssetUrl } from '../../utils/mapAssets';
|
||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||
|
||||
interface MapSummary {
|
||||
year: number;
|
||||
@@ -102,7 +103,7 @@ const resolveStateClass = (state: number): CityStateClass => {
|
||||
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 nationById = computed(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
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 {
|
||||
generalId: number;
|
||||
@@ -48,7 +48,7 @@ const destination = computed<MessageTarget>(
|
||||
nationId: 0,
|
||||
nationName: '재야',
|
||||
color: '#000000',
|
||||
icon: '/image/icons/default.jpg',
|
||||
icon: DEFAULT_GENERAL_ICON_URL,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Vendored
+1
@@ -12,6 +12,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_GAME_API_URL?: string;
|
||||
readonly VITE_GAME_SSE_URL?: string;
|
||||
readonly VITE_GAME_ASSET_URL?: string;
|
||||
readonly VITE_IMAGE_PUBLIC_URL?: string;
|
||||
readonly VITE_GAME_PROFILE?: string;
|
||||
readonly VITE_GATEWAY_WEB_URL?: string;
|
||||
readonly VITE_GATEWAY_USER_ICON_BASE_URL?: string;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
import { installImageAssetCssVariables } from './utils/imageAssets';
|
||||
|
||||
installImageAssetCssVariables();
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export const DEFAULT_GENERAL_ICON_URL = '/image/icons/default.jpg';
|
||||
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = '/gateway/api/user-icons';
|
||||
import {
|
||||
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 = {
|
||||
picture?: string | null;
|
||||
@@ -23,16 +30,16 @@ const encodeLegacyIconPath = (value: string): string =>
|
||||
})
|
||||
.join('/');
|
||||
|
||||
const configuredUserIconBaseUrl = (): string =>
|
||||
import.meta.env?.VITE_GATEWAY_USER_ICON_BASE_URL?.trim() || DEFAULT_GATEWAY_USER_ICON_BASE_URL;
|
||||
|
||||
export const resolveGeneralIconUrl = (
|
||||
source: GeneralIconSource,
|
||||
{ legacyBaseUrl = '/image/icons', userIconBaseUrl = configuredUserIconBaseUrl() }: GeneralIconOptions = {}
|
||||
{
|
||||
legacyBaseUrl = configuredSharedIconPublicUrl(),
|
||||
userIconBaseUrl = configuredUserIconPublicUrl(),
|
||||
}: GeneralIconOptions = {}
|
||||
): string => {
|
||||
const picture = source.picture?.trim() || 'default.jpg';
|
||||
const baseUrl = source.imageServer ? userIconBaseUrl : legacyBaseUrl;
|
||||
const encodedPicture = source.imageServer ? encodeURIComponent(picture) : encodeLegacyIconPath(picture);
|
||||
const encodedPicture = encodeLegacyIconPath(picture);
|
||||
return `${trimTrailingSlashes(baseUrl)}/${encodedPicture}`;
|
||||
};
|
||||
|
||||
@@ -43,7 +50,7 @@ export const resolveGeneralIconBackgroundImage = (source: GeneralIconSource, opt
|
||||
|
||||
export const resolveMessageGeneralIconUrl = (
|
||||
icon: string | null | undefined,
|
||||
userIconBaseUrl = configuredUserIconBaseUrl()
|
||||
userIconBaseUrl = configuredUserIconPublicUrl()
|
||||
): string => {
|
||||
const normalized = icon?.trim();
|
||||
if (!normalized) {
|
||||
@@ -55,6 +62,9 @@ export const resolveMessageGeneralIconUrl = (
|
||||
return resolveGeneralIconUrl({ picture: userIconMatch[1], imageServer: 1 }, { userIconBaseUrl });
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/image/')) {
|
||||
return externalizeLegacyImageUrl(normalized);
|
||||
}
|
||||
if (normalized.startsWith('/') || /^https?:\/\//iu.test(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 => {
|
||||
if (signer.generalIcon) return signer.generalIcon;
|
||||
if (!signer.generalPicture) return null;
|
||||
return resolveGeneralIconUrl(
|
||||
{ picture: signer.generalPicture, imageServer: signer.generalImageServer },
|
||||
{ legacyBaseUrl: '/image/general' }
|
||||
);
|
||||
return resolveGeneralIconUrl({ picture: signer.generalPicture, imageServer: signer.generalImageServer });
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
} from '../src/utils/generalIcon.ts';
|
||||
|
||||
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(
|
||||
resolveGeneralIconUrl(
|
||||
{ picture: '계정 icon/../1.jpg', imageServer: 1 },
|
||||
{ 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(
|
||||
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(
|
||||
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', () => {
|
||||
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(
|
||||
resolveGeneralIconBackgroundImage(
|
||||
{ picture: 'custom.jpg', imageServer: 1 },
|
||||
{ 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', () => {
|
||||
assert.equal(
|
||||
resolveMessageGeneralIconUrl('d_pic/user name.jpg', '/gateway/api/user-icons/'),
|
||||
'/gateway/api/user-icons/user%20name.jpg'
|
||||
resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'),
|
||||
'/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(''), 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user