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