v9.2.1: real filenames back in open-link URLs (was literal "file")
User report: downloaded/opened files named just "file" — v9.2 put a
literal file.docx/file.pdf at the end of open-link URLs (treated as
cosmetic since Content-Disposition carries the real name, but Word
names its URL-copy from the URL path and browsers fall back to the
URL filename)
- OpenLinkService.CreateLink takes fileName and appends it (URL-encoded,
Thai-safe) — version: {base}_v{N}.docx/.pdf per FR-15, draft:
{template}_{timestamp}.docx per FR-10 naming policy
- Draft route split into /d/{templateId}/{stamp}/{fileName} (rebuilds
objectKey + HMAC-validates as before); version route unchanged shape
with real name
- Tests: URL tails assert real names (คำร้อง_v1.pdf/_v1.docx, draft
template name), no /file.docx; new tamper tests — modified stamp/
templateId and swapped docx↔pdf extension (version route) → 404
- backend 89/89; QA reviewer PASS (1 round, minors 2 fixed)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
83a949cfa0
commit
f4b2ff9d28
9 changed files with 104 additions and 35 deletions
|
|
@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||||
|
|
||||||
**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 86 / add-in 66 / web 60 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
|
**สถานะปัจจุบัน: Phase 0–12 — FR-16 ไฟล์ร่าง: automated QA PASS (backend 89 / add-in 66 / web 60 + build ครบ · v9.2: download-first + open-link ของ backend), ค้าง manual บน Word จริง · Phase 0–11 เสร็จครบ (FR-15 ผ่าน QA + manual บน Word for Mac 2026-09-08)** — ค้างยืนยันบน Word for Windows / Word on the web ตามรายการใน `docs/acceptance-review.md`
|
||||||
|
|
||||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,8 +225,8 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
public async Task Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded()
|
public async Task Draft_OpenLink_BadToken_Returns404_AndNoBearerNeeded()
|
||||||
{
|
{
|
||||||
// /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้)
|
// /api/open ใช้ลายเซ็นใน path แทน Bearer ([AllowAnonymous] โดยเจตนา — Word/เบราว์เซอร์แนบ header ไม่ได้)
|
||||||
// รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{objectKey} — ไม่มี query string เพราะ Word 16.101+
|
// รูป URL: {BaseUrl}/api/open/{token}/{expiry}/d/{templateId}/{stamp}/{fileName} — ไม่มี query string
|
||||||
// บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิดหรือหมดอายุ → 404 พร้อมข้อความบอกกดเปิดใหม่
|
// เพราะ Word 16.101+ บน Mac เมิน URL ที่มี query แบบเงียบๆ (v9.2) · token ผิด/หมดอายุ → 404
|
||||||
using var client = fixture.CreateClient();
|
using var client = fixture.CreateClient();
|
||||||
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "openlink bad token");
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, "openlink bad token");
|
||||||
using var draft = await client.PostAsJsonAsync(
|
using var draft = await client.PostAsJsonAsync(
|
||||||
|
|
@ -237,6 +237,13 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
Assert.DoesNotContain("?", link.Url); // ทุกอย่างอยู่ใน path — ไม่มี query string
|
Assert.DoesNotContain("?", link.Url); // ทุกอย่างอยู่ใน path — ไม่มี query string
|
||||||
Assert.StartsWith("https://test-host/api/open/", link.Url); // ออกผ่าน BaseUrl (https) ของ backend ไม่ใช่ presigned MinIO
|
Assert.StartsWith("https://test-host/api/open/", link.Url); // ออกผ่าน BaseUrl (https) ของ backend ไม่ใช่ presigned MinIO
|
||||||
|
|
||||||
|
// ชื่อไฟล์ท้าย URL เป็นชื่อจริงตามหลักเดิม ({template}_{timestamp}.docx) ไม่ใช่คำว่า file —
|
||||||
|
// Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้ URL เป็น fallback ตอนดาวน์โหลด
|
||||||
|
var tail = Uri.UnescapeDataString(new Uri(link.Url).AbsolutePath.Split('/')[^1]);
|
||||||
|
Assert.StartsWith("openlink bad token", tail);
|
||||||
|
Assert.EndsWith(".docx", tail);
|
||||||
|
Assert.DoesNotContain("/file.docx", new Uri(link.Url).AbsolutePath);
|
||||||
|
|
||||||
// แทน token ใน path ด้วยขยะ (segment ที่ 3 หลัง /api/open/)
|
// แทน token ใน path ด้วยขยะ (segment ที่ 3 หลัง /api/open/)
|
||||||
var segments = new Uri(link.Url).AbsolutePath.Split('/');
|
var segments = new Uri(link.Url).AbsolutePath.Split('/');
|
||||||
segments[3] = "garbage-token";
|
segments[3] = "garbage-token";
|
||||||
|
|
@ -247,6 +254,54 @@ public sealed class DraftTests(IntegrationTestFixture fixture)
|
||||||
Assert.Contains("หมดอายุ", body);
|
Assert.Contains("หมดอายุ", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("stamp")] // แก้ stamp ของ object key (valid token) — resource เปลี่ยน ลายเซ็นต้องพัง
|
||||||
|
[InlineData("templateId")] // แก้ templateId ใน path — เช่นกัน
|
||||||
|
public async Task Draft_OpenLink_TamperedRouteSegments_Return404(string which)
|
||||||
|
{
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var (templateId, _) = await TestFlow.CreateConfiguredTemplateAsync(client, $"openlink tamper {which}");
|
||||||
|
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))!;
|
||||||
|
|
||||||
|
// path: /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName}
|
||||||
|
var segments = new Uri(link.Url).AbsolutePath.Split('/');
|
||||||
|
switch (which)
|
||||||
|
{
|
||||||
|
case "stamp":
|
||||||
|
segments[6] = "20990101000000_" + Guid.NewGuid().ToString("N");
|
||||||
|
break;
|
||||||
|
case "templateId":
|
||||||
|
segments[5] = Guid.NewGuid().ToString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
using var anon = fixture.CreateAnonymousClient();
|
||||||
|
using var response = await anon.GetAsync("/" + string.Join("/", segments.Skip(1)));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Version_OpenLink_SwappedExtension_Returns404()
|
||||||
|
{
|
||||||
|
// นามสกุลท้าย URL ของ version เลือก format ซึ่งผูกกับลายเซ็น — เปลี่ยน .docx→.pdf ด้วย token เดิมต้อง 404
|
||||||
|
// (draft ไม่มีเคสนี้: format เป็น docx เสมอและไม่มี pdf ให้สลับ)
|
||||||
|
using var client = fixture.CreateClient();
|
||||||
|
var documentId = Guid.NewGuid();
|
||||||
|
using var upload = await client.PostAsync($"api/documents/{documentId}/versions",
|
||||||
|
ApiExtensions.VersionForm(TestDocx.BuildPlainDocx(), "คำร้อง.docx", Guid.NewGuid().ToString()));
|
||||||
|
var uploaded = (await upload.Content.ReadFromJsonAsync<UploadVersionResult>(IntegrationTestFixture.Json))!;
|
||||||
|
var link = await client.GetFromJsonAsync<DownloadLinkResult>(
|
||||||
|
$"api/documents/{documentId}/versions/{uploaded.VersionId}/download-link");
|
||||||
|
|
||||||
|
var segments = new Uri(link!.Url).AbsolutePath.Split('/'); // /api/open/{t}/{e}/v/{did}/{vid}/{fileName}
|
||||||
|
segments[^1] = Uri.EscapeDataString("คำร้อง_v1.pdf"); // format signed เป็น docx
|
||||||
|
using var anon = fixture.CreateAnonymousClient();
|
||||||
|
using var response = await anon.GetAsync("/" + string.Join("/", segments.Skip(1)));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
|
public async Task Draft_LifecycleRule_Idempotent_AndSyncsRetentionDays()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
||||||
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
|
Assert.Equal("application/pdf", download.Content.Headers.ContentType!.MediaType);
|
||||||
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
|
Assert.Equal("คำร้อง.pdf", download.Content.Headers.ContentDisposition!.FileNameStar);
|
||||||
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
|
var link = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link?format=pdf");
|
||||||
|
// ชื่อไฟล์จริง ({base}_v{N}.pdf) อยู่ท้าย URL ด้วย (Word/browser ตั้งชื่อตาม URL ไม่ใช่แค่ disposition)
|
||||||
|
Assert.EndsWith("คำร้อง_v1.pdf", Uri.UnescapeDataString(new Uri(link!.Url).AbsolutePath.Split('/')[^1]));
|
||||||
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็น (HMAC) แทน
|
using var anon = fixture.CreateAnonymousClient(); // /api/open ไม่ต้อง Bearer — ลิงก์ลายเซ็น (HMAC) แทน
|
||||||
using var signedDownload = await anon.GetAsync(new Uri(link!.Url).PathAndQuery);
|
using var signedDownload = await anon.GetAsync(new Uri(link!.Url).PathAndQuery);
|
||||||
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, signedDownload.StatusCode);
|
||||||
|
|
@ -40,6 +42,7 @@ public sealed class VersionPdfTests(IntegrationTestFixture fixture)
|
||||||
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
|
Assert.Equal("inline", signedDownload.Content.Headers.ContentDisposition!.DispositionType);
|
||||||
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
|
Assert.Equal("คำร้อง_v1.pdf", signedDownload.Content.Headers.ContentDisposition.FileNameStar);
|
||||||
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
|
var docxLink = await client.GetFromJsonAsync<DownloadLinkResult>($"api/documents/{id}/versions/{result.VersionId}/download-link");
|
||||||
|
Assert.EndsWith("คำร้อง_v1.docx", Uri.UnescapeDataString(new Uri(docxLink!.Url).AbsolutePath.Split('/')[^1]));
|
||||||
using var signedDocx = await anon.GetAsync(new Uri(docxLink!.Url).PathAndQuery);
|
using var signedDocx = await anon.GetAsync(new Uri(docxLink!.Url).PathAndQuery);
|
||||||
Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, signedDocx.StatusCode);
|
||||||
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
|
Assert.Equal("attachment", signedDocx.Content.Headers.ContentDisposition!.DispositionType);
|
||||||
|
|
|
||||||
|
|
@ -538,7 +538,11 @@ public class DocumentsController(
|
||||||
if (objectKey is null)
|
if (objectKey is null)
|
||||||
return NotFound("version นี้ไม่มีสำเนา PDF");
|
return NotFound("version นี้ไม่มีสำเนา PDF");
|
||||||
|
|
||||||
var url = openLinks.CreateLink($"v:{documentId}:{versionId}", isPdf ? "pdf" : "docx", out var expiresAt);
|
// ชื่อไฟล์ตามหลักเดิม — อยู่ท้าย URL (Word/browser ตั้งชื่อตาม) และใน Content-Disposition ตอน stream
|
||||||
|
var baseName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? row.FileName[..^5] : row.FileName;
|
||||||
|
var fileName = $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}";
|
||||||
|
var url = openLinks.CreateLink($"v:{documentId}:{versionId}", isPdf ? "pdf" : "docx", fileName, out var expiresAt);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expiresAt={ExpiresAt}",
|
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expiresAt={ExpiresAt}",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ public class OpenController(
|
||||||
IOptions<MinioOptions> minioOptions,
|
IOptions<MinioOptions> minioOptions,
|
||||||
ILogger<OpenController> logger) : ControllerBase
|
ILogger<OpenController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/file.{docx|pdf}</summary>
|
/// <summary>เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/{fileName}
|
||||||
|
/// (fileName = ชื่อจริงตามหลักเดิม เช่น คำร้อง_v1.docx — ใช้ตรวจ format + ให้ Word/browser ตั้งชื่อตาม)</summary>
|
||||||
[HttpGet("{t}/{e}/v/{documentId:guid}/{versionId:guid}/{fileName}")]
|
[HttpGet("{t}/{e}/v/{documentId:guid}/{versionId:guid}/{fileName}")]
|
||||||
[AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้)
|
[AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้)
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
|
@ -50,22 +51,19 @@ public class OpenController(
|
||||||
return await StreamFromStorage(objectKey, $"{baseName}_v{row.VersionNumber}.{(isPdf ? "pdf" : "docx")}", isPdf, ct);
|
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>
|
/// <summary>เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName}
|
||||||
[HttpGet("{t}/{e}/d/{**objectKey}")]
|
/// (stamp = yyyyMMddHHmmss_{guid} ของ object key; fileName = ชื่อจริงจากชื่อ template + timestamp)</summary>
|
||||||
|
[HttpGet("{t}/{e}/d/{templateId:guid}/{stamp}/{fileName}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> OpenDraft(string t, string e, string objectKey, CancellationToken ct)
|
public async Task<IActionResult> OpenDraft(
|
||||||
|
string t, string e, Guid templateId, string stamp, string fileName, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// objectKey = drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx (จบ .docx อยู่แล้ว — ชื่อไทยจริง
|
var objectKey = $"drafts/{templateId}/{stamp}.docx";
|
||||||
// ประกอบจากชื่อ template ตอน stream ไม่ใช่จาก URL ซึ่งต้องเป็น ascii)
|
if (!openLinks.Validate($"d:{objectKey}", "docx", t, e))
|
||||||
if (!objectKey.StartsWith("drafts/", StringComparison.Ordinal)
|
|
||||||
|| !openLinks.Validate($"d:{objectKey}", "docx", t, e))
|
|
||||||
return Expired();
|
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()
|
var template = await db.Templates.AsNoTracking()
|
||||||
.Where(x => x.Id == templateId)
|
.Where(x => x.Id == templateId)
|
||||||
.Select(x => x.Name)
|
.Select(x => x.Name)
|
||||||
|
|
@ -73,12 +71,12 @@ public class OpenController(
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return NotFound("ไม่พบ template ต้นทางของไฟล์ร่าง");
|
return NotFound("ไม่พบ template ต้นทางของไฟล์ร่าง");
|
||||||
|
|
||||||
var timestamp = parts[2].Split('_').FirstOrDefault() ?? string.Empty;
|
var timestamp = stamp.Split('_').FirstOrDefault() ?? string.Empty;
|
||||||
var fileName = DocxGenerationService.BuildGeneratedFileName(template,
|
var displayName = DocxGenerationService.BuildGeneratedFileName(template,
|
||||||
DateTimeOffset.TryParseExact(timestamp, "yyyyMMddHHmmss", null,
|
DateTimeOffset.TryParseExact(timestamp, "yyyyMMddHHmmss", null,
|
||||||
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||||
out var created) ? created : DateTimeOffset.UtcNow);
|
out var created) ? created : DateTimeOffset.UtcNow);
|
||||||
return await StreamFromStorage(objectKey, fileName, isPdf: false, ct);
|
return await StreamFromStorage(objectKey, displayName, isPdf: false, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActionResult Expired() =>
|
private IActionResult Expired() =>
|
||||||
|
|
|
||||||
|
|
@ -391,9 +391,10 @@ public class TemplatesController(
|
||||||
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
|
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ลิงก์เปิดอายุสั้นผ่าน https ของ backend (HMAC) — ชื่อไฟล์ไทย/disposition จัดที่ /api/open
|
// ลิงก์เปิดอายุสั้นผ่าน https ของ backend (HMAC) — ชื่อไฟล์จริง ({template}_{timestamp}.docx) อยู่ท้าย URL
|
||||||
// (Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ จึงไม่ใช้ presigned ของ MinIO ตรงๆ — v9.2)
|
// (Word รุ่นใหม่เมิน URL ที่เป็น http เงียบๆ จึงไม่ใช้ presigned ของ MinIO ตรงๆ — v9.2)
|
||||||
var url = openLinks.CreateLink($"d:{objectKey}", "docx", out var expiresAt);
|
var fileName = DocxGenerationService.BuildGeneratedFileName(template.Name, now);
|
||||||
|
var url = openLinks.CreateLink($"d:{objectKey}", "docx", fileName, out var expiresAt);
|
||||||
|
|
||||||
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}",
|
logger.LogInformation("Draft generated templateId={TemplateId} userId={UserId} bytes={Size} key={ObjectKey}",
|
||||||
template.Id, currentUser.UserId, fileBytes.Length, objectKey);
|
template.Id, currentUser.UserId, fileBytes.Length, objectKey);
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ public interface IOpenLinkService
|
||||||
/// <summary>ออก URL เปิดไฟล์อายุสั้นที่วิ่งผ่าน backend (ตาม BaseUrl ที่ตั้ง ซึ่งต้องเป็น https) —
|
/// <summary>ออก URL เปิดไฟล์อายุสั้นที่วิ่งผ่าน backend (ตาม BaseUrl ที่ตั้ง ซึ่งต้องเป็น https) —
|
||||||
/// Word for Mac รุ่นใหม่ (16.101+, ก.ย. 2026) ยอมรับเฉพาะ URL ที่เป็น https และ "ไม่มี query string"
|
/// Word for Mac รุ่นใหม่ (16.101+, ก.ย. 2026) ยอมรับเฉพาะ URL ที่เป็น https และ "ไม่มี query string"
|
||||||
/// (มี query = เมินเงียบๆ ไม่ยิง request เลย) จึงฝังทุกอย่างลง path และปิดท้ายด้วยชื่อไฟล์ .docx/.pdf</summary>
|
/// (มี query = เมินเงียบๆ ไม่ยิง request เลย) จึงฝังทุกอย่างลง path และปิดท้ายด้วยชื่อไฟล์ .docx/.pdf</summary>
|
||||||
/// <param name="resource">รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:{objectKey}" (ไฟล์ร่าง)</param>
|
/// <param name="resource">รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:drafts/{templateId}/{stamp}.docx" (ไฟล์ร่าง)</param>
|
||||||
/// <param name="format">docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น</param>
|
/// <param name="format">docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น</param>
|
||||||
|
/// <param name="fileName">ชื่อไฟล์ที่ใช้ปิดท้าย URL (ตามหลักเดิม เช่น คำร้อง_v1.docx) —
|
||||||
|
/// Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้เป็น fallback ตอนดาวน์โหลด จึงต้องเป็นชื่อจริง ไม่ใช่ "file"</param>
|
||||||
/// <param name="expiresAt">เวลาหมดอายุ (UTC) ของลิงก์ที่ได้</param>
|
/// <param name="expiresAt">เวลาหมดอายุ (UTC) ของลิงก์ที่ได้</param>
|
||||||
string CreateLink(string resource, string format, out DateTimeOffset expiresAt);
|
string CreateLink(string resource, string format, string fileName, out DateTimeOffset expiresAt);
|
||||||
|
|
||||||
/// <summary>validate token ของลิงก์ — true = ผ่าน (ยังไม่หมดอายุ และ HMAC ตรง resource+format)</summary>
|
/// <summary>validate token ของลิงก์ — true = ผ่าน (ยังไม่หมดอายุ และ HMAC ตรง resource+format)</summary>
|
||||||
bool Validate(string resource, string format, string token, string expiresUnix);
|
bool Validate(string resource, string format, string token, string expiresUnix);
|
||||||
|
|
@ -21,9 +23,9 @@ public interface IOpenLinkService
|
||||||
/// เพื่อคุม scheme/host ให้เป็น https ของ backend) — payload คือ "resource|format|expiry(unix)"
|
/// เพื่อคุม scheme/host ให้เป็น https ของ backend) — payload คือ "resource|format|expiry(unix)"
|
||||||
/// เซ็นด้วย HMACSHA256 ของ OpenLinks:Secret แบบ base64url
|
/// เซ็นด้วย HMACSHA256 ของ OpenLinks:Secret แบบ base64url
|
||||||
///
|
///
|
||||||
/// รูป URL (ทุกอย่างอยู่ใน path — ห้ามมี query string):
|
/// รูป URL (ทุกอย่างอยู่ใน path — ห้ามมี query string; ชื่อไฟล์ท้าย path เป็นชื่อจริง URL-encoded):
|
||||||
/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/v{เลขอะไรก็ได้ปลอดภัย}.{ext}
|
/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/{fileName}
|
||||||
/// draft: {BaseUrl}/api/open/{t}/{e}/d/{objectKey} (objectKey = drafts/…/….docx จบ .docx อยู่แล้ว)</summary>
|
/// draft: {BaseUrl}/api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName} (stamp = yyyyMMddHHmmss_{guid})</summary>
|
||||||
public class OpenLinkService : IOpenLinkService
|
public class OpenLinkService : IOpenLinkService
|
||||||
{
|
{
|
||||||
private readonly OpenLinkOptions _options;
|
private readonly OpenLinkOptions _options;
|
||||||
|
|
@ -33,29 +35,33 @@ public class OpenLinkService : IOpenLinkService
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string CreateLink(string resource, string format, out DateTimeOffset expiresAt)
|
public string CreateLink(string resource, string format, string fileName, out DateTimeOffset expiresAt)
|
||||||
{
|
{
|
||||||
var expirySeconds = Math.Clamp(_options.ExpirySeconds, 30, 600);
|
var expirySeconds = Math.Clamp(_options.ExpirySeconds, 30, 600);
|
||||||
expiresAt = DateTimeOffset.UtcNow.AddSeconds(expirySeconds);
|
expiresAt = DateTimeOffset.UtcNow.AddSeconds(expirySeconds);
|
||||||
var expiresUnix = expiresAt.ToUnixTimeSeconds().ToString();
|
var expiresUnix = expiresAt.ToUnixTimeSeconds().ToString();
|
||||||
var token = Sign(resource, format, expiresUnix);
|
var token = Sign(resource, format, expiresUnix);
|
||||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||||
var ext = format == "pdf" ? "pdf" : "docx";
|
|
||||||
string target;
|
string target;
|
||||||
if (resource.StartsWith("v:", StringComparison.Ordinal))
|
if (resource.StartsWith("v:", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
// v:{documentId}:{versionId} — ชื่อไฟล์ท้าย path เป็นหน้าตาให้ Word เท่านั้น (ชื่อจริงมาจาก DB ตอน stream)
|
|
||||||
var parts = resource.Split(':');
|
var parts = resource.Split(':');
|
||||||
target = $"v/{parts[1]}/{parts[2]}/file.{ext}";
|
target = $"v/{parts[1]}/{parts[2]}";
|
||||||
}
|
}
|
||||||
else if (resource.StartsWith("d:", StringComparison.Ordinal))
|
else if (resource.StartsWith("d:drafts/", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
target = $"d/{resource["d:".Length..]}";
|
// d:drafts/{templateId}/{stamp}.docx → d/{templateId}/{stamp} (ตัดหัว drafts/ กับท้าย .docx — fileName จริงต่อท้ายแทน)
|
||||||
|
var key = resource["d:".Length..];
|
||||||
|
var tail = key["drafts/".Length..];
|
||||||
|
var dot = tail.LastIndexOf('.');
|
||||||
|
if (dot >= 0)
|
||||||
|
tail = tail[..dot];
|
||||||
|
target = $"d/{tail}";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
throw new ArgumentException($"รูปแบบ resource ไม่รองรับ: {resource}", nameof(resource));
|
throw new ArgumentException($"รูปแบบ resource ไม่รองรับ: {resource}", nameof(resource));
|
||||||
|
|
||||||
return $"{baseUrl}/api/open/{token}/{expiresUnix}/{target}";
|
return $"{baseUrl}/api/open/{token}/{expiresUnix}/{target}/{Uri.EscapeDataString(fileName)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Validate(string resource, string format, string token, string expiresUnix)
|
public bool Validate(string resource, string format, string token, string expiresUnix)
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ QA reviewer: **PASS**, 2 rounds, 3 findings fixed (build compatibility, pending
|
||||||
| add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) |
|
| add-in: อ่าน TemplateId / ส่งเฉพาะ startVersioning / append form field | `metadataService.test.ts` (ไฟล์ร่างอ่าน TemplateId), `versionFlow.test.ts` (แนบ/ไม่แนบ templateId), `versioningService.test.ts` (form field) |
|
||||||
| เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test |
|
| เว็บ: เปิด docx URL ที่ถืออยู่เอง (ไฟล์ร่าง) ผ่าน ms-word + fallback + notice | `useVersionOpen.spec.ts` `openDocxUrl` test |
|
||||||
|
|
||||||
ชุดเต็ม (v9.2): backend **86 ผ่าน**, add-in **66 ผ่าน**, web **60 ผ่าน** + production build ผ่าน
|
ชุดเต็ม (v9.2.1): backend **89 ผ่าน**, add-in **66 ผ่าน**, web **60 ผ่าน** + production build ผ่าน
|
||||||
|
|
||||||
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
|
### ค้างยืนยันด้วยมือ (ต้องมี Word จริง)
|
||||||
|
|
||||||
|
|
@ -191,3 +191,5 @@ QA reviewer รอบ hotfix "เปิดใน Word ตอน cold-start" (v9.
|
||||||
QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026-09-10) — reviewer ตรวจ security ของ `/api/open` เชิงลึก (HMAC binding/fixed-time compare/path scoping ของ draft catch-all route — ผ่านทั้งหมด) + รัน suites เองได้ 86/58/66 ตรงตาม docs · minors 4 แก้ครบ: (1) คำ spec header ที่ขัดแย้งตารางทดสอบเอง (2) OpenLinks config ตรวจตั้งแต่สตาร์ทด้วย ValidateOnStart (Secret ว่าง/BaseUrl ไม่ใช่ https = fail ดัง) (3) เอา dead code presigned ออก (GetPresignedGetUrl/PresignedExpirySeconds) + comment/copy เก่าทั้ง web/add-in/CLAUDE.md (4) draft success copy นำด้วยการดาวน์โหลดแทน "กำลังเปิดใน Word"
|
QA reviewer รอบ v9.2 (download-first + open-link): **PASS**, 1 round (2026-09-10) — reviewer ตรวจ security ของ `/api/open` เชิงลึก (HMAC binding/fixed-time compare/path scoping ของ draft catch-all route — ผ่านทั้งหมด) + รัน suites เองได้ 86/58/66 ตรงตาม docs · minors 4 แก้ครบ: (1) คำ spec header ที่ขัดแย้งตารางทดสอบเอง (2) OpenLinks config ตรวจตั้งแต่สตาร์ทด้วย ValidateOnStart (Secret ว่าง/BaseUrl ไม่ใช่ https = fail ดัง) (3) เอา dead code presigned ออก (GetPresignedGetUrl/PresignedExpirySeconds) + comment/copy เก่าทั้ง web/add-in/CLAUDE.md (4) draft success copy นำด้วยการดาวน์โหลดแทน "กำลังเปิดใน Word"
|
||||||
|
|
||||||
QA reviewer รอบ follow-up v9.2 (เลิกยิง ms-word บน Mac default + จดสูตร sideload): **PASS**, 1 round (2026-09-10) — minors 3: แก้ 2 (sync implementation plan กับ spec + ตัวเลข 60, comment เก่าใน create page) · ปฏิเสธ 1 พร้อมเหตุผล: ปุ่มยังชื่อ "เปิดใน Word" บน Mac ทั้งที่ตอนนี้=ดาวน์โหลด — notice หลังกดอธิบายชัดและชื่อปุ่มเดิมมาก่อนการเปลี่ยนนี้ เปลี่ยนชื่อกระทบ UX ที่ทีมคุ้นเคย เก็บเป็นข้อสังเกตไว้ก่อน (จะเปลี่ยนเมื่อมี feedback ผู้ใช้จริง)
|
QA reviewer รอบ follow-up v9.2 (เลิกยิง ms-word บน Mac default + จดสูตร sideload): **PASS**, 1 round (2026-09-10) — minors 3: แก้ 2 (sync implementation plan กับ spec + ตัวเลข 60, comment เก่าใน create page) · ปฏิเสธ 1 พร้อมเหตุผล: ปุ่มยังชื่อ "เปิดใน Word" บน Mac ทั้งที่ตอนนี้=ดาวน์โหลด — notice หลังกดอธิบายชัดและชื่อปุ่มเดิมมาก่อนการเปลี่ยนนี้ เปลี่ยนชื่อกระทบ UX ที่ทีมคุ้นเคย เก็บเป็นข้อสังเกตไว้ก่อน (จะเปลี่ยนเมื่อมี feedback ผู้ใช้จริง)
|
||||||
|
|
||||||
|
QA reviewer รอบ v9.2.1 (ชื่อไฟล์จริงใน URL open-link): **PASS**, 1 round (2026-09-10) — user แจ้งได้แต่คำว่า "file" เพราะ v9.2 ตั้งท้าย URL เป็น file.docx ตายตัว · minors 2 แก้ครบ: sync spec changelog + เพิ่ม tests ป้องกัน (tampered stamp/templateId และสลับนามสกุล .docx↔.pdf บน version route ต้อง 404 — พบระหว่างเขียนว่า draft route ไม่ผูกชื่อท้ายกับลายเซ็นเพราะ format เป็น docx เสมอ จึงย้ายเคสนามสกุลไป version route ที่ format ผูกลายเซ็นจริง) · reviewer ยืนยัน URL name = disposition name เสมอ (คำนวณจาก source เดียวกัน) และ escaping ชื่อไทยผ่าน end-to-end
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ GET /api/documents/{documentId}/versions/{versionId}/download # proxy str
|
||||||
GET /api/documents/{documentId}/versions/{versionId}/download-link # open-link อายุสั้นของ backend (add-in/เว็บใช้อันนี้)
|
GET /api/documents/{documentId}/versions/{versionId}/download-link # open-link อายุสั้นของ backend (add-in/เว็บใช้อันนี้)
|
||||||
```
|
```
|
||||||
- `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง
|
- `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง
|
||||||
- `download-link` (v9.2) — ออก **open-link ของ backend เอง**: URL `https ของ backend` + token HMAC อายุสั้น (config ได้ `OpenLinks__Secret/BaseUrl/ExpirySeconds`, default ~2 นาที, clamp 30 วิ–10 นาที) ฝัง**ใน path** รูป `/api/open/{t}/{e}/v/{did}/{vid}/file.docx` (draft: `/d/{objectKey}`) — ไม่มี query string เพราะ Word for Mac 16.101+ เมิน URL ที่มี query · `/api/open` เป็น `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer (Word/เบราว์เซอร์แนบ header ไม่ได้) แล้ว stream ไฟล์จาก MinIO ผ่าน backend (ไม่ expose MinIO เท่าเดิม) · ยังเป็นกลไกหลักของ add-in เพราะ webview ของ host ทิ้ง blob download (v3)
|
- `download-link` (v9.2) — ออก **open-link ของ backend เอง**: URL `https ของ backend` + token HMAC อายุสั้น (config ได้ `OpenLinks__Secret/BaseUrl/ExpirySeconds`, default ~2 นาที, clamp 30 วิ–10 นาที) ฝัง**ใน path** รูป `/api/open/{t}/{e}/v/{did}/{vid}/{fileName}` (draft: `/d/{templateId}/{stamp}/{fileName}`) — ไม่มี query string เพราะ Word for Mac 16.101+ เมิน URL ที่มี query · `{fileName}` เป็น**ชื่อจริงตามหลักเดิม** (`{base}_v{N}.docx/pdf` / `{template}_{timestamp}.docx`, URL-encoded ชื่อไทย) เพราะ Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้ URL เป็น fallback ตอนดาวน์โหลด · `/api/open` เป็น `[AllowAnonymous]` โดยใช้ลายเซ็นแทน Bearer (Word/เบราว์เซอร์แนบ header ไม่ได้) แล้ว stream ไฟล์จาก MinIO ผ่าน backend (ไม่ expose MinIO เท่าเดิม) · ยังเป็นกลไกหลักของ add-in เพราะ webview ของ host ทิ้ง blob download (v3)
|
||||||
|
|
||||||
### FR-8: Auth (Keycloak / OpenID Connect)
|
### FR-8: Auth (Keycloak / OpenID Connect)
|
||||||
|
|
||||||
|
|
@ -484,7 +484,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
|
||||||
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม
|
- Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม
|
||||||
|
|
||||||
### เพิ่มเติม v9.2 (Word 16.101+ เมิน ms-word ทุกรูปแบบ → download-first + open-link ของ backend)
|
### เพิ่มเติม v9.2 (Word 16.101+ เมิน ms-word ทุกรูปแบบ → download-first + open-link ของ backend)
|
||||||
- Backend: `OpenLinkService` (HMAC อายุสั้น `OpenLinks__*`) + `OpenController` `/api/open/{t}/{e}/v/{did}/{vid}/file.{docx|pdf}` และ `/d/{objectKey}` — ไม่มี query string, `[AllowAnonymous]` ด้วยลายเซ็น, stream จาก MinIO · `download-link` และ `generate-draft` ออก open-link แทน presigned ของ MinIO
|
- Backend: `OpenLinkService` (HMAC อายุสั้น `OpenLinks__*`) + `OpenController` `/api/open/{t}/{e}/v/{did}/{vid}/{fileName}` และ `/d/{templateId}/{stamp}/{fileName}` — ไม่มี query string, `[AllowAnonymous]` ด้วยลายเซ็น, stream จาก MinIO · `{fileName}` เป็นชื่อจริงตามหลักเดิม (v9.2.1: เดิมใช้ `file.docx` ตายตัวทำให้ Word/browser ตั้งชื่อได้แค่คำว่า file) · `download-link` และ `generate-draft` ออก open-link แทน presigned ของ MinIO
|
||||||
- Web: `openDocxUrl` = ดาวน์โหลดทันที (anchor → attachment) + ยิง `ms-word:ofe` ควบคู่ + notice "ไฟล์อยู่ Downloads" — เอา heuristic blur/visibilitychange ออก
|
- Web: `openDocxUrl` = ดาวน์โหลดทันที (anchor → attachment) + ยิง `ms-word:ofe` ควบคู่ + notice "ไฟล์อยู่ Downloads" — เอา heuristic blur/visibilitychange ออก
|
||||||
- การทดสอบบนเครื่องจริง (Mac, Word 16.101.25092124, 2026-09-10): `ms-word:ofe` + http → เมิน; + https (มี query หรือไม่มี) → เมิน (Word เด้งหน้าเลือกไฟล์ ไม่ยิง request ถึง server เลย); + `file://` → เมิน; `open -a Word <local>` → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ browser sandbox และ backend รันบน server กลางใน production) → เหลือ download-first เป็นทางเดียวที่ deterministic
|
- การทดสอบบนเครื่องจริง (Mac, Word 16.101.25092124, 2026-09-10): `ms-word:ofe` + http → เมิน; + https (มี query หรือไม่มี) → เมิน (Word เด้งหน้าเลือกไฟล์ ไม่ยิง request ถึง server เลย); + `file://` → เมิน; `open -a Word <local>` → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ browser sandbox และ backend รันบน server กลางใน production) → เหลือ download-first เป็นทางเดียวที่ deterministic
|
||||||
- ทางเลือกอนาคต (ยังไม่ทำ): helper เล็กติดตั้งบนเครื่อง user รับคำสั่งผ่าน localhost แล้วดาวน์โหลดไป tmp + `open -a Word` — ได้ปุ่มเดียวเปิดอัตโนมัติจริง แต่ต้องมี installer/security/update (scope ใหม่)
|
- ทางเลือกอนาคต (ยังไม่ทำ): helper เล็กติดตั้งบนเครื่อง user รับคำสั่งผ่าน localhost แล้วดาวน์โหลดไป tmp + `open -a Word` — ได้ปุ่มเดียวเปิดอัตโนมัติจริง แต่ต้องมี installer/security/update (scope ใหม่)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue