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:
Suphonchai Phoonsawat 2026-09-10 13:39:23 +07:00
parent e0558e3cf4
commit ee4d8573ee
21 changed files with 340 additions and 145 deletions

View file

@ -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

View 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);
}
}

View file

@ -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>