From 4bcd01a95f138a3974caba974e7c046555c3364a Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 16 Oct 2026 13:54:25 +0700 Subject: [PATCH] feat(wordaddin): serve manifest with automatic origin rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest ของ add-in เดิมเป็น static ใน public/ ฝัง URL dev (localhost:3000) ทั้งไฟล์ — deploy จริงต้อง rewrite มือเองทุกครั้ง ก่อนนำไป sideload ใน Word ตอนนี้ serve ผ่าน route แทน: app/wordaddin/manifest.xml/route.ts อ่าน template (public/wordaddin/manifest.template.xml — ย้ายจาก manifest.xml ด้วย git mv) แล้วเขียนทับ origin ให้ตรงโฮสต์ที่ขอ ไฟล์ — dev/UAT/production ดาวน์โหลด manifest.xml ขอ environment ตัวเองแล้ว sideload ได้ทันที - ถอด requestOrigin จาก N10 route เป็น utils/request-origin.ts ใช้ร่วม + เพิ่ม heuristic: โฮสต์ไม่ใช่ localhost + proto http = บังคับ https (Office รับ https เท่านั้น และ hop ภายในหลัง IIS เป็น http เสมอ — Next server ก็ฉีด x-forwarded-proto เอง จึง ชี้ขาดจากโฮสต์ปลายทาง ไม่เดาว่า header มาจาก proxy หรือถูกฉีด) deep link ของ N10 ได้พฤติกรรมเดียวกันเป็นผลพวง - ตรวจ nft trace แล้ว template ถูกดึงเข้า standalone ด้วย (คู่ กับ COPY public จาก commit ก่อน = ชัวร์สองชั้น) ยืนยัน: tsc + vitest 215 tests ผ่าน · ทดสอบบน dev server ด้วย curl 3 โหมด — direct ได้ http://localhost:3000 (Word ยกเว้น localhost), ผ่าน proxy ส่ง header ครบได้ https://<โฮสต์>, ผ่าน proxy ไม่ส่ง proto (กรณี IIS) ได้ https://<โฮสต์> จากการ bump · next build compile route ผ่าน (ขั้น standalone symlink พังเฉพาะเครื่อง local Windows สิทธิ์ symlink — ไม่เกี่ยวกับโค้ด Docker build ผ่าน) Co-Authored-By: Claude Code --- CLAUDE.md | 1 + app/api/note/external/documents/route.ts | 18 ++------ app/wordaddin/manifest.xml/route.ts | 33 +++++++++++++++ docs/WORDADDIN-DEV.md | 5 ++- .../{manifest.xml => manifest.template.xml} | 10 +++-- utils/request-origin.ts | 41 +++++++++++++++++++ 6 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 app/wordaddin/manifest.xml/route.ts rename public/wordaddin/{manifest.xml => manifest.template.xml} (88%) create mode 100644 utils/request-origin.ts diff --git a/CLAUDE.md b/CLAUDE.md index 84b17d9..8023344 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ app/note/login/page.tsx ← หน้า login (มี session แ app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client) +app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น) components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง) components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) diff --git a/app/api/note/external/documents/route.ts b/app/api/note/external/documents/route.ts index 3d9f4a1..8a4faff 100644 --- a/app/api/note/external/documents/route.ts +++ b/app/api/note/external/documents/route.ts @@ -4,6 +4,7 @@ import { db } from "@/db"; import { files } from "@/db/schema"; import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth"; import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api"; +import { requestOrigin } from "@/utils/request-origin"; // N10 (PLAN-API.md D2) — POST /api/note/external/documents // (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25) @@ -19,21 +20,8 @@ import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api"; const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; -/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้ - * 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto / - * x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย - * fallback จาก host/protocol ของ request ตรง ๆ */ -function requestOrigin(req: Request): string { - const url = new URL(req.url); - const proto = - req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ?? - url.protocol.replace(":", ""); - const host = - req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? - req.headers.get("host") ?? - url.host; - return `${proto}://${host}`; -} +// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม +// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16) export async function POST(req: Request) { const unauthorized = requireExternalApiKey(req); diff --git a/app/wordaddin/manifest.xml/route.ts b/app/wordaddin/manifest.xml/route.ts new file mode 100644 index 0000000..9b19dbd --- /dev/null +++ b/app/wordaddin/manifest.xml/route.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { requestOrigin } from "@/utils/request-origin"; + +// GET /wordaddin/manifest.xml — manifest ของ Word add-in สำหรับ sideload +// +// URL ใน manifest เป็น absolute https ตามข้อบังคับของ Office แต่ต้องต่างกันตาม +// environment — เดิมใช้ไฟล์ static ใน public/ ค่า dev (localhost:3000) แล้วให้ผู้ +// deploy rewrite มือเองทุกครั้ง (docs/WORDADDIN-DEV.md) ตอนนี้ serve ผ่าน route +// แทน: อ่าน template (public/wordaddin/manifest.template.xml — ค่า dev) แล้ว +// เขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์ (dev/UAT/production ใช้ template +// เดียวกัน — โหลดจาก environment ไหน manifest ก็ชี้ environment นั้น) +// +// อ่านจาก public/ เพราะเป็นที่เดียวที่การันตีว่ามีใน standalone ของ Docker +// (runner stage COPY public — ไฟล์ app/ ต้นฉบับไม่ถูกก๊อปเข้า image) + +/** origin dev ที่ฝังใน template — สตริงเดียวที่ถูกแทนที่ทั้งไฟล์ (6 จุด) */ +const DEV_ORIGIN = "https://localhost:3000"; + +const TEMPLATE = path.join(process.cwd(), "public", "wordaddin", "manifest.template.xml"); + +export function GET(req: Request): Response { + const template = fs.readFileSync(TEMPLATE, "utf8"); + const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req)); + return new Response(manifest, { + headers: { + "content-type": "application/xml; charset=utf-8", + // origin ขึ้นกับ header ของผู้ขอ — ห้ามแคกลามผู้ใช้เกินครั้งเดียวต่อ download + "cache-control": "no-store", + }, + }); +} diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 5baa0f4..eb8efbc 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -11,9 +11,10 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม | ที่ | คือ | |---|---| | `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` | +| `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) | | `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/signed-out/untracked/tracking` + ทุก handler | | `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `authService`/`versioningService` เป็น mock | -| `public/wordaddin/manifest.xml` | manifest sideload (URL เป็น absolute https ต่อ environment) | +| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ | | `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) | | `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) | @@ -38,7 +39,7 @@ manifest ต้องชี้ https — 2 ทางเลือก: 3. Word: **File > Options > Trust Center > Trust Center Settings > Trusted Add-in Catalogs** เพิ่มโฟลเดอร์นั้น > restart Word 4. **Insert > My Add-ins > SHARED FOLDER** > เปิด "e-Court Documents Versioning" -Deploy จริง (intranet): rewrite URL ทุกตัวใน manifest จาก `https://localhost:3000/docedit/wordaddin` เป็น `https://<โฮสต์>/docedit/wordaddin` (และ AppDomain + icons ด้วย) +Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี ## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word) diff --git a/public/wordaddin/manifest.xml b/public/wordaddin/manifest.template.xml similarity index 88% rename from public/wordaddin/manifest.xml rename to public/wordaddin/manifest.template.xml index e9cbb7f..6cc87ef 100644 --- a/public/wordaddin/manifest.xml +++ b/public/wordaddin/manifest.template.xml @@ -2,10 +2,12 @@