427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { appendClientIdHeader } from './clientIdentity';
|
|
import { getRegisteredAccessToken, isAllowedRegistrationToken } from './tokenAccess';
|
|
|
|
const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER;
|
|
|
|
export type TokenSettingRecord = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
defaultExpiresInMinutes: number;
|
|
maxExpiresInMinutes: number;
|
|
maxTokensPer30Days: number;
|
|
maxTokensPer7Days: number;
|
|
maxTokensPer5Hours: number;
|
|
oneTimeTokenLimit: number;
|
|
allowedAppIds: string[];
|
|
enabled: boolean;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type TokenSettingActivityRecord = {
|
|
id: number;
|
|
settingId: string;
|
|
activityType: 'created' | 'updated' | 'deleted';
|
|
actorLabel: string | null;
|
|
summary: string;
|
|
detail: string | null;
|
|
clientIp: string | null;
|
|
externalIp: string | null;
|
|
forwardedFor: string | null;
|
|
realIp: string | null;
|
|
host: string | null;
|
|
origin: string | null;
|
|
referer: string | null;
|
|
userAgent: string | null;
|
|
clientId: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type TokenSettingInput = {
|
|
originalId?: string;
|
|
id?: string;
|
|
name: string;
|
|
description?: string;
|
|
defaultExpiresInMinutes?: number;
|
|
maxExpiresInMinutes?: number;
|
|
maxTokensPer30Days?: number;
|
|
maxTokensPer7Days?: number;
|
|
maxTokensPer5Hours?: number;
|
|
oneTimeTokenLimit?: number;
|
|
allowedAppIds?: string[];
|
|
enabled?: boolean;
|
|
};
|
|
|
|
const TOKEN_SETTINGS_API_PATH = '/token-settings';
|
|
const TOKEN_SETTINGS_SYNC_EVENT = 'work-app:token-settings-changed';
|
|
const TOKEN_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
|
|
|
|
type TokenSettingsRequestOptions = {
|
|
shareToken?: string | null;
|
|
path?: string | null;
|
|
};
|
|
|
|
class TokenSettingApiError extends Error {
|
|
status: number;
|
|
|
|
constructor(message: string, status: number) {
|
|
super(message);
|
|
this.name = 'TokenSettingApiError';
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
function normalizeText(value: string | null | undefined) {
|
|
return value?.trim() ?? '';
|
|
}
|
|
|
|
function normalizeSettingId(value: string | null | undefined) {
|
|
return normalizeText(value)
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^a-z0-9._-]/g, '');
|
|
}
|
|
|
|
function normalizePositiveInteger(value: number | undefined, fallback: number, min: number, max: number) {
|
|
if (value === undefined || !Number.isFinite(value)) {
|
|
return fallback;
|
|
}
|
|
|
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
}
|
|
|
|
function normalizeAllowedAppIds(value: string[] | null | undefined) {
|
|
return Array.from(
|
|
new Set(
|
|
(value ?? [])
|
|
.map((item) => normalizeText(item))
|
|
.filter(Boolean),
|
|
),
|
|
).sort((left, right) => left.localeCompare(right, 'en'));
|
|
}
|
|
|
|
function normalizeTokenSetting(record: Partial<TokenSettingRecord>): TokenSettingRecord | null {
|
|
const id = normalizeSettingId(record.id);
|
|
const name = normalizeText(record.name);
|
|
|
|
if (!id || !name) {
|
|
return null;
|
|
}
|
|
|
|
const defaultExpiresInMinutes = normalizePositiveInteger(record.defaultExpiresInMinutes, 60, 0, UNBOUNDED_NUMERIC_LIMIT);
|
|
const resolvedMaxExpiresInMinutes = normalizePositiveInteger(
|
|
record.maxExpiresInMinutes,
|
|
defaultExpiresInMinutes <= 0 ? 0 : 10_080,
|
|
0,
|
|
UNBOUNDED_NUMERIC_LIMIT,
|
|
);
|
|
const maxExpiresInMinutes =
|
|
defaultExpiresInMinutes <= 0 || resolvedMaxExpiresInMinutes <= 0
|
|
? 0
|
|
: Math.max(defaultExpiresInMinutes, resolvedMaxExpiresInMinutes);
|
|
|
|
const legacyMaxTotalTokens =
|
|
'maxTotalTokens' in record
|
|
? normalizePositiveInteger((record as { maxTotalTokens?: number }).maxTotalTokens, 100_000, 0, UNBOUNDED_NUMERIC_LIMIT)
|
|
: 100_000;
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
description: normalizeText(record.description),
|
|
defaultExpiresInMinutes,
|
|
maxExpiresInMinutes,
|
|
maxTokensPer30Days: normalizePositiveInteger(record.maxTokensPer30Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
|
maxTokensPer7Days: normalizePositiveInteger(record.maxTokensPer7Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
|
maxTokensPer5Hours: normalizePositiveInteger(record.maxTokensPer5Hours, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
|
oneTimeTokenLimit: normalizePositiveInteger(record.oneTimeTokenLimit, 0, 0, UNBOUNDED_NUMERIC_LIMIT),
|
|
allowedAppIds: normalizeAllowedAppIds(record.allowedAppIds),
|
|
enabled: record.enabled !== false,
|
|
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
export function sanitizeTokenSettings(items: Partial<TokenSettingRecord>[] | null | undefined) {
|
|
const byId = new Map<string, TokenSettingRecord>();
|
|
|
|
for (const item of items ?? []) {
|
|
const normalized = normalizeTokenSetting(item);
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
|
|
const current = byId.get(normalized.id);
|
|
if (!current || Date.parse(current.updatedAt) <= Date.parse(normalized.updatedAt)) {
|
|
byId.set(normalized.id, normalized);
|
|
}
|
|
}
|
|
|
|
return Array.from(byId.values()).sort((left, right) => {
|
|
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
|
|
if (nameCompare !== 0) {
|
|
return nameCompare;
|
|
}
|
|
|
|
return left.id.localeCompare(right.id, 'en');
|
|
});
|
|
}
|
|
|
|
function emitTokenSettingsChange() {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
window.dispatchEvent(new Event(TOKEN_SETTINGS_SYNC_EVENT));
|
|
}
|
|
|
|
function resolveApiBaseUrl() {
|
|
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
|
return import.meta.env.VITE_WORK_SERVER_URL;
|
|
}
|
|
|
|
return '/api';
|
|
}
|
|
|
|
function resolveFallbackBaseUrl() {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const hostname = window.location.hostname;
|
|
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
|
|
return null;
|
|
}
|
|
|
|
const fallbackUrl = new URL(window.location.origin);
|
|
fallbackUrl.port = '3100';
|
|
fallbackUrl.pathname = '/api';
|
|
fallbackUrl.search = '';
|
|
fallbackUrl.hash = '';
|
|
return fallbackUrl.toString().replace(/\/+$/, '');
|
|
}
|
|
|
|
const API_BASE_URL = resolveApiBaseUrl();
|
|
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
|
|
|
|
async function requestOnce<T>(baseUrl: string, init?: RequestInit, options?: TokenSettingsRequestOptions): Promise<T> {
|
|
const headers = appendClientIdHeader(init?.headers);
|
|
const token = getRegisteredAccessToken();
|
|
const hasBody = init?.body !== undefined && init?.body !== null;
|
|
const controller = new AbortController();
|
|
const timeoutId = window.setTimeout(() => controller.abort(), TOKEN_SETTINGS_REQUEST_TIMEOUT_MS);
|
|
const shareToken = options?.shareToken?.trim() ?? '';
|
|
|
|
if (!shareToken && !isAllowedRegistrationToken(token)) {
|
|
throw new TokenSettingApiError('권한 토큰 등록 후에만 토큰 설정을 관리할 수 있습니다.', 403);
|
|
}
|
|
|
|
if (hasBody && !headers.has('Content-Type')) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
|
|
if (token && !headers.has('X-Access-Token')) {
|
|
headers.set('X-Access-Token', token);
|
|
}
|
|
|
|
if (shareToken && !headers.has('X-Chat-Share-Token')) {
|
|
headers.set('X-Chat-Share-Token', shareToken);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${baseUrl}${options?.path?.trim() || TOKEN_SETTINGS_API_PATH}`, {
|
|
...init,
|
|
headers,
|
|
signal: controller.signal,
|
|
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
|
|
try {
|
|
const payload = JSON.parse(text) as { message?: string };
|
|
throw new TokenSettingApiError(payload.message || '토큰 설정 요청에 실패했습니다.', response.status);
|
|
} catch {
|
|
throw new TokenSettingApiError(text || '토큰 설정 요청에 실패했습니다.', response.status);
|
|
}
|
|
}
|
|
|
|
const text = await response.text();
|
|
|
|
if (!text.trim()) {
|
|
throw new TokenSettingApiError('토큰 설정 응답이 비어 있습니다.', 502);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text) as T;
|
|
} catch {
|
|
throw new TokenSettingApiError('토큰 설정 응답을 해석하지 못했습니다.', 502);
|
|
}
|
|
} finally {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
async function requestTokenSettings<T>(init?: RequestInit, options?: TokenSettingsRequestOptions) {
|
|
try {
|
|
return await requestOnce<T>(API_BASE_URL, init, options);
|
|
} catch (error) {
|
|
const shouldRetryWithFallback =
|
|
FALLBACK_BASE_URL &&
|
|
FALLBACK_BASE_URL !== API_BASE_URL &&
|
|
(error instanceof TokenSettingApiError
|
|
? error.status === 404 || error.status === 408 || error.status === 502
|
|
: error instanceof Error && /404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message));
|
|
|
|
if (!shouldRetryWithFallback) {
|
|
throw error;
|
|
}
|
|
|
|
return requestOnce<T>(FALLBACK_BASE_URL, init, options);
|
|
}
|
|
}
|
|
|
|
function normalizeActivityRecord(record: Partial<TokenSettingActivityRecord>): TokenSettingActivityRecord {
|
|
return {
|
|
id: Number(record.id ?? 0),
|
|
settingId: normalizeSettingId(record.settingId),
|
|
activityType: record.activityType === 'created' || record.activityType === 'deleted' ? record.activityType : 'updated',
|
|
actorLabel: normalizeText(record.actorLabel) || null,
|
|
summary: normalizeText(record.summary),
|
|
detail: normalizeText(record.detail) || null,
|
|
clientIp: normalizeText(record.clientIp) || null,
|
|
externalIp: normalizeText(record.externalIp) || null,
|
|
forwardedFor: normalizeText(record.forwardedFor) || null,
|
|
realIp: normalizeText(record.realIp) || null,
|
|
host: normalizeText(record.host) || null,
|
|
origin: normalizeText(record.origin) || null,
|
|
referer: normalizeText(record.referer) || null,
|
|
userAgent: normalizeText(record.userAgent) || null,
|
|
clientId: normalizeText(record.clientId) || null,
|
|
createdAt: normalizeText(record.createdAt) || new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async function loadTokenSettingsFromServer(options?: TokenSettingsRequestOptions) {
|
|
const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial<TokenSettingRecord>[] | null }>({
|
|
method: 'GET',
|
|
}, options);
|
|
|
|
return sanitizeTokenSettings(response.tokenSettings);
|
|
}
|
|
|
|
async function saveTokenSettingsToServer(items: TokenSettingRecord[], options?: TokenSettingsRequestOptions) {
|
|
const resolved = sanitizeTokenSettings(items);
|
|
const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial<TokenSettingRecord>[] }>({
|
|
method: 'PUT',
|
|
body: JSON.stringify({ tokenSettings: resolved }),
|
|
}, options);
|
|
|
|
return sanitizeTokenSettings(response.tokenSettings);
|
|
}
|
|
|
|
export async function fetchTokenSettingActivities(settingId: string, options?: TokenSettingsRequestOptions) {
|
|
const normalizedSettingId = normalizeSettingId(settingId);
|
|
if (!normalizedSettingId) {
|
|
return [];
|
|
}
|
|
|
|
const response = await requestTokenSettings<{ ok: boolean; activities: Partial<TokenSettingActivityRecord>[] | null }>(
|
|
{ method: 'GET' },
|
|
{ shareToken: options?.shareToken, path: `${TOKEN_SETTINGS_API_PATH}/${encodeURIComponent(normalizedSettingId)}/activities` },
|
|
);
|
|
|
|
return Array.isArray(response.activities) ? response.activities.map((item) => normalizeActivityRecord(item)) : [];
|
|
}
|
|
|
|
export function upsertTokenSetting(items: TokenSettingRecord[], input: TokenSettingInput) {
|
|
const nextItem = normalizeTokenSetting({
|
|
...input,
|
|
id: input.id,
|
|
});
|
|
|
|
if (!nextItem) {
|
|
return sanitizeTokenSettings(items);
|
|
}
|
|
|
|
const originalId = normalizeSettingId(input.originalId);
|
|
const nextItems = items.filter((item) => item.id !== nextItem.id && item.id !== originalId);
|
|
nextItems.push(nextItem);
|
|
return sanitizeTokenSettings(nextItems);
|
|
}
|
|
|
|
export function deleteTokenSetting(items: TokenSettingRecord[], tokenSettingId: string) {
|
|
return sanitizeTokenSettings(items.filter((item) => item.id !== tokenSettingId));
|
|
}
|
|
|
|
export function useTokenSettingRegistry(enabled = true, options?: TokenSettingsRequestOptions) {
|
|
const [tokenSettings, setTokenSettingsState] = useState<TokenSettingRecord[]>([]);
|
|
const [isLoading, setIsLoading] = useState(enabled);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const loadVersionRef = useRef(0);
|
|
const shareToken = options?.shareToken?.trim() ?? '';
|
|
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
setTokenSettingsState([]);
|
|
setIsLoading(false);
|
|
setErrorMessage('');
|
|
return undefined;
|
|
}
|
|
|
|
let mounted = true;
|
|
|
|
const load = async () => {
|
|
const loadVersion = ++loadVersionRef.current;
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
|
|
try {
|
|
const nextItems = await loadTokenSettingsFromServer({ shareToken });
|
|
if (!mounted || loadVersion !== loadVersionRef.current) {
|
|
return;
|
|
}
|
|
|
|
setTokenSettingsState(nextItems);
|
|
} catch (error) {
|
|
if (!mounted || loadVersion !== loadVersionRef.current) {
|
|
return;
|
|
}
|
|
|
|
setErrorMessage(error instanceof Error ? error.message : '토큰 설정을 불러오지 못했습니다.');
|
|
} finally {
|
|
if (mounted && loadVersion === loadVersionRef.current) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void load();
|
|
|
|
const handleSync = () => {
|
|
void load();
|
|
};
|
|
|
|
window.addEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync);
|
|
return () => {
|
|
mounted = false;
|
|
window.removeEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync);
|
|
};
|
|
}, [enabled, shareToken]);
|
|
|
|
const setTokenSettings = async (items: TokenSettingRecord[]) => {
|
|
const savedItems = await saveTokenSettingsToServer(items, { shareToken });
|
|
setTokenSettingsState(savedItems);
|
|
emitTokenSettingsChange();
|
|
return savedItems;
|
|
};
|
|
|
|
return {
|
|
tokenSettings,
|
|
setTokenSettings,
|
|
isLoading,
|
|
errorMessage,
|
|
};
|
|
}
|