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>
104 lines
5.9 KiB
C#
104 lines
5.9 KiB
C#
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}/{fileName}
|
|
/// (fileName = ชื่อจริงตามหลักเดิม เช่น คำร้อง_v1.docx — ใช้ตรวจ format + ให้ Word/browser ตั้งชื่อตาม)</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/{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, Guid templateId, string stamp, string fileName, CancellationToken ct)
|
|
{
|
|
var objectKey = $"drafts/{templateId}/{stamp}.docx";
|
|
if (!openLinks.Validate($"d:{objectKey}", "docx", t, e))
|
|
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 = 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, displayName, 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);
|
|
}
|
|
}
|