Files
baron_qa_write/src/index.js
T
root d197c0cf14
Deploy EG-BIM QA Gateway / deploy (push) Successful in 36s
댓글이미지 업로드 및 상태값 연동
2026-09-21 20:25:43 +09:00

555 lines
28 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 (/^\/api\/feedbacks\/[^/]+\/comments$/.test(url.pathname)) return feedbackComments(request, env, url);
if (/^\/api\/feedbacks\/[^/]+$/.test(url.pathname)) return feedbackDetail(request, env, url);
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;
const uploadedImages = [];
const contentType = request.headers.get('content-type') || '';
try {
if (contentType.toLowerCase().includes('multipart/form-data')) {
const formData = await request.formData();
input = {};
for (const [key, value] of formData.entries()) {
if (key === 'images' && typeof value !== 'string') {
uploadedImages.push(value);
} else if (typeof value === 'string' && !(key in input)) {
input[key] = value;
}
}
} else {
input = await request.json();
}
} catch (error) {
return json({ error: contentType.toLowerCase().includes('multipart/form-data') ? 'invalid_multipart' : '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 oversizedImage = uploadedImages.find((file) => Number(file.size || 0) > 30 * 1024 * 1024);
if (oversizedImage) return json({ error: 'attachment_too_large', message: '첨부파일은 파일당 30MB 이하만 등록할 수 있습니다.' }, 413);
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;
const upstreamHeaders = {
accept: '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
};
let upstreamBody;
if (uploadedImages.length) {
const multipart = new FormData();
multipart.append('title', title);
multipart.append('contents', contents);
multipart.append('Category', category);
multipart.append('requester_id', requesterId);
multipart.append('requester_tenant_id', requesterTenantId);
multipart.append('requester_email', String(user.email || ''));
multipart.append('requester_name', String(user.name || ''));
multipart.append('requester_department', String(user.department || ''));
multipart.append('requester_phone_number', requesterPhone);
multipart.append('is_secret', upstreamPayload.is_secret);
uploadedImages.forEach((file) => multipart.append('images', file, file.name || 'attachment'));
upstreamBody = multipart;
} else {
upstreamHeaders['content-type'] = 'application/json';
upstreamBody = JSON.stringify(upstreamPayload);
}
let response;
try {
response = await fetch(endpoint, {
method: 'POST',
headers: upstreamHeaders,
body: upstreamBody
});
} 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 });
}
async function feedbackComments(request, env, url) {
if (request.method !== 'GET' && request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET, 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);
const feedbackId = decodeURIComponent(url.pathname.split('/')[3] || '');
if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400);
const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, '');
let endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}/comments`;
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 sourceNamespace = env.ABC_SOURCE_NAMESPACE || 'EGBIM_QA';
const headers = {
accept: 'application/json',
'x-api-key': env.ABC_API_KEY,
'x-requester-id': requesterId,
'x-requester-tenant-id': requesterTenantId
};
let body;
let commentId;
const uploadedAttachments = [];
if (request.method === 'GET') {
const query = new URLSearchParams({ includeInternal: 'false' });
endpoint = endpoint + '?' + query.toString();
} else {
let input;
const requestContentType = request.headers.get('content-type') || '';
try {
if (requestContentType.toLowerCase().includes('multipart/form-data')) {
const formData = await request.formData();
input = {};
for (const [key, value] of formData.entries()) {
if (key === 'attachments' && typeof value !== 'string') uploadedAttachments.push(value);
else if (typeof value === 'string' && !(key in input)) input[key] = value;
}
} else {
input = await request.json();
}
} catch (error) {
return json({ error: requestContentType.toLowerCase().includes('multipart/form-data') ? 'invalid_multipart' : 'invalid_json' }, 400);
}
const content = String(input && input.content || '').trim();
if (!content) return json({ error: 'comment_content_required' }, 400);
if (content.length > 5000) return json({ error: 'comment_content_too_long' }, 400);
const invalidAttachment = uploadedAttachments.find((file) => !String(file.type || '').toLowerCase().startsWith('image/'));
if (invalidAttachment) return json({ error: 'comment_image_required', message: '댓글 첨부는 이미지 파일만 등록할 수 있습니다.' }, 415);
const oversizedAttachment = uploadedAttachments.find((file) => Number(file.size || 0) > 30 * 1024 * 1024);
if (oversizedAttachment) return json({ error: 'comment_attachment_too_large', message: '댓글 이미지는 파일당 30MB 이하만 등록할 수 있습니다.' }, 413);
commentId = uuidv7();
headers['x-source-namespace'] = sourceNamespace;
headers['x-source-record-id'] = commentId;
headers['x-idempotency-consumer'] = sourceNamespace;
headers['idempotency-key'] = commentId;
const commentPayload = {
author_id: requesterId,
author_tenant_id: requesterTenantId,
author_name: String(user.name || user.email || ''),
content,
is_internal: false,
actor_type: 'USER',
comment_type: 'COMMENT'
};
if (uploadedAttachments.length) {
const multipart = new FormData();
Object.entries(commentPayload).forEach(([key, value]) => multipart.append(key, String(value)));
uploadedAttachments.forEach((file) => multipart.append('attachments', file, file.name || 'comment-image'));
body = multipart;
} else {
headers['content-type'] = 'application/json';
body = JSON.stringify(commentPayload);
}
}
let response;
try {
response = await fetch(endpoint, { method: request.method, headers, body });
} catch (error) {
console.error('feedback_comments_api_request_failed', error instanceof Error ? error.message : 'unknown');
return json({ error: 'feedback_comments_api_unreachable' }, 502);
}
const result = await response.json().catch(() => ({}));
if (!response.ok) {
const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240);
return json({ error: 'feedback_comments_api_rejected', message: upstreamMessage || '댓글 저장 또는 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status);
}
if (request.method === 'GET') {
const comments = Array.isArray(result) ? result : (result.items || result.comments || result.data?.items || result.data?.comments || result.data || []);
return json({ comments: Array.isArray(comments) ? comments : [] });
}
return json({ id: result.id || result.data?.id || result.comment?.id || commentId, comment: result.comment || result.data || result });
}
async function feedbackDetail(request, env, url) {
if (request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET' });
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);
const feedbackId = decodeURIComponent(url.pathname.split('/')[3] || '');
if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400);
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 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/${encodeURIComponent(feedbackId)}`;
let response;
try {
response = await fetch(endpoint, {
method: 'GET',
headers: {
accept: 'application/json',
'x-api-key': env.ABC_API_KEY,
'x-requester-id': requesterId,
'x-requester-tenant-id': requesterTenantId
}
});
} catch (error) {
console.error('feedback_detail_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) {
const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240);
return json({ error: 'feedback_api_rejected', message: upstreamMessage || '문의 상세 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status);
}
return json({ feedback: result.feedback || result.data || result });
}
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 }) });
}