commit a420407c1847e6415789cd836e3488ba4b294da1 Author: root Date: Fri Sep 18 19:59:06 2026 +0900 fix: support PKCE public client without client secret diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..4f104e0 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Deploy EG-BIM QA Gateway + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Validate required secrets + shell: bash + run: | + set -euo pipefail + test -n "$CLOUDFLARE_API_TOKEN" || { echo "CLOUDFLARE_API_TOKEN is missing"; exit 1; } + test -n "$SESSION_SECRET" || { echo "SESSION_SECRET is missing"; exit 1; } + + - name: Install dependencies + run: npm install --no-fund --no-audit + + - name: Check source + run: npm run check + + - name: Register Worker session secret + shell: bash + run: | + set -euo pipefail + printf '%s' "$SESSION_SECRET" | npx wrangler secret put SESSION_SECRET --name baron-qa-gateway-test + + - name: Upload static files to R2 + run: npm run r2:upload + + - name: Deploy Worker + run: npx wrangler deploy --name baron-qa-gateway-test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..476ace1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +node_modules/ +.wrangler/ +dist/ +.env +*.sql +정적페이지 내 외부서비스 페이지 설계.png +qa-secrets.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..d984d76 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# EG-BIM Q&A static pages + +`index.html`, `write.html`, `detail.html` 세 페이지로 구성한 EG-BIM Q&A UI입니다. 원본 `egbim_homepage`의 Q&A 화면 구성과 패키지 S/W 메뉴 방향을 참고해, PHP/그누보드/DB 의존성 없이 정적 호스팅에서 동작하도록 분리했습니다. + +## 로컬 확인 + +정적 파일 서버에서 루트를 열면 됩니다. + +```bash +python3 -m http.server 4173 +``` + +그 다음 `http://localhost:4173/`에 접속합니다. 브라우저 보안 정책 때문에 `file://` 직접 열기보다 정적 서버를 사용하는 편이 안전합니다. + +## feedback.hmac.kr DB 기준 연동 + +덤프의 EG-BIM 운영 대상은 다음 값으로 고정했습니다. + +- `workspaces.id = 6`, `workspace_code = EGBIM` +- `channels.name = Q&A`, `channels.id = 01a0ae40-51c2-7647-a93c-0249b3759777` +- 카테고리: `ERROR_QNA`, `IMPROVEMENT_QNA`, `GENERAL_QNA` +- 신규 상태: `support_tickets.status_code = RECEIVED`, `feedback_status = NEW` +- 첨부 저장소: `storage_bucket = qa_cdn` + +작성 시 브라우저가 UUID를 하나 생성합니다. 같은 UUID를 `feedback.id`, `feedback.source_record_id`, `support_tickets.idempotency_key`에 사용하므로 중복 제출을 판별할 수 있습니다. API 서버는 envelope를 받아 다음 DB 레코드를 하나의 트랜잭션으로 생성해야 합니다. + +- `feedbacks`: `id`, `channel_id`, `source_namespace`, `source_record_id`, `data` +- `support_tickets`: 작성자/테넌트/제목/내용/분류/상태/`idempotency_key` +- `feedback_comment_attachments`: 답변 댓글 이미지가 생길 때 `comment_id`와 `qa_cdn` 메타데이터 +- `support_attachments`: 운영 호환 레이어가 필요할 때 동일 파일의 티켓 첨부 메타데이터 + +현재 작성 페이지에는 댓글 입력 UI가 없으므로 `comments`와 `commentAttachments`는 빈 배열로 보냅니다. 관리 페이지에서 댓글과 이미지를 작성할 때 같은 규칙으로 `feedback_comments`/`feedback_comment_attachments`와 `ticket_comments`/`support_attachments`를 생성할 수 있도록 envelope를 열어 두었습니다. + +## 연동 지점 + +- SSO: 헤더 로그인 링크와 작성 페이지의 로그인 가이드는 `https://test.baroncs.co.kr/`로 연결됩니다. `baron_user`, `baron_claims`, Descope 쿠키, JWT payload와 `sessionStorage`를 우선 읽고, `ssoSessionEndpoint`가 설정되면 `credentials: include`로 세션 bridge를 호출합니다. 브라우저에서 읽은 JWT claim은 표시/전송용 힌트일 뿐이며, feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다. +- 작성자 식별자: `ssoSubject`/`requesterId`, `userUuid`, `tenantId`/`requesterTenantId`, `tenantIds`, `scope`, `roles`, 이메일·이름·부서·전화번호를 payload에 넣습니다. `requester_id`와 `requester_tenant_id`가 없으면 제출을 차단합니다. +- API: `assets/config.js`의 `apiBaseUrl`에 API origin을 넣으면 `POST {apiBaseUrl}/v1/qa/uploads/presign`으로 업로드 URL을 받고, 파일을 `qa_cdn`에 직접 업로드한 뒤 `POST {apiBaseUrl}/v1/qa/feedbacks`로 DB용 envelope를 보냅니다. 두 엔드포인트의 인증/응답 규격은 실제 feedback 서버에 맞춰야 합니다. +- presign 응답: `{ "uploads": [{ "uploadUrl": "...", "storageKey": "...", "storageBucket": "qa_cdn", "headers": {} }] }` 형태를 기대합니다. R2 access key/secret은 정적 페이지에 넣지 않습니다. +- API 주소가 비어 있으면 테스트를 위해 브라우저 `localStorage`에만 저장하며, 첨부파일은 `local-preview/...` 메타데이터만 생성합니다. + +## Cloudflare R2 + +`wrangler.toml`과 `src/index.js`를 추가해 Worker가 R2 정적 파일을 제공하도록 구성했습니다. `/`와 `/index.html`은 공개 목록, `/egbim/`과 `/write.html`, `/detail.html`은 로그인 보호 경로입니다. 업로드 스크립트는 루트와 `/egbim/` 경로에 현재 EG-BIM 파일을 함께 올려, 기존 링크와 향후 제품별 prefix 확장을 모두 지원합니다. + +```bash +npm install +npm run check +npm run r2:upload +npm run deploy +``` + +`wrangler deploy`는 Worker와 R2 binding을 배포하고, `npm run r2:upload`는 정적 파일을 R2 bucket에 올립니다. `npx wrangler r2 object put`은 각 파일을 개별 업로드하므로 업로드 결과를 확인하기 쉽습니다. + +## Wrangler secret 등록 + +실제 secret은 저장소에 만들지 않습니다. `qa-secrets.json.example`을 복사해 `qa-secrets.json`을 만들고 값을 채운 뒤 등록합니다. + +```bash +cp qa-secrets.json.example qa-secrets.json +openssl rand -hex 32 +npx wrangler secret bulk qa-secrets.json --name baron-qa-gateway-test +``` + +`AUTH_CLIENT_ID`, `AUTH_AUTHORIZE_URL`, `AUTH_TOKEN_URL`, 선택적인 `AUTH_USERINFO_URL`은 `wrangler.toml`에 실제 SSO 값으로 설정해야 합니다. 이 RP는 PKCE Public Client이므로 `AUTH_CLIENT_SECRET`은 사용하지 않으며, `SESSION_SECRET`만 secret으로 등록합니다. + +Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증한 사용자 claim을 서명된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환합니다. + +## Gitea Actions 등록값 + +저장소 Settings → Actions → Secrets에 아래 2개를 등록합니다. + +| 이름 | 종류 | 값 | +|---|---|---| +| `CLOUDFLARE_API_TOKEN` | Secret | Workers Scripts Edit + Workers R2 Storage Edit 권한의 Cloudflare API Token | +| `SESSION_SECRET` | Secret | `openssl rand -hex 32`로 생성한 세션 서명키 | + +`CLOUDFLARE_ACCOUNT_ID`는 secret으로 등록할 필요가 없습니다. `wrangler.toml`에 `81fa2d48964d31dd0da9558f9ce601d1`로 설정되어 있습니다. + +Cloudflare API Token에는 최소한 다음 권한이 필요합니다. + +- Account → Workers Scripts → Edit +- Account → Workers R2 Storage → Edit +- Account → Account Settings → Read +- Custom Domain route를 Actions에서 변경할 경우 Zone → Workers Routes → Edit + +`.gitea/workflows/deploy.yml`은 `main` push 또는 수동 실행 시 세션 secret을 Worker에 등록하고 R2 업로드 후 `baron-qa-gateway-test`를 배포합니다. 실제 secret 값은 로그에 출력하지 않습니다. + +현재 화면은 API 설정 전에도 QA 흐름을 확인할 수 있도록 샘플 글과 로컬 테스트 저장을 포함합니다. 운영 반영 시 `apiBaseUrl`과 feedback API endpoint를 설정하고 로컬 fallback 제거 여부를 결정하세요. diff --git a/app.js b/app.js new file mode 100644 index 0000000..6f7b699 --- /dev/null +++ b/app.js @@ -0,0 +1,386 @@ +(function () { + 'use strict'; + + const config = window.QA_CONFIG || { product: 'egbim', apiBaseUrl: '', ssoUrl: 'https://test.baroncs.co.kr/' }; + const STORAGE_KEY = 'baron.qa.posts.egbim'; + const COMMENTS_KEY = 'baron.qa.comments.egbim'; + const CATEGORY_CODES = { '오류문의': 'ERROR_QNA', '개선문의': 'IMPROVEMENT_QNA', '일반문의': 'GENERAL_QNA' }; + let authReady; + + const seedPosts = [ + { id: 'EG-1048', category: '오류문의', company: '바론컨설턴트', department: '기술연구팀', author: '홍길동', title: '도면 내보내기 중 프로그램이 종료됩니다.', date: '2026-09-17', status: '문의접수', secret: false, content: '도면 내보내기 버튼을 누르면 프로그램이 종료되는 현상이 있습니다.\n재현 절차와 사용 중인 버전을 함께 확인 부탁드립니다.' }, + { id: 'EG-1047', category: '개선문의', company: '한맥기술', department: 'BIM팀', author: '김민지', title: '층별 도면을 한 번에 선택할 수 있으면 좋겠습니다.', date: '2026-09-16', status: '문의검토', secret: false, content: '층별 도면을 일괄 선택하여 내보낼 수 있는 기능을 제안드립니다.' }, + { id: 'EG-1046', category: '일반문의', company: '삼안', department: '설계1팀', author: '이도윤', title: 'EG-BIM 라이선스와 설치 환경을 문의드립니다.', date: '2026-09-15', status: '답변완료', secret: true, content: '설치 가능한 운영체제와 라이선스 정책을 확인하고 싶습니다.' }, + { id: 'EG-1045', category: '오류문의', company: '바론컨설턴트', department: '디지털전환팀', author: '박서준', title: '프로젝트 파일을 열 때 로딩이 오래 걸립니다.', date: '2026-09-12', status: '정밀검토', secret: false, content: '최근 프로젝트 파일을 열 때 약 2분 이상 로딩이 지속됩니다.' }, + { id: 'EG-1044', category: '공지사항', company: '관리자', department: '-', author: '관리자', title: 'EG-BIM Q&A 게시판 운영 안내', date: '2026-09-10', status: '', secret: false, content: 'EG-BIM 사용 중 궁금한 점이나 개선 의견을 남겨주세요.' }, + { id: 'EG-1043', category: '개선문의', company: '한맥기술', department: '사업관리팀', author: '최유진', title: '검색 결과에서 도면 미리보기를 제공해주세요.', date: '2026-09-09', status: '문의접수', secret: false, content: '검색 결과에 도면 미리보기 썸네일이 있으면 확인이 더 편리할 것 같습니다.' }, + { id: 'EG-1042', category: '일반문의', company: '외부 사용자', department: '-', author: '정하늘', title: '교육 자료를 어디서 확인할 수 있나요?', date: '2026-09-08', status: '답변완료', secret: false, content: 'EG-BIM 기본 교육 자료와 사용 가이드 위치를 알려주세요.' } + ]; + + function qs(selector, root) { return (root || document).querySelector(selector); } + function qsa(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); } + function escapeHtml(value) { + return String(value == null ? '' : value).replace(/[&<>'"]/g, function (char) { + return { '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]; + }); + } + + function readJson(key, fallback) { + try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; } catch (error) { return fallback; } + } + + function getPosts() { + const saved = readJson(STORAGE_KEY, []); + return saved.concat(seedPosts.filter(function (seed) { + return !saved.some(function (post) { return post.id === seed.id; }); + })); + } + + function savePost(post) { + const posts = readJson(STORAGE_KEY, []); + posts.unshift(post); + localStorage.setItem(STORAGE_KEY, JSON.stringify(posts)); + } + + function getComments() { return readJson(COMMENTS_KEY, {}); } + + function parseJson(value, fallback) { + if (!value) return fallback; + try { return typeof value === 'string' ? JSON.parse(value) : value; } catch (error) { return fallback; } + } + + function decodeCookieJson(value) { + if (!value) return null; + try { + const binary = window.atob(value); + const bytes = Array.prototype.map.call(binary, function (char) { return '%' + ('00' + char.charCodeAt(0).toString(16)).slice(-2); }).join(''); + return JSON.parse(decodeURIComponent(bytes)); + } catch (error) { + try { return JSON.parse(window.atob(value)); } catch (fallbackError) { return null; } + } + } + + function readCookie(name) { + const item = document.cookie.split('; ').find(function (part) { return part.indexOf(name + '=') === 0; }); + return item ? item.slice(name.length + 1) : ''; + } + + function decodeJwtPayload(token) { + if (!token || token.split('.').length < 2) return {}; + try { + const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(decodeURIComponent(escape(window.atob(encoded + '='.repeat((4 - encoded.length % 4) % 4))))); + } catch (error) { return {}; } + } + + function collectSsoClaims() { + const storedClaims = parseJson( + sessionStorage.getItem('ssoClaims') || sessionStorage.getItem('baronClaims') || sessionStorage.getItem('claims'), + decodeCookieJson(readCookie('baron_claims')) || {} + ); + return Object.assign({}, decodeJwtPayload(sessionStorage.getItem('sessionJwt') || ''), storedClaims); + } + + function normalizeAuthUser(raw, claims) { + raw = raw || {}; + claims = claims || {}; + const custom = raw.customAttributes || raw.custom_attributes || {}; + const claimProfile = claims.profile || {}; + const tenants = raw.tenants || raw.tenantIds || custom.tenants || claims.tenants || claims.tenantIds || []; + const tenantList = Array.isArray(tenants) ? tenants : Object.keys(tenants).map(function (key) { + return typeof tenants[key] === 'object' ? Object.assign({ id: key }, tenants[key]) : { id: key, name: tenants[key] }; + }); + const tenantId = raw.tenantId || raw.tenant_id || custom.tenantId || custom.tenant_id || claims.tenantId || claims.tenant_id || (tenantList[0] && (tenantList[0].id || tenantList[0].tenantId)) || ''; + const tenantIds = tenantList.map(function (tenant) { return typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || ''); }).filter(Boolean); + if (tenantId && tenantIds.indexOf(tenantId) === -1) tenantIds.unshift(tenantId); + const ssoSubject = raw.ssoSubject || raw.oauthSubject || raw.subject || claims.sub || claims.subject || raw.userId || sessionStorage.getItem('descopeUserId') || ''; + const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || sessionStorage.getItem('descopeUserId') || ''; + const loginId = raw.loginId || (raw.loginIds || [])[0] || raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || readCookie('descope_login_id') || ''; + return { + userUuid: userUuid, + ssoSubject: ssoSubject, + requesterId: ssoSubject, + tenantId: tenantId, + requesterTenantId: tenantId, + tenantIds: tenantIds, + scope: raw.scope || claims.scope || sessionStorage.getItem('ssoScope') || '', + roles: raw.roles || raw.roleNames || claims.roles || claims.roleNames || [], + loginId: loginId, + email: raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || '', + name: raw.name || claims.name || claimProfile.name || sessionStorage.getItem('userName') || readCookie('descope_user_name') || '', + phone: raw.phone || raw.phoneNumber || claims.phone || claims.phone_number || sessionStorage.getItem('phone') || '', + company: custom.company || raw.company || claims.company || sessionStorage.getItem('company') || '', + familyCompany: custom.familyCompany || custom.family_company || raw.familyCompany || sessionStorage.getItem('familyCompany') || '', + department: custom.team || custom.department || raw.department || claims.department || sessionStorage.getItem('team') || '', + rawClaims: claims + }; + } + + function getAuthUser() { + const sessionUser = normalizeAuthUser({ + loginId: sessionStorage.getItem('loginId') || '', + userId: sessionStorage.getItem('descopeUserId') || '', + name: sessionStorage.getItem('userName') || '', + company: sessionStorage.getItem('company') || '', + familyCompany: sessionStorage.getItem('familyCompany') || '', + department: sessionStorage.getItem('team') || '', + tenantId: sessionStorage.getItem('tenantId') || sessionStorage.getItem('tenant_id') || '', + tenantIds: parseJson(sessionStorage.getItem('tenantIds'), []) + }, collectSsoClaims()); + if (sessionUser.loginId || sessionUser.ssoSubject) return sessionUser; + const baronUser = decodeCookieJson(readCookie('baron_user')); + const descopeUser = baronUser || { + loginId: readCookie('descope_login_id'), userId: readCookie('descope_user_id'), name: readCookie('descope_user_name'), + email: readCookie('descope_user_email'), phone: readCookie('descope_user_phone'), + customAttributes: decodeCookieJson(readCookie('descope_custom_attributes')) || {} + }; + return descopeUser && (descopeUser.loginId || descopeUser.userId || descopeUser.email) ? normalizeAuthUser(descopeUser, collectSsoClaims()) : null; + } + + async function loadAuthUser() { + const localUser = getAuthUser(); + if (localUser && localUser.tenantId) return localUser; + if (!config.ssoSessionEndpoint) return localUser; + try { + const response = await fetch(config.ssoSessionEndpoint, { credentials: 'include', headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error('SSO 세션 확인에 실패했습니다.'); + const body = await response.json(); + const remoteUser = normalizeAuthUser(body.user || body, body.claims || body.scope || {}); + if (remoteUser.loginId || remoteUser.ssoSubject) return remoteUser; + } catch (error) { + console.warn('[QA] SSO session bridge unavailable:', error.message); + } + return localUser; + } + + function renderAuth(user) { + user = user || getAuthUser(); + qsa('.login-link').forEach(function (element) { element.href = config.ssoUrl || element.href; }); + qsa('[data-auth-name]').forEach(function (element) { element.textContent = user ? (user.name || user.loginId) : '로그인'; }); + qsa('[data-auth-state]').forEach(function (element) { + element.textContent = user ? (user.name || user.loginId) + '님으로 로그인됨' : '글 작성은 로그인 후 이용할 수 있습니다.'; + }); + qsa('[data-auth-required]').forEach(function (element) { element.hidden = Boolean(user); }); + qsa('[data-auth-user]').forEach(function (element) { element.hidden = !user; }); + } + + function bindGlobal() { + renderAuth(getAuthUser()); + authReady = loadAuthUser().then(function (user) { renderAuth(user); return user; }); + const menuButton = qs('.menu-button'); + const nav = qs('.global-nav'); + if (menuButton && nav) { + menuButton.addEventListener('click', function () { + nav.classList.toggle('mobile-open'); + }); + } + } + + function showToast(message) { + const toast = qs('#toast'); + if (!toast) return; + toast.textContent = message; + toast.classList.add('show'); + window.clearTimeout(showToast.timer); + showToast.timer = window.setTimeout(function () { toast.classList.remove('show'); }, 2600); + } + + async function requestJson(path, options) { + if (!config.apiBaseUrl) return { local: true }; + const response = await fetch(config.apiBaseUrl.replace(/\/$/, '') + path, Object.assign({ credentials: 'include' }, options)); + if (!response.ok) { + const errorBody = await response.json().catch(function () { return {}; }); + throw new Error(errorBody.message || 'API 요청에 실패했습니다. (' + response.status + ')'); + } + return response.json().catch(function () { return {}; }); + } + + async function postToApi(path, payload) { + return requestJson(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + } + + function makeUuid() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { + const random = Math.random() * 16 | 0; + const value = char === 'x' ? random : (random & 3 | 8); + return value.toString(16); + }); + } + + async function sha256(file) { + if (!window.crypto || !window.crypto.subtle) return ''; + const digest = await window.crypto.subtle.digest('SHA-256', await file.arrayBuffer()); + return Array.prototype.map.call(new Uint8Array(digest), function (byte) { return ('00' + byte.toString(16)).slice(-2); }).join(''); + } + + async function uploadFiles(files, feedbackId, purpose) { + files = Array.prototype.slice.call(files || []); + if (!files.length) return []; + const descriptors = await Promise.all(files.map(async function (file) { + return { clientId: makeUuid(), originalFileName: file.name, mimeType: file.type || 'application/octet-stream', fileSize: file.size, checksumSha256: await sha256(file) }; + })); + if (!config.apiBaseUrl) { + return descriptors.map(function (item) { return Object.assign(item, { id: makeUuid(), storageBucket: config.storageBucket, storageKey: 'local-preview/' + feedbackId + '/' + item.clientId + '-' + item.originalFileName, purpose: purpose }); }); + } + const presign = await postToApi(config.presignPath, { feedbackId: feedbackId, workspaceId: config.workspaceId, workspaceCode: config.workspaceCode, bucket: config.storageBucket, purpose: purpose, files: descriptors }); + const uploads = presign.uploads || presign.items || []; + if (uploads.length !== descriptors.length) throw new Error('첨부파일 업로드 URL을 모두 받지 못했습니다.'); + return Promise.all(uploads.map(async function (upload, index) { + const file = files[index]; + const headers = Object.assign({}, upload.headers || {}); + if (!headers['Content-Type'] && file.type) headers['Content-Type'] = file.type; + const response = await fetch(upload.uploadUrl, { method: upload.method || 'PUT', headers: headers, body: file }); + if (!response.ok) throw new Error(file.name + ' 업로드에 실패했습니다.'); + if (!upload.storageKey) throw new Error(file.name + '의 storageKey를 받지 못했습니다.'); + return Object.assign(descriptors[index], upload.metadata || {}, { + id: upload.id || makeUuid(), storageBucket: upload.storageBucket || config.storageBucket, + storageKey: upload.storageKey, purpose: purpose + }); + })); + } + + function buildFeedbackPayload(post, user, feedbackId, attachments) { + const categoryCode = CATEGORY_CODES[post.category]; + const source = { namespace: config.sourceNamespace, recordId: feedbackId }; + const imageMetadata = attachments.filter(function (item) { return item.purpose === 'FEEDBACK_ATTACHMENT'; }).map(function (item) { + return { id: item.id, original_file_name: item.originalFileName, storage_bucket: item.storageBucket, storage_key: item.storageKey, mime_type: item.mimeType, file_size: item.fileSize, checksum_sha256: item.checksumSha256 }; + }); + const data = { + id: feedbackId, + createdAt: post.createdAt, + title: post.title, + contents: post.content, + priority: 'MEDIUM', + Category: categoryCode, + requester_id: user.requesterId, + requester_tenant_id: user.requesterTenantId, + requester_email: user.email, + requester_name: user.name, + requester_department: user.department, + is_secret: post.secret ? 1 : 0, + feedback_status: 'NEW', + images: imageMetadata + }; + return { + schemaVersion: 'feedback.hmac.kr/qa.v1', + source: source, + feedback: { id: feedbackId, channelId: config.channelId, workspaceId: config.workspaceId, workspaceCode: config.workspaceCode, sourceNamespace: source.namespace, sourceRecordId: source.recordId, data: data }, + supportTicket: { + workspace_id: config.workspaceId, requester_id: user.requesterId, requester_tenant_id: user.requesterTenantId, ticket_type: 'QNA', source_system: 'EGBIM', title: post.title, category_code: categoryCode, status_code: 'RECEIVED', is_secret: post.secret ? 1 : 0, priority: 'NORMAL', description: post.content, approval_status: 'NOT_REQUIRED', sync_status: 'PENDING', issue_link_status: 'NOT_REQUIRED', feedback_status: 'NEW', idempotency_key: feedbackId, requester_email: user.email, requester_name: user.name, requester_department: user.department, requester_phone_number: user.phone, extra_fields: { feedback_id: feedbackId, channel_id: config.channelId } + }, + attachments: attachments.map(function (item) { return Object.assign({}, item, { sourceNamespace: source.namespace, sourceRecordId: source.recordId }); }), + comments: [], + commentAttachments: [], + author: { userUuid: user.userUuid, ssoSubject: user.ssoSubject, requesterId: user.requesterId, tenantId: user.tenantId, tenantIds: user.tenantIds, scope: user.scope, roles: user.roles, email: user.email, name: user.name, department: user.department, phone: user.phone } + }; + } + + function initList() { + const tableBody = qs('#qaRows'); + if (!tableBody) return; + const searchForm = qs('#searchForm'); + const empty = qs('#tableEmpty'); + const resultCount = qs('#resultCount'); + const pagination = qs('#pagination'); + let page = 1; + + function render() { + const query = (qs('#query').value || '').trim().toLowerCase(); + const checkedCategories = qsa('input[name="category"]:checked').map(function (input) { return input.value; }); + const onlyMine = qs('#onlyMine').checked; + const user = getAuthUser(); + const filtered = getPosts().filter(function (post) { + const matchesCategory = !checkedCategories.length || checkedCategories.indexOf(post.category) > -1; + const haystack = [post.title, post.content, post.company, post.department, post.author].join(' ').toLowerCase(); + const matchesQuery = !query || haystack.indexOf(query) > -1; + const matchesMine = !onlyMine || (user && (post.author === user.name || post.author === user.loginId)); + return matchesCategory && matchesQuery && matchesMine; + }); + const pageSize = config.pageSize || 10; + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + page = Math.min(page, totalPages); + const rows = filtered.slice((page - 1) * pageSize, page * pageSize); + resultCount.textContent = '총 ' + filtered.length + '건'; + empty.style.display = rows.length ? 'none' : 'block'; + tableBody.innerHTML = rows.map(function (post, index) { + const isNotice = post.category === '공지사항'; + const number = isNotice ? '공지' : filtered.length - ((page - 1) * pageSize + index); + const statusClass = post.status === '답변완료' ? 'done' : (post.status === '문의검토' || post.status === '정밀검토' ? 'review' : ''); + return '' + + '' + number + '' + escapeHtml(post.category.replace('문의', '')) + '' + + '' + escapeHtml(post.company) + '' + escapeHtml(post.department) + '' + escapeHtml(post.author) + '' + + '' + (post.secret ? '🔒' : '') + escapeHtml(post.title) + '' + (!isNotice && post.date === '2026-09-17' ? 'N' : '') + '' + + '' + escapeHtml(post.date) + '' + (post.status ? '' + escapeHtml(post.status) + '' : '-') + ''; + }).join(''); + qsa('#qaRows tr').forEach(function (row) { row.addEventListener('click', function () { window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id); }); }); + pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return ''; }).join(''); + qsa('#pagination button').forEach(function (button) { button.addEventListener('click', function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: 'smooth' }); }); }); + } + + searchForm.addEventListener('submit', function (event) { event.preventDefault(); page = 1; render(); }); + qsa('input[name="category"], #onlyMine').forEach(function (input) { input.addEventListener('change', function () { page = 1; render(); }); }); + render(); + } + + function initWrite() { + const form = qs('#qaForm'); + if (!form) return; + const user = getAuthUser(); + const errorBox = qs('#formError'); + if (!user) { + qs('#writeFields').setAttribute('aria-disabled', 'true'); + } + form.addEventListener('submit', async function (event) { + event.preventDefault(); + errorBox.style.display = 'none'; + const currentUser = await (authReady || Promise.resolve(getAuthUser())); + if (!currentUser) { errorBox.textContent = '로그인 후 문의를 등록할 수 있습니다.'; errorBox.style.display = 'block'; return; } + if (!currentUser.requesterId || !currentUser.requesterTenantId) { errorBox.textContent = 'SSO에서 작성자 UUID와 테넌트 정보를 확인하지 못했습니다. 다시 로그인한 뒤 시도해주세요.'; errorBox.style.display = 'block'; return; } + const title = qs('#title').value.trim(); + const content = qs('#content').value.trim(); + if (!qs('#category').value || !title || !content) { errorBox.textContent = '구분, 제목, 내용을 모두 입력해주세요.'; errorBox.style.display = 'block'; return; } + const feedbackId = makeUuid(); + const createdAt = new Date().toISOString(); + const post = { id: feedbackId, feedbackId: feedbackId, category: qs('#category').value, company: currentUser.familyCompany || currentUser.company || '외부 사용자', department: currentUser.department || '-', author: currentUser.name || currentUser.loginId, title: title, date: createdAt.slice(0, 10), createdAt: createdAt, status: '접수', secret: qs('#secret').checked, content: content }; + const submitButton = qs('#submitButton'); + submitButton.disabled = true; + submitButton.textContent = '등록 중...'; + try { + const attachments = await uploadFiles(qs('#attachment').files, feedbackId, 'FEEDBACK_ATTACHMENT'); + const payload = buildFeedbackPayload(post, currentUser, feedbackId, attachments); + const result = await postToApi(config.createFeedbackPath, payload); + if (result.local) savePost(post); + showToast(result.local ? '테스트 글로 저장했습니다. UUID가 생성되었습니다.' : '문의가 등록되었습니다.'); + window.setTimeout(function () { window.location.href = 'detail.html?id=' + encodeURIComponent(post.id); }, 500); + } catch (error) { + errorBox.textContent = error.message || '등록 중 오류가 발생했습니다.'; + errorBox.style.display = 'block'; + submitButton.disabled = false; + submitButton.textContent = '문의 등록'; + } + }); + } + + function initDetail() { + const detail = qs('#detail'); + if (!detail) return; + const id = new URLSearchParams(window.location.search).get('id') || 'EG-1048'; + const post = getPosts().find(function (item) { return item.id === id; }) || seedPosts[0]; + qs('[data-detail-category]').textContent = post.category; + qs('[data-detail-title]').textContent = post.title; + qs('[data-detail-status]').textContent = post.status || '공지'; + qs('[data-detail-date]').textContent = post.date; + qs('[data-detail-author]').textContent = post.author; + qs('[data-detail-company]').textContent = post.company; + qs('[data-detail-department]').textContent = post.department; + qs('[data-detail-content]').textContent = post.content; + qs('[data-detail-secret]').hidden = !post.secret; + const comments = getComments()[post.id] || []; + qs('#commentCount').textContent = comments.length; + qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '
' + escapeHtml(comment.author) + '' + escapeHtml(comment.date) + '
' + escapeHtml(comment.content) + '
'; }).join('') : '
등록된 답변이 없습니다.
'; + qs('#backButton').addEventListener('click', function () { window.location.href = 'index.html'; }); + } + + document.addEventListener('DOMContentLoaded', function () { bindGlobal(); initList(); initWrite(); initDetail(); }); +})(); diff --git a/assets/config.js b/assets/config.js new file mode 100644 index 0000000..3b18233 --- /dev/null +++ b/assets/config.js @@ -0,0 +1,15 @@ +/* Static deployment configuration. Keep credentials and R2 secrets out of this file. */ +window.QA_CONFIG = Object.assign({ + product: 'egbim', + apiBaseUrl: '', + ssoUrl: '/auth/login', + ssoSessionEndpoint: '/auth/session', + presignPath: '/v1/qa/uploads/presign', + createFeedbackPath: '/v1/qa/feedbacks', + workspaceId: 6, + workspaceCode: 'EGBIM', + channelId: '01a0ae40-51c2-7647-a93c-0249b3759777', + sourceNamespace: 'baron_qa_write', + storageBucket: 'qa_cdn', + pageSize: 10 +}, window.QA_CONFIG || {}); diff --git a/assets/fonts/notokr-bold.woff2 b/assets/fonts/notokr-bold.woff2 new file mode 100644 index 0000000..bcae199 Binary files /dev/null and b/assets/fonts/notokr-bold.woff2 differ diff --git a/assets/fonts/notokr-medium.woff2 b/assets/fonts/notokr-medium.woff2 new file mode 100644 index 0000000..8450f5c Binary files /dev/null and b/assets/fonts/notokr-medium.woff2 differ diff --git a/assets/fonts/notokr-regular.woff2 b/assets/fonts/notokr-regular.woff2 new file mode 100644 index 0000000..2a205a3 Binary files /dev/null and b/assets/fonts/notokr-regular.woff2 differ diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..4ae8fbf --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,342 @@ +@font-face { + font-family: 'Noto Sans KR'; + src: url('./fonts/notokr-regular.woff2') format('woff2'); + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: 'Noto Sans KR'; + src: url('./fonts/notokr-medium.woff2') format('woff2'); + font-weight: 500; + font-display: swap; +} + +@font-face { + font-family: 'Noto Sans KR'; + src: url('./fonts/notokr-bold.woff2') format('woff2'); + font-weight: 700; + font-display: swap; +} + +:root { + --navy: #173f78; + --ink: #171717; + --muted: #707070; + --line: #e5e5e5; + --soft: #f7f8fa; + --accent: #005bff; + --green: #0c7857; + --header-height: 88px; +} + +* { box-sizing: border-box; } +html { min-width: 320px; scroll-behavior: smooth; } +body { + margin: 0; + color: var(--ink); + background: #fff; + font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif; + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +a { color: inherit; text-decoration: none; } +button, input, select, textarea { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } + +.site-header { + position: relative; + z-index: 10; + height: var(--header-height); + border-bottom: 1px solid #ededed; + background: rgba(255, 255, 255, .98); +} + +.header-inner { + display: flex; + align-items: center; + height: 100%; + max-width: 1840px; + padding: 0 32px; + margin: 0 auto; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + color: var(--navy); + font-size: 17px; + font-weight: 700; + white-space: nowrap; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 24px; + border: 2px solid var(--navy); + border-radius: 50%; + font-size: 14px; + font-style: italic; + letter-spacing: -1px; +} + +.global-nav { + display: flex; + align-items: stretch; + height: 100%; + gap: clamp(22px, 2.5vw, 52px); + margin-left: auto; + margin-right: 38px; +} + +.nav-item { + position: relative; + display: inline-flex; + align-items: center; + color: #3d3d3d; + font-size: 16px; + white-space: nowrap; +} + +.nav-item.package::after { + content: ''; + position: absolute; + right: -13px; + bottom: 25px; + left: -13px; + height: 2px; + background: var(--navy); + transform: scaleX(0); + transition: transform .2s ease; +} + +.nav-item.package:hover::after, +.nav-item.package:focus-within::after { transform: scaleX(1); } + +.mega-menu { + position: absolute; + top: calc(100% - 1px); + left: 50%; + display: grid; + grid-template-columns: 116px 1fr; + width: 360px; + padding: 22px 20px; + border-radius: 0 0 10px 10px; + background: #fff; + box-shadow: 0 15px 36px rgba(0, 0, 0, .13); + opacity: 0; + pointer-events: none; + transform: translate(-50%, -6px); + transition: opacity .2s ease, transform .2s ease; +} + +.nav-item.package:hover .mega-menu, +.nav-item.package:focus-within .mega-menu { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, 0); +} + +.mega-label { color: var(--accent); font-weight: 500; padding-top: 5px; } +.mega-links { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; } +.mega-links a { color: #555; font-size: 14px; } +.mega-links a:hover, .mega-links a[aria-current='page'] { color: var(--accent); } + +.header-tools { display: flex; align-items: center; gap: 22px; flex: 0 0 auto; } +.locale { font-weight: 700; } +.locale span { color: #c9c9c9; margin: 0 8px; } +.locale a:last-child { color: #9a9a9a; font-weight: 400; } +.login-link { display: inline-flex; align-items: center; gap: 7px; font-weight: 500; } +.user-icon { width: 20px; height: 20px; border: 1.8px solid #111; border-radius: 50%; position: relative; margin-top: -9px; } +.user-icon::after { content: ''; position: absolute; top: 17px; left: -5px; width: 26px; height: 12px; border: 1.8px solid #111; border-bottom: 0; border-radius: 16px 16px 0 0; } +.menu-button { display: inline-flex; flex-direction: column; gap: 6px; border: 0; background: none; padding: 6px 0 6px 8px; } +.menu-button span { display: block; width: 24px; height: 1.5px; background: #111; } + +.page-hero { + display: flex; + min-height: 248px; + align-items: center; + justify-content: center; + color: #fff; + text-align: center; + background: linear-gradient(115deg, #11342b 0%, #10221f 50%, #07110f 100%); +} + +.hero-inner { padding: 42px 24px; } +.hero-kicker { display: block; color: #b9d9ca; font-size: 14px; letter-spacing: .06em; } +.page-hero h1 { margin: 5px 0 5px; font-size: 42px; letter-spacing: -.05em; } +.page-hero p { margin: 0; color: #d5dfdc; font-size: 16px; } + +.sub-nav { background: rgba(8, 30, 24, .96); color: #fff; } +.sub-nav-inner { display: flex; max-width: 1280px; margin: 0 auto; } +.sub-nav a { flex: 1; padding: 17px 12px; text-align: center; color: #cbd6d2; } +.sub-nav a:hover, .sub-nav a[aria-current='page'] { color: #fff; background: #1c7056; font-weight: 700; } + +.page-shell { max-width: 1280px; padding: 66px 32px 110px; margin: 0 auto; } +.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 30px; } +.section-heading h2 { margin: 0; font-size: 28px; letter-spacing: -.05em; } +.section-heading p { margin: 3px 0 0; color: var(--muted); } + +.filter-panel { padding: 22px 24px; margin-bottom: 25px; border: 1px solid var(--line); background: var(--soft); } +.filter-row { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; } +.filter-row + .filter-row { padding-top: 17px; margin-top: 17px; border-top: 1px solid var(--line); } +.filter-label { min-width: 48px; font-weight: 700; } +.chip-group { display: flex; gap: 7px; flex-wrap: wrap; } +.chip { display: inline-flex; align-items: center; gap: 5px; padding: 7px 13px; border: 1px solid #d7d7d7; border-radius: 3px; background: #fff; color: #666; font-size: 13px; } +.chip:has(input:checked) { border-color: var(--navy); background: var(--navy); color: #fff; } +.chip input { position: absolute; opacity: 0; pointer-events: none; } +.filter-spacer { flex: 1; } +.search-form { display: flex; gap: 8px; min-width: min(100%, 400px); } +.search-form input { width: 100%; min-width: 0; height: 40px; padding: 0 13px; border: 1px solid #d2d2d2; background: #fff; outline: 0; } +.search-form input:focus { border-color: var(--navy); } +.button { display: inline-flex; align-items: center; justify-content: center; height: 42px; padding: 0 21px; border: 1px solid transparent; border-radius: 3px; font-weight: 500; white-space: nowrap; } +.button-primary { background: var(--navy); color: #fff; } +.button-primary:hover { background: #0e315f; } +.button-outline { border-color: #bfc5cb; background: #fff; color: #555; } +.button-outline:hover { border-color: var(--navy); color: var(--navy); } +.button-accent { background: var(--accent); color: #fff; } +.button-accent:hover { background: #0049cc; } + +.table-wrap { overflow-x: auto; border-top: 2px solid #252525; } +.qa-table { width: 100%; min-width: 850px; border-collapse: collapse; table-layout: fixed; } +.qa-table th { height: 48px; padding: 0 12px; border-bottom: 1px solid #252525; font-weight: 500; } +.qa-table td { height: 62px; padding: 0 12px; border-bottom: 1px solid var(--line); color: #5c5c5c; text-align: center; } +.qa-table tbody tr { cursor: pointer; transition: background .15s ease; } +.qa-table tbody tr:hover { background: #fafafa; } +.qa-table .subject { overflow: hidden; color: #222; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.subject-link { font-weight: 500; } +.notice-row td { background: #fcfcf8; font-weight: 500; } +.category { display: inline-flex; align-items: center; justify-content: center; min-width: 66px; padding: 3px 7px; border-radius: 2px; background: #f0f4fb; color: var(--navy); font-size: 12px; } +.category.notice { background: #fff6e7; color: #9b6915; } +.lock { display: inline-block; margin-right: 6px; color: #888; font-size: 12px; } +.new-mark { margin-left: 4px; color: var(--accent); font-size: 11px; font-weight: 700; } +.status { display: inline-flex; justify-content: center; min-width: 76px; padding: 3px 7px; border: 1px solid #dadada; border-radius: 2px; color: #666; font-size: 12px; } +.status.done { border-color: #b3ddce; background: #effaf6; color: var(--green); } +.status.review { border-color: #c5d8ef; background: #f2f7fd; color: #356594; } +.table-empty { display: none; padding: 70px 20px; border-bottom: 1px solid var(--line); color: var(--muted); text-align: center; } + +.table-actions { display: flex; align-items: center; justify-content: space-between; padding-top: 23px; } +.result-count { color: var(--muted); font-size: 13px; } +.pagination { display: flex; justify-content: center; gap: 5px; margin: 38px 0 0; } +.pagination button { width: 32px; height: 32px; border: 0; background: none; color: #555; } +.pagination button:hover { color: var(--navy); } +.pagination button.active { border-radius: 50%; background: var(--navy); color: #fff; } + +.form-card, .detail-card { border-top: 2px solid #242424; } +.form-row { display: grid; grid-template-columns: 145px 1fr; min-height: 68px; border-bottom: 1px solid var(--line); } +.form-label { display: flex; align-items: center; padding: 17px 18px; background: #fafafa; font-weight: 500; } +.required { color: #d74444; } +.form-control { display: flex; align-items: center; min-width: 0; padding: 13px 17px; } +.form-control.column { align-items: stretch; flex-direction: column; gap: 8px; } +.input, .select, .textarea { width: 100%; padding: 10px 12px; border: 1px solid #d5d5d5; border-radius: 2px; background: #fff; outline: 0; } +.input, .select { height: 42px; } +.textarea { min-height: 260px; resize: vertical; line-height: 1.7; } +.input:focus, .select:focus, .textarea:focus { border-color: var(--navy); box-shadow: 0 0 0 2px rgba(23, 63, 120, .08); } +.select { max-width: 230px; } +.check { display: inline-flex; align-items: center; gap: 8px; color: #555; } +.check input { width: 17px; height: 17px; accent-color: var(--navy); } +.help-text { color: #888; font-size: 12px; } +.file-input { padding: 8px 0; } +.form-notice { padding: 18px; margin-bottom: 22px; border: 1px solid #d9e3ef; background: #f6f9fd; color: #49617a; } +.form-notice strong { display: block; margin-bottom: 3px; color: #294867; } +.form-error { display: none; padding: 14px 16px; margin-bottom: 18px; border: 1px solid #f0b2b2; background: #fff7f7; color: #b42318; } +.form-footer { display: flex; justify-content: center; gap: 8px; padding-top: 26px; } + +.detail-card { padding-bottom: 24px; } +.detail-head { padding: 23px 22px 20px; border-bottom: 1px solid var(--line); } +.detail-title-line { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.detail-title-line h2 { margin: 0; font-size: 22px; letter-spacing: -.04em; } +.detail-meta { display: flex; gap: 14px; flex-wrap: wrap; padding-top: 15px; color: #888; font-size: 13px; } +.detail-meta span + span::before { content: ''; display: inline-block; width: 1px; height: 11px; margin: 0 14px 0 0; background: #ddd; vertical-align: -1px; } +.detail-body { min-height: 260px; padding: 38px 22px 56px; white-space: pre-wrap; word-break: break-word; } +.attachments { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafafa; } +.attachments h3 { margin: 0 0 8px; font-size: 14px; } +.attachments a { color: var(--navy); text-decoration: underline; } +.detail-actions { display: flex; justify-content: space-between; padding: 23px 22px 0; } +.comment-box { margin-top: 54px; } +.comment-box h3 { padding-bottom: 15px; margin: 0; border-bottom: 2px solid #222; font-size: 17px; } +.comment { padding: 19px 10px; border-bottom: 1px solid var(--line); } +.comment-meta { display: flex; justify-content: space-between; margin-bottom: 7px; color: #777; font-size: 13px; } +.comment-content { white-space: pre-wrap; } +.comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; } + +.site-footer { padding: 38px 32px; background: #111; color: #b9b9b9; } +.footer-inner { display: flex; align-items: flex-start; justify-content: space-between; max-width: 1280px; margin: 0 auto; gap: 40px; } +.footer-brand { color: #fff; } +.footer-copy { margin: 5px 0 0; color: #777; font-size: 12px; } +.footer-links { display: flex; gap: 23px; color: #d9d9d9; font-size: 13px; } + +.toast { position: fixed; right: 26px; bottom: 26px; z-index: 30; padding: 14px 18px; border-radius: 4px; background: #1c1c1c; color: #fff; box-shadow: 0 8px 24px rgba(0,0,0,.2); opacity: 0; pointer-events: none; transform: translateY(10px); transition: opacity .2s ease, transform .2s ease; } +.toast.show { opacity: 1; transform: translateY(0); } +.mobile-menu { display: none; } + +@media (max-width: 1100px) { + .global-nav { gap: 18px; margin-right: 20px; } + .nav-item { font-size: 14px; } + .header-tools { gap: 12px; } + .brand { font-size: 15px; } +} + +@media (max-width: 820px) { + :root { --header-height: 68px; } + .header-inner { padding: 0 18px; } + .global-nav, .locale { display: none; } + .global-nav.mobile-open { position: absolute; top: var(--header-height); right: 0; left: 0; display: flex; flex-direction: column; align-items: stretch; height: auto; gap: 0; padding: 8px 18px 18px; margin: 0; border-bottom: 1px solid var(--line); background: #fff; box-shadow: 0 12px 25px rgba(0, 0, 0, .08); } + .global-nav.mobile-open .nav-item { padding: 13px 0; } + .global-nav.mobile-open .mega-menu { position: static; display: none; width: auto; padding: 10px 0 10px 16px; box-shadow: none; opacity: 1; transform: none; } + .global-nav.mobile-open .package:focus-within .mega-menu, .global-nav.mobile-open .package:hover .mega-menu { display: grid; } + .header-tools { margin-left: auto; } + .mobile-menu { display: block; } + .page-hero { min-height: 185px; } + .page-hero h1 { font-size: 33px; } + .sub-nav-inner { overflow-x: auto; } + .sub-nav a { min-width: 130px; padding: 14px 10px; font-size: 13px; } + .page-shell { padding: 43px 18px 75px; } + .section-heading { display: block; margin-bottom: 22px; } + .section-heading h2 { font-size: 24px; } + .filter-panel { padding: 16px; } + .filter-row { gap: 13px; } + .filter-row + .filter-row { margin-top: 14px; padding-top: 14px; } + .filter-spacer { display: none; } + .search-form { width: 100%; min-width: 0; } + .table-actions { padding-top: 17px; } + .form-row { grid-template-columns: 90px 1fr; } + .form-label, .form-control { padding: 12px 10px; font-size: 13px; } + .detail-title-line { display: block; } + .detail-title-line .status { margin-top: 12px; } + .detail-head, .detail-body, .attachments, .detail-actions { padding-right: 14px; padding-left: 14px; } + .detail-actions { gap: 8px; } + .detail-actions .button { flex: 1; padding: 0 10px; } + .footer-inner { display: block; } + .footer-links { margin-top: 18px; } +} + +@media (max-width: 480px) { + .brand-mark { width: 31px; height: 21px; font-size: 12px; } + .brand { gap: 5px; font-size: 13px; } + .login-link { font-size: 0; } + .login-link .user-icon { margin-right: 8px; } + .button { padding: 0 14px; } + .form-footer { justify-content: stretch; } + .form-footer .button { flex: 1; } +} diff --git a/detail.html b/detail.html new file mode 100644 index 0000000..1de1188 --- /dev/null +++ b/detail.html @@ -0,0 +1,9 @@ + + +문의 상세 | EG-BIM Q&A + + +
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

