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

@ -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 &lt; 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);