Files
baron_qa_write/src/index.js
T
root 9bbd9ed8ce
Deploy EG-BIM QA Gateway / deploy (push) Successful in 40s
디렉토리 구조 개선(egbim 분리)
2026-09-21 16:48:42 +09:00

383 lines
19 KiB
JavaScript

const encoder = new TextEncoder();
const decoder = new TextDecoder();
export default {
async fetch(request, env) {
const url = new URL(request.url);
try {
if (url.pathname === '/auth/login') return startLogin(request, env);
if (url.pathname === '/auth/callback') return finishLogin(request, env);
if (url.pathname === '/auth/logout') return logout(request, env);
if (url.pathname === '/auth/session') return sessionResponse(request, env);
if (url.pathname === '/api/feedbacks') return createFeedback(request, env);
if (request.method !== 'GET' && request.method !== 'HEAD') {
return json({ error: 'method_not_allowed' }, 405);
}
if (url.pathname === '/' || url.pathname === '/index.html') {
return Response.redirect(new URL('/egbim/', url), 301);
}
if (matchesConfiguredPath(url.pathname, env.INTERNAL_ONLY_PREFIXES) && !isInternalRequest(request, env)) {
return json({ error: 'not_found' }, 404);
}
if (isProtectedPath(url.pathname, env) && !(await getSession(request, env))) {
const returnUrl = url.pathname + url.search;
return Response.redirect(new URL('/auth/login?return_url=' + encodeURIComponent(returnUrl), url), 302);
}
if (url.pathname === '/egbim' || url.pathname === '/tova' || url.pathname === '/gaia') {
return Response.redirect(new URL(url.pathname + '/', url), 301);
}
const objectKey = objectKeyForPath(url.pathname);
let object = await env.QA_BUCKET.get(objectKey);
if (!object && objectKey.endsWith('/index.html')) {
object = await env.QA_BUCKET.get(objectKey.replace(/\/index\.html$/, 'index.html'));
}
if (!object) return new Response('Not found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('x-content-type-options', 'nosniff');
headers.set('referrer-policy', 'strict-origin-when-cross-origin');
headers.set('cache-control', url.pathname.endsWith('.html') || url.pathname.endsWith('.js') ? 'no-cache' : 'public, max-age=3600');
return new Response(request.method === 'HEAD' ? null : object.body, { headers });
} catch (error) {
console.error(error);
return json({ error: 'internal_error' }, 500);
}
}
};
function csv(value) {
return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
}
function pathMatches(pathname, values) {
return csv(values).some((value) => value.endsWith('/') ? pathname.startsWith(value) : pathname === value);
}
function matchesConfiguredPath(pathname, value) {
return pathMatches(pathname, value);
}
function isProtectedPath(pathname, env) {
return pathMatches(pathname, env.PROTECTED_EXACT_PATHS) || pathMatches(pathname, env.PROTECTED_PREFIXES);
}
function isInternalRequest(request, env) {
const expected = env.INTERNAL_SHARED_SECRET;
return Boolean(expected && request.headers.get('x-internal-secret') === expected);
}
function objectKeyForPath(pathname) {
const normalized = pathname.replace(/^\/+/, '');
if (normalized === '') return 'index.html';
if (normalized.endsWith('/')) return normalized + 'index.html';
return normalized;
}
function json(payload, status = 200, extraHeaders = {}) {
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extraHeaders });
return new Response(JSON.stringify(payload), { status, headers });
}
function redirect(url, headers = {}) {
const responseHeaders = new Headers();
Object.entries(headers).forEach(([name, value]) => {
if (Array.isArray(value)) value.forEach((item) => responseHeaders.append(name, item));
else responseHeaders.set(name, value);
});
responseHeaders.set('location', url);
return new Response(null, { status: 302, headers: responseHeaders });
}
function base64url(value) {
const bytes = value instanceof Uint8Array ? value : encoder.encode(value);
let binary = '';
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function fromBase64url(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
async function sha256(value) {
return crypto.subtle.digest('SHA-256', typeof value === 'string' ? encoder.encode(value) : value);
}
async function hmac(value, secret) {
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
return crypto.subtle.sign('HMAC', key, encoder.encode(value));
}
async function signValue(value, secret) {
return value + '.' + base64url(new Uint8Array(await hmac(value, secret)));
}
async function verifyValue(signed, secret) {
if (!signed || !secret) return null;
const separator = signed.lastIndexOf('.');
if (separator < 1) return null;
const value = signed.slice(0, separator);
const signature = signed.slice(separator + 1);
const expected = base64url(new Uint8Array(await hmac(value, secret)));
if (signature.length !== expected.length) return null;
let difference = 0;
for (let index = 0; index < signature.length; index += 1) difference |= signature.charCodeAt(index) ^ expected.charCodeAt(index);
return difference === 0 ? value : null;
}
function randomBytes(size = 32) {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return bytes;
}
function randomString(size = 32) {
return base64url(randomBytes(size));
}
function uuidv7() {
const bytes = randomBytes(16);
const timestamp = Date.now();
bytes[0] = Math.floor(timestamp / 0x10000000000) & 0xff;
bytes[1] = Math.floor(timestamp / 0x100000000) & 0xff;
bytes[2] = Math.floor(timestamp / 0x1000000) & 0xff;
bytes[3] = Math.floor(timestamp / 0x10000) & 0xff;
bytes[4] = Math.floor(timestamp / 0x100) & 0xff;
bytes[5] = timestamp & 0xff;
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function getCookie(request, name) {
const header = request.headers.get('cookie') || '';
const match = header.split(';').map((part) => part.trim()).find((part) => part.startsWith(name + '='));
return match ? match.slice(name.length + 1) : '';
}
function cookie(name, value, maxAge, options = {}) {
const parts = [`${name}=${value}`, `Max-Age=${maxAge}`, 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax'];
if (options.delete) parts.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT');
return parts.join('; ');
}
function parseJwt(token) {
if (!token || token.split('.').length < 2) return {};
try { return JSON.parse(decoder.decode(fromBase64url(token.split('.')[1]))); } catch (error) { return {}; }
}
function requiredAuthConfig(env) {
const keys = ['AUTH_CLIENT_ID', 'AUTH_AUTHORIZE_URL', 'AUTH_TOKEN_URL', 'AUTH_REDIRECT_URI', 'SESSION_SECRET'];
return keys.filter((key) => !env[key]);
}
function normalizeClaims(claims) {
const profile = claims.profile && typeof claims.profile === 'object' ? claims.profile : {};
const custom = claims.customAttributes || claims.custom_attributes || {};
const profilePhones = Array.isArray(profile.phones) ? profile.phones : (Array.isArray(claims.phones) ? claims.phones : []);
const firstProfilePhone = profilePhones[0];
const profilePhone = profile.phone || profile.phone_number || profile.phoneNumber || profile.mobile || profile.mobilePhone || firstProfilePhone?.phoneNumber || firstProfilePhone?.phone_number || firstProfilePhone?.number || firstProfilePhone?.value || firstProfilePhone?.phone || firstProfilePhone || '';
const customPhone = custom.phone || custom.phone_number || custom.phoneNumber || custom.mobile || custom.mobilePhone || '';
const tenants = claims.tenants || claims.tenantIds || custom.tenants || [];
const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map((key) => ({ id: key, ...(tenants[key] || {}) }));
const tenantId = claims.tenant_id || claims.tenantId || custom.tenant_id || custom.tenantId || tenantList[0]?.id || tenantList[0]?.tenantId || '';
const tenantIds = tenantList.map((tenant) => typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || '')).filter(Boolean);
if (tenantId && !tenantIds.includes(tenantId)) tenantIds.unshift(tenantId);
const primaryTenant = tenantList.find((tenant) => typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId) || tenantList[0] || {};
const subject = claims.sub || claims.subject || claims.userId || claims.user_id || '';
return {
userUuid: claims.userId || claims.user_id || subject,
ssoSubject: subject,
requesterId: subject,
tenantId,
requesterTenantId: tenantId,
tenantIds,
scope: claims.scope || '',
roles: claims.roles || claims.roleNames || [],
loginId: claims.email || claims.loginId || claims.preferred_username || profile.email || '',
email: claims.email || profile.email || '',
name: claims.name || profile.name || claims.display_name || '',
phone: claims.phone || claims.phone_number || claims.phoneNumber || claims.mobile || claims.mobilePhone || customPhone || profilePhone,
company: custom.company || claims.company || claims.organization || profile.company || '',
familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || claims.affiliation || '',
department: custom.team || custom.department || claims.department || primaryTenant.name || '',
position: custom.position || claims.position || profile.position || primaryTenant.position || primaryTenant.title || '',
grade: custom.grade || claims.grade || profile.grade || primaryTenant.grade || ''
};
}
function safeReturnUrl(value, origin) {
try {
const target = new URL(value || '/', origin);
return target.origin === origin ? target.pathname + target.search : '/';
} catch (error) { return '/'; }
}
async function startLogin(request, env) {
const missing = requiredAuthConfig(env);
if (missing.length) return json({ error: 'auth_not_configured', missing }, 503);
const url = new URL(request.url);
const returnUrl = safeReturnUrl(url.searchParams.get('return_url'), url.origin);
const state = randomString(24);
const verifier = randomString(48);
const nonce = randomString(24);
const statePayload = base64url(JSON.stringify({ state, verifier, nonce, returnUrl, createdAt: Date.now() }));
const signedState = await signValue(statePayload, env.SESSION_SECRET);
const challenge = base64url(new Uint8Array(await sha256(verifier)));
const authorize = new URL(env.AUTH_AUTHORIZE_URL);
authorize.searchParams.set('client_id', env.AUTH_CLIENT_ID);
authorize.searchParams.set('response_type', 'code');
authorize.searchParams.set('redirect_uri', env.AUTH_REDIRECT_URI);
authorize.searchParams.set('scope', env.AUTH_SCOPE || 'openid profile email');
authorize.searchParams.set('state', state);
authorize.searchParams.set('nonce', nonce);
authorize.searchParams.set('code_challenge', challenge);
authorize.searchParams.set('code_challenge_method', 'S256');
return redirect(authorize.toString(), { 'set-cookie': cookie(env.OAUTH_COOKIE_NAME, signedState, 600) });
}
async function finishLogin(request, env) {
const missing = requiredAuthConfig(env);
if (missing.length) return json({ error: 'auth_not_configured', missing }, 503);
const url = new URL(request.url);
const signedState = getCookie(request, env.OAUTH_COOKIE_NAME);
const statePayload = await verifyValue(signedState, env.SESSION_SECRET);
if (!statePayload) return json({ error: 'invalid_oauth_state' }, 400);
let state;
try { state = JSON.parse(decoder.decode(fromBase64url(statePayload))); } catch (error) { return json({ error: 'invalid_oauth_state' }, 400); }
if (url.searchParams.get('state') !== state.state || Date.now() - state.createdAt > 10 * 60 * 1000) return json({ error: 'invalid_oauth_state' }, 400);
const code = url.searchParams.get('code');
if (!code) return json({ error: 'oauth_code_missing', detail: url.searchParams.get('error_description') || url.searchParams.get('error') || '' }, 400);
const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', client_id: env.AUTH_CLIENT_ID, redirect_uri: env.AUTH_REDIRECT_URI, code, code_verifier: state.verifier });
const tokenResponse = await fetch(env.AUTH_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: tokenParams });
const tokens = await tokenResponse.json().catch(() => ({}));
if (!tokenResponse.ok) return json({ error: 'oauth_token_exchange_failed', detail: tokens.error_description || tokens.error || '' }, 502);
let claims = parseJwt(tokens.id_token || tokens.access_token);
if (state.nonce && claims.nonce && state.nonce !== claims.nonce) return json({ error: 'invalid_oauth_nonce' }, 400);
if (env.AUTH_USERINFO_URL && tokens.access_token) {
const userResponse = await fetch(env.AUTH_USERINFO_URL, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: 'application/json' } });
const userInfo = await userResponse.json().catch(() => ({}));
if (userResponse.ok) claims = { ...claims, ...userInfo };
}
const user = normalizeClaims(claims);
if (!user.ssoSubject) return json({ error: 'sso_subject_missing' }, 502);
const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 }));
const session = await signValue(sessionPayload, env.SESSION_SECRET);
return redirect(new URL(state.returnUrl || '/', url.origin).toString(), { 'set-cookie': [cookie(env.SESSION_COOKIE_NAME, session, Number(env.SESSION_TTL_SECONDS || 3600)), cookie(env.OAUTH_COOKIE_NAME, '', 0, { delete: true })] });
}
async function getSession(request, env) {
const signedSession = getCookie(request, env.SESSION_COOKIE_NAME);
const value = await verifyValue(signedSession, env.SESSION_SECRET);
if (!value) return null;
try {
const payload = JSON.parse(decoder.decode(fromBase64url(value)));
return payload.expiresAt > Date.now() ? payload.user : null;
} catch (error) { return null; }
}
async function sessionResponse(request, env) {
const user = await getSession(request, env);
return json({ authenticated: Boolean(user), user }, 200, { 'access-control-allow-credentials': 'true' });
}
async function createFeedback(request, env) {
if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'POST' });
const user = await getSession(request, env);
if (!user) return json({ error: 'unauthenticated' }, 401);
if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503);
if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503);
let input;
try {
input = await request.json();
} catch (error) {
return json({ error: 'invalid_json' }, 400);
}
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'invalid_json' }, 400);
const title = String(input.title || '').trim();
const contents = String(input.contents || '').trim();
const category = String(input.Category || '').trim();
if (!title || !contents || !category) return json({ error: 'title_contents_category_required' }, 400);
if (!['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].includes(category)) return json({ error: 'invalid_category' }, 400);
const feedbackId = isUuidv7(input.feedbackId) ? input.feedbackId : uuidv7();
const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || '');
const requesterTenantId = String(user.requesterTenantId || user.tenantId || '');
if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422);
const requesterPhone = String(user.phone || '');
if (!requesterPhone) return json({ error: 'requester_phone_missing' }, 422);
const upstreamPayload = {
title,
contents,
Category: category,
requester_id: requesterId,
requester_tenant_id: requesterTenantId,
requester_email: String(user.email || ''),
requester_name: String(user.name || ''),
requester_department: String(user.department || ''),
requester_phone_number: requesterPhone,
is_secret: input.is_secret === true || input.is_secret === 'true' || input.is_secret === 1 || input.is_secret === '1' ? 'true' : 'false'
};
if (Array.isArray(input.images) && input.images.length) upstreamPayload.images = input.images;
const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, '');
const endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks`;
const sourceNamespace = env.ABC_SOURCE_NAMESPACE || 'EGBIM_QA';
const idempotencyConsumer = env.ABC_IDEMPOTENCY_CONSUMER || sourceNamespace;
let response;
try {
response = await fetch(endpoint, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'x-api-key': env.ABC_API_KEY,
'x-source-namespace': sourceNamespace,
'x-source-record-id': feedbackId,
'x-idempotency-consumer': idempotencyConsumer,
'idempotency-key': feedbackId
},
body: JSON.stringify(upstreamPayload)
});
} catch (error) {
console.error('feedback_api_request_failed', error instanceof Error ? error.message : 'unknown');
return json({ error: 'feedback_api_unreachable' }, 502);
}
const result = await response.json().catch(() => ({}));
if (!response.ok) {
console.error('feedback_api_rejected', response.status);
const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240);
return json({ error: 'feedback_api_rejected', message: upstreamMessage || 'ABC API가 문의 저장을 거부했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status);
}
const id = result.id || result.data?.id || result.feedback?.id;
if (!id) return json({ error: 'feedback_id_missing' }, 502);
return json({ id });
}
function isUuidv7(value) {
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function logout(request, env) {
const url = new URL(request.url);
const target = safeReturnUrl(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin);
return redirect(new URL(target, url.origin).toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) });
}