diff --git a/.github/workflows/build-with-vault-refresh.yml b/.github/workflows/build-with-vault-refresh.yml index 9a4d5c6..96de25b 100644 --- a/.github/workflows/build-with-vault-refresh.yml +++ b/.github/workflows/build-with-vault-refresh.yml @@ -2,10 +2,20 @@ name: Build With Vault Refresh on: workflow_dispatch: + inputs: + vault_changed_files: + description: "바뀐 vault 경로(쉼표 구분). Watch OneDrive 가 채운다. 비우면 강제 리프레시" + type: string + default: "" push: branches: - dev +# 생성물을 같은 OneDrive 경로에 쓰므로 두 실행이 겹치지 않게 한다. +concurrency: + group: build-with-vault-refresh-${{ github.ref }} + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest @@ -24,17 +34,25 @@ jobs: - name: Install dependencies run: npm ci - # vault 콘텐츠의 정본은 개인 OneDrive 의 hsol-info-blob 폴더다(2026-09-27 hsol-info-blob 저장소 폐기). - # sync-onedrive-to-blob.yml 이 올린 Blob info/vault 를 hsol-info-blob/vault 로 내려받아 기존 스크립트 경로를 그대로 쓴다. - # 내려받은 직후의 생성물 해시를 적어 두고, 아래 업로드 스텝이 실제로 바뀐 것만 Blob 에 되올린다. - - name: Pull vault from Blob + - name: Install rclone + run: | + curl -fsSL https://rclone.org/install.sh | sudo bash + rclone version | head -1 + + # 콘텐츠의 정본이자 유일한 저장소는 개인 OneDrive 의 hsol-info-blob 폴더다(2026-09-27 부터). + # vault 를 hsol-info-blob/vault 로 받아 기존 스크립트 경로를 그대로 쓴다. 사이트 생성기가 읽지 않는 경로는 받지 않는다. + # 받은 직후의 생성물 해시를 적어 두고, 뒤의 게시 스텝이 실제로 바뀐 것만 OneDrive 에 되쓴다. + - name: Pull vault from OneDrive env: - BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} - # 사이트 생성기가 읽지 않는 경로는 받지 않는다(예전 Blob 에 남은 datasources 등). - BLOB_PULL_EXCLUDE: "datasources/,.obsidian/,Templates/,Attachments/" + DATABASE_URL: ${{ secrets.DATABASE_URL }} + ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }} + ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }} run: | set -euo pipefail - node ops/scripts/pull-blob-to-vault.mjs --vault-dir hsol-info-blob/vault --clean + node ops/scripts/with-onedrive.mjs -- rclone copy od:hsol-info-blob/vault hsol-info-blob/vault \ + --exclude "datasources/**" --exclude ".obsidian/**" --exclude "Templates/**" --exclude "Attachments/**" \ + --exclude "*.base" --exclude ".DS_Store" --transfers 16 --checkers 16 --stats-one-line --stats 30s + echo "받은 파일: $(find hsol-info-blob/vault -type f | wc -l)개" mkdir -p generated for f in vault/object-views/site-data.json vault/object-views/onepager-ko.html vault/object-views/onepager-en.html; do if [ -f "hsol-info-blob/$f" ]; then echo "$(sha1sum "hsol-info-blob/$f" | cut -d' ' -f1) $f"; fi @@ -49,8 +67,10 @@ jobs: ANTHROPIC_MAX_TOKENS: "64000" GIT_DIFF_BASE_SHA: ${{ github.event.before }} GIT_DIFF_HEAD_SHA: ${{ github.sha }} - # vault(서브모듈) 수정이 있을 때만 자동 실행. 수동 실행(workflow_dispatch)은 명시적 의도라 강제. - CONTENT_REFRESH_FORCE: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }} + # vault 원본(OneDrive)이 바뀌면 Watch OneDrive 가 바뀐 경로를 넘겨 디스패치한다 -> 그 목록으로 변경 판단. + # 코드 push 에서는 목록이 없어 스킵. 목록 없이 수동 실행하면 명시적 의도로 보고 강제. + VAULT_CHANGED_FILES: ${{ inputs.vault_changed_files }} + CONTENT_REFRESH_FORCE: ${{ github.event_name == 'workflow_dispatch' && inputs.vault_changed_files == '' && '1' || '' }} # 빌드 로그(build_log 테이블) 적층용. 없으면 로그 적층만 건너뛰고 리프레시는 정상. DATABASE_URL: ${{ secrets.DATABASE_URL }} # 파이프라인 종류별 LLM 동작 토글(contentPipeline). 없으면 코드 기본값으로 폴백(빌드 정상). @@ -73,50 +93,13 @@ jobs: AI_GATEWAY_MODEL: anthropic/claude-opus-4.7 run: npm run content:translate:onepager - # 게이트를 여기서 한 번 더 돌린다. prebuild(Build 스텝)에서도 돌지만, 그건 아래 blob push 와 - # PDF 업로드가 끝난 뒤라 위반이 이미 배포된 뒤에야 빌드를 세운다 — PDF 는 Blob 에서 바로 + # 게이트를 여기서 한 번 더 돌린다. prebuild(Build 스텝)에서도 돌지만, 그건 아래 OneDrive 게시와 + # PDF 업로드가 끝난 뒤라 위반이 이미 배포된 뒤에야 빌드를 세운다 — PDF 는 OneDrive 에서 바로 # 내려가므로 빌드 실패와 무관하게 살아 있다(PRF-149). 재생성 직후·발행 직전이 유일하게 # 유효한 지점이다. - name: Gate regenerated one-pager (self-designation) run: npm run check:self-designation - # 생성물(site-data, 원페이저)은 Blob 이 정본이다. 바뀐 것만 info/vault 로 올린다. - # OneDrive 로의 역미러는 hsol-info-blob 동기화 워크플로가 다음 회차에 한다. - - name: Upload regenerated vault outputs to Blob - env: - BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} - run: | - set -euo pipefail - node --input-type=module -e ' - import { readFileSync, existsSync } from "node:fs"; - import { put } from "@vercel/blob"; - const token = process.env.BLOB_READ_WRITE_TOKEN; - const pulled = new Map( - (existsSync("generated/pulled-generated.sha1") ? readFileSync("generated/pulled-generated.sha1", "utf8") : "") - .split("\n").filter(Boolean).map((l) => { const [h, f] = l.split(" "); return [f, h]; }), - ); - const { createHash } = await import("node:crypto"); - const files = ["vault/object-views/site-data.json", "vault/object-views/onepager-ko.html", "vault/object-views/onepager-en.html"]; - const types = { json: "application/json", html: "text/html; charset=utf-8" }; - const changed = []; - for (const f of files) { - const p = `hsol-info-blob/${f}`; - if (!existsSync(p)) continue; - const buf = readFileSync(p); - const h = createHash("sha1").update(buf).digest("hex"); - if (pulled.get(f) === h) continue; - await put(`info/${f}`, buf, { access: "private", addRandomSuffix: false, allowOverwrite: true, contentType: types[f.split(".").pop()], token }); - changed.push(f); - console.log(`uploaded info/${f}`); - } - if (changed.length) { - const ref = { parentSha: process.env.GITHUB_SHA, parentRef: process.env.GITHUB_REF, workflowRunId: Number(process.env.GITHUB_RUN_ID), recordedAt: new Date().toISOString(), changed }; - await put("info/vault/object-views/hsol-info-parent-ref.json", JSON.stringify(ref, null, 2), { access: "private", addRandomSuffix: false, allowOverwrite: true, contentType: "application/json", token }); - } else { - console.log("No regenerated vault outputs; skip upload."); - } - ' - - name: Upload content refresh failure dumps if: failure() uses: actions/upload-artifact@v4 @@ -128,12 +111,17 @@ jobs: - name: Generate site.ts from vault run: npm run content:generate:site-ts - - name: Generate one-pager PDF (html -> pdf -> Blob) - env: - # 없으면 로컬 PDF만 만들고 업로드는 graceful skip(빌드 실패 안 함). - BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + - name: Generate one-pager PDF (html -> pdf) run: npm run content:generate:onepager-pdf + # 사이트는 생성물을 OneDrive 에서 바로 읽는다. 바뀐 것만 vault/object-views 에 쓴다. + - name: Publish generated outputs to OneDrive + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }} + ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }} + run: node ops/scripts/publish-generated.mjs + - name: Lint run: npm run lint diff --git a/.github/workflows/sync-onedrive-to-blob.yml b/.github/workflows/sync-onedrive-to-blob.yml deleted file mode 100644 index a5a69b5..0000000 --- a/.github/workflows/sync-onedrive-to-blob.yml +++ /dev/null @@ -1,77 +0,0 @@ -# hsol.info 콘텐츠의 정본은 개인 OneDrive 의 hsol-info-blob 폴더다. 이 워크플로가 OneDrive -> Vercel Blob 으로 올린다. -# vault : OneDrive hsol-info-blob/vault/ -> Blob info/vault/ (site-data, 원페이저, Ask Hansol) -# assets : OneDrive hsol-info-blob/assets/ -> Blob info/assets/ (/files/<키>) -# 기사 : vault/objects/news-articles 가 바뀌면 Neon articles 미러 갱신 + dbId 를 OneDrive 원본에 역기록 -# Build With Vault Refresh 가 Blob 에 올린 생성물(site-data.json, onepager-*.html)은 OneDrive 로 역미러한다. -# 2026-09-27 hsol-info-blob 저장소 폐기와 함께 이 저장소로 옮겼다. 코드: ops/scripts/sync-onedrive-to-blob.mjs -# -# Repository secrets -# BLOB_READ_WRITE_TOKEN Blob 읽기/쓰기 -# ONEDRIVE_TOKEN_KEY OneDrive 토큰 암호화 키(32바이트 base64). 잃으면 재인증 필요 -# ONEDRIVE_DRIVE_ID 개인 OneDrive drive id -# DATABASE_URL Neon (기사 미러) -# ONEDRIVE_BOOTSTRAP_TOKEN (선택) 재인증할 때만. rclone authorize onedrive 출력 JSON - -name: Sync OneDrive to Vercel Blob - -on: - schedule: - # 매시 17분(UTC). 리프레시 토큰이 계속 갱신되도록 주기 실행이 필요하다. - - cron: "17 * * * *" - workflow_dispatch: - inputs: - dry_run: - description: "업로드/삭제/역기록 없이 차이만 출력" - type: boolean - default: false - tracks: - description: "동기화할 트랙 (assets,vault)" - type: string - default: "assets,vault" - force_articles: - description: "변경이 없어도 기사 DB 동기화 실행" - type: boolean - default: false - maint_op: - description: "정비 작업(stats). 지정하면 동기화 대신 실행" - type: string - default: "" - -# 토큰이 실행마다 회전하므로 두 실행이 겹치면 한쪽 토큰이 버려진다. 반드시 직렬. -concurrency: - group: sync-onedrive-to-blob - cancel-in-progress: false - -jobs: - sync: - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci --ignore-scripts --no-audit --no-fund - - - name: Install rclone - run: | - curl -fsSL https://rclone.org/install.sh | sudo bash - rclone version | head -1 - - - name: Sync OneDrive -> Blob - env: - BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} - ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }} - ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }} - ONEDRIVE_BOOTSTRAP_TOKEN: ${{ secrets.ONEDRIVE_BOOTSTRAP_TOKEN }} - DATABASE_URL: ${{ secrets.DATABASE_URL }} - SYNC_TRACKS: ${{ inputs.tracks || 'assets,vault' }} - FORCE_ARTICLES: ${{ inputs.force_articles && '1' || '' }} - MAINT_OP: ${{ inputs.maint_op }} - run: node ops/scripts/sync-onedrive-to-blob.mjs ${{ inputs.dry_run && '--dry-run' || '' }} diff --git a/.github/workflows/watch-onedrive.yml b/.github/workflows/watch-onedrive.yml new file mode 100644 index 0000000..98c101b --- /dev/null +++ b/.github/workflows/watch-onedrive.yml @@ -0,0 +1,77 @@ +# hsol.info 콘텐츠의 정본이자 유일한 저장소는 개인 OneDrive 의 hsol-info-blob 폴더다. 사이트는 OneDrive 를 직접 읽는다. +# 이 워크플로는 vault 변경을 감시해 뒤따르는 일을 건다. 코드: ops/scripts/watch-onedrive.mjs +# 기사 : vault/objects/news-articles 가 바뀌면 Neon articles 미러 갱신 + dbId 를 OneDrive 원본에 역기록 +# 빌드 : vault 가 바뀌면 Build With Vault Refresh 를 바뀐 경로 목록과 함께 디스패치(site-data, 원페이저 재생성 판단) +# 매시 실행이 OneDrive 리프레시 토큰도 살려 둔다(사이트 런타임도 필요할 때 갱신한다). +# +# Repository secrets +# DATABASE_URL Neon (토큰, 변경 기준선, 기사 미러) +# ONEDRIVE_TOKEN_KEY OneDrive 토큰 암호화 키(32바이트 base64). Vercel env 와 같은 값. 잃으면 재인증 필요 +# ONEDRIVE_DRIVE_ID 개인 OneDrive drive id +# ONEDRIVE_BOOTSTRAP_TOKEN (선택) 재인증할 때만. rclone authorize onedrive 출력 JSON + +name: Watch OneDrive + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + inputs: + dry_run: + description: "기록, 역기록, 디스패치 없이 차이만 출력" + type: boolean + default: false + force_articles: + description: "변경이 없어도 기사 DB 동기화 실행" + type: boolean + default: false + +# 기준선과 토큰을 같이 쓰므로 직렬로 돈다. +concurrency: + group: watch-onedrive + cancel-in-progress: false + +jobs: + watch: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + # vault 가 바뀌면 Build With Vault Refresh 를 디스패치한다(GITHUB_TOKEN 의 workflow_dispatch 는 새 실행을 만든다). + actions: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Install rclone + run: | + curl -fsSL https://rclone.org/install.sh | sudo bash + rclone version | head -1 + + - name: Watch OneDrive vault + id: watch + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + ONEDRIVE_TOKEN_KEY: ${{ secrets.ONEDRIVE_TOKEN_KEY }} + ONEDRIVE_DRIVE_ID: ${{ secrets.ONEDRIVE_DRIVE_ID }} + ONEDRIVE_BOOTSTRAP_TOKEN: ${{ secrets.ONEDRIVE_BOOTSTRAP_TOKEN }} + FORCE_ARTICLES: ${{ inputs.force_articles && '1' || '' }} + run: node ops/scripts/watch-onedrive.mjs ${{ inputs.dry_run && '--dry-run' || '' }} + + # 예전 서브모듈 포인터 이동을 대신하는 신호. vault 원본이 바뀐 회차에만 사이트 생성물 재생성을 건다. + - name: Dispatch site refresh when vault changed + if: ${{ steps.watch.outputs.vault_changed_files != '' && !inputs.dry_run }} + env: + GH_TOKEN: ${{ github.token }} + FILES: ${{ steps.watch.outputs.vault_changed_files }} + run: | + set -euo pipefail + echo "vault 변경 파일: $(echo "$FILES" | tr ',' '\n' | wc -l)개 -> Build With Vault Refresh 디스패치" + gh workflow run build-with-vault-refresh.yml -R "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME" -f vault_changed_files="$FILES" diff --git a/AGENTS.md b/AGENTS.md index 1140a95..df4617e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,11 +23,10 @@ vault 에 반영한다. 상세 트리거·절차·예외는 `hsol-info-blob/CLAU - 이 저장소의 Vercel 배포 대상 프로젝트는 팀 `hsol`의 `hsol-info`이다(`vercel link --scope hsol --project hsol-info`로 맞출 수 있다). Web Analytics를 쓰려면 해당 프로젝트에서 기능을 활성화하고 루트 레이아웃에 `@vercel/analytics/next`의 ``를 둔다([시작 가이드](https://vercel.com/docs/analytics/quickstart?framework=nextjs)). Google Analytics 4(gtag)는 `NEXT_PUBLIC_GA_MEASUREMENT_ID`가 비어 있지 않을 때만 스크립트와 클라이언트 `page_path` 갱신을 넣고, 없으면 넣지 않는다. - `vercel env pull`은 기본이 development 환경이라, Production·Preview에만 있는 변수는 `.env.local`에 포함되지 않을 수 있다. - 이 저장소는 Vercel 서버 렌더로 배포한다. `next.config.ts` 에 `output: "export"` 가 없고 `force-dynamic` 라우트(`/resume`, `/api/*`, `/manage/*` 등)가 여럿이라, 정적보내기 전제(산출물 `out/`, `app/api/*` 미배포로 `/api/*` 404, `force-dynamic` 금지, `vercel.json` 의 `outputDirectory` 중복 주의)는 지금 이 저장소에 적용되지 않는다. 다시 정적 export 로 돌린다면 그 넷을 함께 되짚어야 한다. -- `getSiteData()`는 Blob -> `hsol-info-blob/vault/object-views/site-data.json` -> 커밋된 `src/data/site.ts`의 `HSOL_DATA` 순으로 폴백한다. -- 2026-09-27 부터 vault 콘텐츠의 정본은 개인 OneDrive 의 `hsol-info-blob` 폴더다(`/Users/hsol/OneDrive/hsol-info-blob`). GitHub `hsol-info-blob` 저장소와 서브모듈은 폐기했다. 로컬의 `hsol-info-blob/` 는 git 이 추적하지 않는 링크 폴더로, `vault` 등은 OneDrive 를, `scripts`, `newsletter-ops`, `ask-hansol-seeding` 은 이 저장소의 `ops/` 를 가리킨다. **OneDrive -> Blob 업로드는 오직** 이 저장소의 `sync-onedrive-to-blob.yml`(매시, `ops/scripts/sync-onedrive-to-blob.mjs`)이 한다. 뉴스룸 기사 Neon 미러와 dbId 역기록도 같은 워크플로가 한다. `build-with-vault-refresh.yml`은 Blob `info/vault` 를 `hsol-info-blob/vault` 로 내려받아(`ops/scripts/pull-blob-to-vault.mjs`) 쓰고, 바뀐 생성물(`site-data.json`, `onepager-*.html`)과 부모 커밋 기록(`hsol-info-parent-ref.json`)은 Blob 에 직접 올린다. OneDrive 로의 역미러는 다음 동기화 회차가 한다. vault 변경만으로는 refresh 가 자동 실행되지 않으니 수동 실행(workflow_dispatch)으로 돌린다. `content:refresh:claude` 실패 시 `generated/content-refresh-failures/`를 아티팩트로 올린다. +- `getSiteData()`는 OneDrive(`vault/object-views/site-data.json`, Graph) -> 로컬 `hsol-info-blob/vault/object-views/site-data.json` -> 커밋된 `src/data/site.ts`의 `HSOL_DATA` 순으로 폴백한다. +- 2026-09-27 부터 콘텐츠의 정본이자 유일한 저장소는 개인 OneDrive 의 `hsol-info-blob` 폴더다(`/Users/hsol/OneDrive/hsol-info-blob`). GitHub `hsol-info-blob` 저장소, 서브모듈, Vercel Blob 은 쓰지 않는다. 로컬의 `hsol-info-blob/` 는 git 이 추적하지 않는 링크 폴더로, `vault` 등은 OneDrive 를, `scripts`, `newsletter-ops`, `ask-hansol-seeding` 은 이 저장소의 `ops/` 를 가리킨다. 사이트 런타임은 `src/lib/onedrive/graph.ts` 로 OneDrive 를 직접 읽는다(site-data, 원페이저, 이력서 PDF, Ask Hansol 문맥, `/files/<키>` = `assets/<키>`). 토큰은 Neon `onedrive_token` 에 암호화해 두고 런타임과 CI 가 같이 갱신한다(`ops/scripts/lib/onedrive-token.mjs`). `watch-onedrive.yml`(매시, `ops/scripts/watch-onedrive.mjs`)이 vault 변경을 감지해 뉴스룸 기사 Neon 미러와 dbId 역기록을 하고, 바뀐 경로 목록과 함께 `build-with-vault-refresh.yml` 을 디스패치한다. 빌드는 rclone 으로 vault 를 `hsol-info-blob/vault` 에 받아 쓰고(`ops/scripts/with-onedrive.mjs`), 바뀐 생성물(`site-data.json`, `onepager-*.html`, `onepager-*.pdf`, `hsol-info-parent-ref.json`)을 OneDrive `vault/object-views/` 에 직접 쓴다(`ops/scripts/publish-generated.mjs`). `content:refresh:claude` 실패 시 `generated/content-refresh-failures/`를 아티팩트로 올린다. - `scripts/refresh-site-data-with-claude.ts`는 `ANTHROPIC_MAX_TOKENS`(기본 64000)로 출력 상한을 두며, 응답이 `stop_reason=max_tokens`로 잘리면 파싱하지 않고 실패·덤프한다. 실패 본문은 기본적으로 `generated/content-refresh-failures/`에 쓴다. `tool_use` 페이로드에서 문자열로 감긴 JSON·루트 래핑(`data`/`siteData` 등)·모델이 자주 내는 대체 키 형태는 정규화·매핑 후 스키마 검증을 다시 시도한다. -- Blob->vault 풀 스크립트(`ops/scripts/pull-blob-to-vault.mjs`)는 `.DS_Store`(및 `.DS-Store` 파일명)를 내려받지 않고 로컬 정리 시에도 제외한다. -- Ask Hansol API(`src/app/api/ask-hansol/route.ts`)는 시스템 프롬프트에 `vault/README.md`(vault 탐색·읽기 절차용 지침이며 답변 사실을 채우는 발췌 문서가 아님)와 `vault/object-views/AI-클론-운영-매뉴얼.md`를 넣고, Blob 토큰은 `ASK_HANSOL_BLOB_TOKEN`, `BLOB_READ_WRITE_TOKEN`, `BLOB_READ_TOKEN` 순으로 쓴다. Claude는 `blob_lookup`으로 본문 근거 문서를 추가 조회한다. +- Ask Hansol API(`src/app/api/ask-hansol/route.ts`)는 시스템 프롬프트에 `vault/README.md`(vault 탐색·읽기 절차용 지침이며 답변 사실을 채우는 발췌 문서가 아님)와 `vault/object-views/AI-클론-운영-매뉴얼.md`를 넣는다. vault 원본은 개인 OneDrive `hsol-info-blob/vault/` 이고 `src/lib/onedrive/graph.ts` 가 Graph 로 읽는다(토큰은 Neon `onedrive_token`). Claude는 `vault_lookup`으로 본문 근거 문서를 추가 조회한다. - Ask Hansol API가 서버 함수로 실제 배포되는 환경에서는 세션 히스토리 GET이 DB를 읽으므로 `export const dynamic = "force-dynamic"`으로 두고, 클라이언트 히스토리·질문 `fetch`에는 `cache: "no-store"`를 쓴다(`force-static`이면 GET이 빌드·CDN에 고정되어 대화 목록이 비어 보일 수 있다). 순수 정적 export만 쓰는 빌드와는 타깃이 다를 수 있다. - Ask Hansol 답변 URL 처리는 `src/lib/ask-hansol/answer-linkify.ts`에서 마크다운·괄호 등을 평문으로 정리한 뒤 클라이언트에서 분리 렌더하며, `https`/`http`뿐 아니라 `www.` 접두·스킴 없는 호스트 형태·`mailto:` 등도 링크로 인식한다. - Preview Deployment Protection 환경에서 `ask-hansol-selection`이 `ask-hansol`로 서버-서버 재호출할 때는 원 요청의 `cookie`/`authorization`을 전달해야 내부 호출 401을 피할 수 있다. diff --git a/docs/architecture-diagram.md b/docs/architecture-diagram.md index 07aaaef..a9c0598 100644 --- a/docs/architecture-diagram.md +++ b/docs/architecture-diagram.md @@ -35,7 +35,7 @@ 2. **선별** — 후보를 "방문자에게 닿는 데이터 흐름이 바뀌었나?" 기준으로 거른다. 애매하면 뺀다(도식이 커지는 것 자체가 비용). 3. **수정** — 아래 스타일 규칙을 지키며 Mermaid를 고친다. 기존 서브그래프 골격 - (온톨로지 vault → CI/Blob → 외부 인프라 → 서버 → 클라이언트 → 방문자)은 유지하고, + (온톨로지 vault → CI/OneDrive → 외부 인프라 → 서버 → 클라이언트 → 방문자)은 유지하고, 새 영역이 필요할 때만 서브그래프를 추가한다. 4. **검증** — dev 서버에서 `/architecture`를 열어 실제 렌더를 확인한다. Mermaid는 문법 오류 시 조용히 빈 화면이 되므로 **스크린샷까지 확인**한다 diff --git a/ops/scripts/lib/onedrive-token.mjs b/ops/scripts/lib/onedrive-token.mjs new file mode 100644 index 0000000..965567e --- /dev/null +++ b/ops/scripts/lib/onedrive-token.mjs @@ -0,0 +1,93 @@ +/** + * 개인 OneDrive(Microsoft Graph) 토큰 저장소. Neon `onedrive_token` 테이블에 AES-256-GCM 으로 암호화해 둔다. + * + * 토큰은 rclone 형식 JSON({access_token, token_type, refresh_token, expiry}) 그대로다. + * CI(rclone)와 사이트 런타임(src/lib/onedrive/token.ts)이 같은 행을 읽고 쓴다. 리프레시 토큰이 쓸 때마다 + * 새로 나오므로, 쓰는 쪽은 항상 최신 토큰을 되써야 한다. 앱은 rclone 기본 앱(client id 는 공개값)이다. + * + * 환경변수 + * DATABASE_URL (또는 POSTGRES_URL) Neon + * ONEDRIVE_TOKEN_KEY 32바이트 base64. 잃으면 재인증(rclone authorize onedrive) + * ONEDRIVE_BOOTSTRAP_TOKEN (선택) 저장된 토큰이 없을 때만 쓰는 rclone authorize 출력 JSON + */ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { neon } from "@neondatabase/serverless"; + +export const TOKEN_ROW_ID = "personal"; + +export function getSql() { + const url = (process.env.DATABASE_URL || process.env.POSTGRES_URL || "").trim(); + if (!url) throw new Error("DATABASE_URL 이 없다."); + return neon(url); +} + +export function keyFrom(b64, name = "ONEDRIVE_TOKEN_KEY") { + if (!b64) throw new Error(`${name} 이 없다.`); + const key = Buffer.from(b64, "base64"); + if (key.length !== 32) throw new Error(`${name} 는 base64 로 인코딩한 32바이트여야 한다.`); + return key; +} + +export function encrypt(key, plaintext) { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64"); +} + +export function decrypt(key, payloadB64) { + const buf = Buffer.from(payloadB64, "base64"); + const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8"); +} + +export async function ensureTables(sql) { + await sql`CREATE TABLE IF NOT EXISTS onedrive_token ( + id text PRIMARY KEY, + payload text NOT NULL, + expires_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now() + )`; + await sql`CREATE TABLE IF NOT EXISTS onedrive_sync_state ( + key text PRIMARY KEY, + value jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() + )`; +} + +/** 저장된 토큰(rclone JSON 문자열). 없으면 ONEDRIVE_BOOTSTRAP_TOKEN, 그것도 없으면 에러. */ +export async function loadToken(sql, key) { + const rows = await sql`SELECT payload FROM onedrive_token WHERE id = ${TOKEN_ROW_ID}`; + if (rows[0]) return decrypt(key, rows[0].payload); + const boot = process.env.ONEDRIVE_BOOTSTRAP_TOKEN?.trim(); + if (boot) { + console.log("토큰: 저장된 토큰이 없어 ONEDRIVE_BOOTSTRAP_TOKEN 사용"); + return boot; + } + throw new Error("OneDrive 토큰이 없다. ONEDRIVE_BOOTSTRAP_TOKEN 을 넣고 다시 실행해야 한다."); +} + +export async function saveToken(sql, key, tokenJson) { + let expiresAt = null; + try { + const t = JSON.parse(tokenJson); + if (t.expiry) expiresAt = new Date(t.expiry).toISOString(); + } catch { + throw new Error("저장하려는 토큰이 JSON 이 아니다."); + } + const payload = encrypt(key, tokenJson); + await sql`INSERT INTO onedrive_token (id, payload, expires_at, updated_at) + VALUES (${TOKEN_ROW_ID}, ${payload}, ${expiresAt}, now()) + ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at, updated_at = now()`; +} + +export async function getState(sql, key) { + const rows = await sql`SELECT value FROM onedrive_sync_state WHERE key = ${key}`; + return rows[0]?.value ?? null; +} + +export async function setState(sql, key, value) { + await sql`INSERT INTO onedrive_sync_state (key, value, updated_at) VALUES (${key}, ${JSON.stringify(value)}::jsonb, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`; +} diff --git a/ops/scripts/lib/rclone-onedrive.mjs b/ops/scripts/lib/rclone-onedrive.mjs new file mode 100644 index 0000000..8badd03 --- /dev/null +++ b/ops/scripts/lib/rclone-onedrive.mjs @@ -0,0 +1,53 @@ +/** + * CI 에서 rclone 으로 개인 OneDrive 를 여는 헬퍼. 토큰은 Neon(onedrive-token.mjs)에서 읽고, + * rclone 이 갱신한 최신 토큰을 닫을 때 되쓴다. 원격 이름은 `od:` 이고 루트는 OneDrive 최상위다. + * + * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID + */ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { ensureTables, getSql, keyFrom, loadToken, saveToken } from "./onedrive-token.mjs"; + +const execFileAsync = promisify(execFile); + +export async function openOneDrive() { + const driveId = process.env.ONEDRIVE_DRIVE_ID?.trim(); + if (!driveId) throw new Error("ONEDRIVE_DRIVE_ID 가 없다."); + const key = keyFrom(process.env.ONEDRIVE_TOKEN_KEY); + const sql = getSql(); + await ensureTables(sql); + const tokenBefore = await loadToken(sql, key); + + const dir = await mkdtemp(path.join(os.tmpdir(), "od-")); + const conf = path.join(dir, "rclone.conf"); + await writeFile(conf, `[od]\ntype = onedrive\ndrive_type = personal\ndrive_id = ${driveId}\ntoken = ${tokenBefore}\n`, { + mode: 0o600, + }); + + async function rclone(args, opts = {}) { + const { stdout } = await execFileAsync("rclone", ["--config", conf, ...args], { + maxBuffer: 256 * 1024 * 1024, + ...opts, + }); + return stdout; + } + + async function close() { + try { + const text = await readFile(conf, "utf8").catch(() => ""); + const line = text.split("\n").find((l) => l.startsWith("token = ")); + const latest = line ? line.slice("token = ".length).trim() : null; + if (latest && latest !== tokenBefore) { + await saveToken(sql, key, latest); + console.log("OneDrive 토큰 갱신분을 Neon 에 저장"); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + + return { conf, sql, rclone, close }; +} diff --git a/ops/scripts/publish-generated.mjs b/ops/scripts/publish-generated.mjs new file mode 100644 index 0000000..058d95c --- /dev/null +++ b/ops/scripts/publish-generated.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Build With Vault Refresh 가 만든 생성물을 개인 OneDrive `hsol-info-blob/vault/object-views/` 에 쓴다. + * 사이트는 이 파일들을 OneDrive 에서 바로 읽는다. + * + * - site-data.json, onepager-ko.html, onepager-en.html: 내려받은 직후 해시(generated/pulled-generated.sha1)와 + * 달라진 것만 + * - onepager-.pdf (generated/onepager-.pdf): 같은 언어 HTML 이 바뀌었거나 OneDrive 에 아직 없을 때만. + * PDF 는 렌더할 때마다 바이트가 달라져 해시로 비교하지 않는다 + * - 하나라도 올렸으면 hsol-info-parent-ref.json(어느 커밋, 어느 실행이 만들었는지) + * + * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID + */ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { openOneDrive } from "./lib/rclone-onedrive.mjs"; + +const LOCAL_VAULT = process.env.LOCAL_VAULT_DIR || "hsol-info-blob/vault"; +const OD_VIEWS = `${process.env.ONEDRIVE_VAULT_ROOT || "hsol-info-blob/vault"}/object-views`; +const TEXT_OUTPUTS = ["object-views/site-data.json", "object-views/onepager-ko.html", "object-views/onepager-en.html"]; + +const pulled = new Map( + (existsSync("generated/pulled-generated.sha1") ? readFileSync("generated/pulled-generated.sha1", "utf8") : "") + .split("\n") + .filter(Boolean) + .map((l) => { + const [h, f] = l.split(" "); + return [f.replace(/^vault\//, ""), h]; + }), +); +const sha1 = (buf) => createHash("sha1").update(buf).digest("hex"); + +const uploads = []; +const changedText = new Set(); +for (const rel of TEXT_OUTPUTS) { + const p = path.join(LOCAL_VAULT, rel); + if (!existsSync(p)) continue; + if (pulled.get(rel) === sha1(readFileSync(p))) continue; + uploads.push([p, `${OD_VIEWS}/${path.basename(rel)}`]); + changedText.add(rel); +} +for (const lang of ["ko", "en"]) { + const pdf = `generated/onepager-${lang}.pdf`; + if (!existsSync(pdf)) continue; + const htmlChanged = changedText.has(`object-views/onepager-${lang}.html`); + const missing = !existsSync(path.join(LOCAL_VAULT, `object-views/onepager-${lang}.pdf`)); + if (htmlChanged || missing) uploads.push([pdf, `${OD_VIEWS}/onepager-${lang}.pdf`]); +} + +if (!uploads.length) { + console.log("No regenerated vault outputs; skip OneDrive upload."); + process.exit(0); +} + +const ref = { + parentSha: process.env.GITHUB_SHA, + parentRef: process.env.GITHUB_REF, + workflowRunId: Number(process.env.GITHUB_RUN_ID) || null, + recordedAt: new Date().toISOString(), + changed: uploads.map(([, dest]) => `vault/object-views/${path.basename(dest)}`), +}; +const refPath = path.join(os.tmpdir(), "hsol-info-parent-ref.json"); +writeFileSync(refPath, JSON.stringify(ref, null, 2)); +uploads.push([refPath, `${OD_VIEWS}/hsol-info-parent-ref.json`]); + +const od = await openOneDrive(); +try { + for (const [src, dest] of uploads) { + await od.rclone(["copyto", src, `od:${dest}`]); + console.log(`uploaded od:${dest}`); + } +} finally { + await od.close(); +} diff --git a/ops/scripts/pull-blob-to-vault.mjs b/ops/scripts/pull-blob-to-vault.mjs deleted file mode 100644 index 45b333d..0000000 --- a/ops/scripts/pull-blob-to-vault.mjs +++ /dev/null @@ -1,183 +0,0 @@ -/** - * hsol-info-blob 전용: Vercel Blob prefix에서 로컬 vault로 내려받기(증분 + 상태 파일). - * 로컬 개발 시 이 저장소 루트에서 실행한다. - */ -import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { list } from "@vercel/blob"; - -const DEFAULT_VAULT_DIR = "vault"; -const DEFAULT_PREFIX = "info/vault"; -const SYNC_STATE_FILE = ".blob-sync-state.json"; -const IGNORED_FILENAMES = new Set([".DS_Store", ".DS-Store"]); -const CONCURRENCY = Math.max(1, Number(process.env.BLOB_PULL_CONCURRENCY) || 16); -/** 내려받지 않을 vault 상대경로 접두(쉼표 구분). CI 는 사이트가 쓰지 않는 datasources/ 등을 뺀다. */ -const EXCLUDED_PREFIXES = (process.env.BLOB_PULL_EXCLUDE || "").split(",").map((s) => s.trim()).filter(Boolean); - -function parseArgs() { - const args = process.argv.slice(2); - const options = { - vaultDir: process.env.BLOB_VAULT_DIR || DEFAULT_VAULT_DIR, - prefix: process.env.BLOB_PATH_PREFIX || DEFAULT_PREFIX, - clean: false, - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (arg === "--vault-dir") options.vaultDir = args[++i]; - else if (arg === "--prefix") options.prefix = args[++i]; - else if (arg === "--clean") options.clean = true; - } - - return options; -} - -function toPosixPath(filePath) { - return filePath.split(path.sep).join("/"); -} - -async function pathExists(targetPath) { - try { - await access(targetPath); - return true; - } catch { - return false; - } -} - -async function loadSyncState(statePath) { - try { - const raw = await readFile(statePath, "utf8"); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") return {}; - return parsed; - } catch { - return {}; - } -} - -async function collectFiles(rootDir, currentDir = rootDir) { - const entries = await readdir(currentDir, { withFileTypes: true }); - const files = []; - - for (const entry of entries) { - if (entry.name === ".git") continue; - if (IGNORED_FILENAMES.has(entry.name)) continue; - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - const nested = await collectFiles(rootDir, fullPath); - files.push(...nested); - continue; - } - - if (entry.isFile()) { - files.push(fullPath); - } - } - - return files; -} - -function shouldIgnoreRelativePath(relativePath) { - const normalized = relativePath.replace(/\\/g, "/"); - const filename = normalized.split("/").pop() ?? ""; - if (EXCLUDED_PREFIXES.some((p) => normalized.startsWith(p))) return true; - return IGNORED_FILENAMES.has(filename); -} - -async function listAllBlobs(prefix, token) { - let cursor; - const blobs = []; - - do { - const page = await list({ - prefix, - cursor, - token, - limit: 1000, - }); - blobs.push(...page.blobs); - cursor = page.cursor; - } while (cursor); - - return blobs; -} - -async function main() { - const { vaultDir, prefix, clean } = parseArgs(); - const token = process.env.BLOB_READ_WRITE_TOKEN; - if (!token) { - throw new Error("BLOB_READ_WRITE_TOKEN 환경변수가 필요합니다."); - } - - const normalizedPrefix = `${prefix.replace(/^\/+|\/+$/g, "")}/`; - const vaultRoot = path.resolve(process.cwd(), vaultDir); - const statePath = path.join(vaultRoot, SYNC_STATE_FILE); - await mkdir(vaultRoot, { recursive: true }); - const previousState = await loadSyncState(statePath); - const nextState = {}; - - const blobs = await listAllBlobs(normalizedPrefix, token); - if (blobs.length === 0) { - console.log(`prefix(${normalizedPrefix}) 하위에 Blob 파일이 없습니다.`); - return; - } - - const expectedLocalPaths = new Set(); - let downloadedCount = 0; - let skippedCount = 0; - const work = []; - for (const blob of blobs) { - const relativePath = blob.pathname.slice(normalizedPrefix.length); - if (!relativePath) continue; - if (shouldIgnoreRelativePath(relativePath)) continue; - const destinationPath = path.join(vaultRoot, relativePath); - expectedLocalPaths.add(path.resolve(destinationPath)); - work.push({ blob, relativePath, destinationPath }); - } - - let cursor = 0; - const runner = async () => { - while (cursor < work.length) { - const { blob, relativePath, destinationPath } = work[cursor++]; - const blobTimestamp = blob.uploadedAt ? new Date(blob.uploadedAt).toISOString() : ""; - const previousTimestamp = previousState[relativePath]; - if (blobTimestamp && previousTimestamp === blobTimestamp && (await pathExists(destinationPath))) { - nextState[relativePath] = blobTimestamp; - skippedCount += 1; - continue; - } - await mkdir(path.dirname(destinationPath), { recursive: true }); - const response = await fetch(blob.url, { headers: { Authorization: `Bearer ${token}` } }); - if (!response.ok) { - throw new Error(`Blob 다운로드 실패: ${blob.url} (${response.status})`); - } - await writeFile(destinationPath, Buffer.from(await response.arrayBuffer())); - nextState[relativePath] = blobTimestamp || previousTimestamp || ""; - downloadedCount += 1; - } - }; - await Promise.all(Array.from({ length: Math.min(CONCURRENCY, work.length) }, runner)); - console.log(`대상 ${work.length}건`); - - if (clean) { - const localFiles = await collectFiles(vaultRoot); - for (const localFile of localFiles) { - const resolved = path.resolve(localFile); - if (!expectedLocalPaths.has(resolved)) { - await rm(localFile, { force: true }); - const rel = path.relative(vaultRoot, localFile); - console.log(`로컬 정리: ${toPosixPath(rel)}`); - } - } - } - - await writeFile(statePath, `${JSON.stringify(nextState, null, 2)}\n`); - console.log(`증분 동기화 완료: 다운로드 ${downloadedCount}건, 스킵 ${skippedCount}건`); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/ops/scripts/sync-onedrive-to-blob.mjs b/ops/scripts/sync-onedrive-to-blob.mjs deleted file mode 100644 index 6852bec..0000000 --- a/ops/scripts/sync-onedrive-to-blob.mjs +++ /dev/null @@ -1,424 +0,0 @@ -/** - * OneDrive(개인) -> Vercel Blob 단방향 동기화. 콘텐츠의 정본은 OneDrive 다. - * hsol.info 저장소의 GitHub Actions(.github/workflows/sync-onedrive-to-blob.yml)에서 실행한다. - * (2026-09-27 hsol-info-blob 저장소 폐기와 함께 이쪽으로 이전) - * - * 트랙 - * - vault : OneDrive `hsol-info-blob/vault/**` -> Blob `info/vault/**` - * 사이트(site-data, 원페이저)와 Ask Hansol 이 읽는 경로. 배포 사이트가 읽지 않는 경로는 제외. - * hsol.info CI 가 만드는 생성물(VAULT_GENERATED)은 Blob 이 정본이라 올리지 않고, - * 반대로 Blob 에서 OneDrive 로 되써서(역미러) OneDrive 에서도 최신본을 볼 수 있게 한다. - * - assets : OneDrive `hsol-info-blob/assets/{public,private}/**` -> Blob `info/assets/**` - * 사이트가 /files/<키> 로 서빙한다. - * - 기사 : vault 트랙에서 `objects/news-articles/` 가 바뀌면 Neon articles 미러를 갱신하고, - * 새로 INSERT 된 기사의 dbId 를 OneDrive 원본 frontmatter 에 되쓴다. - * - * 증분 판정은 트랙별 manifest(OneDrive QuickXorHash)로 한다. vault 트랙은 매 실행 Blob 목록과 대조해 - * OneDrive 에 없는 파일을 지운다(생성물 제외). - * - * 인증: rclone OneDrive 토큰. MS 리프레시 토큰은 쓸 때마다 새것으로 바뀌므로 실행이 끝날 때 최신 토큰을 - * AES-256-GCM 으로 암호화해 Blob `info/_system/onedrive-token.enc` 에 되써 둔다. 키는 GitHub secret 에만 있다. - * - * 필요 환경변수: BLOB_READ_WRITE_TOKEN, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID - * 선택: ONEDRIVE_BOOTSTRAP_TOKEN, DATABASE_URL(없으면 기사 동기화 생략), SYNC_TRACKS(기본 "assets,vault"), - * FORCE_ARTICLES=1(변경 없어도 기사 동기화) - * 플래그: --dry-run (업로드/삭제/역기록 없이 차이만 출력) - */ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; -import { del, list, put } from "@vercel/blob"; - -const execFileAsync = promisify(execFile); -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); - -const BLOB_TOKEN = process.env.BLOB_READ_WRITE_TOKEN; -const KEY_B64 = process.env.ONEDRIVE_TOKEN_KEY; -const DRIVE_ID = process.env.ONEDRIVE_DRIVE_ID; -const BOOTSTRAP = process.env.ONEDRIVE_BOOTSTRAP_TOKEN; -const DATABASE_URL = process.env.DATABASE_URL || process.env.POSTGRES_URL; -const ACCESS = process.env.BLOB_ACCESS || "private"; -const TRACKS = (process.env.SYNC_TRACKS || "assets,vault").split(",").map((s) => s.trim()).filter(Boolean); -const TOKEN_BLOB_PATH = "info/_system/onedrive-token.enc"; -const CONCURRENCY = Math.max(1, Number(process.env.BLOB_SYNC_CONCURRENCY) || 8); -const DRY_RUN = process.argv.includes("--dry-run"); - -const IGNORED_NAMES = new Set([".DS_Store", ".DS-Store", ".blob-sync-state.json", ".gitkeep", "Icon\r"]); - -/** 배포 사이트가 Blob 에서 읽지 않는 vault 경로. 이전 sync-vault-to-blob 과 같은 기준. */ -const VAULT_EXCLUDED_PREFIXES = [".obsidian/", "Templates/", "Attachments/", "datasources/"]; -const VAULT_EXCLUDED_EXTS = new Set([".base"]); -/** hsol.info CI 가 만들어 Blob 에 직접 올리는 생성물. Blob 이 정본이고 OneDrive 로 역미러한다. */ -const VAULT_GENERATED = new Set([ - "object-views/site-data.json", - "object-views/onepager-ko.html", - "object-views/onepager-en.html", - "object-views/hsol-info-parent-ref.json", -]); - -const TRACK_DEFS = { - assets: { - odRoot: process.env.ONEDRIVE_ASSETS_ROOT || "hsol-info-blob/assets", - prefix: "info/assets", - manifest: "info/assets/manifest.json", - include: (rel) => { - const parts = rel.split("/"); - return parts.length >= 2 && (parts[0] === "public" || parts[0] === "private"); - }, - reconcile: false, - }, - vault: { - odRoot: process.env.ONEDRIVE_VAULT_ROOT || "hsol-info-blob/vault", - prefix: "info/vault", - manifest: "info/_system/vault-manifest.json", - include: (rel) => - !VAULT_EXCLUDED_PREFIXES.some((p) => rel.startsWith(p)) && - !VAULT_EXCLUDED_EXTS.has(path.extname(rel).toLowerCase()) && - !VAULT_GENERATED.has(rel), - reconcile: true, - }, -}; - -const CONTENT_TYPES = { - ".md": "text/markdown; charset=utf-8", - ".json": "application/json", - ".html": "text/html; charset=utf-8", - ".txt": "text/plain; charset=utf-8", - ".csv": "text/csv; charset=utf-8", - ".yaml": "text/yaml; charset=utf-8", - ".yml": "text/yaml; charset=utf-8", - ".pdf": "application/pdf", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".avif": "image/avif", - ".svg": "image/svg+xml", - ".mp4": "video/mp4", - ".mov": "video/quicktime", - ".mp3": "audio/mpeg", - ".wav": "audio/wav", - ".zip": "application/zip", - ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".hwp": "application/x-hwp", - ".hwpx": "application/haansofthwpx", - ".epub": "application/epub+zip", -}; - -const contentTypeOf = (key) => CONTENT_TYPES[path.extname(key).toLowerCase()] ?? "application/octet-stream"; -const sha1 = (buf) => createHash("sha1").update(buf).digest("hex"); - -function requireEnv() { - const missing = []; - if (!BLOB_TOKEN) missing.push("BLOB_READ_WRITE_TOKEN"); - if (!KEY_B64) missing.push("ONEDRIVE_TOKEN_KEY"); - if (!DRIVE_ID) missing.push("ONEDRIVE_DRIVE_ID"); - if (missing.length) throw new Error(`환경변수 누락: ${missing.join(", ")}`); - const key = Buffer.from(KEY_B64, "base64"); - if (key.length !== 32) throw new Error("ONEDRIVE_TOKEN_KEY 는 base64 로 인코딩한 32바이트여야 한다."); - return key; -} - -function encrypt(key, plaintext) { - const iv = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", key, iv); - const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64"); -} - -function decrypt(key, payloadB64) { - const buf = Buffer.from(payloadB64, "base64"); - const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12)); - decipher.setAuthTag(buf.subarray(12, 28)); - return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8"); -} - -async function listAllBlobs(prefix) { - const blobs = []; - let cursor; - do { - const page = await list({ prefix, cursor, token: BLOB_TOKEN, limit: 1000 }); - blobs.push(...page.blobs); - cursor = page.cursor; - } while (cursor); - return blobs; -} - -async function fetchBlob(url) { - const res = await fetch(url, { headers: { Authorization: `Bearer ${BLOB_TOKEN}` }, cache: "no-store" }); - if (!res.ok) throw new Error(`Blob 읽기 실패 ${url}: HTTP ${res.status}`); - return Buffer.from(await res.arrayBuffer()); -} - -/** private Blob 을 pathname 으로 읽는다. 없으면 null. */ -async function readBlobBuffer(pathname) { - const page = await list({ prefix: pathname, token: BLOB_TOKEN, limit: 5 }); - const hit = page.blobs.find((b) => b.pathname === pathname); - return hit ? fetchBlob(hit.url) : null; -} - -async function putBlob(pathname, body, contentType) { - await put(pathname, body, { - access: ACCESS, - addRandomSuffix: false, - allowOverwrite: true, - contentType, - token: BLOB_TOKEN, - }); -} - -async function loadToken(key) { - const stored = await readBlobBuffer(TOKEN_BLOB_PATH); - if (stored) { - console.log("토큰: Blob 에 저장된 최신 토큰 사용"); - return decrypt(key, stored.toString("utf8").trim()); - } - if (BOOTSTRAP) { - console.log("토큰: Blob 에 저장된 토큰이 없어 ONEDRIVE_BOOTSTRAP_TOKEN 사용"); - return BOOTSTRAP.trim(); - } - throw new Error("OneDrive 토큰이 없다. ONEDRIVE_BOOTSTRAP_TOKEN secret 을 넣고 다시 실행해야 한다."); -} - -let CONF = null; -async function rclone(args, opts = {}) { - const { stdout } = await execFileAsync("rclone", ["--config", CONF, ...args], { - maxBuffer: 256 * 1024 * 1024, - ...opts, - }); - return stdout; -} - -async function readTokenFromConf() { - const text = await readFile(CONF, "utf8"); - const line = text.split("\n").find((l) => l.startsWith("token = ")); - return line ? line.slice("token = ".length).trim() : null; -} - -async function runWithConcurrency(items, limit, worker) { - let index = 0; - const runner = async () => { - while (index < items.length) { - const i = index++; - await worker(items[i], i); - } - }; - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner)); -} - -/** 숨김 파일과 시스템 파일. `.obsidian/` 은 트랙 규칙에서 따로 거른다. */ -function isJunk(rel) { - return rel.split("/").some((p) => IGNORED_NAMES.has(p) || (p.startsWith(".") && p !== ".obsidian")); -} - -/** OneDrive 트리를 읽어 { rel -> meta } 로 돌려준다. rel 은 NFC. */ -async function listOneDrive(odRoot, include) { - const raw = await rclone(["lsjson", "-R", "--files-only", "--hash", "--hash-type", "QuickXorHash", `od:${odRoot}`]); - const out = new Map(); - for (const item of JSON.parse(raw)) { - const rel = item.Path.normalize("NFC"); - if (isJunk(rel) || !include(rel)) continue; - out.set(rel, { - srcPath: item.Path, - size: item.Size, - hash: item.Hashes?.quickxor ?? item.Hashes?.QuickXorHash ?? `${item.Size}:${item.ModTime}`, - modTime: item.ModTime, - }); - } - return out; -} - -async function syncTrack(name, workDir) { - const def = TRACK_DEFS[name]; - const remote = await listOneDrive(def.odRoot, def.include); - const manifestBuf = await readBlobBuffer(def.manifest); - const prevFiles = manifestBuf ? JSON.parse(manifestBuf.toString("utf8")).files ?? {} : {}; - - const uploadKeys = new Set([...remote.entries()].filter(([k, v]) => prevFiles[k]?.hash !== v.hash).map(([k]) => k)); - const deleteKeys = new Set(Object.keys(prevFiles).filter((k) => !remote.has(k))); - - if (def.reconcile) { - // manifest 와 무관하게 Blob 실물과 대조한다. 첫 이관이나 manifest 유실에도 어긋나지 않게. - const blobs = await listAllBlobs(`${def.prefix}/`); - const actual = new Set(blobs.map((b) => b.pathname.slice(def.prefix.length + 1).normalize("NFC"))); - // 동기화 대상인 경로만 정리한다. 제외 경로(datasources/ 등)에 남아 있는 예전 파일은 건드리지 않는다. - for (const k of actual) if (!remote.has(k) && !VAULT_GENERATED.has(k) && def.include(k) && !isJunk(k)) deleteKeys.add(k); - for (const k of remote.keys()) if (!actual.has(k)) uploadKeys.add(k); - } - - console.log(`[${name}] OneDrive ${remote.size}개 / 업로드 ${uploadKeys.size}개 / 삭제 ${deleteKeys.size}개`); - - if (DRY_RUN) { - for (const k of uploadKeys) console.log(` + ${k}`); - for (const k of deleteKeys) console.log(` - ${k}`); - return { changed: [...uploadKeys], deleted: [...deleteKeys] }; - } - - // 안전장치: OneDrive 가 비어 보이거나(업로드 미완료, 경로 오류) 절반 넘게 지우려 하면 멈춘다. - // 사이트와 Ask Hansol 이 Blob 을 읽으므로 대량 삭제는 곧 장애다. 의도한 경우만 ALLOW_MASS_DELETE=1. - const baseline = Math.max(Object.keys(prevFiles).length, def.reconcile ? remote.size + deleteKeys.size : 0); - const massDelete = remote.size === 0 ? deleteKeys.size > 0 : deleteKeys.size > Math.max(20, baseline * 0.5); - if (massDelete && process.env.ALLOW_MASS_DELETE !== "1") { - throw new Error( - `[${name}] 대량 삭제 차단: OneDrive ${remote.size}개인데 Blob 에서 ${deleteKeys.size}개를 지우려 한다. ` + - "OneDrive 업로드가 끝났는지 확인하고, 의도한 삭제면 ALLOW_MASS_DELETE=1 로 실행한다.", - ); - } - - const nextFiles = {}; - for (const [k, v] of Object.entries(prevFiles)) if (remote.has(k)) nextFiles[k] = v; - - await runWithConcurrency([...uploadKeys], CONCURRENCY, async (rel) => { - const meta = remote.get(rel); - const localPath = path.join(workDir, name, rel); - await rclone(["copyto", `od:${def.odRoot}/${meta.srcPath}`, localPath]); - const pathname = `${def.prefix}/${rel}`; - const contentType = contentTypeOf(rel); - await putBlob(pathname, await readFile(localPath), contentType); - await rm(localPath, { force: true }); - nextFiles[rel] = { size: meta.size, hash: meta.hash, modTime: meta.modTime, contentType, pathname }; - console.log(` 업로드: ${pathname}`); - }); - - const deletes = [...deleteKeys]; - for (let i = 0; i < deletes.length; i += 500) { - const chunk = deletes.slice(i, i + 500); - await del(chunk.map((k) => `${def.prefix}/${k}`), { token: BLOB_TOKEN }); - for (const k of chunk) console.log(` 삭제: ${def.prefix}/${k}`); - } - - if (uploadKeys.size || deleteKeys.size || !manifestBuf) { - const sorted = Object.fromEntries(Object.entries(nextFiles).sort(([a], [b]) => a.localeCompare(b))); - await putBlob( - def.manifest, - JSON.stringify({ generatedAt: new Date().toISOString(), source: `onedrive:${def.odRoot}`, files: sorted }, null, 2), - "application/json", - ); - } - return { changed: [...uploadKeys], deleted: deletes }; -} - -/** hsol.info CI 가 Blob 에 올린 생성물을 OneDrive vault 로 되쓴다. */ -async function mirrorGeneratedToOneDrive(workDir) { - const odRoot = TRACK_DEFS.vault.odRoot; - for (const rel of VAULT_GENERATED) { - const blobBuf = await readBlobBuffer(`${TRACK_DEFS.vault.prefix}/${rel}`).catch(() => null); - if (!blobBuf) continue; - const odBuf = await rclone(["cat", `od:${odRoot}/${rel}`], { encoding: "buffer" }).catch(() => null); - if (odBuf && sha1(odBuf) === sha1(blobBuf)) continue; - console.log(`[생성물] Blob -> OneDrive: ${rel}${DRY_RUN ? " (dry-run)" : ""}`); - if (DRY_RUN) continue; - const local = path.join(workDir, "generated", rel); - await mkdir(path.dirname(local), { recursive: true }); - await writeFile(local, blobBuf); - await rclone(["copyto", local, `od:${odRoot}/${rel}`]); - } -} - -/** 기사 원본을 받아 Neon 에 반영하고, dbId 가 새로 붙은 파일만 OneDrive 로 되쓴다. */ -async function syncArticles(workDir) { - if (!DATABASE_URL) { - console.log("[기사] DATABASE_URL 이 없어 건너뜀"); - return; - } - const odDir = `${TRACK_DEFS.vault.odRoot}/objects/news-articles`; - const local = path.join(workDir, "articles"); - await rclone(["copy", `od:${odDir}`, local, "--exclude", ".*"]); - const names = (await readdir(local)).filter((n) => n.endsWith(".md")); - const before = new Map(); - for (const n of names) before.set(n, sha1(await readFile(path.join(local, n)))); - - const args = [path.join(SCRIPT_DIR, "sync-articles-to-neon.mjs"), ...(DRY_RUN ? ["--dry"] : [])]; - const { stdout } = await execFileAsync("node", args, { - env: { ...process.env, VAULT_ARTICLES_DIR: local, DATABASE_URL }, - maxBuffer: 64 * 1024 * 1024, - }); - for (const line of stdout.split("\n")) if (line) console.log(`[기사] ${line}`); - - if (DRY_RUN) return; - for (const n of names) { - const after = sha1(await readFile(path.join(local, n))); - if (after === before.get(n)) continue; - await rclone(["copyto", path.join(local, n), `od:${odDir}/${n}`]); - console.log(`[기사] dbId 역기록 -> OneDrive: ${n}`); - } -} - -/** - * 정비 작업. 워크플로 입력 maint_op 로 실행한다(쉼표로 여러 개). - * - stats : OneDrive 정본 폴더 현황 - * 대량 적재는 Mac OneDrive 앱을 거치지 않는다. 한글 이름 파일을 수천 개 한꺼번에 올리면 서버 이름과 로컬 이름이 - * 어긋나 앱이 멈춘다(2026-09-27). 필요하면 rclone 으로 서버에 직접 올리고 앱은 내려받기만 하게 한다. - */ -async function maintenance(op) { - if (op === "stats") { - for (const p of ["hsol-info-blob", "hsol-info-blob/assets"]) { - console.log(`$ rclone size od:${p}`); - console.log((await rclone(["size", `od:${p}`]).catch((e) => e.message)).trim()); - } - } else { - throw new Error(`알 수 없는 maint_op: ${op}`); - } -} - -async function main() { - const key = requireEnv(); - const workDir = await mkdtemp(path.join(os.tmpdir(), "od-sync-")); - CONF = path.join(workDir, "rclone.conf"); - let tokenBefore = null; - - try { - tokenBefore = await loadToken(key); - await writeFile( - CONF, - `[od]\ntype = onedrive\ndrive_type = personal\ndrive_id = ${DRIVE_ID}\ntoken = ${tokenBefore}\n`, - { mode: 0o600 }, - ); - - if (process.env.MAINT_OP) { - for (const op of process.env.MAINT_OP.split(",").map((s) => s.trim()).filter(Boolean)) await maintenance(op); - return; - } - - const results = {}; - for (const name of TRACKS) { - if (!TRACK_DEFS[name]) throw new Error(`알 수 없는 트랙: ${name}`); - results[name] = await syncTrack(name, workDir); - } - - if (results.vault) { - await mirrorGeneratedToOneDrive(workDir); - const touched = [...results.vault.changed, ...results.vault.deleted].some((k) => - k.startsWith("objects/news-articles/"), - ); - if (touched || process.env.FORCE_ARTICLES === "1") await syncArticles(workDir); - } - } finally { - // 성공이든 실패든 rclone 이 갱신한 토큰은 반드시 보존한다. 잃으면 재인증이 필요하다. - try { - const tokenAfter = await readTokenFromConf().catch(() => null); - const latest = tokenAfter || tokenBefore; - if (latest && !DRY_RUN) { - // 키 교체(저장소 이전) 때만 쓴다. 다음 실행부터는 새 키로만 풀린다. - const saveKey = process.env.ONEDRIVE_TOKEN_KEY_NEXT ? Buffer.from(process.env.ONEDRIVE_TOKEN_KEY_NEXT, "base64") : key; - await putBlob(TOKEN_BLOB_PATH, encrypt(saveKey, latest), "text/plain"); - console.log(`토큰 저장: ${TOKEN_BLOB_PATH}${tokenAfter && tokenAfter !== tokenBefore ? " (갱신됨)" : ""}`); - } - } finally { - await rm(workDir, { recursive: true, force: true }); - } - } -} - -main().catch((error) => { - console.error(error?.stack || String(error)); - process.exit(1); -}); diff --git a/ops/scripts/watch-onedrive.mjs b/ops/scripts/watch-onedrive.mjs new file mode 100644 index 0000000..e859ddd --- /dev/null +++ b/ops/scripts/watch-onedrive.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * 개인 OneDrive `hsol-info-blob/vault/` 변경 감시. 사이트는 OneDrive 를 직접 읽으므로 복사는 하지 않는다. + * hsol.info 저장소 GitHub Actions(.github/workflows/watch-onedrive.yml)에서 매시 실행한다. + * + * - 기사 : `objects/news-articles/` 가 바뀌면 Neon articles 미러를 갱신하고, 새로 INSERT 된 기사의 dbId 를 + * OneDrive 원본 frontmatter 에 되쓴다. + * - 빌드 : 사이트 생성물에 영향이 있을 수 있는 vault 경로가 바뀌면 그 목록을 GITHUB_OUTPUT + * `vault_changed_files` 로 넘긴다. 워크플로가 Build With Vault Refresh 를 디스패치한다. + * + * 변경 판정은 OneDrive QuickXorHash 목록을 Neon `onedrive_sync_state`('vault-manifest')와 비교한다. + * 첫 실행(기록 없음)은 기준선만 남기고 변경으로 보지 않는다. + * + * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID + * 선택: FORCE_ARTICLES=1, ONEDRIVE_VAULT_ROOT(기본 hsol-info-blob/vault) + * 플래그: --dry-run (기록, 역기록, 출력 없이 차이만 출력) + */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { appendFile, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { getState, setState } from "./lib/onedrive-token.mjs"; +import { openOneDrive } from "./lib/rclone-onedrive.mjs"; + +const execFileAsync = promisify(execFile); +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DRY_RUN = process.argv.includes("--dry-run"); +const VAULT_ROOT = process.env.ONEDRIVE_VAULT_ROOT || "hsol-info-blob/vault"; +const ARTICLES_REL = "objects/news-articles/"; +const MANIFEST_KEY = "vault-manifest"; + +const IGNORED_NAMES = new Set([".DS_Store", ".DS-Store", ".gitkeep", "Icon\r"]); +/** 사이트 생성에 쓰지 않는 vault 경로. */ +const EXCLUDED_PREFIXES = [".obsidian/", "Templates/", "Attachments/", "datasources/"]; +const EXCLUDED_EXTS = new Set([".base"]); +/** hsol.info CI(Build With Vault Refresh)가 만들어 OneDrive 에 쓰는 생성물. 자기 트리거를 막으려고 뺀다. */ +export const VAULT_GENERATED = new Set([ + "object-views/site-data.json", + "object-views/onepager-ko.html", + "object-views/onepager-en.html", + "object-views/onepager-ko.pdf", + "object-views/onepager-en.pdf", + "object-views/hsol-info-parent-ref.json", +]); + +const sha1 = (buf) => createHash("sha1").update(buf).digest("hex"); + +function isJunk(rel) { + return rel.split("/").some((p) => IGNORED_NAMES.has(p) || (p.startsWith(".") && p !== ".obsidian")); +} + +function include(rel) { + return ( + !isJunk(rel) && + !EXCLUDED_PREFIXES.some((p) => rel.startsWith(p)) && + !EXCLUDED_EXTS.has(path.extname(rel).toLowerCase()) && + !VAULT_GENERATED.has(rel) + ); +} + +/** OneDrive 트리를 { rel(NFC) -> hash } 로. */ +async function listHashes(od, root) { + const raw = await od.rclone(["lsjson", "-R", "--files-only", "--hash", "--hash-type", "QuickXorHash", `od:${root}`]); + const out = {}; + for (const item of JSON.parse(raw)) { + const rel = item.Path.normalize("NFC"); + out[rel] = item.Hashes?.quickxor ?? item.Hashes?.QuickXorHash ?? `${item.Size}:${item.ModTime}`; + } + return out; +} + +/** 기사 원본을 받아 Neon 에 반영하고, dbId 가 새로 붙은 파일을 OneDrive 에 되쓴다. 되쓴 파일 목록을 돌려준다. */ +async function syncArticles(od) { + const odDir = `${VAULT_ROOT}/${ARTICLES_REL.replace(/\/$/, "")}`; + const local = await mkdtemp(path.join(os.tmpdir(), "articles-")); + try { + await od.rclone(["copy", `od:${odDir}`, local, "--exclude", ".*"]); + const names = (await readdir(local)).filter((n) => n.endsWith(".md")); + const before = new Map(); + for (const n of names) before.set(n, sha1(await readFile(path.join(local, n)))); + + const args = [path.join(SCRIPT_DIR, "sync-articles-to-neon.mjs"), ...(DRY_RUN ? ["--dry"] : [])]; + const { stdout } = await execFileAsync("node", args, { + env: { ...process.env, VAULT_ARTICLES_DIR: local }, + maxBuffer: 64 * 1024 * 1024, + }); + for (const line of stdout.split("\n")) if (line) console.log(`[기사] ${line}`); + + const written = []; + if (DRY_RUN) return written; + for (const n of names) { + if (sha1(await readFile(path.join(local, n))) === before.get(n)) continue; + await od.rclone(["copyto", path.join(local, n), `od:${odDir}/${n}`]); + console.log(`[기사] dbId 역기록 -> OneDrive: ${n}`); + written.push(`${ARTICLES_REL}${n}`.normalize("NFC")); + } + return written; + } finally { + await rm(local, { recursive: true, force: true }); + } +} + +async function writeOutput(files) { + if (!process.env.GITHUB_OUTPUT || !files.length) return; + // workflow_dispatch 입력은 합계 65,535자 제한. 힌트 목록이라 앞부분만 넘겨도 변경 여부 판단은 같다. + let joined = ""; + for (const f of files) { + if (joined.length + f.length + 1 > 30000) break; + joined += (joined ? "," : "") + f; + } + await appendFile(process.env.GITHUB_OUTPUT, `vault_changed_files=${joined}\n`); +} + +async function main() { + const od = await openOneDrive(); + try { + const all = await listHashes(od, VAULT_ROOT); + const current = Object.fromEntries(Object.entries(all).filter(([rel]) => include(rel))); + const count = Object.keys(current).length; + if (count === 0) throw new Error(`OneDrive ${VAULT_ROOT} 가 비어 보인다. 기준선을 덮어쓰지 않고 멈춘다.`); + + const prev = (await getState(od.sql, MANIFEST_KEY))?.files ?? null; + const changed = []; + if (prev) { + for (const [rel, hash] of Object.entries(current)) if (prev[rel] !== hash) changed.push(rel); + for (const rel of Object.keys(prev)) if (!(rel in current)) changed.push(rel); + changed.sort(); + } + console.log( + prev + ? `[vault] OneDrive ${count}개 / 변경 ${changed.length}개` + : `[vault] OneDrive ${count}개 / 첫 실행: 기준선만 기록`, + ); + for (const rel of changed.slice(0, 50)) console.log(` * ${rel}`); + if (changed.length > 50) console.log(` ... 외 ${changed.length - 50}개`); + + const articlesTouched = changed.some((rel) => rel.startsWith(ARTICLES_REL)); + if (articlesTouched || process.env.FORCE_ARTICLES === "1") { + const written = await syncArticles(od); + if (written.length) { + // 역기록한 파일은 새 해시로 기준선을 맞춰 다음 회차에 다시 변경으로 잡히지 않게 한다. + const fresh = await listHashes(od, `${VAULT_ROOT}/${ARTICLES_REL.replace(/\/$/, "")}`); + for (const [name, hash] of Object.entries(fresh)) { + const rel = `${ARTICLES_REL}${name}`; + if (rel in current) current[rel] = hash; + } + } + } + + if (DRY_RUN) return; + await setState(od.sql, MANIFEST_KEY, { files: current, updatedAt: new Date().toISOString() }); + if (changed.length) console.log(`[vault] 사이트 재생성 대상 변경 ${changed.length}개`); + await writeOutput(changed); + } finally { + await od.close(); + } +} + +main().catch((error) => { + console.error(error?.stack || String(error)); + process.exit(1); +}); diff --git a/ops/scripts/with-onedrive.mjs b/ops/scripts/with-onedrive.mjs new file mode 100644 index 0000000..5e5906a --- /dev/null +++ b/ops/scripts/with-onedrive.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * rclone 설정(`od:` = 개인 OneDrive)을 만든 채로 명령을 실행하고, 끝나면 갱신된 토큰을 Neon 에 되쓴다. + * + * node ops/scripts/with-onedrive.mjs -- bash -c 'rclone copy od:hsol-info-blob/vault ./vault' + * + * 자식 프로세스에는 RCLONE_CONFIG 가 설정돼 있어 rclone 을 그냥 부르면 된다. + * 필요 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID + */ +import { spawn } from "node:child_process"; +import process from "node:process"; +import { openOneDrive } from "./lib/rclone-onedrive.mjs"; + +const sep = process.argv.indexOf("--"); +const cmd = sep >= 0 ? process.argv.slice(sep + 1) : process.argv.slice(2); +if (!cmd.length) { + console.error("사용법: node ops/scripts/with-onedrive.mjs -- <명령> [인자...]"); + process.exit(2); +} + +const od = await openOneDrive(); +let code = 1; +try { + code = await new Promise((resolve) => { + const child = spawn(cmd[0], cmd.slice(1), { + stdio: "inherit", + env: { ...process.env, RCLONE_CONFIG: od.conf }, + }); + child.on("close", (c) => resolve(c ?? 1)); + child.on("error", (e) => { + console.error(e.message); + resolve(1); + }); + }); +} finally { + await od.close(); +} +process.exit(code); diff --git a/package-lock.json b/package-lock.json index 30d1abb..095a2ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@ai-sdk/anthropic": "^4.0.1", "@neondatabase/serverless": "^1.1.0", "@vercel/analytics": "^2.0.1", - "@vercel/blob": "^2.3.3", "@vercel/edge-config": "^1.4.3", "ai": "^7.0.4", "framer-motion": "^12.41.0", @@ -1947,7 +1946,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2020,7 +2018,6 @@ "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", @@ -2585,22 +2582,6 @@ } } }, - "node_modules/@vercel/blob": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.3.3.tgz", - "integrity": "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg==", - "license": "Apache-2.0", - "dependencies": { - "async-retry": "^1.3.3", - "is-buffer": "^2.0.5", - "is-node-process": "^1.2.0", - "throttleit": "^2.1.0", - "undici": "^6.23.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@vercel/edge-config": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@vercel/edge-config/-/edge-config-1.4.3.tgz", @@ -2652,7 +2633,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2942,15 +2922,6 @@ "node": ">= 0.4" } }, - "node_modules/async-retry": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", - "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", - "license": "MIT", - "dependencies": { - "retry": "0.13.1" - } - }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -3111,7 +3082,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3422,7 +3392,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -3832,7 +3801,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4431,7 +4399,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4605,7 +4572,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5654,29 +5620,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -5865,12 +5808,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "license": "MIT" - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6093,7 +6030,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6696,7 +6632,6 @@ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", "license": "MIT", - "peer": true, "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", @@ -6736,7 +6671,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -7327,8 +7261,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -7446,7 +7379,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", "integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.18", "@swc/helpers": "0.5.15", @@ -7949,7 +7881,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8183,7 +8114,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8193,7 +8123,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8450,15 +8379,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9218,18 +9138,6 @@ "node": ">=0.8" } }, - "node_modules/throttleit": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", - "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", @@ -9280,7 +9188,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9375,7 +9282,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -9486,7 +9392,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9514,15 +9419,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9535,7 +9431,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -9875,7 +9770,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a1be03a..6221290 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "@ai-sdk/anthropic": "^4.0.1", "@neondatabase/serverless": "^1.1.0", "@vercel/analytics": "^2.0.1", - "@vercel/blob": "^2.3.3", "@vercel/edge-config": "^1.4.3", "ai": "^7.0.4", "framer-motion": "^12.41.0", diff --git a/scripts/generate-onepager-pdf.ts b/scripts/generate-onepager-pdf.ts index cc5df0f..5a8aaea 100644 --- a/scripts/generate-onepager-pdf.ts +++ b/scripts/generate-onepager-pdf.ts @@ -1,16 +1,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { put } from "@vercel/blob"; import { pdfPageCount, renderOnePagerPdf } from "./lib/onepager-pdf"; /** - * 원페이저 HTML(onepager-.html) -> A4 PDF 변환 후 Vercel Blob 업로드. + * 원페이저 HTML(onepager-.html) -> A4 PDF 변환. 결과는 generated/onepager-.pdf 에 남기고, + * OneDrive 업로드는 다음 CI 스텝(ops/scripts/publish-generated.mjs)이 한다. * refresh(ko html 생성) → translate(en html 생성) 다음 CI 스텝에서 실행. * Playwright 인쇄 엔진이라 텍스트 선택 가능·벡터 출력. - * BLOB 토큰이 없으면 로컬 파일만 남기고 업로드는 건너뛴다(빌드 실패시키지 않음). * EN HTML 이 아직 없으면 EN 만 건너뛴다 — KO 는 그대로 나간다. */ -const BLOB_PREFIX = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, ""); type Target = { lang: "ko" | "en"; htmlPath: string; localPdfPath: string }; @@ -31,7 +29,6 @@ const TARGETS: Target[] = [ ]; async function main() { - const token = process.env.BLOB_READ_WRITE_TOKEN ?? process.env.ASK_HANSOL_BLOB_TOKEN; let rendered = 0; for (const target of TARGETS) { @@ -50,34 +47,6 @@ async function main() { console.log( `[onepager-pdf] Wrote local PDF: ${target.localPdfPath} (${pdf.length} bytes, ${pdfPageCount(pdf)} pages).`, ); - - if (!token) { - console.log("[onepager-pdf] No BLOB_READ_WRITE_TOKEN; skipped Blob upload (local only)."); - continue; - } - - const pdfResult = await put(`${BLOB_PREFIX}/resume/onepager-${target.lang}.pdf`, pdf, { - access: "private", - token, - allowOverwrite: true, - addRandomSuffix: false, - contentType: "application/pdf", - }); - console.log(`[onepager-pdf] Uploaded ${target.lang} PDF to Blob: ${pdfResult.url}`); - - // HTML 도 Blob 에 올려 런타임(getOnePagerHtml)이 submodule 없이도 읽게 한다. - const htmlResult = await put( - `${BLOB_PREFIX}/vault/object-views/onepager-${target.lang}.html`, - fragment, - { - access: "private", - token, - allowOverwrite: true, - addRandomSuffix: false, - contentType: "text/html; charset=utf-8", - }, - ); - console.log(`[onepager-pdf] Uploaded ${target.lang} HTML to Blob: ${htmlResult.url}`); } if (rendered === 0) console.log("[onepager-pdf] Nothing to render."); diff --git a/scripts/refresh-site-data-with-claude.ts b/scripts/refresh-site-data-with-claude.ts index 6d5d62f..4ab7037 100644 --- a/scripts/refresh-site-data-with-claude.ts +++ b/scripts/refresh-site-data-with-claude.ts @@ -1950,7 +1950,7 @@ async function main() { } // vault(서브모듈 hsol-info-blob) 포인터가 안 바뀐 코드-only 푸시에선 스킵 → site-data 유지. if (hasExistingSiteData && !vaultChange.changed && !forceRefresh) { - logStep("No vault change (hsol-info-blob submodule pointer unchanged). Keep existing site-data as-is."); + logStep("No vault change (no VAULT_CHANGED_FILES from OneDrive sync). Keep existing site-data as-is."); return; } diff --git a/src/app/api/ask-hansol-advice/route.ts b/src/app/api/ask-hansol-advice/route.ts index 87ed646..d3c2589 100644 --- a/src/app/api/ask-hansol-advice/route.ts +++ b/src/app/api/ask-hansol-advice/route.ts @@ -6,7 +6,7 @@ import { chatText } from "@/lib/llm"; import { fetchComprehensiveProfileContext, fetchVaultReadmeGuideBody, -} from "@/lib/ask-hansol/blob-context"; +} from "@/lib/ask-hansol/vault-context"; import { summarizeMemoryMerge } from "@/lib/ask-hansol/memory-summarize"; import { ASK_HANSOL_FALLBACK_MESSAGE, isValidAskHansolSessionId } from "@/lib/ask-hansol/shared"; import { diff --git a/src/app/api/ask-hansol-jd/route.ts b/src/app/api/ask-hansol-jd/route.ts index c20ab4f..31d746e 100644 --- a/src/app/api/ask-hansol-jd/route.ts +++ b/src/app/api/ask-hansol-jd/route.ts @@ -6,7 +6,7 @@ import { chatText } from "@/lib/llm"; import { fetchComprehensiveProfileContext, fetchVaultReadmeGuideBody, -} from "@/lib/ask-hansol/blob-context"; +} from "@/lib/ask-hansol/vault-context"; import { summarizeMemoryMerge } from "@/lib/ask-hansol/memory-summarize"; import { ASK_HANSOL_FALLBACK_MESSAGE, isValidAskHansolSessionId } from "@/lib/ask-hansol/shared"; import { diff --git a/src/app/api/ask-hansol/route.ts b/src/app/api/ask-hansol/route.ts index f73de02..24de56b 100644 --- a/src/app/api/ask-hansol/route.ts +++ b/src/app/api/ask-hansol/route.ts @@ -6,9 +6,9 @@ import { chatText } from "@/lib/llm"; import type { SiteData } from "@/content/schema"; import { normalizeAskAnswerPlainText } from "@/lib/ask-hansol/answer-linkify"; import { - fetchBlobContext, + fetchVaultContext, fetchVaultReadmeGuideBody, -} from "@/lib/ask-hansol/blob-context"; +} from "@/lib/ask-hansol/vault-context"; import { summarizeMemoryMerge } from "@/lib/ask-hansol/memory-summarize"; import { ASK_HANSOL_FALLBACK_MESSAGE, @@ -39,15 +39,15 @@ type AskHansolPageContext = { detail?: string; }; -async function runBlobLookupTool(input: unknown): Promise { +async function runVaultLookupTool(input: unknown): Promise { const query = typeof input === "object" && input !== null && "query" in input ? String((input as { query?: unknown }).query ?? "").trim() : ""; - if (!query) return "Blob 조회 실패: query가 비어 있습니다."; + if (!query) return "vault 조회 실패: query가 비어 있습니다."; - const context = await fetchBlobContext(query); - if (!context) return "Blob 조회 결과가 없습니다. 운영 매뉴얼 또는 관련 문서를 찾지 못했습니다."; + const context = await fetchVaultContext(query); + if (!context) return "vault 조회 결과가 없습니다. 운영 매뉴얼 또는 관련 문서를 찾지 못했습니다."; return context; } @@ -135,17 +135,17 @@ function buildSystemPrompt( const pageCtx = `[현재 방문 화면 컨텍스트]\n${formatPageContext(pageContext)}\n\n`; const readmeBlock = vaultReadmeGuideBody?.trim() - ? `\n\n[vault 읽기 지침 — Blob vault/README.md 원문. vault 안에서 문서를 찾고 읽는 방법·순서·범위를 이해하는 데만 쓴다. 방문자 질문의 사실·인물·경력·사건 근거로 인용하거나 답 본문을 채우지 않는다.]\n${vaultReadmeGuideBody.trim()}\n` + ? `\n\n[vault 읽기 지침 — vault/README.md 원문. vault 안에서 문서를 찾고 읽는 방법·순서·범위를 이해하는 데만 쓴다. 방문자 질문의 사실·인물·경력·사건 근거로 인용하거나 답 본문을 채우지 않는다.]\n${vaultReadmeGuideBody.trim()}\n` : ""; return `${mem}${pageCtx}당신은 임한솔(Hansol Lim) 본인을 대신해 포트폴리오 사이트 방문자에게 답하는 어시스턴트입니다. 한국어로 3~5문장 이내로 답하세요. - 사용자가 보고 있는 화면 문맥을 먼저 고려하세요. 화면 컨텍스트에 "주목영역(스크롤)"이 있으면 그 값은 뷰포트에 가장 많이 보이는 블록의 id이므로, 질문이 짧거나 모호할 때 그 구간을 우선해 답하세요. 예: 상세화면이면 해당 관점을, 홈이면 전체 소개 관점으로 답하세요. -- vault/README.md는 “무엇이 사실인가”가 아니라 vault를 어떻게 읽을지에 대한 지침이다. 시스템에 붙은 README 원문 블록이 있으면 답하기 전에 먼저 익혀, blob_lookup으로 필요한 본문 문서를 고른다. -- blob_lookup으로 읽은 내용만 근거로 삼는다. 사실·근거·배경은 전부 vault(Blob) 문서에서만 찾는다. vault에 적혀 있고 조회된 사실은 이 Ask 맥락에서 전부 말해도 된다(비공개 예외를 두지 않는다). 가족·배우자·결혼 여부 등도 vault에 있으면 그대로 답에 넣는다. vault에 없거나 조회되지 않은 것은 모른다고 답한다. +- vault/README.md는 “무엇이 사실인가”가 아니라 vault를 어떻게 읽을지에 대한 지침이다. 시스템에 붙은 README 원문 블록이 있으면 답하기 전에 먼저 익혀, vault_lookup으로 필요한 본문 문서를 고른다. +- vault_lookup으로 읽은 내용만 근거로 삼는다. 사실·근거·배경은 전부 vault 문서에서만 찾는다. vault에 적혀 있고 조회된 사실은 이 Ask 맥락에서 전부 말해도 된다(비공개 예외를 두지 않는다). 가족·배우자·결혼 여부 등도 vault에 있으면 그대로 답에 넣는다. vault에 없거나 조회되지 않은 것은 모른다고 답한다. - 말투·역할·스타일은 "AI-클론-운영-매뉴얼"을 참고하되, 사실·인물·관계는 오직 vault가 우선이다. 매뉴얼에 적힌 "외부 비공개""사이트에 올리지 않음" 같은 문구는 다른 채널용일 수 있으며, 이 포트폴리오 Ask에서는 그것을 이유로 vault에 있는 가족·배우자 관련 사실을 숨기거나 거절하지 않는다. -- 방문자에게는 조회 과정·저장소 이름을 드러내지 않는다. 예를 들어 "vault에서 확인했습니다", "Blob에서", "운영 매뉴얼상 ~라서 공개하지 않습니다" 같은 메타·출처 설명은 쓰지 말고, 답은 항상 자연스러운 대화 톤만 쓴다. +- 방문자에게는 조회 과정·저장소 이름을 드러내지 않는다. 예를 들어 "vault에서 확인했습니다", "OneDrive에서", "운영 매뉴얼상 ~라서 공개하지 않습니다" 같은 메타·출처 설명은 쓰지 말고, 답은 항상 자연스러운 대화 톤만 쓴다. - vault 문서에 나오는 \`[[제목]]\` 같은 위키링크/내부 참조 표기를 답변에 그대로 쓰지 마라. 대괄호 없이 일반 단어·문장으로 풀어 쓴다(예: \`[[프루퍼]]\` → 프루퍼). -- 확신이 부족하면 답변 전에 반드시 blob_lookup 도구를 호출해 vault 문서를 조회한 뒤 답하세요. +- 확신이 부족하면 답변 전에 반드시 vault_lookup 도구를 호출해 vault 문서를 조회한 뒤 답하세요. 세션 메모리(위 요약)와 messages의 최근 대화를 함께 참고하되, **가장 마지막 user 메시지**에 직접 답하세요. 이미 말한 내용은 한 줄로만 짚고 중복 설명은 줄이세요. ${readmeBlock} @@ -173,14 +173,14 @@ function toPlainAnswer(text: string): string { return normalizeAskAnswerPlainText(text); } -/** Ask Hansol용 Blob 조회 툴. AI SDK가 tool_use/tool_result 루프를 자동 처리한다. */ -const blobLookupTool = tool({ +/** Ask Hansol용 vault 조회 툴. AI SDK가 tool_use/tool_result 루프를 자동 처리한다. */ +const vaultLookupTool = tool({ description: - "Ask Hansol용 Blob 문서를 조회합니다. 결과에 vault/README.md가 있으면 읽는 법 지침일 뿐 사실 근거가 아님. 사실은 objects 등 본문을 따르며, 본문에 가족·배우자 사실이 있으면 방문자에게 그대로 말해도 됨. 방문자 답변 문장에는 vault/Blob/매뉴얼·조회 과정을 언급하지 말 것.", + "Ask Hansol용 vault 문서를 조회합니다. 결과에 vault/README.md가 있으면 읽는 법 지침일 뿐 사실 근거가 아님. 사실은 objects 등 본문을 따르며, 본문에 가족·배우자 사실이 있으면 방문자에게 그대로 말해도 됨. 방문자 답변 문장에는 vault/저장소/매뉴얼·조회 과정을 언급하지 말 것.", inputSchema: z.object({ query: z.string().describe("조회할 주제 또는 질문"), }), - execute: async ({ query }) => runBlobLookupTool({ query }), + execute: async ({ query }) => runVaultLookupTool({ query }), }); async function askAnthropicChat( @@ -195,7 +195,7 @@ async function askAnthropicChat( ...priorTurns.map((t) => ({ role: t.role, content: t.content })), { role: "user" as const, content: latestUserText }, ], - tools: { blob_lookup: blobLookupTool }, + tools: { vault_lookup: vaultLookupTool }, maxSteps: 4, }); return answer ? toPlainAnswer(answer) : null; diff --git a/src/app/architecture/page.tsx b/src/app/architecture/page.tsx index 37125dd..142d672 100644 --- a/src/app/architecture/page.tsx +++ b/src/app/architecture/page.tsx @@ -12,7 +12,7 @@ import { const PAGE_URL = `${SITE_URL}/architecture`; const PAGE_NAME = "사이트 구조 — hsol.info"; const PAGE_DESC = - "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·Vercel Blob을 잇는지 보여줍니다."; + "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·OneDrive를 잇는지 보여줍니다."; const ARCHITECTURE_JSON_LD = asGraph([ buildWebPageNode({ @@ -30,7 +30,7 @@ const ARCHITECTURE_JSON_LD = asGraph([ export const metadata: Metadata = { title: "사이트 구조 — hsol.info", description: - "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·Vercel Blob을 잇는지 보여줍니다.", + "hsol.info의 저장소·콘텐츠·배포 관계를 한 장에 정리한 구조도. AI 클론 포트폴리오가 어떻게 vault·SSR·OneDrive를 잇는지 보여줍니다.", alternates: { canonical: "/architecture" }, openGraph: { type: "article", diff --git a/src/app/files/[...key]/route.ts b/src/app/files/[...key]/route.ts index 31f3361..b9c5868 100644 --- a/src/app/files/[...key]/route.ts +++ b/src/app/files/[...key]/route.ts @@ -1,20 +1,18 @@ -import { list } from "@vercel/blob"; import type { NextRequest } from "next/server"; -import { getBlobPrefix, getBlobToken } from "@/lib/content/blob"; +import { getItem, isOneDriveConfigured } from "@/lib/onedrive/graph"; import { MANAGE_COOKIE, verifySession } from "@/lib/manage-auth"; /** * 바이너리 에셋 서빙: /files//<경로> * - * 원본은 개인 OneDrive `hsol.info-assets/` 이고, hsol-info-blob 저장소의 - * sync-onedrive-to-blob 워크플로가 Blob `info/assets/<키>` 로 올린다. store 가 private 이라 - * 브라우저가 Blob URL 을 직접 못 열기 때문에 여기서 토큰을 붙여 대신 받아 흘려보낸다. + * 원본은 개인 OneDrive `hsol-info-blob/assets/<키>` 다. OneDrive 는 비공개라 서버가 Graph 로 + * 파일의 단기 다운로드 URL 을 얻어 대신 받아 흘려보낸다(Range 그대로 전달). * * - public/... : 누구나. CDN 캐시 허용 * - private/... : /manage 세션(Sign in with Vercel)이 있을 때만. 없으면 존재 여부도 숨기려고 404 * - * 규약 정본: hsol-info-blob vault/README.md "바이너리 에셋은 OneDrive 에 둔다" + * 규약 정본: OneDrive hsol-info-blob/vault/README.md "바이너리" */ export const runtime = "nodejs"; @@ -52,11 +50,6 @@ async function hasManageSession(req: NextRequest): Promise { return Boolean(secret && token && (await verifySession(token, secret))); } -async function resolveAsset(token: string, pathname: string) { - const page = await list({ prefix: pathname, token, limit: 5 }).catch(() => null); - return page?.blobs.find((b) => b.pathname === pathname) ?? null; -} - function contentDisposition(key: string, download: boolean): string { const filename = key.split("/").pop() ?? "file"; const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); @@ -71,28 +64,33 @@ async function serve(req: NextRequest, params: Promise<{ key: string[] }>, headO const isPrivate = key.startsWith("private/"); if (isPrivate && !(await hasManageSession(req))) return notFound(); - const token = getBlobToken(); - if (!token) return notFound(); - - const blob = await resolveAsset(token, `${getBlobPrefix()}/assets/${key}`); - if (!blob) return notFound(); + if (!isOneDriveConfigured()) return notFound(); + const item = await getItem(`assets/${key}`).catch(() => null); + const downloadUrl = item?.["@microsoft.graph.downloadUrl"]; + if (!item || !downloadUrl) return notFound(); - const upstreamHeaders: Record = { Authorization: `Bearer ${token}` }; + const upstreamHeaders: Record = {}; const range = req.headers.get("range"); if (range) upstreamHeaders.Range = range; - const upstream = await fetch(blob.url, { - method: headOnly ? "HEAD" : "GET", - headers: upstreamHeaders, - cache: "no-store", - }).catch(() => null); - if (!upstream || (!upstream.ok && upstream.status !== 206)) return notFound(); + const upstream = headOnly + ? null + : await fetch(downloadUrl, { headers: upstreamHeaders, cache: "no-store" }).catch(() => null); + if (!headOnly && (!upstream || (!upstream.ok && upstream.status !== 206))) return notFound(); const headers = new Headers(); - for (const h of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"]) { - const v = upstream.headers.get(h); - if (v) headers.set(h, v); + if (upstream) { + for (const h of ["content-length", "content-range", "accept-ranges"]) { + const v = upstream.headers.get(h); + if (v) headers.set(h, v); + } + } else { + headers.set("content-length", String(item.size)); + headers.set("accept-ranges", "bytes"); } + headers.set("content-type", item.file?.mimeType || upstream?.headers.get("content-type") || "application/octet-stream"); + if (item.eTag) headers.set("etag", item.eTag); + if (item.lastModifiedDateTime) headers.set("last-modified", new Date(item.lastModifiedDateTime).toUTCString()); headers.set("Content-Disposition", contentDisposition(key, req.nextUrl.searchParams.has("download"))); if (isPrivate) { headers.set("Cache-Control", "private, no-store"); @@ -102,7 +100,7 @@ async function serve(req: NextRequest, params: Promise<{ key: string[] }>, headO headers.set("Cache-Control", "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"); } - return new Response(headOnly ? null : upstream.body, { status: upstream.status, headers }); + return new Response(upstream ? upstream.body : null, { status: upstream?.status ?? 200, headers }); } export async function GET(req: NextRequest, { params }: { params: Promise<{ key: string[] }> }) { diff --git a/src/app/resume/page.tsx b/src/app/resume/page.tsx index a2b908b..3f2d26d 100644 --- a/src/app/resume/page.tsx +++ b/src/app/resume/page.tsx @@ -6,7 +6,7 @@ import { getOnePagerHtml } from "@/lib/content/onepager"; const SITE_URL = "https://hsol.info"; -// 정적 프리렌더 대신 요청 시 렌더 — Blob의 최신 원페이저를 리빌드 없이 반영한다. +// 정적 프리렌더 대신 요청 시 렌더 — OneDrive 의 최신 원페이저를 리빌드 없이 반영한다. export const dynamic = "force-dynamic"; const PAGE_TITLE = "임한솔 이력서·포트폴리오 (Hansol Lim Resume)"; diff --git a/src/app/resume/pdf/route.ts b/src/app/resume/pdf/route.ts index 7c147f8..8ab8856 100644 --- a/src/app/resume/pdf/route.ts +++ b/src/app/resume/pdf/route.ts @@ -1,10 +1,9 @@ import { NextResponse } from "next/server"; -import { getBlobPrefix, getBlobToken, resolveBlobUrl } from "@/lib/content/blob"; +import { isOneDriveConfigured, readBytes } from "@/lib/onedrive/graph"; /** - * /resume/pdf — CI 에서 사전 생성해 Blob 에 올린 원페이저 PDF 를 내려준다. - * Blob store 가 private 라 브라우저 직접 접근이 안 되므로, 서버가 토큰으로 가져와 스트리밍한다 - * (site-data 읽기와 동일한 private read 패턴). + * /resume/pdf - CI 가 사전 생성해 개인 OneDrive `hsol-info-blob/vault/object-views/onepager-.pdf` + * 에 올린 원페이저 PDF 를 내려준다. OneDrive 는 비공개라 서버가 Graph 로 받아 흘려보낸다. * * `?lang=en` 이면 영문판을 내려준다. 언어 상태는 클라이언트 localStorage 에만 있어서 서버는 * 요청만 보고 언어를 알 수 없다 — 링크에 실어 보내는 게 유일한 수단이다(useResumePdfHref). @@ -18,16 +17,8 @@ const DOWNLOAD_FILENAME = { } as const; async function fetchPdf(lang: "ko" | "en"): Promise { - const token = getBlobToken(); - if (!token) return null; - const url = await resolveBlobUrl(token, getBlobPrefix(), `resume/onepager-${lang}.pdf`); - if (!url) return null; - const upstream = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }).catch(() => null); - if (!upstream || !upstream.ok) return null; - return upstream.arrayBuffer(); + if (!isOneDriveConfigured()) return null; + return readBytes(`vault/object-views/onepager-${lang}.pdf`).catch(() => null); } export async function GET(request: Request) { diff --git a/src/components/portfolio/ArchitectureDiagramPage.tsx b/src/components/portfolio/ArchitectureDiagramPage.tsx index 7223777..5a02146 100644 --- a/src/components/portfolio/ArchitectureDiagramPage.tsx +++ b/src/components/portfolio/ArchitectureDiagramPage.tsx @@ -6,7 +6,7 @@ import { BlockList } from "@/components/portfolio/blocks/BlockList"; import { BlockCallbacksProvider } from "@/components/portfolio/blocks/context"; /** - * /architecture — vault·SiteData·Blob·CI·Ask 연결 구조를 Mermaid 한 장으로. + * /architecture — vault·SiteData·OneDrive·CI·Ask 연결 구조를 Mermaid 한 장으로. * 셸(app-layout/shell/Foot)과 블록 시퀀스(back·plate·viewHead+mermaid)만 조립한다. * 레이아웃은 site-data.layout.pages.architecture(없으면 DEFAULT_LAYOUT)가 정한다. */ diff --git a/src/content/default-layout.ts b/src/content/default-layout.ts index 3164a05..54e276a 100644 --- a/src/content/default-layout.ts +++ b/src/content/default-layout.ts @@ -183,7 +183,7 @@ export const DEFAULT_LAYOUT: SiteLayout = { room: "META · ARCH", coord: "Z0", titleText: "사이트 구조", - lede: "온톨로지 vault와 SiteData, Blob·CI, Next 런타임·Ask가 서로 어떻게 연결되는지 한 도식으로 정리했습니다.", + lede: "온톨로지 vault와 SiteData, OneDrive·CI, Next 런타임·Ask가 서로 어떻게 연결되는지 한 도식으로 정리했습니다.", media: "architecture-mermaid", }, }, diff --git a/src/content/site-architecture.mermaid.ts b/src/content/site-architecture.mermaid.ts index bad4fa7..ca4e2b9 100644 --- a/src/content/site-architecture.mermaid.ts +++ b/src/content/site-architecture.mermaid.ts @@ -93,18 +93,16 @@ flowchart TB SD_COMPOSE[composition 페이지 컴포넌트-트리] end - subgraph L5["L5. 저장 및 Blob 동기화"] + subgraph L5["L5. 저장 OneDrive 단일 정본"] direction LR - BLOB_REPO[vault submodule git
hsol-info-blob origin] - VBLOB[(원격 Blob 저장소)] - WF_BLOB_SYNC[hsol-info-blob 자체 CI
Blob 업로드 전담] + OD_STORE[(개인 OneDrive
hsol-info-blob 폴더)] + WF_WATCH[변경 감시 워크플로
매시 기사 반영·빌드 디스패치] end subgraph L6["L6. CI 빌드 - hsol/hsol.info"] direction LR GH[(메인 repo git 호스팅)] WF_REF[빌드 갱신 워크플로] - WF_SUB[submodule 검증] SCRIPT_REF[콘텐츠 갱신 스크립트 LLM ETL] SCRIPT_GEN[폴백 스냅샷 생성] ZOD[스키마 검증 zod] @@ -112,7 +110,7 @@ flowchart TB SITE_TS[빌드 타임 폴백 스냅샷] COMPOSE_GEN[컴포지션 빌더
레이아웃 자가개선] ONEPAGER_GEN[원페이저 HTML·PDF 생성] - SYNC_ART[기사 동기화 수동 ETL] + SYNC_ART[기사 동기화 ETL] end end end @@ -200,31 +198,31 @@ flowchart TB DS_BLOG --> OV_BLOG_ARCH DS_MED --> OV_MED_ARCH - EDIT_HUMAN -- commit·push --> BLOB_REPO - EDIT_CLAUDE -- commit·push --> BLOB_REPO - - PEO --> BLOB_REPO - CON --> BLOB_REPO - ORG --> BLOB_REPO - PRJ --> BLOB_REPO - ART --> BLOB_REPO - OV_PERSONA --> BLOB_REPO - OV_WRIT --> BLOB_REPO - OV_PORT --> BLOB_REPO - OV_TIME --> BLOB_REPO - OV_BACK --> BLOB_REPO - OV_README --> BLOB_REPO - OV_NET --> BLOB_REPO - OV_BLOG_ARCH --> BLOB_REPO - OV_MED_ARCH --> BLOB_REPO - SD_META --> BLOB_REPO - NEWS_ART --> BLOB_REPO - - BLOB_REPO -- push event --> WF_BLOB_SYNC - WF_BLOB_SYNC -- Blob 업로드 --> VBLOB + EDIT_HUMAN -- OneDrive 동기화 --> OD_STORE + EDIT_CLAUDE -- 파일 편집 --> OD_STORE + + PEO --> OD_STORE + CON --> OD_STORE + ORG --> OD_STORE + PRJ --> OD_STORE + ART --> OD_STORE + OV_PERSONA --> OD_STORE + OV_WRIT --> OD_STORE + OV_PORT --> OD_STORE + OV_TIME --> OD_STORE + OV_BACK --> OD_STORE + OV_README --> OD_STORE + OV_NET --> OD_STORE + OV_BLOG_ARCH --> OD_STORE + OV_MED_ARCH --> OD_STORE + SD_META --> OD_STORE + NEWS_ART --> OD_STORE + + OD_STORE -- 변경 감지 --> WF_WATCH + WF_WATCH -- 바뀐 경로와 함께 디스패치 --> WF_REF + WF_WATCH --> SYNC_ART GH --> WF_REF - GH --> WF_SUB WF_REF --> SCRIPT_REF OV_README ==> SCRIPT_REF OV_WRIT ==> SCRIPT_REF @@ -234,7 +232,8 @@ flowchart TB PEO ==> SCRIPT_REF SCRIPT_REF --> ANTHROPIC ANTHROPIC --> SD_META - WF_REF -- submodule commit·push
Blob에는 직접 업로드 안 함 --> BLOB_REPO + OD_STORE -- vault 내려받기 --> WF_REF + WF_REF -- 생성물 게시 --> OD_STORE WF_REF --> FAIL WF_REF --> SCRIPT_GEN SD_META --> SCRIPT_GEN @@ -245,7 +244,7 @@ flowchart TB ANTHROPIC --> SD_COMPOSE COMPOSE_GEN -- 개선 기록 --> NEON WF_REF --> ONEPAGER_GEN - ONEPAGER_GEN --> VBLOB + ONEPAGER_GEN --> OD_STORE NEWS_ART --> SYNC_ART SYNC_ART -- 기사 미러 --> NEON WF_REF --> VERCEL @@ -253,8 +252,7 @@ flowchart TB VERCEL --> LAYOUT LAYOUT --> PAGE PAGE --> LOADER - LOADER --> VBLOB - LOADER --> BLOB_REPO + LOADER -- Graph API --> OD_STORE LOADER --> SITE_TS LOADER --> CACHE CACHE --> LOADER @@ -297,7 +295,7 @@ flowchart TB RETRIEVAL --> TOOL_LOOP TOOL_LOOP --> ANTHROPIC - TOOL_LOOP --> VBLOB + TOOL_LOOP --> OD_STORE TOOL_LOOP --> LINKIFY API_AH --> DB_MSG @@ -308,7 +306,7 @@ flowchart TB NEWS_SSR --> NEON BUILDLOG_SSR --> NEON - RESUME_SSR --> VBLOB + RESUME_SSR --> OD_STORE CHATDOCK --> IO IO --> CHATDOCK @@ -345,8 +343,8 @@ flowchart TB class PEO,ORG,PRJ,ART,CON,PLA,EVT,ALOG,NEWS_ART,DS_BLOG,DS_MED,DS_LI,DS_BK mermaid-obj class OV_PERSONA,OV_WRIT,OV_PORT,OV_TIME,OV_BACK,OV_NET,OV_BLOG_ARCH,OV_MED_ARCH,OV_README mermaid-view class SD_META,SD_HOME,SD_HIRE_COPY,SD_COLLAB_COPY,SD_BUILDER_COPY,SD_CURIOUS_COPY,SD_ASK_UI,SD_RESUME,SD_FAQ,SD_COMPOSE mermaid-hubInner - class BLOB_REPO,VBLOB,WF_BLOB_SYNC mermaid-store - class GH,WF_REF,WF_SUB,SCRIPT_REF,SCRIPT_GEN,ZOD,FAIL,SITE_TS,COMPOSE_GEN,ONEPAGER_GEN,SYNC_ART mermaid-ci + class OD_STORE,WF_WATCH mermaid-store + class GH,WF_REF,SCRIPT_REF,SCRIPT_GEN,ZOD,FAIL,SITE_TS,COMPOSE_GEN,ONEPAGER_GEN,SYNC_ART mermaid-ci class ANTHROPIC,VERCEL,NEON mermaid-ext class LAYOUT,PAGE,LOADER,CACHE,API_AH,API_SPEC,API_SEL,RETRIEVAL,TOOL_LOOP,DB_MSG,DB_MEM,LINKIFY,NEWS_SSR,RESUME_SSR,BUILDLOG_SSR mermaid-srv class PORT_APP,HOME_PAGE,VH,VL,VB,VC,CHATDOCK,ATOMS,IO,LS,ASK_CL mermaid-cli diff --git a/src/content/site-structure.ts b/src/content/site-structure.ts index 670edd2..5dce2b5 100644 --- a/src/content/site-structure.ts +++ b/src/content/site-structure.ts @@ -112,7 +112,7 @@ export const SITE_STRUCTURE: Record = { shell: "standalone", wrapperClass: "view", inSitemap: false, - role: "vault·SiteData·Blob·CI·Ask 연결 구조를 Mermaid 한 장으로.", + role: "vault·SiteData·OneDrive·CI·Ask 연결 구조를 Mermaid 한 장으로.", }, }; diff --git a/src/lib/ask-hansol/blob-context.ts b/src/lib/ask-hansol/blob-context.ts deleted file mode 100644 index 5a42cb2..0000000 --- a/src/lib/ask-hansol/blob-context.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Ask Hansol — Vercel Blob vault 조회/문맥 구성 공용 모듈. - * 대화형 Ask(`/api/ask-hansol`)와 JD 적합도 분석(`/api/ask-hansol-jd`)이 함께 쓴다. - * 방문자 답변에는 vault/Blob/조회 과정을 드러내지 않는다는 정책은 호출부 프롬프트에서 강제한다. - */ - -export type BlobEntry = { pathname: string; url: string }; - -export type RetrievalSkill = { - id: string; - keywords: string[]; - blobPaths: string[]; -}; - -/** 모든 Ask 요청에 항상 포함하는 기본 문맥(읽기 지침 + 운영 매뉴얼). */ -export const BASE_CONTEXT_PATHS = [ - "vault/README.md", - "vault/object-views/AI-클론-운영-매뉴얼.md", -]; - -export const BLOB_CONTEXT_MAX_CHARS = Number( - process.env.ASK_HANSOL_BLOB_CONTEXT_MAX_CHARS ?? 12_000, -); - -export const RETRIEVAL_SKILLS: RetrievalSkill[] = [ - { - id: "persona-core", - keywords: ["페르소나", "성격", "정체성", "톤", "말투", "persona"], - blobPaths: ["vault/objects/concepts/임한솔-persona.md"], - }, - { - id: "profile-career", - keywords: ["경력", "커리어", "이력", "경험", "요약", "career"], - blobPaths: ["vault/objects/people/임한솔.md"], - }, - { - id: "writing-content", - keywords: ["블로그", "글", "아카이브", "콘텐츠", "작문", "writing"], - blobPaths: ["vault/objects/concepts/임한솔-writing-style.md"], - }, -]; - -export function keywordTokens(text: string): string[] { - return text - .toLowerCase() - .replace(/[?!.,]/g, " ") - .split(/\s+/) - .map((token) => - token.replace(/(은|는|이|가|을|를|요|나요|까요|인가요|입니다|있나요|하나요)$/g, ""), - ) - .filter((token) => token.length >= 2); -} - -export function getBlobToken(): string | null { - return ( - process.env.ASK_HANSOL_BLOB_TOKEN ?? - process.env.BLOB_READ_WRITE_TOKEN ?? - process.env.BLOB_READ_TOKEN ?? - null - ); -} - -function blobContextLimit(): number { - return Number.isFinite(BLOB_CONTEXT_MAX_CHARS) && BLOB_CONTEXT_MAX_CHARS > 0 - ? BLOB_CONTEXT_MAX_CHARS - : 12_000; -} - -async function listAllBlobs(prefix: string, token: string): Promise { - const { list } = await import("@vercel/blob"); - const page = await list({ prefix, token, limit: 20 }); - return page.blobs.map((blob) => ({ - pathname: blob.pathname, - url: blob.url, - })); -} - -async function resolveBlobEntryForRelativePath( - relativePath: string, - token: string, - basePrefix: string, -): Promise { - const pathCandidates = [ - `${basePrefix}/${relativePath}`.replace(/\/+/g, "/"), - relativePath.replace(/^\/+/g, ""), - ]; - for (const fullPath of pathCandidates) { - const candidates = await listAllBlobs(fullPath, token).catch(() => []); - const exact = candidates.find((blob) => blob.pathname === fullPath); - const picked = exact ?? candidates[0]; - if (picked) return picked; - } - return null; -} - -export function sortVaultReadmeFirst(entries: BlobEntry[]): BlobEntry[] { - const isVaultReadme = (pathname: string) => /(^|\/)vault\/README\.md$/i.test(pathname); - return [...entries].sort((a, b) => { - const ar = isVaultReadme(a.pathname) ? 0 : 1; - const br = isVaultReadme(b.pathname) ? 0 : 1; - if (ar !== br) return ar - br; - return a.pathname.localeCompare(b.pathname); - }); -} - -export async function fetchBlobText(url: string, token: string): Promise { - const attempt = async (withAuth: boolean) => { - const response = await fetch(url, { - headers: withAuth ? { Authorization: `Bearer ${token}` } : undefined, - cache: "no-store", - }); - if (!response.ok) return null; - return response.text(); - }; - - try { - const text = await attempt(true); - if (text) return text; - } catch {} - - try { - return await attempt(false); - } catch { - return null; - } -} - -/** Blob의 vault/README.md — vault를 찾고 읽는 절차·규칙용. 사실 근거 문서가 아님. */ -export async function fetchVaultReadmeGuideBody(): Promise { - const token = getBlobToken(); - if (!token) return null; - const basePrefix = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, ""); - const entry = await resolveBlobEntryForRelativePath("vault/README.md", token, basePrefix); - if (!entry) return null; - const text = await fetchBlobText(entry.url, token); - if (!text) return null; - return text.slice(0, blobContextLimit()); -} - -function pickRetrievalSkills(query: string): RetrievalSkill[] { - const tokens = keywordTokens(query); - const matched = RETRIEVAL_SKILLS.filter((skill) => { - const skillTokenSet = skill.keywords.flatMap((k) => keywordTokens(k)); - if (skillTokenSet.length === 0 || tokens.length === 0) return false; - const overlap = tokens.filter((token) => skillTokenSet.includes(token)).length; - return overlap >= 1; - }); - - if (matched.length > 0) return matched.slice(0, 2); - // 기본 스킬: 일반 질문에도 핵심 프로필은 항상 참조 가능 - return RETRIEVAL_SKILLS.filter((s) => s.id === "profile-career"); -} - -async function resolveSkillBlobs( - skills: RetrievalSkill[], - token: string, - basePrefix: string, - maxDocs = 4, -): Promise { - const dedup = new Map(); - const enqueuePath = async (relativePath: string) => { - const pathCandidates = [ - `${basePrefix}/${relativePath}`.replace(/\/+/g, "/"), - relativePath.replace(/^\/+/g, ""), - ]; - - for (const fullPath of pathCandidates) { - const candidates = await listAllBlobs(fullPath, token).catch(() => []); - const exact = candidates.find((blob) => blob.pathname === fullPath); - const picked = exact ?? candidates[0]; - if (picked) { - dedup.set(picked.pathname, picked); - return; - } - } - }; - - // Always include base context for every Ask Hansol request. - for (const basePath of BASE_CONTEXT_PATHS) { - await enqueuePath(basePath); - } - - for (const skill of skills) { - for (const relativePath of skill.blobPaths) { - await enqueuePath(relativePath); - } - } - - return [...dedup.values()].slice(0, maxDocs); -} - -async function buildContextFromBlobs(entries: BlobEntry[], token: string): Promise { - const limit = blobContextLimit(); - const chunks = await Promise.all( - entries.map(async (blob) => { - const text = await fetchBlobText(blob.url, token); - if (!text) return null; - const isVaultReadme = /(^|\/)vault\/README\.md$/i.test(blob.pathname); - const header = isVaultReadme - ? `### ${blob.pathname} (vault 읽기 지침 — 사실·인물·경력 근거로 쓰지 말 것)` - : `### ${blob.pathname}`; - return `${header}\n${text.slice(0, limit)}`; - }), - ); - return chunks.filter(Boolean).join("\n\n"); -} - -/** 대화형 Ask용: 질문 키워드로 스킬을 골라 관련 문서만 끌어온다. */ -export async function fetchBlobContext(query: string): Promise { - const token = getBlobToken(); - if (!token) return ""; - - const basePrefix = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, ""); - const skills = pickRetrievalSkills(query); - const selected = sortVaultReadmeFirst(await resolveSkillBlobs(skills, token, basePrefix)); - if (selected.length === 0) return ""; - return buildContextFromBlobs(selected, token); -} - -/** - * JD 적합도 분석용: 키워드와 무관하게 페르소나·경력·작문 등 핵심 프로필 문서를 - * 종합적으로 끌어온다. 기본 문맥(읽기 지침·운영 매뉴얼)도 함께 포함된다. - */ -export async function fetchComprehensiveProfileContext(): Promise { - const token = getBlobToken(); - if (!token) return ""; - - const basePrefix = (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, ""); - const selected = sortVaultReadmeFirst( - await resolveSkillBlobs(RETRIEVAL_SKILLS, token, basePrefix, 8), - ); - if (selected.length === 0) return ""; - return buildContextFromBlobs(selected, token); -} diff --git a/src/lib/ask-hansol/vault-context.ts b/src/lib/ask-hansol/vault-context.ts new file mode 100644 index 0000000..d342455 --- /dev/null +++ b/src/lib/ask-hansol/vault-context.ts @@ -0,0 +1,122 @@ +import { readOneDriveText } from "@/lib/content/onedrive-text"; + +/** + * Ask Hansol - vault 문맥 구성 공용 모듈. vault 원본은 개인 OneDrive `hsol-info-blob/vault/`. + * 대화형 Ask(`/api/ask-hansol`), JD 적합도 분석(`/api/ask-hansol-jd`), 자문(`/api/ask-hansol-advice`)이 함께 쓴다. + * 방문자 답변에는 vault/저장소/조회 과정을 드러내지 않는다는 정책은 호출부 프롬프트에서 강제한다. + */ + +export type RetrievalSkill = { + id: string; + keywords: string[]; + paths: string[]; +}; + +/** 모든 Ask 요청에 항상 포함하는 기본 문맥(읽기 지침 + 운영 매뉴얼). */ +export const BASE_CONTEXT_PATHS = [ + "vault/README.md", + "vault/object-views/AI-클론-운영-매뉴얼.md", +]; + +export const VAULT_CONTEXT_MAX_CHARS = Number( + process.env.ASK_HANSOL_VAULT_CONTEXT_MAX_CHARS ?? process.env.ASK_HANSOL_BLOB_CONTEXT_MAX_CHARS ?? 12_000, +); + +export const RETRIEVAL_SKILLS: RetrievalSkill[] = [ + { + id: "persona-core", + keywords: ["페르소나", "성격", "정체성", "톤", "말투", "persona"], + paths: ["vault/objects/concepts/임한솔-persona.md"], + }, + { + id: "profile-career", + keywords: ["경력", "커리어", "이력", "경험", "요약", "career"], + paths: ["vault/objects/people/임한솔.md"], + }, + { + id: "writing-content", + keywords: ["블로그", "글", "아카이브", "콘텐츠", "작문", "writing"], + paths: ["vault/objects/concepts/임한솔-writing-style.md"], + }, +]; + +export function keywordTokens(text: string): string[] { + return text + .toLowerCase() + .replace(/[?!.,]/g, " ") + .split(/\s+/) + .map((token) => + token.replace(/(은|는|이|가|을|를|요|나요|까요|인가요|입니다|있나요|하나요)$/g, ""), + ) + .filter((token) => token.length >= 2); +} + +function contextLimit(): number { + return Number.isFinite(VAULT_CONTEXT_MAX_CHARS) && VAULT_CONTEXT_MAX_CHARS > 0 + ? VAULT_CONTEXT_MAX_CHARS + : 12_000; +} + +const isVaultReadme = (p: string) => /(^|\/)vault\/README\.md$/i.test(p); + +export function sortVaultReadmeFirst(paths: string[]): string[] { + return [...paths].sort((a, b) => { + const ar = isVaultReadme(a) ? 0 : 1; + const br = isVaultReadme(b) ? 0 : 1; + if (ar !== br) return ar - br; + return a.localeCompare(b); + }); +} + +/** vault/README.md - vault 를 찾고 읽는 절차와 규칙용. 사실 근거 문서가 아님. */ +export async function fetchVaultReadmeGuideBody(): Promise { + const text = await readOneDriveText("vault/README.md"); + return text ? text.slice(0, contextLimit()) : null; +} + +function pickRetrievalSkills(query: string): RetrievalSkill[] { + const tokens = keywordTokens(query); + const matched = RETRIEVAL_SKILLS.filter((skill) => { + const skillTokenSet = skill.keywords.flatMap((k) => keywordTokens(k)); + if (skillTokenSet.length === 0 || tokens.length === 0) return false; + const overlap = tokens.filter((token) => skillTokenSet.includes(token)).length; + return overlap >= 1; + }); + + if (matched.length > 0) return matched.slice(0, 2); + // 기본 스킬: 일반 질문에도 핵심 프로필은 항상 참조 가능 + return RETRIEVAL_SKILLS.filter((s) => s.id === "profile-career"); +} + +function selectPaths(skills: RetrievalSkill[], maxDocs: number): string[] { + const ordered = [...BASE_CONTEXT_PATHS, ...skills.flatMap((s) => s.paths)]; + return sortVaultReadmeFirst([...new Set(ordered)].slice(0, maxDocs)); +} + +async function buildContext(paths: string[]): Promise { + const limit = contextLimit(); + const chunks = await Promise.all( + paths.map(async (p) => { + const text = await readOneDriveText(p); + if (!text) return null; + const header = isVaultReadme(p) + ? `### ${p} (vault 읽기 지침 - 사실, 인물, 경력 근거로 쓰지 말 것)` + : `### ${p}`; + return `${header}\n${text.slice(0, limit)}`; + }), + ); + return chunks.filter(Boolean).join("\n\n"); +} + +/** 대화형 Ask용: 질문 키워드로 스킬을 골라 관련 문서만 끌어온다. */ +export async function fetchVaultContext(query: string): Promise { + return buildContext(selectPaths(pickRetrievalSkills(query), 4)); +} + +/** + * JD 적합도 분석, 자문용: 키워드와 무관하게 페르소나, 경력, 작문 등 핵심 프로필 문서를 + * 종합적으로 끌어온다. 기본 문맥(읽기 지침, 운영 매뉴얼)도 함께 포함된다. + */ +export async function fetchComprehensiveProfileContext(): Promise { + return buildContext(selectPaths(RETRIEVAL_SKILLS, 8)); +} diff --git a/src/lib/content/blob.ts b/src/lib/content/blob.ts deleted file mode 100644 index 69a782d..0000000 --- a/src/lib/content/blob.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { list } from "@vercel/blob"; - -/** - * Vercel Blob 공통 헬퍼. site-data·원페이저 등 vault 산출물을 같은 store 에서 읽는다. - */ - -export function getBlobToken(): string | null { - return ( - process.env.ASK_HANSOL_BLOB_TOKEN ?? - process.env.BLOB_READ_WRITE_TOKEN ?? - process.env.BLOB_READ_TOKEN ?? - null - ); -} - -/** Blob 경로 접두사(기본 "info"). 앞뒤 슬래시 제거. */ -export function getBlobPrefix(): string { - return (process.env.BLOB_PREFIX || "info").replace(/^\/+|\/+$/g, ""); -} - -/** - * `/` 와 `` 후보로 Blob 에서 파일 URL 을 해석한다. - * 정확히 일치하는 pathname 우선, 없으면 첫 결과. 못 찾으면 null. - */ -export async function resolveBlobUrl( - token: string, - basePrefix: string, - relPath: string, -): Promise { - const pathCandidates = [`${basePrefix}/${relPath}`.replace(/\/+/g, "/"), relPath]; - - for (const fullPath of pathCandidates) { - const page = await list({ prefix: fullPath, token, limit: 20 }).catch(() => null); - if (!page) continue; - const exact = page.blobs.find((blob) => blob.pathname === fullPath); - const picked = exact ?? page.blobs[0]; - if (picked) return picked.url; - } - - return null; -} diff --git a/src/lib/content/onedrive-text.ts b/src/lib/content/onedrive-text.ts new file mode 100644 index 0000000..295bc97 --- /dev/null +++ b/src/lib/content/onedrive-text.ts @@ -0,0 +1,29 @@ +import { unstable_cache } from "next/cache"; +import { isOneDriveConfigured, readText } from "@/lib/onedrive/graph"; + +/** + * 사이트가 읽는 OneDrive 텍스트(site-data, 원페이저, Ask Hansol 문맥)를 Next 데이터 캐시에 얹는다. + * Graph 조회는 콜드 스타트에 1~3초 걸려서, 인스턴스 사이에서 공유되는 캐시(5분 재검증, stale-while-revalidate) + * 가 페이지 응답 시간을 지킨다. 파일을 고치면 늦어도 약 5분 뒤 반영된다. + */ +const REVALIDATE_SECONDS = Number(process.env.ONEDRIVE_TEXT_REVALIDATE_SECONDS ?? 300); + +const cachedRead = unstable_cache(async (relPath: string) => readText(relPath), ["onedrive-text-v1"], { + revalidate: REVALIDATE_SECONDS, + tags: ["onedrive-text"], +}); + +/** OneDrive `hsol-info-blob/` 텍스트. 설정이 없거나 실패하면 null. */ +export async function readOneDriveText(relPath: string): Promise { + if (!isOneDriveConfigured()) return null; + try { + return await cachedRead(relPath); + } catch { + // Next 요청 컨텍스트 밖(스크립트)이면 데이터 캐시를 못 쓴다. 인메모리 캐시만 쓰는 직접 읽기로. + try { + return await readText(relPath); + } catch { + return null; + } + } +} diff --git a/src/lib/content/onepager.ts b/src/lib/content/onepager.ts index 6d8b483..e81259c 100644 --- a/src/lib/content/onepager.ts +++ b/src/lib/content/onepager.ts @@ -1,34 +1,23 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { getBlobPrefix, getBlobToken, resolveBlobUrl } from "@/lib/content/blob"; +import { readOneDriveText } from "@/lib/content/onedrive-text"; export { wrapOnePagerHtml } from "@/lib/content/onepager-html"; /** - * 원페이저 HTML 조각 로더. getSiteData 와 같은 폴백 체인: Blob -> 로컬 vault -> null. + * 원페이저 HTML 조각 로더. getSiteData 와 같은 폴백 체인: OneDrive -> 로컬 vault -> null. * site-data 와 분리된 별도 아티팩트(vault/object-views/onepager-ko.html). */ -const ONEPAGER_BLOB_PATH = "vault/object-views/onepager-ko.html"; +const ONEPAGER_ONEDRIVE_PATH = "vault/object-views/onepager-ko.html"; const ONEPAGER_LOCAL_PATH = "hsol-info-blob/vault/object-views/onepager-ko.html"; const CACHE_TTL_MS = Number(process.env.ONEPAGER_CACHE_TTL_MS ?? 5 * 60 * 1000); let cached: { html: string | null; expiresAt: number } | null = null; let inflight: Promise | null = null; -async function fetchFromBlob(): Promise { - const token = getBlobToken(); - if (!token) return null; - const url = await resolveBlobUrl(token, getBlobPrefix(), ONEPAGER_BLOB_PATH); - if (!url) return null; - // no-store: 가변 Blob 을 force-cache 로 읽으면 Next 데이터 캐시에 고정돼 갱신이 안 보인다. - // 아래 인메모리 캐시(CACHE_TTL_MS)가 호출량을 억제한다. (site-data 로더와 동일한 이유) - const response = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }).catch(() => null); - if (!response || !response.ok) return null; - const text = await response.text(); - return text.trim() ? text : null; +async function fetchFromOneDrive(): Promise { + const text = await readOneDriveText(ONEPAGER_ONEDRIVE_PATH); + return text?.trim() ? text : null; } async function fetchFromLocalVault(): Promise { @@ -47,7 +36,7 @@ export async function getOnePagerHtml(): Promise { if (cached && now < cached.expiresAt) return cached.html; if (inflight) return inflight; - inflight = (async () => (await fetchFromBlob()) ?? (await fetchFromLocalVault()))() + inflight = (async () => (await fetchFromOneDrive()) ?? (await fetchFromLocalVault()))() .then((html) => { cached = { html, expiresAt: Date.now() + CACHE_TTL_MS }; return html; diff --git a/src/lib/content/site-data.ts b/src/lib/content/site-data.ts index d7a99c4..e489e70 100644 --- a/src/lib/content/site-data.ts +++ b/src/lib/content/site-data.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { HSOL_DATA } from "@/data/site"; import { siteDataSchema, type SiteData } from "@/content/schema"; -import { getBlobPrefix, getBlobToken, resolveBlobUrl } from "@/lib/content/blob"; +import { readOneDriveText } from "@/lib/content/onedrive-text"; const SITE_DATA_PATH = "vault/object-views/site-data.json"; const CACHE_TTL_MS = Number(process.env.SITE_DATA_CACHE_TTL_MS ?? 5 * 60 * 1000); @@ -10,32 +10,11 @@ const CACHE_TTL_MS = Number(process.env.SITE_DATA_CACHE_TTL_MS ?? 5 * 60 * 1000) let cached: { data: SiteData; expiresAt: number } | null = null; let inflight: Promise | null = null; -async function fetchSiteDataFromBlob(): Promise { - const token = getBlobToken(); - if (!token) { - throw new Error("Blob token is missing. Set BLOB_READ_WRITE_TOKEN or BLOB_READ_TOKEN."); - } - - const basePrefix = getBlobPrefix(); - const blobUrl = await resolveBlobUrl(token, basePrefix, SITE_DATA_PATH); - if (!blobUrl) { - throw new Error(`Blob file not found: ${SITE_DATA_PATH}`); - } - - // no-store: 가변 private Blob 을 force-cache 로 읽으면 Next 데이터 캐시에 무기한 고정돼 - // 콘텐츠 갱신(refresh)이 배포보다 늦게 도착하면 옛 데이터가 계속 서빙된다. 아래 인메모리 - // 캐시(CACHE_TTL_MS)가 호출량을 억제하므로, 원본 fetch 는 항상 신선하게 가져온다. - const response = await fetch(blobUrl, { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }).catch(() => null); - - if (!response || !response.ok) { - throw new Error(`Failed to fetch blob site data: ${blobUrl}`); - } - - const json = await response.json(); - return siteDataSchema.parse(json); +/** 정본: 개인 OneDrive `hsol-info-blob/vault/object-views/site-data.json` (CI 가 생성해 올린다). */ +async function fetchSiteDataFromOneDrive(): Promise { + const text = await readOneDriveText(SITE_DATA_PATH); + if (!text) throw new Error(`OneDrive file not found: ${SITE_DATA_PATH}`); + return siteDataSchema.parse(JSON.parse(text)); } async function fetchSiteDataFromLocalVault(): Promise { @@ -44,7 +23,7 @@ async function fetchSiteDataFromLocalVault(): Promise { return siteDataSchema.parse(JSON.parse(raw)); } -/** Committed baseline (`src/data/site.ts`) — used when Blob + local vault are unavailable (e.g. Vercel CI without submodule). */ +/** Committed baseline (`src/data/site.ts`) - OneDrive 와 로컬 vault 를 모두 못 읽을 때. */ function fetchSiteDataFromBundled(): SiteData { return HSOL_DATA; } @@ -55,7 +34,7 @@ export async function getSiteData(): Promise { if (inflight) return inflight; - inflight = fetchSiteDataFromBlob() + inflight = fetchSiteDataFromOneDrive() .catch(async () => fetchSiteDataFromLocalVault()) .catch(async () => fetchSiteDataFromBundled()) .then((data) => { diff --git a/src/lib/onedrive/graph.ts b/src/lib/onedrive/graph.ts new file mode 100644 index 0000000..fd125a4 --- /dev/null +++ b/src/lib/onedrive/graph.ts @@ -0,0 +1,205 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { neon } from "@neondatabase/serverless"; + +/** + * 개인 OneDrive(Microsoft Graph) 읽기 클라이언트. hsol.info 콘텐츠의 정본이자 유일한 저장소다. + * + * 경로는 OneDrive `hsol-info-blob/` 기준 상대경로("vault/object-views/site-data.json", "assets/public/.."). + * 토큰은 Neon `onedrive_token` 행(ops/scripts/lib/onedrive-token.mjs 와 같은 형식, AES-256-GCM)을 + * CI 와 같이 쓴다. 액세스 토큰이 만료되면 여기서 리프레시해 새 토큰을 되쓴다. + * + * 환경변수: DATABASE_URL, ONEDRIVE_TOKEN_KEY, ONEDRIVE_DRIVE_ID, ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_SECRET + * (선택) ONEDRIVE_ROOT(기본 hsol-info-blob) + */ + +const GRAPH = "https://graph.microsoft.com/v1.0"; +const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; +const TOKEN_ROW_ID = "personal"; +const EARLY_REFRESH_MS = 3 * 60 * 1000; +const TEXT_CACHE_TTL_MS = Number(process.env.ONEDRIVE_TEXT_CACHE_TTL_MS ?? 5 * 60 * 1000); + +type StoredToken = { access_token: string; token_type?: string; refresh_token: string; expiry: string }; + +export type DriveItem = { + id: string; + name: string; + size: number; + eTag?: string; + lastModifiedDateTime?: string; + file?: { mimeType?: string }; + folder?: unknown; + "@microsoft.graph.downloadUrl"?: string; +}; + +let memToken: { accessToken: string; expiresAt: number } | null = null; +let inflightToken: Promise | null = null; +const textCache = new Map(); + +function env(name: string): string { + const v = process.env[name]?.trim(); + if (!v) throw new Error(`${name} 미설정`); + return v; +} + +function databaseUrl(): string { + return (process.env.DATABASE_URL || process.env.POSTGRES_URL || "").trim(); +} + +export function isOneDriveConfigured(): boolean { + const names = ["ONEDRIVE_TOKEN_KEY", "ONEDRIVE_DRIVE_ID", "ONEDRIVE_CLIENT_ID", "ONEDRIVE_CLIENT_SECRET"]; + return names.every((n) => Boolean(process.env[n]?.trim())) && Boolean(databaseUrl()); +} + +function tokenKey(): Buffer { + const key = Buffer.from(env("ONEDRIVE_TOKEN_KEY"), "base64"); + if (key.length !== 32) throw new Error("ONEDRIVE_TOKEN_KEY 는 32바이트 base64 여야 한다."); + return key; +} + +function sql() { + const url = databaseUrl(); + if (!url) throw new Error("DATABASE_URL 미설정"); + return neon(url); +} + +function encrypt(key: Buffer, plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64"); +} + +function decrypt(key: Buffer, payloadB64: string): string { + const buf = Buffer.from(payloadB64, "base64"); + const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12)); + decipher.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8"); +} + +async function loadStoredToken(): Promise { + const rows = (await sql()`SELECT payload FROM onedrive_token WHERE id = ${TOKEN_ROW_ID}`) as { + payload: string; + }[]; + if (!rows[0]) throw new Error("Neon onedrive_token 에 토큰이 없다."); + return JSON.parse(decrypt(tokenKey(), rows[0].payload)) as StoredToken; +} + +async function saveStoredToken(token: StoredToken): Promise { + const payload = encrypt(tokenKey(), JSON.stringify(token)); + await sql()`INSERT INTO onedrive_token (id, payload, expires_at, updated_at) + VALUES (${TOKEN_ROW_ID}, ${payload}, ${token.expiry}, now()) + ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, expires_at = EXCLUDED.expires_at, updated_at = now()`; +} + +async function refreshToken(stored: StoredToken): Promise { + const body = new URLSearchParams({ + client_id: env("ONEDRIVE_CLIENT_ID"), + client_secret: env("ONEDRIVE_CLIENT_SECRET"), + grant_type: "refresh_token", + refresh_token: stored.refresh_token, + }); + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + cache: "no-store", + }); + const json = (await res.json().catch(() => ({}))) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + error?: string; + }; + if (!res.ok || !json.access_token) { + throw new Error(`OneDrive 토큰 리프레시 실패: HTTP ${res.status} ${json.error ?? ""}`.trim()); + } + const next: StoredToken = { + access_token: json.access_token, + token_type: json.token_type ?? "Bearer", + refresh_token: json.refresh_token ?? stored.refresh_token, + expiry: new Date(Date.now() + (json.expires_in ?? 3600) * 1000).toISOString(), + }; + await saveStoredToken(next); + return next; +} + +async function getAccessToken(force = false): Promise { + const now = Date.now(); + if (!force && memToken && memToken.expiresAt - EARLY_REFRESH_MS > now) return memToken.accessToken; + if (inflightToken) return inflightToken; + + inflightToken = (async () => { + let stored = await loadStoredToken(); + const exp = Date.parse(stored.expiry); + if (force || !Number.isFinite(exp) || exp - EARLY_REFRESH_MS <= Date.now()) { + stored = await refreshToken(stored); + } + memToken = { accessToken: stored.access_token, expiresAt: Date.parse(stored.expiry) }; + return stored.access_token; + })().finally(() => { + inflightToken = null; + }); + return inflightToken; +} + +/** 정비용: 저장된 리프레시 토큰으로 즉시 갱신해 Neon 에 되쓴다. */ +export async function refreshOneDriveToken(): Promise { + await getAccessToken(true); +} + +function itemUrl(relPath: string): string { + const root = (process.env.ONEDRIVE_ROOT || "hsol-info-blob").replace(/^\/+|\/+$/g, ""); + const full = [root, ...relPath.split("/")].filter(Boolean).map(encodeURIComponent).join("/"); + return `${GRAPH}/drives/${encodeURIComponent(env("ONEDRIVE_DRIVE_ID"))}/root:/${full}`; +} + +/** Graph 호출. 401 이면 토큰을 한 번 강제 갱신해 다시 시도한다. */ +async function graphFetch(url: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + const token = await getAccessToken(attempt > 0); + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }); + if (res.status !== 401) return res; + } + throw new Error("OneDrive 인증 실패(401)"); +} + +/** 한글 파일명은 올린 경로에 따라 NFC/NFD 가 섞여 있을 수 있어 둘 다 시도한다. */ +function pathVariants(relPath: string): string[] { + const nfc = relPath.normalize("NFC"); + const nfd = relPath.normalize("NFD"); + return nfc === nfd ? [nfc] : [nfc, nfd]; +} + +/** 파일 메타데이터(다운로드 URL 포함). 없거나 폴더면 null. */ +export async function getItem(relPath: string): Promise { + for (const p of pathVariants(relPath)) { + const res = await graphFetch(itemUrl(p)); + if (res.status === 404) continue; + if (!res.ok) throw new Error(`OneDrive 조회 실패 ${relPath}: HTTP ${res.status}`); + const item = (await res.json()) as DriveItem; + return item.folder ? null : item; + } + return null; +} + +/** 파일 내용을 바이트로. 없으면 null. */ +export async function readBytes(relPath: string): Promise { + const item = await getItem(relPath); + const url = item?.["@microsoft.graph.downloadUrl"]; + if (!url) return null; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`OneDrive 다운로드 실패 ${relPath}: HTTP ${res.status}`); + return res.arrayBuffer(); +} + +/** 텍스트 파일 읽기. 인스턴스 메모리에 5분 캐시(오류는 캐시하지 않는다). 없으면 null. */ +export async function readText(relPath: string): Promise { + const now = Date.now(); + const hit = textCache.get(relPath); + if (hit && hit.expiresAt > now) return hit.text; + const buf = await readBytes(relPath); + const text = buf ? new TextDecoder("utf-8").decode(buf) : null; + textCache.set(relPath, { text, expiresAt: now + TEXT_CACHE_TTL_MS }); + return text; +} diff --git a/tsconfig.json b/tsconfig.json index fba2bf3..29d76a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "hsol-info-blob", "generated"] }