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
|
|
@ -538,7 +538,11 @@ public class DocumentsController(
|
|||
if (objectKey is null)
|
||||
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(
|
||||
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expiresAt={ExpiresAt}",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ public class OpenController(
|
|||
IOptions<MinioOptions> minioOptions,
|
||||
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}")]
|
||||
[AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้)
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
|
|
@ -50,22 +51,19 @@ public class OpenController(
|
|||
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}")]
|
||||
/// <summary>เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName}
|
||||
/// (stamp = yyyyMMddHHmmss_{guid} ของ object key; fileName = ชื่อจริงจากชื่อ template + timestamp)</summary>
|
||||
[HttpGet("{t}/{e}/d/{templateId:guid}/{stamp}/{fileName}")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[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 อยู่แล้ว — ชื่อไทยจริง
|
||||
// ประกอบจากชื่อ template ตอน stream ไม่ใช่จาก URL ซึ่งต้องเป็น ascii)
|
||||
if (!objectKey.StartsWith("drafts/", StringComparison.Ordinal)
|
||||
|| !openLinks.Validate($"d:{objectKey}", "docx", t, e))
|
||||
var objectKey = $"drafts/{templateId}/{stamp}.docx";
|
||||
if (!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)
|
||||
|
|
@ -73,12 +71,12 @@ public class OpenController(
|
|||
if (template is null)
|
||||
return NotFound("ไม่พบ template ต้นทางของไฟล์ร่าง");
|
||||
|
||||
var timestamp = parts[2].Split('_').FirstOrDefault() ?? string.Empty;
|
||||
var fileName = DocxGenerationService.BuildGeneratedFileName(template,
|
||||
var timestamp = stamp.Split('_').FirstOrDefault() ?? string.Empty;
|
||||
var displayName = 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);
|
||||
return await StreamFromStorage(objectKey, displayName, isPdf: false, ct);
|
||||
}
|
||||
|
||||
private IActionResult Expired() =>
|
||||
|
|
|
|||
|
|
@ -391,9 +391,10 @@ public class TemplatesController(
|
|||
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)
|
||||
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}",
|
||||
template.Id, currentUser.UserId, fileBytes.Length, objectKey);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ 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="resource">รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:drafts/{templateId}/{stamp}.docx" (ไฟล์ร่าง)</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>
|
||||
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>
|
||||
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)"
|
||||
/// เซ็นด้วย 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>
|
||||
/// รูป URL (ทุกอย่างอยู่ใน path — ห้ามมี query string; ชื่อไฟล์ท้าย path เป็นชื่อจริง URL-encoded):
|
||||
/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/{fileName}
|
||||
/// draft: {BaseUrl}/api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName} (stamp = yyyyMMddHHmmss_{guid})</summary>
|
||||
public class OpenLinkService : IOpenLinkService
|
||||
{
|
||||
private readonly OpenLinkOptions _options;
|
||||
|
|
@ -33,29 +35,33 @@ public class OpenLinkService : IOpenLinkService
|
|||
_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);
|
||||
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}";
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue