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; /// 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 ทั้งหมด [ApiController] [Route("api/open")] public class OpenController( AppDbContext db, IObjectStorageService storage, IOpenLinkService openLinks, IOptions minioOptions, ILogger logger) : ControllerBase { /// เปิดไฟล์ของ version: /api/open/{t}/{e}/v/{documentId}/{versionId}/{fileName} /// (fileName = ชื่อจริงตามหลักเดิม เช่น คำร้อง_v1.docx — ใช้ตรวจ format + ให้ Word/browser ตั้งชื่อตาม) [HttpGet("{t}/{e}/v/{documentId:guid}/{versionId:guid}/{fileName}")] [AllowAnonymous] // การรับรองคือลายเซ็นใน URL (วิ่งเข้า Word/เบราว์เซอร์ที่แนบ header ไม่ได้) [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task 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); } /// เปิดไฟล์ร่าง (FR-16): /api/open/{t}/{e}/d/{templateId}/{stamp}/{fileName} /// (stamp = yyyyMMddHHmmss_{guid} ของ object key; fileName = ชื่อจริงจากชื่อ template + timestamp) [HttpGet("{t}/{e}/d/{templateId:guid}/{stamp}/{fileName}")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task 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 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); } }