Phase 12 v9.2: download-first + backend open-links (Word 16.101+ broke ms-word)
การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe
ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/
file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน)
open -a Word <local file> ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox +
backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic
คือ download-first
- Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) +
OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey}
— ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query),
[AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition
ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link
แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง
- Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ
best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป
ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด)
- Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md
กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต
- backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS
(1 round, minors 4 แก้ครบ)
หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ
OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
e0558e3cf4
commit
ee4d8573ee
21 changed files with 340 additions and 145 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||
|
||||
**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 85 / add-in 66 / web 59 + build ครบ · v9: เปิดใน Word ทันที + แก้ตัดคำไทย), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
|
||||
**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 86 / add-in 66 / web 58 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
|
||||
|
||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||
|
||||
|
|
@ -37,9 +37,10 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
|
|||
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
|
||||
10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection
|
||||
11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up`
|
||||
12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง)
|
||||
13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว
|
||||
14. **`@nuxtjs/tailwindcss` บน Nuxt 4 ต้องระบุ `cssPath: '~/assets/css/tailwind.css'` ใน nuxt.config.ts เสมอ** — default แบบ relative resolve ต่อ rootDir แล้ว fallback ไปไฟล์ default ของ tailwindcss package **แบบเงียบ** (token/component classes ทั้งหมดหายจาก build โดยไม่มี error) · แก้ `tailwind.config.ts` แล้วต้อง restart dev server · สีทั้งหมดเป็น CSS variables RGB triplet ใน tailwind.css — ห้ามใส่ hex (break `/opacity` utilities เงียบๆ)
|
||||
12. **ดาวน์โหลดไฟล์จาก add-in ใช้ลิงก์เปิดอายุสั้นจาก download-link (open-link ของ backend) + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง)
|
||||
13. **`ms-word:ofe` ใช้เป็น best-effort เท่านั้น — ห้ามพึ่งเป็นกลไกหลัก** (v9.2): Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ (http/https/query/ไม่มี query/file://) โดยเด้งหน้าเลือกไฟล์และไม่ยิง request ถึง server เลย — การเปิดไฟล์จากเว็บต้อง **download-first** (ดาวน์โหลดทันที + ยิง ms-word ควบคู่) · ลิงก์เปิดไฟล์ใช้ **open-link ของ backend** (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query string — ฝั่ง MinIO presigned เป็น http ใช้กับ Word ไม่ได้) · prod ต้องตั้ง `OpenLinks__Secret`/`OpenLinks__BaseUrl` (https)
|
||||
14. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว
|
||||
15. **`@nuxtjs/tailwindcss` บน Nuxt 4 ต้องระบุ `cssPath: '~/assets/css/tailwind.css'` ใน nuxt.config.ts เสมอ** — default แบบ relative resolve ต่อ rootDir แล้ว fallback ไปไฟล์ default ของ tailwindcss package **แบบเงียบ** (token/component classes ทั้งหมดหายจาก build โดยไม่มี error) · แก้ `tailwind.config.ts` แล้วต้อง restart dev server · สีทั้งหมดเป็น CSS variables RGB triplet ใน tailwind.css — ห้ามใส่ hex (break `/opacity` utilities เงียบๆ)
|
||||
|
||||
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
|
||||
|
||||
|
|
@ -99,4 +100,4 @@ npx vitest run test/useTheme.spec.ts # รัน test เดี
|
|||
|
||||
## ไฟล์ร่าง (FR-16 / spec v8 · ปรับ v9)
|
||||
|
||||
ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` — ไม่สร้าง Document/version ใน DB; generate แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/` (lifecycle ลบรายวัน — `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup) แล้วคืน **presigned URL อายุสั้น** ให้เว็บเปิดใน Word ทันทีผ่าน `ms-word:ofe` + ลิงก์สำรอง (`useVersionOpen.openDocxUrl`) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. การแทนที่ค่าเขียนที่ **run ข้อความ placeholder จริง** (run ที่ถือ `w:hint="cs"`+`w:cs` — template ไทยแตก placeholder เป็น `[`/ข้อความ/`]` หลาย run) และ SDT เปล่าสืบทอด rPr จาก sdtPr ไม่งั้น Word ตัดคำไทยผิด. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`.
|
||||
ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม → `POST /api/templates/{id}/generate-draft` — ไม่สร้าง Document/version ใน DB; generate แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/` (lifecycle ลบรายวัน — `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup) แล้วคืน **open-link อายุสั้นของ backend** (HMAC ใน path — ดูกฎ 13) ให้เว็บ download-first ทันที + ยิง `ms-word:ofe` ควบคู่ (`useVersionOpen.openDocxUrl`) — validate ชุดเดียวกับ generate, ไม่มี `message`. ไฟล์ถูก stamp custom property **`TemplateId` ตัวเดียว** (ไม่มี SavedByAddIn/DocumentTrackingId) จึงเป็น untracked ในสายตา add-in. เมื่อกด Start Versioning, add-in อ่าน TemplateId แล้วแนบ form field `templateId` (ใช้เฉพาะตอน auto-create Document; validate ทุกครั้งที่ส่ง: ไม่ใช่ GUID/ไม่พบ → 400, deactivated → ผูกได้) → **Document ผูก template + v1** (ไม่มี v0) + field sync เต็มระบบ. การแทนที่ค่าเขียนที่ **run ข้อความ placeholder จริง** (run ที่ถือ `w:hint="cs"`+`w:cs` — template ไทยแตก placeholder เป็น `[`/ข้อความ/`]` หลาย run) และ SDT เปล่าสืบทอด rPr จาก sdtPr ไม่งั้น Word ตัดคำไทยผิด. การตัดสินใจ: `docs/adr/0002-draft-stateless.md`.
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
Assert.False(string.IsNullOrWhiteSpace(link.Url));
|
||||
Assert.True(link.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30));
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
|
||||
using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
|
||||
|
|
@ -70,8 +70,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
|
||||
using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
|
||||
|
|
@ -185,8 +185,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var link = (await response.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
using var file = await browser.GetAsync(link.Url);
|
||||
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
|
||||
using var file = await anon.GetAsync(new Uri(link.Url).PathAndQuery);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
var fileBytes = await file.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(templateId.ToString(), TestDocx.ReadCustomProperty(fileBytes, AddInMetadata.TemplateIdPropertyName));
|
||||
|
|
@ -210,8 +210,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
$"api/templates/{templateId}/generate-draft", new { values }, IntegrationTestFixture.Json);
|
||||
Assert.Equal(HttpStatusCode.OK, draft.StatusCode);
|
||||
var link = (await draft.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
using var browser = new HttpClient();
|
||||
var draftBytes = await (await browser.GetAsync(link.Url)).Content.ReadAsByteArrayAsync();
|
||||
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็นแทน
|
||||
var draftBytes = await (await anon.GetAsync(new Uri(link.Url).PathAndQuery)).Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(draftBytes, "position"));
|
||||
|
||||
// generate v0 ใช้ ReplaceSdtContent ตัวเดียวกัน — ตรวจซ้ำจุดเดียวกันผ่านอีก endpoint
|
||||
|
|
@ -221,6 +221,32 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal("วิศวกรซอฟต์แวร์", TestDocx.ReadSdtText(docBytes, "position"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded()
|
||||
{
|
||||
// /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้)
|
||||
// รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{objectKey} — ไม่มี query string เพราะ Word 16.101+
|
||||
// บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิดหรือหมดอายุ → 404 พร้อมข้อความบอกกดเปิดใหม่
|
||||
using var client = fixture.CreateClient();
|
||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "openlink bad token");
|
||||
using var draft = await client.PostAsJsonAsync(
|
||||
$"api/templates/{templateId}/generate-draft",
|
||||
new { values = TestFlow.DefaultValues }, IntegrationTestFixture.Json);
|
||||
var link = (await draft.Content.ReadFromJsonAsync<DraftLinkResult>(IntegrationTestFixture.Json))!;
|
||||
Assert.Contains("/api/open/", link.Url);
|
||||
Assert.DoesNotContain("?", link.Url); // ทุกอย่างอยู่ใน path — ไม่มี query string
|
||||
Assert.StartsWith("https://test-host/api/open/", link.Url); // ออกผ่าน BaseUrl (https) ของ backend ไม่ใช่ presigned MinIO
|
||||
|
||||
// แทน token ใน path ด้วยขยะ (segment ที่ 3 หลัง /api/open/)
|
||||
var segments = new Uri(link.Url).AbsolutePath.Split('/');
|
||||
segments[3] = "garbage-token";
|
||||
using var anon = fixture.CreateAnonymousClient();
|
||||
using var bad = await anon.GetAsync("/" + string.Join("/", segments.Skip(1)));
|
||||
Assert.Equal(HttpStatusCode.NotFound, bad.StatusCode);
|
||||
var body = await bad.Content.ReadAsStringAsync();
|
||||
Assert.Contains("หมดอายุ", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ public sealed class IntegrationTestFixture : IAsyncLifetime
|
|||
["Authentication:ValidAudiences:0"] = "word-addin-officejs",
|
||||
["Authentication:ValidAudiences:1"] = "word-versioning-web",
|
||||
["Cors:AllowedOrigins:0"] = "https://localhost:3000",
|
||||
// open-link (v9.2): BaseUrl เป็น dummy — test เอา PathAndQuery ของ URL ไป GET ผ่าน host ของ factory
|
||||
["OpenLinks:Secret"] = "test-open-link-secret",
|
||||
["OpenLinks:BaseUrl"] = "https://test-host",
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,14 +33,14 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
|||
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
|
||||
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
|
||||
using var browser = new HttpClient();
|
||||
using var signedDownload = await browser.GetAsync(link!.Url);
|
||||
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็น (HMAC) แทน
|
||||
using var signedDownload = await anon.GetAsync(new Uri(link!.Url).PathAndQuery);
|
||||
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
|
||||
Assert.Equal(Pdf, await signedDownload.Content.ReadAsByteArrayAsync());
|
||||
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
|
||||
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
|
||||
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
|
||||
using var signedDocx = await browser.GetAsync(docxLink!.Url);
|
||||
using var signedDocx = await anon.GetAsync(new Uri(docxLink!.Url).PathAndQuery);
|
||||
Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode);
|
||||
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
|
||||
Assert.Equal("คำร้อง_v1.docx", signedDocx.Content.Headers.ContentDisposition.FileNameStar);
|
||||
|
|
@ -114,7 +114,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
|||
var user = new TestUser();
|
||||
var controller = new WordVersioning.Api.Controllers.DocumentsController(db, storage,
|
||||
new WordVersioning.Api.Services.HashService(), null!,
|
||||
new WordVersioning.Api.Services.FieldSyncService(db, user), user,
|
||||
new WordVersioning.Api.Services.FieldSyncService(db, user), null!, user,
|
||||
Microsoft.Extensions.Options.Options.Create(new WordVersioning.Api.Services.MinioOptions()),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<WordVersioning.Api.Controllers.DocumentsController>.Instance);
|
||||
var id = Guid.NewGuid();
|
||||
|
|
@ -165,7 +165,6 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null) => throw new NotSupportedException();
|
||||
public Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default) => Task.FromResult(false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public class DocumentsController(
|
|||
IHashService hasher,
|
||||
IDocxGenerationService docxGeneration,
|
||||
IFieldSyncService fieldSync,
|
||||
IOpenLinkService openLinks,
|
||||
ICurrentUser currentUser,
|
||||
IOptions<MinioOptions> minioOptions,
|
||||
ILogger<DocumentsController> logger) : ControllerBase
|
||||
|
|
@ -514,9 +515,9 @@ public class DocumentsController(
|
|||
isPdf ? Path.ChangeExtension(downloadName, ".pdf") : downloadName);
|
||||
}
|
||||
|
||||
/// <summary>FR-7/FR-15 — presigned URL with filename; format=pdf selects inline PDF (404 when absent).
|
||||
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
|
||||
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
|
||||
/// <summary>FR-7/FR-15 — ลิงก์เปิดไฟล์อายุสั้น (HMAC) ที่วิ่งผ่าน https ของ backend; format=pdf = inline PDF (404 เมื่อไม่มี).
|
||||
/// ออก URL ให้ add-in/เว็บเปิดในเบราว์เซอร์หรือ ms-word:ofe ของผู้ใช้ — webview ของ Word ทิ้ง blob download
|
||||
/// เงียบๆ และ Word รุ่นใหม่เมิน URL ที่เป็น http จึงไม่ใช้ presigned ของ MinIO ตรงๆ อีก (v9.2)</summary>
|
||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
|
||||
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
|
@ -536,18 +537,14 @@ public class DocumentsController(
|
|||
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
||||
if (objectKey is null)
|
||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? row.FileName[..^5] : row.FileName;
|
||||
var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}";
|
||||
var disposition = $"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
|
||||
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
||||
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds), disposition);
|
||||
|
||||
var url = openLinks.CreateLink($"v:{documentId}:{versionId}", isPdf ? "pdf" : "docx", out var expiresAt);
|
||||
|
||||
logger.LogInformation(
|
||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",
|
||||
documentId, versionId, currentUser.UserId, expirySeconds);
|
||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expiresAt={ExpiresAt}",
|
||||
documentId, versionId, currentUser.UserId, expiresAt);
|
||||
|
||||
return Ok(new VersionDownloadLinkResponse(url, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(expirySeconds)));
|
||||
return Ok(new VersionDownloadLinkResponse(url, expiresAt));
|
||||
}
|
||||
|
||||
/// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request
|
||||
|
|
|
|||
106
backend/WordVersioning.Api/Controllers/OpenController.cs
Normal file
106
backend/WordVersioning.Api/Controllers/OpenController.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WordVersioning.Api.Data;
|
||||
using WordVersioning.Api.Services;
|
||||
|
||||
namespace WordVersioning.Api.Controllers;
|
||||
|
||||
/// <summary>FR-7/FR-15/FR-16 — endpoint เปิดไฟล์อายุสั้นของ backend เอง (ลายเซ็น HMAC ใน path แทน Bearer)
|
||||
/// ให้ ms-word:ofe / เบราว์เซอร์ดึงไฟล์ผ่าน https ของ backend โดยตรง — Word for Mac รุ่นใหม่ (16.101+)
|
||||
/// ยอมรับเฉพาะ URL ที่เป็น https และไม่มี query string จึงฝัง token/expiry/รหัสไฟล์ลง path ทั้งหมด</summary>
|
||||
[ApiController]
|
||||
[Route("api/open")]
|
||||
public class OpenController(
|
||||
AppDbContext db,
|
||||
IObjectStorageService storage,
|
||||
IOpenLinkService openLinks,
|
||||
IOptions<MinioOptions> minioOptions,
|
||||
ILogger<OpenController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/file.{docx|pdf}</summary>
|
||||
[HttpGet("{t}/{e}/v/{documentId:guid}/{versionId:guid}/{fileName}")]
|
||||
[AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้)
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> OpenVersion(
|
||||
string t, string e, Guid documentId, Guid versionId, string fileName, CancellationToken ct)
|
||||
{
|
||||
var format = fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) ? "pdf" : "docx";
|
||||
if (!openLinks.Validate($"v:{documentId}:{versionId}", format, t, e))
|
||||
return Expired();
|
||||
|
||||
var isPdf = format == "pdf";
|
||||
var row = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id,
|
||||
(v, d) => new { v.ObjectKey, v.PdfObjectKey, v.VersionNumber, d.FileName })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (row is null)
|
||||
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||
|
||||
var objectKey = isPdf ? row.PdfObjectKey : row.ObjectKey;
|
||||
if (objectKey is null)
|
||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||
|
||||
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? row.FileName[..^5] : row.FileName;
|
||||
return await StreamFromStorage(objectKey, $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}", isPdf, ct);
|
||||
}
|
||||
|
||||
/// <summary>เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/drafts/{templateId}/{ไฟล์}.docx</summary>
|
||||
[HttpGet("{t}/{e}/d/{**objectKey}")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> OpenDraft(string t, string e, string objectKey, CancellationToken ct)
|
||||
{
|
||||
// objectKey = drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx (จบ .docx อยู่แล้ว — ชื่อไทยจริง
|
||||
// ประกอบจากชื่อ template ตอน stream ไม่ใช่จาก URL ซึ่งต้องเป็น ascii)
|
||||
if (!objectKey.StartsWith("drafts/", StringComparison.Ordinal)
|
||||
|| !openLinks.Validate($"d:{objectKey}", "docx", t, e))
|
||||
return Expired();
|
||||
|
||||
var parts = objectKey.Split('/');
|
||||
if (parts.Length != 3 || !Guid.TryParse(parts[1], out var templateId))
|
||||
return Expired();
|
||||
var template = await db.Templates.AsNoTracking()
|
||||
.Where(x => x.Id == templateId)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (template is null)
|
||||
return NotFound("ไม่พบ template ต้นทางของไฟล์ร่าง");
|
||||
|
||||
var timestamp = parts[2].Split('_').FirstOrDefault() ?? string.Empty;
|
||||
var fileName = DocxGenerationService.BuildGeneratedFileName(template,
|
||||
DateTimeOffset.TryParseExact(timestamp, "yyyyMMddHHmmss", null,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||
out var created) ? created : DateTimeOffset.UtcNow);
|
||||
return await StreamFromStorage(objectKey, fileName, isPdf: false, ct);
|
||||
}
|
||||
|
||||
private IActionResult Expired() =>
|
||||
NotFound("ลิงก์หมดอายุหรือไม่ถูกต้อง — กรุณากดเปิดไฟล์ใหม่อีกครั้ง");
|
||||
|
||||
private async Task<IActionResult> StreamFromStorage(string objectKey, string fileName, bool isPdf, CancellationToken ct)
|
||||
{
|
||||
Stream stream;
|
||||
try
|
||||
{
|
||||
stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, objectKey, ct);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
logger.LogError("Open link target missing in storage key={ObjectKey}", objectKey);
|
||||
return NotFound("ไฟล์ไม่พบใน storage");
|
||||
}
|
||||
|
||||
// disposition นโยบายเดียวกับ presigned เดิม: PDF inline, docx attachment ชื่อไทยตาม RFC 5987
|
||||
// (File() ไม่แตะ Content-Disposition เมื่อไม่ส่ง fileDownloadName — เราเซ็ตเองจึงอยู่รอด)
|
||||
Response.Headers.ContentDisposition =
|
||||
$"{(isPdf ? "inline" : "attachment")}; filename*=UTF-8''{Uri.EscapeDataString(fileName)}";
|
||||
return File(stream, isPdf ? PdfUploadValidation.PdfContentType : DocxUploadValidation.DocxContentType);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ public class TemplatesController(
|
|||
AppDbContext db,
|
||||
IObjectStorageService storage,
|
||||
IDocxGenerationService docxGeneration,
|
||||
IOpenLinkService openLinks,
|
||||
ICurrentUser currentUser,
|
||||
IOptions<MinioOptions> minioOptions,
|
||||
ILogger<TemplatesController> logger) : ControllerBase
|
||||
|
|
@ -390,17 +391,14 @@ public class TemplatesController(
|
|||
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
|
||||
}
|
||||
|
||||
// presigned อายุสั้น + ชื่อไฟล์ไทยตาม RFC 5987 (นโยบายเดียวกับ download-link ของ version)
|
||||
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
||||
var fileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now);
|
||||
var url = storage.GetPresignedGetUrl(
|
||||
minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds),
|
||||
$"attachment; filename*=UTF-8''{Uri.EscapeDataString(fileName)}");
|
||||
// ลิงก์เปิดอายุสั้นผ่าน https ของ backend (HMAC) — ชื่อไฟล์ไทย/disposition จัดที่ /api/open
|
||||
// (Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ จึงไม่ใช้ presigned ของ MinIO ตรงๆ — v9.2)
|
||||
var url = openLinks.CreateLink($"d:{objectKey}", "docx", out var expiresAt);
|
||||
|
||||
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}",
|
||||
template.Id, currentUser.UserId, fileBytes.Length, objectKey);
|
||||
|
||||
return Ok(new GenerateDraftResponse(url, now.AddSeconds(expirySeconds)));
|
||||
return Ok(new GenerateDraftResponse(url, expiresAt));
|
||||
}
|
||||
|
||||
/// <summary>deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้</summary>
|
||||
|
|
|
|||
|
|
@ -41,10 +41,17 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
|||
|
||||
// MinIO / hash / docx generation / field sync / current user
|
||||
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.SectionName));
|
||||
// OpenLinks ต้องตั้งครบตั้งแต่สตาร์ท — Secret ว่าง = ลายเซ็นปลอมได้, BaseUrl ไม่ใช่ https = Word เมิน (fail ดังกว่าเงียบ)
|
||||
builder.Services.AddOptions<OpenLinkOptions>()
|
||||
.Bind(builder.Configuration.GetSection(OpenLinkOptions.SectionName))
|
||||
.Validate(o => !string.IsNullOrEmpty(o.Secret), "OpenLinks:Secret ต้องตั้งค่า (env: OpenLinks__Secret)")
|
||||
.Validate(o => o.BaseUrl.StartsWith("https://", StringComparison.Ordinal), "OpenLinks:BaseUrl ต้องเป็น https (Word 16.101+ เมิน http)")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddSingleton<IObjectStorageService, ObjectStorageService>();
|
||||
builder.Services.AddSingleton<IHashService, HashService>();
|
||||
builder.Services.AddSingleton<IDocxGenerationService, DocxGenerationService>();
|
||||
builder.Services.AddScoped<IFieldSyncService, FieldSyncService>();
|
||||
builder.Services.AddScoped<IOpenLinkService, OpenLinkService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ public class MinioOptions
|
|||
|
||||
public string TemplatesBucket { get; set; } = "docx-templates";
|
||||
|
||||
/// <summary>อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec</summary>
|
||||
public int PresignedExpirySeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>FR-16 — จำนวนวันที่ไฟล์ร่าง (object ใต้ prefix `drafts/` ของ VersionsBucket) ถูกลบโดย
|
||||
/// lifecycle rule (ผู้ใช้เปิดผ่าน presigned URL ในไม่กี่นาทีแรก หลังจากนั้นเป็นขยะ)</summary>
|
||||
public int DraftRetentionDays { get; set; } = 1;
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ public interface IObjectStorageService
|
|||
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
|
||||
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
|
||||
|
||||
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
|
||||
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
|
||||
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null);
|
||||
|
||||
/// <summary>FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน
|
||||
/// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days < 1 คือ throw)
|
||||
/// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/`
|
||||
|
|
@ -82,29 +78,6 @@ public class ObjectStorageService : IObjectStorageService
|
|||
return new S3ResponseStream(response);
|
||||
}
|
||||
|
||||
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry, string? contentDisposition = null)
|
||||
{
|
||||
var request = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = key,
|
||||
Verb = HttpVerb.GET,
|
||||
Expires = DateTime.UtcNow + expiry,
|
||||
};
|
||||
if (contentDisposition is not null)
|
||||
request.ResponseHeaderOverrides.ContentDisposition = contentDisposition;
|
||||
// GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network)
|
||||
var url = _s3.GetPreSignedURL(request);
|
||||
// AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้
|
||||
// เพราะ SigV4 เซ็นค่า host header (host:port) ไม่รวม scheme → signature ยังถูกต้อง
|
||||
if (!_useHttps && url.StartsWith("https://", StringComparison.Ordinal))
|
||||
{
|
||||
url = "http://" + url["https://".Length..];
|
||||
}
|
||||
_logger.LogInformation("Issued presigned GET {Bucket}/{Key} expiry={Expiry}s", bucket, key, expiry.TotalSeconds);
|
||||
return url;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(days, 1);
|
||||
|
|
|
|||
18
backend/WordVersioning.Api/Services/OpenLinkOptions.cs
Normal file
18
backend/WordVersioning.Api/Services/OpenLinkOptions.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
namespace WordVersioning.Api.Services;
|
||||
|
||||
/// <summary>config ของ "open link" — ลิงก์เปิดไฟล์อายุสั้นที่ออกโดย backend เอง
|
||||
/// bind จาก section "OpenLinks" ใน appsettings/env (rule: ห้าม hardcode)</summary>
|
||||
public class OpenLinkOptions
|
||||
{
|
||||
public const string SectionName = "OpenLinks";
|
||||
|
||||
/// <summary>secret ใช้เซ็น HMAC ของ token — deploy จริงต้องตั้งค่าเป็นค่าลับจริง (env: OpenLinks__Secret)</summary>
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>base URL ที่ใช้ประกอบเป็นลิงก์ (ต้องเป็น https — Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ)
|
||||
/// เช่น dev = https://localhost:7232 · prod = https://api.example.com</summary>
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>อายุของลิงก์ (วินาที) — สั้นๆ ตามนโยบายเดียวกับ presigned เดิม</summary>
|
||||
public int ExpirySeconds { get; set; } = 120;
|
||||
}
|
||||
78
backend/WordVersioning.Api/Services/OpenLinkService.cs
Normal file
78
backend/WordVersioning.Api/Services/OpenLinkService.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace WordVersioning.Api.Services;
|
||||
|
||||
public interface IOpenLinkService
|
||||
{
|
||||
/// <summary>ออก URL เปิดไฟล์อายุสั้นที่วิ่งผ่าน backend (ตาม BaseUrl ที่ตั้ง ซึ่งต้องเป็น https) —
|
||||
/// Word for Mac รุ่นใหม่ (16.101+, ก.ย. 2026) ยอมรับเฉพาะ URL ที่เป็น https และ "ไม่มี query string"
|
||||
/// (มี query = เมินเงียบๆ ไม่ยิง request เลย) จึงฝังทุกอย่างลง path และปิดท้ายด้วยชื่อไฟล์ .docx/.pdf</summary>
|
||||
/// <param name="resource">รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:{objectKey}" (ไฟล์ร่าง)</param>
|
||||
/// <param name="format">docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น</param>
|
||||
/// <param name="expiresAt">เวลาหมดอายุ (UTC) ของลิงก์ที่ได้</param>
|
||||
string CreateLink(string resource, string format, out DateTimeOffset expiresAt);
|
||||
|
||||
/// <summary>validate token ของลิงก์ — true = ผ่าน (ยังไม่หมดอายุ และ HMAC ตรง resource+format)</summary>
|
||||
bool Validate(string resource, string format, string token, string expiresUnix);
|
||||
}
|
||||
|
||||
/// <summary>ลิงก์เปิดไฟล์แบบเซ็น HMAC อายุสั้น (แนวคิดเดียวกับ presigned URL ของ S3 แต่ออกเอง
|
||||
/// เพื่อคุม scheme/host ให้เป็น https ของ backend) — payload คือ "resource|format|expiry(unix)"
|
||||
/// เซ็นด้วย HMACSHA256 ของ OpenLinks:Secret แบบ base64url
|
||||
///
|
||||
/// รูป URL (ทุกอย่างอยู่ใน path — ห้ามมี query string):
|
||||
/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/v{เลขอะไรก็ได้ปลอดภัย}.{ext}
|
||||
/// draft: {BaseUrl}/api/open/{t}/{e}/d/{objectKey} (objectKey = drafts/…/….docx จบ .docx อยู่แล้ว)</summary>
|
||||
public class OpenLinkService : IOpenLinkService
|
||||
{
|
||||
private readonly OpenLinkOptions _options;
|
||||
|
||||
public OpenLinkService(IOptions<OpenLinkOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public string CreateLink(string resource, string format, out DateTimeOffset expiresAt)
|
||||
{
|
||||
var expirySeconds = Math.Clamp(_options.ExpirySeconds, 30, 600);
|
||||
expiresAt = DateTimeOffset.UtcNow.AddSeconds(expirySeconds);
|
||||
var expiresUnix = expiresAt.ToUnixTimeSeconds().ToString();
|
||||
var token = Sign(resource, format, expiresUnix);
|
||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||
var ext = format == "pdf" ? "pdf" : "docx";
|
||||
string target;
|
||||
if (resource.StartsWith("v:", StringComparison.Ordinal))
|
||||
{
|
||||
// v:{documentId}:{versionId} — ชื่อไฟล์ท้าย path เป็นหน้าตาให้ Word เท่านั้น (ชื่อจริงมาจาก DB ตอน stream)
|
||||
var parts = resource.Split(':');
|
||||
target = $"v/{parts[1]}/{parts[2]}/file.{ext}";
|
||||
}
|
||||
else if (resource.StartsWith("d:", StringComparison.Ordinal))
|
||||
{
|
||||
target = $"d/{resource["d:".Length..]}";
|
||||
}
|
||||
else
|
||||
throw new ArgumentException($"รูปแบบ resource ไม่รองรับ: {resource}", nameof(resource));
|
||||
|
||||
return $"{baseUrl}/api/open/{token}/{expiresUnix}/{target}";
|
||||
}
|
||||
|
||||
public bool Validate(string resource, string format, string token, string expiresUnix)
|
||||
{
|
||||
if (!long.TryParse(expiresUnix, out var unix) || DateTimeOffset.UtcNow.ToUnixTimeSeconds() > unix)
|
||||
return false;
|
||||
var expected = Sign(resource, format, expiresUnix);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(expected),
|
||||
System.Text.Encoding.UTF8.GetBytes(token));
|
||||
}
|
||||
|
||||
private string Sign(string resource, string format, string expiresUnix)
|
||||
{
|
||||
using var hmac = new HMACSHA256(System.Text.Encoding.UTF8.GetBytes(_options.Secret));
|
||||
var payload = $"{resource}|{format}|{expiresUnix}";
|
||||
var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payload));
|
||||
return Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_'); // base64url
|
||||
}
|
||||
}
|
||||
|
|
@ -22,5 +22,12 @@
|
|||
"Enabled": true,
|
||||
"HttpPort": 5075,
|
||||
"HttpsPort": 7232
|
||||
},
|
||||
|
||||
"OpenLinks": {
|
||||
"//": "ลิงก์เปิดไฟล์อายุสั้นที่ออกโดย backend (Word รุ่นใหม่เมิน URL http แบบเงียบๆ) — prod ต้องตั้ง Secret จริงและ BaseUrl เป็น https domain",
|
||||
"Secret": "dev-open-link-secret-change-in-prod",
|
||||
"BaseUrl": "https://localhost:7232",
|
||||
"ExpirySeconds": 120
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,12 @@
|
|||
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "https://localhost:3000", "http://localhost:5173" ]
|
||||
},
|
||||
|
||||
"OpenLinks": {
|
||||
"//": "ลิงก์เปิดไฟล์อายุสั้น (HMAC) — prod ต้องตั้ง OpenLinks__Secret และ OpenLinks__BaseUrl (https) ผ่าน env",
|
||||
"Secret": "",
|
||||
"BaseUrl": "",
|
||||
"ExpirySeconds": 120
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending
|
|||
| stamp เฉพาะ `TemplateId` — ไม่มี SavedByAddIn/DocumentTrackingId | test เดียวกัน (อ่าน custom properties จากไฟล์จริง) |
|
||||
| **ค่าที่แทนที่ได้ complex script ของ run ข้อความไทย (w:hint="cs"+w:cs) · SDT เปล่าสืบทอด sdtPr rPr** (v9 — แก้การตัดคำไทย) | `DraftTests.Draft_ThaiPlaceholder_UsesComplexScriptRun_ForWordBreaking` (template จำลองโครงสร้าง T14) |
|
||||
| **ค่าที่มีอักขระควบคุมที่ XML ห้าม (เช่น 0x0B จากการ paste) ถูกตัดออก สร้างไฟล์สำเร็จ ไม่กลายเป็น 500** (v9 — bug จริงจากการใช้งาน) | `DraftTests.Draft_ValueWithInvalidXmlControlChars_IsStrippedNot500` (ครอบทั้ง draft และ generate v0) |
|
||||
| **open-link ของ backend (v9.2): ไม่มี query string + จบ .docx + `[AllowAnonymous]` ด้วย HMAC + token ผิด → 404** | `DraftTests.Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded` + `VersionPdfTests` (inline/attachment + ชื่อไทยผ่าน /api/open) |
|
||||
| validate เหมือน FR-10: required ขาด/type ผิด → 400, unknown → 404, deactivate → 400, ค่าเกินนิยาม ignore | `DraftTests` ที่เหลือ (5 tests) |
|
||||
| Start Versioning ของไฟล์ร่าง: upload + templateId → **v1** + Document ผูก template + field sync ครบ + snapshot แบบมีนิยาม | `TemplateLinkingTests.Upload_WithTemplateId_OnNewDocument_CreatesLinkedDocV1WithFieldSync` |
|
||||
| upload ถัดไปไม่ส่ง templateId → v2, TemplateId คงเดิม | test เดียวกัน (upload รอบสอง) |
|
||||
|
|
@ -156,7 +157,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending
|
|||
| add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) |
|
||||
| เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test |
|
||||
|
||||
ชุดเต็ม (v9): backend **85 ผ่าน**, add-in **66 ผ่าน**, web **59 ผ่าน** + production build ผ่าน (backend 82 / web 59 ของ v8 + แก้ v9)
|
||||
ชุดเต็ม (v9.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **58 ผ่าน** + production build ผ่าน
|
||||
|
||||
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
|
||||
|
||||
|
|
@ -166,6 +167,19 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending
|
|||
|
||||
รายการเต็ม: [แผน FR-16](plans/fr16-draft-generation-plan.md).
|
||||
|
||||
### การทดสอบ `ms-word:ofe` บนเครื่องจริง (Mac, Word 16.101.25092124 — 2026-09-10)
|
||||
|
||||
| ทดสอบ | ผล |
|
||||
|---|---|
|
||||
| `ms-word:ofe|u|<http://localhost MinIO presigned>` | ❌ Word เด้งหน้าเลือกไฟล์ (ไม่ยิง request) |
|
||||
| `ms-word:ofe|u|<https สั้น ไม่มี query>` | ❌ เหมือนกัน (เคยเปิดได้ 1 ครั้ง — ไม่ deterministic) |
|
||||
| `ms-word:ofe|u|<https มี query สั้น/ยาว>` | ❌ Word ไม่ยิง request ถึง server เลย (ยืนยันด้วย log) |
|
||||
| `ms-word:ofe|u|file:///tmp/...` | ❌ หน้าเลือกไฟล์ |
|
||||
| `open-word:` / `launch-word:` scheme | ❌ ไม่ยิง request |
|
||||
| `open -a "Microsoft Word" /tmp/.../v1.docx` | ✅ เปิดได้ (แต่เรียกจากเว็บไม่ได้ — browser sandbox + backend รัน server กลางใน production) |
|
||||
|
||||
→ ข้อสรุป: **download-first** คือกลไกเดียวที่ deterministic ทุกเครื่อง (commit ตามด้วย v9.2); `ms-word:ofe` เหลือเป็น best-effort ควบคู่
|
||||
|
||||
QA reviewer: **PASS**, 2 rounds, 8 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: blocker `.at(-1)` ใน test ใหม่ตัดกับ tsconfig `lib: es2020` ทำ `addin npm run build` พัง (jest ไม่ type-check จึงรอดมา) + minors 4 (นับจำนวน test ผิดในตารางนี้, stale notices ระหว่างสองปุ่ม submit, AlertBox สำเร็จไม่ใช่ `type="success"`, `isTrackedDocument` ไม่มี production caller → เก็บไว้พร้อมหมายเหตุเหตุผล) · round 2: minors 2 + pre-existing 1 (หัวเอกสาร/สถานะเก่าใน acceptance-review, CLAUDE.md, implementation-plan header). ค้าง manual บน Word จริงตามรายการด้านบน.
|
||||
|
||||
QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): **PASS**, 2 rounds, 10 findings แก้ครบไม่มีที่ถูกปฏิเสธ (2026-09-10) — round 1: minors 6 (dead `downloadFile`/`filenameFrom`, glossary "stateless" เก่า, lifecycle config เปลี่ยนแล้วเป็น no-op เงียบๆ + days ไม่ validate, ไม่มี test คุม lifecycle, comment เก่าใน TestDocx, typo ใน spec) · round 2: minors 2 + nits 2 (comment เก่าอีก 2 จุด, "(stateless)" ตกค้าง 2 บรรทัด, lifecycle test เพิ่ม assertion ผ่านค่าคืน `true/false`, ตัว verdict นี้เอง) — และ test ใหม่จับ bug จริงได้: `Configuration.Rules` เป็น null ได้ทำให้ startup ตั้ง rule ล้มแบบเงียบมาตั้งแต่แรก (โดน catch กลืน) แก้ด้วย `?? []`.
|
||||
|
|
@ -173,3 +187,5 @@ QA reviewer รอบ v9 (เปิดใน Word + ตัดคำไทย): *
|
|||
QA reviewer รอบ hotfix อักขระควบคุม 0x0B จากการ paste: **PASS**, 1 round (2026-09-10) — reviewer พิสูจน์ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เปลี่ยนอักขระ VT/FF ล่องหนใน test เป็น escape sequence \u000B/\u000C กันหายระหว่างแก้ไขไฟล์) · ปฏิเสธ 2 พร้อมเหตุผล: (1) fast-path ตรวจ surrogate คู่ครบเป็น invalid ทำให้ค่ามีอีโมจิเดิน slow path — ผลลัพธ์ยังถูก (loop ประกอบคู่กลับคืน) และค่า field สั้นจึงแพงแค่หยดเดียว ไม่คุ้มจะ duplicate pair-logic (2) broad catch ให้ 502 แทน 500 สำหรับ bug ภายใน — สอดคล้อง convention 502 ของ "ไฟล์ template/ค่ามีปัญหา" ที่มีอยู่ และเหตุการณ์จริงที่ catch นี้มีไว้กันคือ input-triggered แท้ๆ (stack ยัง log ฝั่ง server)
|
||||
|
||||
QA reviewer รอบ hotfix "เปิดใน Word ตอน cold-start" (v9.1): **PASS**, 1 round (2026-09-10) — reviewer stash-verify ว่า test ใหม่ fail จริงบนโค้ดก่อนแก้ · minors 3: แก้ 1 (เพิ่ม test re-click ยกเลิก auto-download ค้าง — save ครั้งเดียว URL ล่าสุด) · ยอมรับ 2 พร้อมเหตุผล: (1) ถ้า Word cold-start มาถึงหน้าต่างภายใน 3 วิ heuristic เห็น "สลับแอป" จึงไม่ auto-download ทั้งที่เอกสารไม่เปิด — เป็นข้อจำกัดเชิงหลักการของ heuristic (แยก "Word เปิด" กับ "เปิดเอกสาร" ไม่ได้จาก web) spec ระบุชัดว่าไม่ใช่หลักฐานความสำเร็จ + มี manual check item ครอบ (2) ถ้า Word เปิดเอกสารสำเร็จแต่ช้ากว่า 3 วิ ผู้ใช้ได้ไฟล์ซ้ำใน Downloads — ต้นทุนต่ำยอมรับได้ · ตรวจแล้ว: PDF path ไม่โดน auto-download, ทุก docx entry point (version rows/v0/ไฟล์ร่าง) ผ่าน openDocxUrl ครบ
|
||||
|
||||
QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026-09-10) — reviewer ตรวจ security ของ `/api/open` เชิงลึก (HMAC binding/fixed-time compare/path scoping ของ draft catch-all route — ผ่านทั้งหมด) + รัน suites เองได้ 86/58/66 ตรงตาม docs · minors 4 แก้ครบ: (1) คำ spec header ที่ขัดแย้งตารางทดสอบเอง (2) OpenLinks config ตรวจตั้งแต่สตาร์ทด้วย ValidateOnStart (Secret ว่าง/BaseUrl ไม่ใช่ https = fail ดัง) (3) เอา dead code presigned ออก (GetPresignedGetUrl/PresignedExpirySeconds) + comment/copy เก่าทั้ง web/add-in/CLAUDE.md (4) draft success copy นำด้วยการดาวน์โหลดแทน "กำลังเปิดใน Word"
|
||||
|
|
|
|||
|
|
@ -646,4 +646,5 @@ npm test # vitest (46 ตัว)
|
|||
- [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web)
|
||||
- [x] QA reviewer PASS (2 rounds, 2026-09-10)
|
||||
- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 59) + QA reviewer PASS (2 rounds, 2026-09-10)
|
||||
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **Word เปิดทันที + ตัดคำไทยถูก** → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web
|
||||
- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่, เอา heuristic ออก) + `download-link`/draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 58
|
||||
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **ไฟล์ตก Downloads ทันที + ตัดคำไทยถูก** → เปิดไฟล์เอง → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
||||
|
||||
> ฉบับแก้ไข v9.2 (2026-09-10): **Word for Mac 16.101+ (ก.ย. 2026) ทำลาย `ms-word:ofe` บนเครื่องที่ทดสอบ** — ทุกรูปแบบถูกเมินแบบไม่ deterministic (https ไม่มี query เคยสำเร็จ 1 ครั้ง; ยืนยันด้วยการทดสอบ 6 รอบบนเครื่องจริง) → เปลี่ยน UX เปิดไฟล์เป็น **download-first** (ดาวน์โหลดทันที + ยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ยังใช้ได้) + `download-link` เปลี่ยนจาก presigned ของ MinIO เป็น **open-link ของ backend เอง** (HMAC อายุสั้นฝังใน path ไม่มี query, วิ่งผ่าน https ของ backend, `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer) · เอา heuristic สลับแอปออก (โดนหลอกด้วยหน้าเลือกไฟล์ของ Word จนไม่ดาวน์โหลด)
|
||||
> ฉบับแก้ไข v9 (2026-09-10): ปรับ FR-16 ตามผลใช้งานจริง — ไฟล์ร่างเก็บชั่วคราวใน MinIO (prefix `drafts/` + lifecycle ลบรายวัน) เพื่อคืน presigned URL ให้เว็บ**เปิดใน Word ทันที** (เดิมคืนไฟล์ตรงๆ ผู้ใช้ต้องเปิดเอง) + ค่าที่แทนที่เขียนที่ run ข้อความ placeholder จริง (complex script ไทย) แก้การตัดคำภาษาไทย
|
||||
> ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property `TemplateId` ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field `templateId` ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0
|
||||
> ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง
|
||||
|
|
@ -182,13 +183,13 @@ GET /api/documents/{documentId}/versions
|
|||
- คืน list ของ version เรียงจากใหม่ไปเก่า พร้อม `hasPdf` ระบุว่ามีสำเนา PDF
|
||||
|
||||
### FR-7: ดาวน์โหลด version ที่ระบุ
|
||||
- v7: ทั้ง `/download` และ `/download-link` รับ `?format=pdf` (case-insensitive; ค่าอื่น/default เป็น docx), ไม่มี PDF → 404; presigned URL มี Content-Disposition ชื่อ `{base}_v{N}.docx` (attachment) / `.pdf` (inline), รองรับชื่อไทยด้วย RFC 5987
|
||||
- v7: ทั้ง `/download` และ `/download-link` รับ `?format=pdf` (case-insensitive; ค่าอื่น/default เป็น docx), ไม่มี PDF → 404; v9.2: ลิงก์มี Content-Disposition ชื่อ `{base}_v{N}.docx` (attachment) / `.pdf` (inline), รองรับชื่อไทยด้วย RFC 5987
|
||||
```
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download # proxy stream (curl/Swagger)
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (add-in ใช้อันนี้)
|
||||
GET /api/documents/{documentId}/versions/{versionId}/download-link # open-link อายุสั้นของ backend (add-in/เว็บใช้อันนี้)
|
||||
```
|
||||
- `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง
|
||||
- `download-link` — ออก **presigned URL อายุสั้น** (config ได้, default ~2 นาที, clamp 30 วิ–10 นาที) ให้ client เปิดในเบราว์เซอร์ของเครื่อง — เป็น**กลไกหลักของ add-in** เพราะ webview ของ host ทิ้ง blob download แบบเงียบๆ (v3) · เข้าถึง MinIO โดยตรงโดยไม่ต้องแนบ token แต่หมดอายุทันทีเมื่อเกินเวลา
|
||||
- `download-link` (v9.2) — ออก **open-link ของ backend เอง**: URL `https ของ backend` + token HMAC อายุสั้น (config ได้ `OpenLinks__Secret/BaseUrl/ExpirySeconds`, default ~2 นาที, clamp 30 วิ–10 นาที) ฝัง**ใน path** รูป `/api/open/{t}/{e}/v/{did}/{vid}/file.docx` (draft: `/d/{objectKey}`) — ไม่มี query string เพราะ Word for Mac 16.101+ เมิน URL ที่มี query · `/api/open` เป็น `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer (Word/เบราว์เซอร์แนบ header ไม่ได้) แล้ว stream ไฟล์จาก MinIO ผ่าน backend (ไม่ expose MinIO เท่าเดิม) · ยังเป็นกลไกหลักของ add-in เพราะ webview ของ host ทิ้ง blob download (v3)
|
||||
|
||||
### FR-8: Auth (Keycloak / OpenID Connect)
|
||||
|
||||
|
|
@ -291,17 +292,17 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
- เก็บ PDF ใน bucket docx-versions, sibling key `{documentId}/v{N:D4}_{clientVersionId}.pdf`; `PdfObjectKey` nullable
|
||||
- docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ `hasPdf:false`; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่
|
||||
- upload/history response มี `hasPdf`; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน `openBrowserWindow`
|
||||
- เว็บ history ทั้ง desktop/mobile เปิด docx ผ่าน `ms-word:ofe|u|<presigned-url>` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~3s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ — ถ้าไม่เห็นการสลับแอปภายในหน้าต่างรอ ให้**ดาวน์โหลดไฟล์ให้อัตโนมัติ** + แนะนำเปิดจาก Downloads หรือกดซ้ำ (v9.1: `ms-word:ofe` บน macOS ตอน Word cold-start มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — กดครั้งที่สองขณะ Word เปิดอยู่แล้วจะสำเร็จ)
|
||||
- เว็บ history ทั้ง desktop/mobile เปิด docx แบบ **download-first** (v9.2): กดปุ่ม → **ดาวน์โหลดไฟล์ทันทีทุกครั้ง** (ลิงก์เปิดของ backend, disposition attachment) + ยิง `ms-word:ofe|u|<url>` ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะ Word for Mac 16.101+ เมิน URL ทุกรูปแบบแต่เด้งหน้าเลือกไฟล์เหมือนสลับแอปจริง ทำให้ heuristic โดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10)
|
||||
- หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง
|
||||
|
||||
### FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9)
|
||||
- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น presigned GET URL อายุสั้น (attachment + ชื่อไฟล์ไทย RFC 5987) ให้เว็บเปิดใน Word ทันที (v8 เดิมคืนไฟล์เป็น stream ตรงๆ — ผู้ใช้ต้องเปิดจาก Downloads เอง)
|
||||
- **`POST /api/templates/{templateId}/generate-draft`** body `{ values }` — ไม่สร้าง `Document`/`DocumentVersion`/`DocumentFieldValue` ใน DB; generate ไฟล์แล้ว**เก็บชั่วคราว**ใน bucket `docx-versions` ใต้ prefix `drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx` ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config `Minio__DraftRetentionDays`, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน **200 `{ url, expiresAt }`** เป็น open-link อายุสั้นของ backend (v9.2 — ดู FR-7; attachment + ชื่อไฟล์ไทย RFC 5987 ตอน stream) ให้เว็บดาวน์โหลด/เปิดต่อ (v8 เดิมคืนไฟล์เป็น stream ตรงๆ; v9.0–9.1 เคยเป็น presigned ของ MinIO ซึ่งเป็น http จึงเอาออก)
|
||||
- validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี `message` (ไม่มี version จึงไม่มี commit message) · **ค่าที่กรอกจะถูกตัดอักขระที่ XML 1.0 ห้ามออกก่อนเขียนลงไฟล์** (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — มักติดมากับการ paste เช่น vertical tab 0x0B ซึ่งทำ XmlWriter โยนตอน save) ใช้ร่วมทั้ง generate และ generate-draft · OpenXML โยน exception ที่ไม่ได้ตั้งใจ → 502 พร้อมข้อความอ่านออก ไม่ปล่อย 500 stack
|
||||
- stamp custom property **`TemplateId` = GUID ของ template ตัวเดียว** — ไม่ stamp `SavedByAddIn`/`DocumentTrackingId` ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404")
|
||||
- **ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง"** (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น `[` / ข้อความ / `]` หลาย run โดย run ข้อความถือ `w:hint="cs"` + `w:cs` (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด `rPr` จาก `sdtPr` (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control)
|
||||
- **ผูก template ตอน Start Versioning:** `POST /api/documents/{documentId}/versions` รับ `templateId` (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create `Document` (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → **ผูกได้** (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = **version 1** (max+1 — ไม่มี v0)
|
||||
- Add-in: อ่าน property `TemplateId` ตอน untracked แล้วแนบ `templateId` **เฉพาะตอน Start Versioning** (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template
|
||||
- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **เปิดใน Word ทันที** ผ่าน `ms-word:ofe|u|<presigned-url>` พร้อมลิงก์ดาวน์โหลดสำรองอายุสั้น + heuristic 3 วิ (สลับแอปไม่ได้ = ดาวน์โหลดให้อัตโนมัติ — กลไกเดียวกับ flow เปิด v0 `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
|
||||
- Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → **download-first เหมือน flow เปิด v0** (v9.2 — ดาวน์โหลดทันที + ยิง ms-word ควบคู่, กลไกเดียวกันผ่าน `useVersionOpen`); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม
|
||||
|
||||
## 7. Data Model (Backend)
|
||||
|
||||
|
|
@ -482,6 +483,12 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
|
|||
- Add-in: อ่าน `TemplateId` ใน `readTrackedProperties` + ส่ง `templateId` เฉพาะ `startVersioning` + hint ไฟล์ร่างใน task pane
|
||||
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม
|
||||
|
||||
### เพิ่มเติม v9.2 (Word 16.101+ เมิน ms-word ทุกรูปแบบ → download-first + open-link ของ backend)
|
||||
- Backend: `OpenLinkService` (HMAC อายุสั้น `OpenLinks__*`) + `OpenController` `/api/open/{t}/{e}/v/{did}/{vid}/file.{docx|pdf}` และ `/d/{objectKey}` — ไม่มี query string, `[AllowAnonymous]` ด้วยลายเซ็น, stream จาก MinIO · `download-link` และ `generate-draft` ออก open-link แทน presigned ของ MinIO
|
||||
- Web: `openDocxUrl` = ดาวน์โหลดทันที (anchor → attachment) + ยิง `ms-word:ofe` ควบคู่ + notice "ไฟล์อยู่ Downloads" — เอา heuristic blur/visibilitychange ออก
|
||||
- การทดสอบบนเครื่องจริง (Mac, Word 16.101.25092124, 2026-09-10): `ms-word:ofe` + http → เมิน; + https (มี query หรือไม่มี) → เมิน (Word เด้งหน้าเลือกไฟล์ ไม่ยิง request ถึง server เลย); + `file://` → เมิน; `open -a Word <local>` → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ browser sandbox และ backend รันบน server กลางใน production) → เหลือ download-first เป็นทางเดียวที่ deterministic
|
||||
- ทางเลือกอนาคต (ยังไม่ทำ): helper เล็กติดตั้งบนเครื่อง user รับคำสั่งผ่าน localhost แล้วดาวน์โหลดไป tmp + `open -a Word` — ได้ปุ่มเดียวเปิดอัตโนมัติจริง แต่ต้องมี installer/security/update (scope ใหม่)
|
||||
|
||||
### เพิ่มเติม v9 (ปรับ FR-16 ตามผลใช้งานจริง)
|
||||
- Backend: generate-draft เก็บ object ใต้ `drafts/` + คืน `{ url, expiresAt }` (presigned) + `EnsurePrefixExpiryAsync` ตั้ง lifecycle ตอน startup (idempotent/best-effort) + `ReplaceSdtContent` เขียนค่าที่ run ข้อความยาวสุด (complex script ไทย) + SDT เปล่าสืบทอด `sdtPr` rPr
|
||||
- Web: `useVersionOpen.openDocxUrl` — แยกแกนเปิด docx URL ออกจาก `openVersion` ให้ไฟล์ร่างใช้ร่วม (ถอด CORS expose และ `downloadFile` init ที่เลิกใช้)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,27 @@
|
|||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { versionDownloadPath, wordOpenUrl, type VersionFormat } from '../utils/versionDownload'
|
||||
|
||||
/** Shared by history and generate success. Links stay available even if the focus heuristic fires. */
|
||||
/** Shared by history and generate success — ลิงก์เปิดอายุสั้นของ backend (open-link) ใช้ทั้งดาวน์โหลดและเปิดแบบ best-effort */
|
||||
export function useVersionOpen() {
|
||||
const { apiJson } = useApi()
|
||||
const openingVersionId = ref<string | null>(null)
|
||||
const downloadError = ref<string | null>(null)
|
||||
const fallbackUrl = ref<string | null>(null)
|
||||
const openNotice = ref<string | null>(null)
|
||||
let cleanup = () => {}
|
||||
let generation = 0
|
||||
let pendingTab: Window | null = null
|
||||
|
||||
/**
|
||||
* รอสังเกตการสลับไป Word (blur/visibilitychange) กี่มิลลิวินาทีก่อนสรุปว่าเปิดไม่สำเร็จ —
|
||||
* ยาวพอให้ Word cold-start โอกาสเปิดได้ทัน แต่ไม่นานจนผู้ใช้งง (ms-word:ofe บน macOS ตอน Word
|
||||
* ยังไม่เปิดค้างอยู่มักทำ URL event หลุด → เห็นหน้า Recent แทนเอกสาร — ข้อจำกัดของ platform)
|
||||
*/
|
||||
const WORD_SWITCH_WAIT_MS = 3000
|
||||
|
||||
function resetOpen() {
|
||||
generation++
|
||||
pendingTab?.close()
|
||||
pendingTab = null
|
||||
cleanup()
|
||||
openingVersionId.value = null
|
||||
downloadError.value = null
|
||||
fallbackUrl.value = null
|
||||
openNotice.value = null
|
||||
}
|
||||
|
||||
/** ให้เบราว์เซอร์เซฟ URL ลง Downloads (presigned URL ส่ง disposition: attachment อยู่แล้ว —
|
||||
/** ให้เบราว์เซอร์เซฟ URL ลง Downloads (ลิงก์เปิดของ backend ส่ง disposition: attachment อยู่แล้ว —
|
||||
* หน้าเว็บอยู่ที่เดิม ไม่ navigate ไปไหน) */
|
||||
function saveToDownloads(url: string) {
|
||||
const a = document.createElement('a')
|
||||
|
|
@ -40,30 +31,17 @@ export function useVersionOpen() {
|
|||
a.remove()
|
||||
}
|
||||
|
||||
/** เปิด presigned docx URL ใน Word ผ่าน ms-word:ofe — ถ้าไม่เห็นการสลับแอปภายใน WORD_SWITCH_WAIT_MS
|
||||
* ถือว่าเปิดไม่สำเร็จ: ดาวน์โหลดไฟล์ให้อัตโนมัติ + แจ้งผู้ใช้ (กดซ้ำตอน Word เปิดอยู่แล้วจะสำเร็จ)
|
||||
/** เปิดไฟล์ docx แบบ download-first (v9.2): ดาวน์โหลดไฟล์ให้ทันทีทุกครั้ง แล้วยิง ms-word:ofe
|
||||
* ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังทำงาน (Windows / Word รุ่นก่อน) —
|
||||
* เลิกพึ่ง heuristic การสลับแอป เพราะ Word for Mac 16.101+ (ก.ย. 2026) เมิน URL ทุกรูปแบบ
|
||||
* โดยเด้งหน้าเลือกไฟล์ (เหมือนสลับแอปจริง — heuristic โดนหลอกจนไม่ดาวน์โหลดให้)
|
||||
* ใช้กับลิงก์ที่ถืออยู่เองได้เลย (ไฟล์ร่าง FR-16: response ของ generate-draft คือ URL แล้ว) */
|
||||
function openDocxUrl(url: string) {
|
||||
resetOpen()
|
||||
fallbackUrl.value = url
|
||||
let switched = false
|
||||
const onBlur = () => { switched = true }
|
||||
const onVisibility = () => { if (document.hidden) switched = true }
|
||||
window.addEventListener('blur', onBlur)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!switched) {
|
||||
openNotice.value =
|
||||
'Word ไม่ได้เปิดเอกสารอัตโนมัติ (มักเกิดตอน Word เพิ่งถูกเปิดขึ้นครั้งแรก) — ดาวน์โหลดไฟล์ให้แล้วที่โฟลเดอร์ Downloads ให้เปิดจากที่นั่น หรือกดเปิดใน Word อีกครั้งเมื่อ Word เปิดอยู่แล้ว'
|
||||
saveToDownloads(url)
|
||||
}
|
||||
cleanup()
|
||||
}, WORD_SWITCH_WAIT_MS)
|
||||
cleanup = () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
window.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
saveToDownloads(url)
|
||||
openNotice.value =
|
||||
'ดาวน์โหลดไฟล์แล้วที่โฟลเดอร์ Downloads — เปิดจากที่นั่นได้เลย (บางเครื่อง Word จะเปิดให้อัตโนมัติ)'
|
||||
window.location.assign(wordOpenUrl(url))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ async function submit() {
|
|||
}
|
||||
}
|
||||
|
||||
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ presigned URL
|
||||
* แล้วเปิดใน Word ทันทีผ่าน ms-word:ofe พร้อมลิงก์ดาวน์โหลดสำรอง (เหมือน flow เปิด v0)
|
||||
/** FR-16 — สร้างไฟล์ร่าง (draft): validate ชุดเดียวกันแล้ว POST ค่าไปขอ open-link (อายุสั้น)
|
||||
* แล้ว download-first เหมือน flow เปิด v0 (ดาวน์โหลดทันที + ยิง ms-word ควบคู่)
|
||||
* backend ไม่สร้าง Document/version — เอกสารจะเกิดตอนกด Start Versioning ใน Word (เป็น v1 พร้อมผูก template นี้) */
|
||||
async function submitDraft() {
|
||||
draftSuccess.value = null
|
||||
|
|
@ -129,7 +129,7 @@ async function submitDraft() {
|
|||
))!
|
||||
openDocxUrl(link.url)
|
||||
draftSuccess.value =
|
||||
'กำลังเปิดไฟล์ร่างใน Word — เปิด task pane ของ add-in แล้วกด Start Versioning This Document เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||
'ดาวน์โหลดไฟล์ร่างให้แล้ว (โฟลเดอร์ Downloads) — เปิดไฟล์ใน Word แล้วกด Start Versioning This Document ใน task pane ของ add-in เพื่อเริ่ม track (เอกสารจะถูกผูกกับ template นี้และบันทึกเป็น version 1)'
|
||||
} catch (err) {
|
||||
draftError.value = err instanceof Error ? err.message : 'สร้างไฟล์ร่างไม่สำเร็จ'
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ export function versionDownloadPath(documentId: string, versionId: string, forma
|
|||
}
|
||||
|
||||
/** Office URI Schemes: preserve the signed URL, including its existing percent escapes. */
|
||||
export function wordOpenUrl(presignedUrl: string): string {
|
||||
return `ms-word:ofe|u|${presignedUrl}`
|
||||
export function wordOpenUrl(fileUrl: string): string {
|
||||
return `ms-word:ofe|u|${fileUrl}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ function mount() {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('useApi', () => ({ apiJson }))
|
||||
tab = { opener: {}, closed: false, location: { replace }, close }
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
||||
|
|
@ -30,7 +29,6 @@ afterEach(() => {
|
|||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('version open lifecycle', () => {
|
||||
|
|
@ -84,19 +82,16 @@ describe('version open lifecycle', () => {
|
|||
expect(state.downloadError.value).toBe('popup error')
|
||||
expect(state.openingVersionId.value).toBeNull()
|
||||
})
|
||||
it('offers Word fallback after timeout and removes listeners on unmount', async () => {
|
||||
it('openVersion (docx) ดาวน์โหลดทันที + ยิง ms-word + ตั้ง notice และเคลียร์ตอน unmount', async () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const remove = vi.spyOn(window, 'removeEventListener')
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน auto-download click จริง
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) // กัน download-first click จริง
|
||||
apiJson.mockResolvedValue({ url: 'https://files.example/v.docx?signature=a%2Fb' })
|
||||
const state = mount()
|
||||
await state.openVersion('d', 'v')
|
||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/v.docx?signature=a%2Fb')
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(state.openNotice.value).toBeTruthy()
|
||||
expect(state.fallbackUrl.value).toBeTruthy()
|
||||
app.unmount()
|
||||
expect(remove).toHaveBeenCalledWith('blur', expect.any(Function))
|
||||
})
|
||||
it('openDocxUrl opens a URL the caller already holds (ไฟล์ร่าง FR-16) without extra request', async () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
|
|
@ -106,10 +101,9 @@ describe('version open lifecycle', () => {
|
|||
expect(apiJson).not.toHaveBeenCalled()
|
||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
|
||||
expect(state.fallbackUrl.value).toBe('https://files.example/draft.docx?sig=a%2Fb')
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(state.openNotice.value).toBeTruthy()
|
||||
})
|
||||
it('auto-downloads the file to Downloads when Word did not take over within the wait window', () => {
|
||||
it('download-first: ดาวน์โหลดไฟล์ทันทีที่กด (ไม่ต้องรอ heuristic — Word 16.101+ บน Mac โดนหลอกด้วยหน้าเลือกไฟล์)', () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const saved: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
|
||||
|
|
@ -117,30 +111,13 @@ describe('version open lifecycle', () => {
|
|||
})
|
||||
const state = mount()
|
||||
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
|
||||
// ยังไม่มีอะไรเกิดก่อนหมดเวลารอ
|
||||
vi.advanceTimersByTime(2999)
|
||||
expect(saved).toHaveLength(0)
|
||||
expect(state.openNotice.value).toBeNull()
|
||||
// ครบหน้าต่างรอ (ไม่มีการ blur = Word ไม่ได้เปิด) → เซฟไฟล์ให้อัตโนมัติ + แจ้งเหตุผล
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(saved).toHaveLength(1)
|
||||
expect(saved[0].href).toBe('https://files.example/draft.docx?sig=a%2Fb')
|
||||
expect(state.openNotice.value).toContain('Downloads')
|
||||
// ยังยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ scheme ยังใช้ได้ (Windows / Word รุ่นก่อน)
|
||||
expect(assign).toHaveBeenCalledWith('ms-word:ofe|u|https://files.example/draft.docx?sig=a%2Fb')
|
||||
})
|
||||
it('does not auto-download when the app switch was detected (Word เปิดจริง)', async () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const saved: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
|
||||
saved.push(this)
|
||||
})
|
||||
const state = mount()
|
||||
state.openDocxUrl('https://files.example/draft.docx?sig=a%2Fb')
|
||||
window.dispatchEvent(new Event('blur')) // ผู้ใช้สลับไป Word
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(saved).toHaveLength(0)
|
||||
expect(state.openNotice.value).toBeNull()
|
||||
})
|
||||
it('re-click cancels the pending auto-download — only the latest URL is saved once', () => {
|
||||
it('re-click downloads again with the latest URL', () => {
|
||||
const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {})
|
||||
const saved: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function clicked(this: HTMLAnchorElement) {
|
||||
|
|
@ -148,10 +125,9 @@ describe('version open lifecycle', () => {
|
|||
})
|
||||
const state = mount()
|
||||
state.openDocxUrl('https://files.example/first.docx')
|
||||
vi.advanceTimersByTime(2000)
|
||||
state.openDocxUrl('https://files.example/second.docx') // resetOpen ยกเลิก timer อันเก่า
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(saved).toHaveLength(1)
|
||||
expect(saved[0].href).toBe('https://files.example/second.docx')
|
||||
state.openDocxUrl('https://files.example/second.docx') // resetOpen เคลียร์ notice เดิมแล้วดาวน์โหลดใหม่
|
||||
expect(saved).toHaveLength(2)
|
||||
expect(saved[1].href).toBe('https://files.example/second.docx')
|
||||
expect(state.fallbackUrl.value).toBe('https://files.example/second.docx')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue