feat(core): 리버스 프록시 신뢰 설정 지원 및 미설정 진단

TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성에서 X-Forwarded-* 가 전부
무시되어 화면 백지·IP 왜곡·webhook 403 이 함께 발생했다. 코어에 신뢰 프록시 설정
지점이 아예 없던 것이 원인이다.

- config/trustedproxy.php 로 내장 TrustProxies 미들웨어에 값을 공급한다.
 bootstrap/app.php 는 건드리지 않는다 — withMiddleware 클로저는 .env 로드 전에
 평가되어 env 가 항상 null 이 되는 조용한 no-op 이다.
- 판정은 App\Support\TrustedProxyDiagnostic 단일 SSoT 에서 계산하고 대시보드
 알림·환경설정 고급 탭·설치 마법사·trusted-proxy:status 네 면이 소비한다.
 판정식은 "HTTPS 인식 실패" 가 아니라 "X-Forwarded-* 수신 중 AND 신뢰 프록시
 미설정" 이다 — HTTP 전용 사이트가 프록시 뒤에 있으면 화면은 정상인 채로
 나머지만 조용히 어긋나기 때문이다.
- 값 편집 UI 와 쓰기 엔드포인트는 두지 않는다(잠금 역설 + XFF 위조 경로).
- 혼합 콘텐츠 차단을 부트스트랩 폴백의 별도 사유로 가른다. 새로고침으로 낫지
 않으므로 버튼을 렌더하지 않고, 원인·조치는 콘솔로 운영자에게 보낸다.
- 대시보드 알림을 심각도로 배치한다 — warning 은 상단 배너, 그 외는 하단 카드.
 같은 알림이 두 곳에 뜨지 않으며, 여러 건은 간격을 두고 쌓인다.

