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

@ -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()
{

View file

@ -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",
}));
});

View file

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

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>

View file

@ -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>();

View file

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

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

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

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

View file

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

View file

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