+

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 등록

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.

답변 0

+
+ diff --git a/index.html b/index.html new file mode 100644 index 0000000..676048a --- /dev/null +++ b/index.html @@ -0,0 +1,41 @@ + + + + + + + EG-BIM Q&A | 바론컨설턴트 + + + + + +
+
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

+ +
+

문의하기(Q&A)

EG-BIM 사용 중 궁금한 점과 개선 의견을 남겨주세요.

문의 등록
+
+
구분
+
검색
+
+
총 0건문의 내용에 개인정보를 입력하지 마세요.
+
EG-BIM 문의 목록
번호구분회사부서작성자제목등록일상태
검색 조건에 맞는 문의가 없습니다.
+ +
+
+ +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..18a8fea --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "baron-qa-gateway-test", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "r2:upload": "bash scripts/upload-r2.sh", + "check": "node --check src/index.js && node --check app.js" + }, + "devDependencies": { + "wrangler": "4.134.0" + } +} diff --git a/qa-secrets.json.example b/qa-secrets.json.example new file mode 100644 index 0000000..324eab4 --- /dev/null +++ b/qa-secrets.json.example @@ -0,0 +1,3 @@ +{ + "SESSION_SECRET": "replace-with-openssl-rand-hex-32-output" +} diff --git a/scripts/upload-r2.sh b/scripts/upload-r2.sh new file mode 100755 index 0000000..4efd2e2 --- /dev/null +++ b/scripts/upload-r2.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +bucket="${1:-baron-qa-pages}" + +upload() { + local file="$1" + local key="$2" + local content_type="$3" + npx wrangler r2 object put "$bucket/$key" --file "$file" --content-type "$content_type" --remote +} + +upload index.html index.html text/html +upload write.html write.html text/html +upload detail.html detail.html text/html +upload app.js app.js application/javascript +upload index.html egbim/index.html text/html +upload write.html egbim/write.html text/html +upload detail.html egbim/detail.html text/html +upload app.js egbim/app.js application/javascript + +while IFS= read -r file; do + key="${file#./}" + case "$file" in + *.css) content_type="text/css" ;; + *.js) content_type="application/javascript" ;; + *.woff2) content_type="font/woff2" ;; + *.svg) content_type="image/svg+xml" ;; + *.png) content_type="image/png" ;; + *.jpg|*.jpeg) content_type="image/jpeg" ;; + *) content_type="application/octet-stream" ;; + esac + upload "$file" "$key" "$content_type" + upload "$file" "egbim/$key" "$content_type" +done < <(find ./assets -type f -not -name '*.map' -print | sort) + +echo "Uploaded static files to R2 bucket: $bucket" diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..ae23e0a --- /dev/null +++ b/src/index.js @@ -0,0 +1,274 @@ +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 (request.method !== 'GET' && request.method !== 'HEAD') { + return json({ error: 'method_not_allowed' }, 405); + } + + 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') ? '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 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 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 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 || '', + company: custom.company || claims.company || claims.organization || '', + familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || '', + department: custom.team || custom.department || claims.department || '' + }; +} + +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' }); +} + +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 }) }); +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..5b58f29 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,37 @@ +name = "baron-qa-gateway-test" +main = "src/index.js" +compatibility_date = "2026-09-18" +account_id = "81fa2d48964d31dd0da9558f9ce601d1" +preview_urls = false + +routes = [ + { pattern = "qa-test.baroncs.co.kr", custom_domain = true } +] + +[vars] +AUTH_REDIRECT_URI = "https://qa-test.baroncs.co.kr/auth/callback" +AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa-test.baroncs.co.kr/" +AUTH_SCOPE = "openid tenants profile email" +AUTH_CLIENT_ID = "e16898a7-acd2-420a-a224-cdcd89ff66cd" +AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth" +AUTH_TOKEN_URL = "https://sso.hmac.kr/oidc/oauth2/token" +AUTH_USERINFO_URL = "https://sso.hmac.kr/oidc/userinfo" +SESSION_COOKIE_NAME = "baron_qa_session" +OAUTH_COOKIE_NAME = "baron_qa_oauth" +SESSION_TTL_SECONDS = "3600" + +PUBLIC_EXACT_PATHS = "/,/index.html,/favicon.ico" +PUBLIC_PREFIXES = "/egbim/,/tova/,/gaia/,/shared/,/assets/,/auth/" +PROTECTED_PREFIXES = "/egbim/,/tova/,/gaia/" +PROTECTED_EXACT_PATHS = "/write.html,/detail.html" +INTERNAL_ONLY_PREFIXES = "/protected/" + +[[r2_buckets]] +binding = "QA_BUCKET" +bucket_name = "baron-qa-pages" + +[observability] +enabled = true + +[observability.logs] +enabled = true diff --git a/write.html b/write.html new file mode 100644 index 0000000..91634ed --- /dev/null +++ b/write.html @@ -0,0 +1,9 @@ + + +문의 등록 | EG-BIM Q&A + + +
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

+

문의 등록

로그인한 사용자 정보로 문의가 등록됩니다.

로그인 상태를 확인하고 있습니다.
공개 설정
개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.
파일은 presigned URL을 통해 qa_cdn 버킷에 저장됩니다. 파일당 최대 30MB
+
+