공개 이슈: (@lyg-kaban 제보)
This commit is contained in:
HeuJung
2026-08-28 18:09:27 +09:00
parent 46ada4dddb
commit b6c5e1f323
55 changed files with 2861 additions and 38 deletions
@@ -12,11 +12,18 @@
- 이미지 업로드 시 쓰는 압축 라이브러리도 함께 담았습니다.
- 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다.
- 확장(템플릿·모듈·플러그인)을 제거할 때 `custom/` 에 넣어 둔 파일의 사본이 보관되며, 제거 창이 그 보관 경로를 보여 줍니다. 보관된 파일이 없으면 창은 종전대로 곧바로 닫힙니다.
- 환경설정 > 고급 에 리버스 프록시 진단이 추가되었습니다. 사이트가 받고 있는 프록시 정보, HTTPS 인식 여부, 방문자 IP 로 인식된 값과 직전 호출 IP 를 나란히 보여 줍니다. 읽기 전용이며 값은 `.env` 에서만 변경합니다.
### Changed
- 관리자 화면에서 게시물·상품 등의 HTML 본문을 표시할 때 쓰는 정화 라이브러리를 최신 버전으로 고정했습니다. 종전에는 이 라이브러리를 직접 지정하지 않아 다른 구성요소가 딸려 들여온 구버전이 쓰였고, 사용자 템플릿보다 낮은 버전으로 동작했습니다.
### Fixed
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
- 알림이 여러 건일 때 서로 맞붙어 표시되던 문제를 수정했습니다. 이제 간격을 두고 차례로 쌓입니다.
## [1.0.7] - 2026-08-24
### Added
@@ -0,0 +1,352 @@
/**
* @file admin-dashboard-alert-severity.test.tsx
* @description 대시보드 시스템 알림의 심각도별 배치·색상 회귀 테스트 (#124 W10)
*
* 배경:
* - 알림 카드의 색상 분기가 `alert.subtype` 리터럴 2개(`incompatible_core`,
* `recovery_available`)에만 걸려 있었고, 심각도를 담은 `alert.type` 은 스타일 판정에
* 한 번도 쓰이지 않았다. 목록에 없는 subtype 은 전부 else 로 떨어져 **경고가 회색
* 일반 안내로** 렌더됐다(`static_publish_*` 가 이미 그 상태였다).
* - 게다가 시스템 알림 카드는 대시보드 **맨 아래** 섹션이라, 화면이 정상으로 보이는
* 구성에서는 운영자가 경고에 도달하지 못할 수 있었다. 그래서 PO 결정(2026-08-28)으로
* **경고 등급 알림은 상단 배너로 승격**하고 나머지는 하단 카드에 남긴다.
*
* 이 테스트가 잠그는 계약:
* - `type === 'warning'` → 상단 배너(`#dashboard_alert_banners` / `top_alert_*`)
* - 그 외 → 하단 카드(`#system_alerts_card` / `alert_*`)
* - 같은 알림이 두 곳에 **중복 노출되지 않는다**
* - 경고가 여러 건이면 **모두 쌓이고 서로 덮지 않는다**
* - 배치가 바뀌어도 행의 액션(복구·닫기)은 따라간다 — 상단 승격이 기능을 빼앗지 않는다
* - `recovery_available` 의 복구 버튼은 **스타일이 아니라 의미 판정**이므로 `subtype` 유지
*
* 합성 레이아웃이 아니라 **실제 admin_dashboard.json** 을 읽는다. 합성 입력으로만
* 검증하면 실물 레이아웃이 되돌아가도 테스트는 계속 통과한다.
*/
import React from 'react';
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
const TestDiv: React.FC<{ id?: string; className?: string; children?: React.ReactNode }> = ({
id,
className,
children,
}) => (
<div id={id} className={className}>
{children}
</div>
);
const TestSpan: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
id,
className,
children,
text,
}) => (
<span id={id} className={className}>
{children || text}
</span>
);
const TestP: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
id,
className,
children,
text,
}) => (
<p id={id} className={className}>
{children || text}
</p>
);
const TestH1: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
id,
className,
children,
text,
}) => (
<h1 id={id} className={className}>
{children || text}
</h1>
);
const TestH2: React.FC<{ id?: string; className?: string; children?: React.ReactNode; text?: string }> = ({
id,
className,
children,
text,
}) => (
<h2 id={id} className={className}>
{children || text}
</h2>
);
const TestButton: React.FC<{
id?: string;
className?: string;
type?: string;
children?: React.ReactNode;
text?: string;
}> = ({ id, className, type, children, text }) => (
<button id={id} className={className} type={type as any}>
{children || text}
</button>
);
const TestA: React.FC<{ id?: string; className?: string; href?: string; children?: React.ReactNode; text?: string }> = ({
id,
className,
href,
children,
text,
}) => (
<a id={id} className={className} href={href}>
{children || text}
</a>
);
const TestIcon: React.FC<{ id?: string; name?: string; className?: string }> = ({ id, name, className }) => (
<i id={id} className={className} data-icon={name} />
);
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
const TestToast: React.FC = () => null;
const TestModalRoot: React.FC = () => null;
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
H1: { component: TestH1, metadata: { name: 'H1', type: 'basic' } },
H2: { component: TestH2, metadata: { name: 'H2', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
A: { component: TestA, metadata: { name: 'A', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
Toast: { component: TestToast, metadata: { name: 'Toast', type: 'composite' } },
ModalRoot: { component: TestModalRoot, metadata: { name: 'ModalRoot', type: 'composite' } },
};
return registry;
}
function stripPermissions(nodes: any): void {
if (Array.isArray(nodes)) {
nodes.forEach(stripPermissions);
return;
}
if (!nodes || typeof nodes !== 'object') return;
delete nodes.permissions;
for (const key of Object.keys(nodes)) {
if (key === 'permissions') continue;
stripPermissions(nodes[key]);
}
}
/** 실제 레이아웃 파일 로드 (extends 제거 — 독립 렌더) */
function loadDashboardLayout(): any {
const file = path.resolve(__dirname, '../../layouts/admin_dashboard.json');
const layout = JSON.parse(fs.readFileSync(file, 'utf8'));
delete layout.extends;
layout.components = layout.slots?.content ?? [];
delete layout.slots;
stripPermissions(layout.components);
return layout;
}
/**
* 알림 4종 — 심각도(type)가 **배치**를, 의미(subtype)가 **액션**을 각각 가른다.
*
* 배열 순서를 섞어 둔다: 필터링 후 인덱스는 버킷별로 다시 매겨지므로, 원본 순서를 그대로
* 가정하는 구현이면 여기서 드러난다.
*/
const ALERTS = [
// (1) 기존 warning — 상단, amber 유지 (회귀 없음)
{ type: 'warning', subtype: 'incompatible_core', icon: 'exclamation', title: '비호환', message: 'm1' },
// (2) info + 복구 액션 — 하단, blue 유지 + 버튼 유지
{
type: 'info',
subtype: 'recovery_available',
icon: 'check-circle',
title: '복구 가능',
message: 'm2',
recover_endpoint: '/api/admin/x/1/recover',
extension_type: 'module',
identifier: 'm1',
},
// (3) subtype 목록에 없던 warning — 종전에는 하단에 회색으로 묻혀 있었다
{ type: 'warning', subtype: 'static_publish_write_failed', icon: 'exclamation-triangle', title: '게시 실패', message: 'm3' },
// (4) 신규 warning — (3) 과 같은 경로
{ type: 'warning', subtype: 'trusted_proxy_missing', icon: 'exclamation-triangle', title: '신뢰 프록시 미설정', message: 'm4' },
];
/** 알림 목록만 채우고 나머지 데이터소스는 비워 렌더한다 */
async function renderAlerts(alerts: any[]) {
const layout = loadDashboardLayout();
const testUtils = createLayoutTest(layout, { componentRegistry: setupTestRegistry() });
const empty = { data: { data: [], current_page: 1, last_page: 1, per_page: 5, total: 0 } };
testUtils.mockApi('dashboard_stats', { response: { data: {} } });
testUtils.mockApi('dashboard_activities', { response: { data: [] } });
testUtils.mockApi('dashboard_modules', { response: empty });
testUtils.mockApi('dashboard_plugins', { response: empty });
testUtils.mockApi('dashboard_templates', { response: empty });
testUtils.mockApi('dashboard_recent_notifications', { response: { data: [] } });
testUtils.mockApi('dashboard_alerts', { response: { data: alerts } });
await testUtils.render();
return testUtils;
}
/**
* 지정 영역의 인덱스 i 알림 행과 아이콘 className 을 읽는다.
*
* @param region 'top'(상단 배너) 또는 'bottom'(하단 시스템 알림 카드)
* @param i 그 영역 안에서의 인덱스 (필터링 후 다시 매겨진다)
* @returns 행·아이콘의 className
*/
function readAlertClasses(region: 'top' | 'bottom', i: number): { row: string; icon: string } {
const prefix = region === 'top' ? 'top_alert' : 'alert';
const row = document.querySelector('#' + prefix + '_item_' + i);
const icon = document.querySelector('#' + prefix + '_icon_' + i);
return {
row: row?.getAttribute('class') ?? '',
icon: icon?.getAttribute('class') ?? '',
};
}
describe('대시보드 알림 심각도별 배치와 색상', () => {
beforeEach(() => {
setupTestRegistry();
});
it('경고 등급 알림은 subtype 과 무관하게 상단 배너에 amber + 테두리로 렌더된다', async () => {
const testUtils = await renderAlerts(ALERTS);
// 경고 3건이 상단에 모두 쌓인다 — 하나가 다른 하나를 덮지 않는다
const banner = document.querySelector('#dashboard_alert_banners');
expect(banner, '상단 배너 영역이 렌더되지 않았다').not.toBeNull();
expect(banner!.querySelectorAll('[id^="top_alert_item_"]').length).toBe(3);
for (const i of [0, 1, 2]) {
const { row, icon } = readAlertClasses('top', i);
expect(row, 'top_alert_item_' + i + ' 행 배경').toContain('bg-amber-50');
expect(row, 'top_alert_item_' + i + ' 행 테두리').toContain('border-amber-200');
expect(row, 'top_alert_item_' + i + ' 는 회색으로 떨어지면 안 된다').not.toContain('bg-gray-50');
expect(icon, 'top_alert_icon_' + i + ' 아이콘 색').toContain('text-amber-600');
}
// 제목 3건이 모두 살아 있다 (덮어쓰기 없음)
for (const title of ['비호환', '게시 실패', '신뢰 프록시 미설정']) {
expect(screen.getByText(title), title + ' 이 사라졌다').toBeTruthy();
}
testUtils.cleanup();
});
it('경고가 아닌 알림만 하단 카드에 남는다 — 같은 알림이 두 곳에 중복 노출되지 않는다', async () => {
const testUtils = await renderAlerts(ALERTS);
// 하단에는 info 1건만 (인덱스는 필터 후 0 부터 다시 매겨진다)
const list = document.querySelector('#alerts_list');
expect(list).not.toBeNull();
expect(list!.querySelectorAll('[id^="alert_item_"]').length).toBe(1);
const { row, icon } = readAlertClasses('bottom', 0);
expect(row).toContain('bg-blue-50');
expect(row).toContain('border-blue-200');
expect(icon).toContain('text-blue-600');
// 상단에 올라간 경고가 하단에 다시 나타나지 않는다
expect(screen.queryAllByText('신뢰 프록시 미설정').length).toBe(1);
testUtils.cleanup();
});
it('경고만 있으면 하단 카드 자체가 뜨지 않는다', async () => {
const testUtils = await renderAlerts(ALERTS.filter((a) => a.type === 'warning'));
expect(document.querySelector('#dashboard_alert_banners')).not.toBeNull();
expect(document.querySelector('#system_alerts_card'), '빈 시스템 알림 카드가 남았다').toBeNull();
testUtils.cleanup();
});
it('경고가 없으면 상단 배너 영역이 뜨지 않는다', async () => {
const testUtils = await renderAlerts(ALERTS.filter((a) => a.type !== 'warning'));
expect(document.querySelector('#system_alerts_card')).not.toBeNull();
expect(document.querySelector('#dashboard_alert_banners'), '빈 배너 영역이 남았다').toBeNull();
testUtils.cleanup();
});
it('심각도가 없는 알림은 하단에 회색으로 떨어진다', async () => {
const testUtils = await renderAlerts([
{ subtype: 'unknown_thing', icon: 'info-circle', title: '미지정', message: 'm' },
]);
const { row, icon } = readAlertClasses('bottom', 0);
expect(row).toContain('bg-gray-50');
expect(row).not.toContain('bg-amber-50');
expect(icon).toContain('text-gray-500');
expect(document.querySelector('#dashboard_alert_banners')).toBeNull();
testUtils.cleanup();
});
it('복구 버튼은 subtype 판정을 유지한다 — info 라고 모두 뜨지 않는다', async () => {
const testUtils = await renderAlerts([
// recovery_available 이 아닌 info — 복구 버튼이 뜨면 안 된다
{ type: 'info', subtype: 'something_else', icon: 'info-circle', title: '일반 안내', message: 'm' },
// recovery_available — 복구 버튼이 떠야 한다
{
type: 'info',
subtype: 'recovery_available',
icon: 'check-circle',
title: '복구 가능',
message: 'm',
recover_endpoint: '/api/admin/x/1/recover',
extension_type: 'module',
identifier: 'm1',
},
]);
expect(document.querySelector('#alert_recover_button_0'), 'recovery_available 이 아닌 info 에 복구 버튼이 떴다').toBeNull();
expect(document.querySelector('#alert_recover_button_1'), 'recovery_available 의 복구 버튼이 사라졌다').not.toBeNull();
testUtils.cleanup();
});
it('상단으로 올라간 경고도 닫기 버튼을 그대로 갖는다 — 배치가 기능을 빼앗지 않는다', async () => {
const testUtils = await renderAlerts([
{
type: 'warning',
subtype: 'incompatible_core',
icon: 'exclamation',
title: '비호환',
message: 'm',
extension_type: 'plugin',
identifier: 'p1',
},
]);
const dismiss = document.querySelector('#top_alert_dismiss_button_0');
expect(dismiss, '상단 배너에서 닫기 버튼이 사라졌다').not.toBeNull();
testUtils.cleanup();
});
});
@@ -13,7 +13,8 @@
* - 플러그인 카드 (plugin_item / info / name / version / badge)
* - 템플릿 카드 (template_item / info / name / version / badge)
* - 최근 알림 (recent_notification_item / dot / content / subject / recipient / time)
* - 시스템 알림 (alert_item / icon / content / title / message / time / actions / buttons)
* - 시스템 알림 — 하단 카드 (alert_item / icon / content / title / message / time / actions / buttons)
* - 시스템 알림 — 상단 경고 배너 (top_alert_item / ... , 경고 2건 이상 적재)
*
* 수정 전(정적 id): 각 영역 row 2개 이상 mock → 동일 id 가 2회 이상 → 테스트 fail.
* 수정 후(동적 id): id 에 {{$idx}} 접미 → row 별 고유 → 중복 0 → green.
@@ -239,6 +240,7 @@ describe('대시보드 iteration HTML id 유일성', () => {
response: {
data: [
{
type: 'info',
subtype: 'recovery_available',
icon: 'check-circle',
title: '복구 가능 1',
@@ -248,7 +250,9 @@ describe('대시보드 iteration HTML id 유일성', () => {
extension_type: 'module',
identifier: 'm1',
},
// 경고 2건 — 상단 배너 영역이 여러 건을 쌓을 때도 id 가 겹치지 않아야 한다.
{
type: 'warning',
subtype: 'incompatible_core',
icon: 'exclamation',
title: '비호환 1',
@@ -257,6 +261,14 @@ describe('대시보드 iteration HTML id 유일성', () => {
extension_type: 'plugin',
identifier: 'p1',
},
{
type: 'warning',
subtype: 'trusted_proxy_missing',
icon: 'exclamation-triangle',
title: '신뢰 프록시 미설정',
message: '메시지 3',
time: '1분 전',
},
],
},
});
@@ -0,0 +1,250 @@
/**
* @file admin-settings-trusted-proxy-diagnostic.test.tsx
* @description 환경설정 > 고급 의 리버스 프록시 진단 블록 (#124 W8 ②)
*
* 이 블록은 **읽기 전용**이다. 값 편집을 화면에 두지 않는 이유는 둘이다 —
* ① 프록시 뒤에서는 관리자 화면 자체가 뜨지 않는 것이 이 결함이므로 정작 필요한
* 순간에 그 화면에 도달할 수 없다(잠금 역설),
* ② 웹에서 편집 가능해지면 관리자 계정 탈취가 곧 X-Forwarded-For 위조 경로가 된다.
*
* 그래서 입력 컨트롤이 0개라는 사실이 계약이고, 이 테스트가 그것을 잠근다. 값 자체는
* 서버 진단(App\Support\TrustedProxyDiagnostic)이 유일한 판정자이므로 화면은 그것을
* 표시만 한다 — 화면에서 조건을 다시 쓰면 서버와 다른 답을 내놓게 된다.
*
* 합성 레이아웃이 아니라 **실제 _tab_advanced.json** 을 읽는다.
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
const advancedPartial = JSON.parse(
readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_tab_advanced.json'), 'utf-8')
);
const TestDiv: React.FC<any> = ({ id, className, children }) => (
<div id={id} className={className}>
{children}
</div>
);
const TestSpan: React.FC<any> = ({ id, className, children, text }) => (
<span id={id} className={className}>
{children || text}
</span>
);
const TestH3: React.FC<any> = ({ id, className, children, text }) => (
<h3 id={id} className={className}>
{children || text}
</h3>
);
const TestInput: React.FC<any> = ({ name, type }) => <input name={name} type={type} />;
const TestToggle: React.FC<any> = ({ name }) => <input type="checkbox" role="switch" name={name} />;
const TestSelect: React.FC<any> = ({ name }) => <select name={name} />;
const TestTextarea: React.FC<any> = ({ name }) => <textarea name={name} />;
const TestButton: React.FC<any> = ({ children, text }) => <button type="button">{children || text}</button>;
const TestA: React.FC<any> = ({ id, href, target, rel, className, children, text }) => (
<a id={id} href={href} target={target} rel={rel} className={className}>
{children || text}
</a>
);
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
/**
* 테스트용 컴포넌트 레지스트리를 구성합니다.
*
* 입력 계열을 **모두 등록**한다 — 등록하지 않으면 렌더되지 않아 "컨트롤 0개" 가
* 계약 준수가 아니라 미등록 때문에 통과한다.
*
* @returns 구성된 레지스트리
*/
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'basic' } },
Textarea: { component: TestTextarea, metadata: { name: 'Textarea', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
A: { component: TestA, metadata: { name: 'A', type: 'basic' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/**
* id 로 노드를 깊이 우선 탐색합니다.
*
* @param node 탐색 시작 노드
* @param id 찾을 노드 id
* @returns 찾은 노드 또는 null
*/
function findNodeById(node: any, id: string): any {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
for (const child of node.children ?? []) {
const found = findNodeById(child, id);
if (found) return found;
}
return null;
}
/**
* partial 루트들에서 id 노드를 찾습니다.
*
* @param id 찾을 노드 id
* @returns 찾은 노드 또는 null
*/
function findInPartial(id: string): any {
for (const root of advancedPartial.components ?? [advancedPartial]) {
const found = findNodeById(root, id);
if (found) return found;
}
return null;
}
/**
* 주어진 서버 진단 응답으로 카드를 렌더합니다.
*
* @param diagnostic 서버가 내려준 진단 결과
* @returns 레이아웃 테스트 유틸
*/
function renderCard(diagnostic: Record<string, unknown>) {
const card = findInPartial('card_trusted_proxy');
expect(card, 'card_trusted_proxy 노드가 _tab_advanced.json 에 없습니다').not.toBeNull();
const testUtils = createLayoutTest({
version: '1.0.0',
layout_name: 'test_trusted_proxy_diagnostic',
data_sources: [
{
id: 'trustedProxy',
type: 'api',
endpoint: '/api/admin/settings/trusted-proxy',
method: 'GET',
auto_fetch: true,
auth_required: true,
},
],
components: [card],
} as any);
testUtils.mockApi('trustedProxy', { response: { data: diagnostic } });
return testUtils;
}
/** 프록시 헤더는 받는데 신뢰 설정이 없는 상태 (조치 필요) */
const WARNING = {
forwarded_headers: ['X-Forwarded-For', 'X-Forwarded-Proto'],
trusted_configured: false,
configured_proxies: null,
is_secure: false,
client_ip: '10.0.0.5',
remote_addr: '10.0.0.5',
status: 'warning',
};
/** 신뢰 설정이 있는 정상 상태 */
const OK = {
forwarded_headers: ['X-Forwarded-For', 'X-Forwarded-Proto'],
trusted_configured: true,
configured_proxies: '*',
is_secure: true,
client_ip: '203.0.113.77',
remote_addr: '10.0.0.5',
status: 'ok',
};
describe('환경설정 고급 — 리버스 프록시 진단 블록', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
afterEach(() => {
(registry as any).registry = {};
});
it('입력 컨트롤을 하나도 렌더하지 않는다 (읽기 전용 계약)', async () => {
const testUtils = renderCard(WARNING);
await testUtils.render();
// 블록이 실제로 렌더됐음을 먼저 확정한다 — 그래야 아래 부재 단언이 의미를 갖는다.
expect(document.querySelector('#trusted_proxy_status_badge')).not.toBeNull();
expect(
document.querySelectorAll('input, select, textarea').length,
'값 편집은 .env 전용입니다 — 화면에 편집 컨트롤을 두지 않습니다'
).toBe(0);
testUtils.cleanup();
});
it('문서 안내가 실제로 클릭 가능한 GitHub 링크다', async () => {
const testUtils = renderCard(WARNING);
await testUtils.render();
const link = document.querySelector('#trusted_proxy_doc_link') as HTMLAnchorElement | null;
expect(link, '문서 링크가 렌더되지 않았다').not.toBeNull();
expect(link!.getAttribute('href')).toBe(
'https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md',
);
// 관리자 화면을 떠나지 않도록 새 탭으로 열고, opener 를 넘기지 않는다.
expect(link!.getAttribute('target')).toBe('_blank');
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
// 링크는 읽기 전용 계약을 깨지 않는다 (입력 컨트롤이 아니다)
expect(document.querySelectorAll('input, select, textarea').length).toBe(0);
testUtils.cleanup();
});
it('경고 상태에서는 방문자 IP 와 직전 호출 IP 를 나란히 보여 준다', async () => {
const testUtils = renderCard(WARNING);
await testUtils.render();
const text = document.body.textContent ?? '';
// 두 값이 같으면서 프록시 헤더를 받고 있는 상태 = 모든 방문자가 한 사람으로 기록된다.
expect(text).toContain('10.0.0.5');
expect(text).toContain('X-Forwarded-For');
expect(document.querySelector('#trusted_proxy_same_ip_hint'), '동일 IP 안내가 표시되지 않았습니다').not.toBeNull();
testUtils.cleanup();
});
it('정상 상태에서는 설정값을 보여 주고 동일 IP 안내를 감춘다', async () => {
const testUtils = renderCard(OK);
await testUtils.render();
const text = document.body.textContent ?? '';
expect(text).toContain('203.0.113.77');
expect(document.querySelector('#trusted_proxy_same_ip_hint'), '정상 상태에 동일 IP 안내가 떴습니다').toBeNull();
testUtils.cleanup();
});
it('상태 배지는 서버가 준 status 를 그대로 따른다', async () => {
const warning = renderCard(WARNING);
await warning.render();
const warningClass = document.querySelector('#trusted_proxy_status_badge')?.getAttribute('class') ?? '';
expect(warningClass).toContain('bg-amber-100');
warning.cleanup();
const ok = renderCard(OK);
await ok.render();
const okClass = document.querySelector('#trusted_proxy_status_badge')?.getAttribute('class') ?? '';
expect(okClass).toContain('bg-green-100');
ok.cleanup();
});
});
@@ -143,7 +143,7 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () =>
if (testUtils) testUtils.cleanup();
});
it('incompatible_core 알림이 있을 때 시스템 알림 카드가 렌더되고 본문이 표시된다', async () => {
it('경고 등급 알림은 상단 배너에 렌더되고 하단 시스템 알림 카드는 뜨지 않는다', async () => {
testUtils = createLayoutTest(dashboardLayout, {
translations,
locale: 'ko',
@@ -176,6 +176,8 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () =>
response: {
data: [
{
// ExtensionCompatibilityAlertListener 가 실제로 넣는 값 — 배치를 가르는 축이다.
type: 'warning',
subtype: 'incompatible_core',
extension_type: 'plugin',
identifier: 'sirsoft-payment',
@@ -190,12 +192,17 @@ describe('어드민 대시보드 - 시스템 알림 (incompatible_core)', () =>
await testUtils.render();
// 시스템 알림 카드 헤더 (if:false 회귀 검증 — 렌더되어야 함)
expect(screen.getByText('시스템 알림')).toBeTruthy();
// 알림 본문 — title 과 message
// 본문은 상단 배너에 그대로 표시된다 (배치가 바뀌어도 내용이 사라지지 않는다)
expect(screen.getByText('결제 플러그인 자동 비활성화')).toBeTruthy();
expect(screen.getByText('코어 >=7.5.0 필요 (현재 7.0.0)')).toBeTruthy();
expect(screen.getByText('5분 전')).toBeTruthy();
// 경고만 있으면 하단 '시스템 알림' 카드는 뜨지 않는다 — 같은 알림이 두 곳에
// 중복 노출되면 안 된다. 본문이 위에서 확인됐으므로 이 부재 단언이 곧 배치 증거다.
expect(screen.queryByText('시스템 알림')).toBeNull();
// 제목은 정확히 1회만 나타난다 (상단·하단 양쪽 렌더 = 중복 노출 회귀 차단)
expect(screen.queryAllByText('결제 플러그인 자동 비활성화').length).toBe(1);
});
it('알림 배열이 비어있을 때 시스템 알림 카드가 렌더되지 않는다', async () => {
@@ -215,6 +215,7 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
response: {
data: [
{
type: 'info',
subtype: 'recovery_available',
extension_type: 'plugin',
identifier: 'sirsoft-payment',
@@ -288,6 +289,7 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
response: {
data: [
{
type: 'info',
subtype: 'recovery_available',
extension_type: 'plugin',
identifier: 'sirsoft-payment',
@@ -1823,7 +1823,24 @@
"geoip_update_success": "GeoIP DB update completed.",
"geoip_update_failed": "GeoIP DB update failed.",
"seo_sitemap_cache": "Sitemap cache",
"seo_sitemap_cache_desc": "Caches sitemap.xml."
"seo_sitemap_cache_desc": "Caches sitemap.xml.",
"trusted_proxy": "Reverse proxy (trusted proxies)",
"trusted_proxy_desc": "Lets the site recognize the real address and visitor IP when HTTPS is terminated in front of it (AWS ALB, CloudFront, Cloudflare, nginx). The value can only be changed through TRUSTED_PROXIES in .env.",
"trusted_proxy_status": "Diagnostic result",
"trusted_proxy_status_ok": "OK",
"trusted_proxy_status_warning": "Action required",
"trusted_proxy_status_not_applicable": "Cannot determine",
"trusted_proxy_configured": "TRUSTED_PROXIES value",
"trusted_proxy_not_configured": "Not set (no proxy is trusted)",
"trusted_proxy_forwarded_headers": "Forwarded headers received",
"trusted_proxy_forwarded_headers_none": "None",
"trusted_proxy_is_secure": "HTTPS detection",
"trusted_proxy_is_secure_yes": "Detected as HTTPS",
"trusted_proxy_is_secure_no": "Detected as HTTP",
"trusted_proxy_client_ip": "Address recognized as the visitor IP",
"trusted_proxy_remote_addr": "Immediate caller IP (REMOTE_ADDR)",
"trusted_proxy_same_ip_hint": "These two values match while proxy headers are being received — every visitor is being recorded as the same person.",
"trusted_proxy_doc_hint": "Configuration and post-adoption steps:"
},
"notification_definitions": {
"title": "Notification Settings",
@@ -1654,6 +1654,7 @@
"template_info": "Template Info",
"templates": "Templates",
"tokenValidation": "Token Validation",
"trustedProxy": "Trusted proxy diagnostic",
"user": "User",
"users": "Users"
}
@@ -1827,7 +1827,24 @@
"geoip_update_success": "GeoIP DB 업데이트가 완료되었습니다.",
"geoip_update_failed": "GeoIP DB 업데이트에 실패했습니다.",
"seo_sitemap_cache": "Sitemap 캐시",
"seo_sitemap_cache_desc": "sitemap.xml 을 캐시합니다."
"seo_sitemap_cache_desc": "sitemap.xml 을 캐시합니다.",
"trusted_proxy": "리버스 프록시 (신뢰 프록시)",
"trusted_proxy_desc": "HTTPS 를 앞단에서 처리하는 구성(AWS ALB, CloudFront, Cloudflare, nginx)에서 접속 주소와 방문자 IP 를 올바르게 인식하기 위한 설정입니다. 값은 .env 의 TRUSTED_PROXIES 로만 변경할 수 있습니다.",
"trusted_proxy_status": "진단 결과",
"trusted_proxy_status_ok": "정상",
"trusted_proxy_status_warning": "조치 필요",
"trusted_proxy_status_not_applicable": "판정 불가",
"trusted_proxy_configured": "TRUSTED_PROXIES 설정값",
"trusted_proxy_not_configured": "미설정 (아무 프록시도 신뢰하지 않음)",
"trusted_proxy_forwarded_headers": "수신 중인 프록시 헤더",
"trusted_proxy_forwarded_headers_none": "없음",
"trusted_proxy_is_secure": "HTTPS 인식",
"trusted_proxy_is_secure_yes": "HTTPS 로 인식",
"trusted_proxy_is_secure_no": "HTTP 로 인식",
"trusted_proxy_client_ip": "방문자 IP 로 인식된 값",
"trusted_proxy_remote_addr": "직전 호출 IP (REMOTE_ADDR)",
"trusted_proxy_same_ip_hint": "두 값이 같으면서 프록시 헤더를 받고 있습니다 — 모든 방문자가 한 사람으로 기록되고 있는 상태입니다.",
"trusted_proxy_doc_hint": "설정 방법과 도입 시 후속 조치:"
},
"notification_definitions": {
"title": "알림 설정",
@@ -1654,6 +1654,7 @@
"template_info": "템플릿 정보",
"templates": "템플릿 목록",
"tokenValidation": "토큰 검증",
"trustedProxy": "신뢰 프록시 진단",
"user": "사용자",
"users": "사용자 목록"
}
@@ -346,6 +346,207 @@
}
]
},
{
"id": "dashboard_alert_banners",
"if": "{{((dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type === 'warning').length) > 0}}",
"permissions": [
"core.dashboard.alerts"
],
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3 mb-6"
},
"children": [
{
"id": "top_alert_item_{{$idx}}",
"type": "basic",
"name": "Div",
"props": {
"className": "{{alert.type === 'warning' ? 'flex items-start gap-3 p-4 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800' : (alert.type === 'info' ? 'flex items-start gap-3 p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' : 'flex items-start gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-700')}}"
},
"iteration": {
"source": "{{(dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type === 'warning')}}",
"item_var": "alert",
"index_var": "$idx"
},
"children": [
{
"id": "top_alert_icon_{{$idx}}",
"type": "basic",
"name": "Icon",
"props": {
"name": "{{alert.icon ?? 'info-circle'}}",
"className": "{{alert.type === 'warning' ? 'text-amber-600 dark:text-amber-400 mt-0.5 shrink-0' : (alert.type === 'info' ? 'text-blue-600 dark:text-blue-400 mt-0.5 shrink-0' : 'text-gray-500 dark:text-gray-400 mt-0.5 shrink-0')}}"
}
},
{
"id": "top_alert_content_{{$idx}}",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"id": "top_alert_title_{{$idx}}",
"type": "basic",
"name": "P",
"props": {
"className": "text-heading"
},
"text": "{{alert.title ?? ''}}"
},
{
"id": "top_alert_message_{{$idx}}",
"type": "basic",
"name": "P",
"if": "{{alert.message}}",
"props": {
"className": "text-secondary text-xs mt-0.5"
},
"text": "{{alert.message ?? ''}}"
},
{
"id": "top_alert_time_{{$idx}}",
"type": "basic",
"name": "P",
"if": "{{alert.time}}",
"props": {
"className": "form-hint"
},
"text": "{{alert.time ?? ''}}"
},
{
"id": "top_alert_actions_{{$idx}}",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 mt-2"
},
"children": [
{
"id": "top_alert_recover_button_{{$idx}}",
"type": "basic",
"name": "Button",
"if": "{{alert.subtype === 'recovery_available'}}",
"props": {
"type": "button",
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white rounded"
},
"actions": [
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "{{alert.recover_endpoint}}",
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:extensions.alerts.recovered_success"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "dashboard_alerts"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "check-circle",
"size": "xs"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:extensions.alerts.recover_action"
}
]
},
{
"id": "top_alert_dismiss_button_{{$idx}}",
"type": "basic",
"name": "Button",
"if": "{{alert.extension_type && alert.identifier}}",
"props": {
"type": "button",
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 rounded",
"aria-label": "$t:extensions.alerts.dismiss_action"
},
"actions": [
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/extensions/{{alert.extension_type}}/{{alert.identifier}}/dismiss",
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "dashboard_alerts"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "times",
"size": "xs"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:extensions.alerts.dismiss_action"
}
]
}
]
}
]
}
]
}
]
},
{
"id": "quick_menu",
"type": "basic",
@@ -1386,7 +1587,7 @@
"children": [
{
"id": "system_alerts_card",
"if": "{{(dashboard_alerts?.data?.data ? dashboard_alerts.data.data.length : (dashboard_alerts?.data?.length ?? 0)) > 0}}",
"if": "{{((dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type !== 'warning').length) > 0}}",
"permissions": [
"core.dashboard.alerts"
],
@@ -1432,17 +1633,19 @@
"id": "alerts_list",
"type": "basic",
"name": "Div",
"props": {},
"props": {
"className": "space-y-3"
},
"children": [
{
"id": "alert_item_{{$idx}}",
"type": "basic",
"name": "Div",
"props": {
"className": "{{alert.subtype === 'incompatible_core' ? 'flex items-start gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800' : (alert.subtype === 'recovery_available' ? 'flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' : 'flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700')}}"
"className": "{{alert.type === 'warning' ? 'flex items-start gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800' : (alert.type === 'info' ? 'flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800' : 'flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700')}}"
},
"iteration": {
"source": "{{dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])}}",
"source": "{{(dashboard_alerts?.data?.data ? dashboard_alerts.data.data : (dashboard_alerts?.data ?? [])).filter(a => a.type !== 'warning')}}",
"item_var": "alert",
"index_var": "$idx"
},
@@ -1453,7 +1656,7 @@
"name": "Icon",
"props": {
"name": "{{alert.icon ?? 'info-circle'}}",
"className": "{{alert.subtype === 'incompatible_core' ? 'text-amber-600 dark:text-amber-400 mt-0.5 shrink-0' : (alert.subtype === 'recovery_available' ? 'text-blue-600 dark:text-blue-400 mt-0.5 shrink-0' : 'text-gray-500 dark:text-gray-400 mt-0.5 shrink-0')}}"
"className": "{{alert.type === 'warning' ? 'text-amber-600 dark:text-amber-400 mt-0.5 shrink-0' : (alert.type === 'info' ? 'text-blue-600 dark:text-blue-400 mt-0.5 shrink-0' : 'text-gray-500 dark:text-gray-400 mt-0.5 shrink-0')}}"
}
},
{
@@ -126,6 +126,27 @@
}
}
},
{
"id": "trustedProxy",
"label_key": "$t:editor.data_source.trustedProxy",
"type": "api",
"endpoint": "/api/admin/settings/trusted-proxy",
"method": "GET",
"if": "{{(query.tab || _global.activeSettingsTab || 'general') === 'advanced'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": {
"forwarded_headers": [],
"trusted_configured": false,
"configured_proxies": null,
"is_secure": null,
"client_ip": null,
"remote_addr": null,
"status": "not_applicable"
}
}
},
{
"id": "appKey",
"label_key": "$t:editor.data_source.appKey",
@@ -1683,6 +1683,230 @@
]
}
]
},
{
"id": "card_trusted_proxy",
"type": "basic",
"name": "Div",
"props": {
"className": "card-stack-item"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "card-title"
},
"text": "$t:admin.settings.advanced.trusted_proxy"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "card-description"
},
"text": "$t:admin.settings.advanced.trusted_proxy_desc"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"id": "trusted_proxy_status_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:admin.settings.advanced.trusted_proxy_status"
},
{
"id": "trusted_proxy_status_badge",
"type": "basic",
"name": "Span",
"props": {
"className": "{{trustedProxy?.data?.status === 'warning' ? 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : (trustedProxy?.data?.status === 'ok' ? 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300')}}"
},
"text": "{{trustedProxy?.data?.status === 'warning' ? $t('admin.settings.advanced.trusted_proxy_status_warning') : (trustedProxy?.data?.status === 'ok' ? $t('admin.settings.advanced.trusted_proxy_status_ok') : $t('admin.settings.advanced.trusted_proxy_status_not_applicable'))}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:admin.settings.advanced.trusted_proxy_configured"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "{{trustedProxy?.data?.trusted_configured ? (trustedProxy?.data?.configured_proxies ?? '-') : $t('admin.settings.advanced.trusted_proxy_not_configured')}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:admin.settings.advanced.trusted_proxy_forwarded_headers"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "{{(trustedProxy?.data?.forwarded_headers ?? []).length > 0 ? trustedProxy?.data.forwarded_headers.join(', ') : $t('admin.settings.advanced.trusted_proxy_forwarded_headers_none')}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:admin.settings.advanced.trusted_proxy_is_secure"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "{{trustedProxy?.data?.is_secure === true ? $t('admin.settings.advanced.trusted_proxy_is_secure_yes') : (trustedProxy?.data?.is_secure === false ? $t('admin.settings.advanced.trusted_proxy_is_secure_no') : '-')}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:admin.settings.advanced.trusted_proxy_client_ip"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "{{trustedProxy?.data?.client_ip ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:admin.settings.advanced.trusted_proxy_remote_addr"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "{{trustedProxy?.data?.remote_addr ?? '-'}}"
}
]
},
{
"id": "trusted_proxy_same_ip_hint",
"type": "basic",
"name": "Div",
"if": "{{trustedProxy?.data?.status === 'warning' && trustedProxy?.data?.client_ip && trustedProxy?.data?.client_ip === trustedProxy?.data?.remote_addr}}",
"props": {
"className": "form-hint text-amber-600 dark:text-amber-400"
},
"text": "$t:admin.settings.advanced.trusted_proxy_same_ip_hint"
},
{
"id": "trusted_proxy_doc_hint",
"type": "basic",
"name": "Div",
"props": {
"className": "form-hint"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.advanced.trusted_proxy_doc_hint"
},
{
"id": "trusted_proxy_doc_link",
"type": "basic",
"name": "A",
"props": {
"href": "https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "ml-1 underline hover:no-underline"
},
"text": "docs/backend/reverse-proxy.md"
}
]
}
]
}
]
}
],
"props": {