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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue