using System.Security.Cryptography; using Microsoft.Extensions.Options; namespace WordVersioning.Api.Services; public interface IOpenLinkService { /// ออก URL เปิดไฟล์อายุสั้นที่วิ่งผ่าน backend (ตาม BaseUrl ที่ตั้ง ซึ่งต้องเป็น https) — /// Word for Mac รุ่นใหม่ (16.101+, ก.ย. 2026) ยอมรับเฉพาะ URL ที่เป็น https และ "ไม่มี query string" /// (มี query = เมินเงียบๆ ไม่ยิง request เลย) จึงฝังทุกอย่างลง path และปิดท้ายด้วยชื่อไฟล์ .docx/.pdf /// รหัสระบุไฟล์: "v:{documentId}:{versionId}" หรือ "d:drafts/{templateId}/{stamp}.docx" (ไฟล์ร่าง) /// docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น /// ชื่อไฟล์ที่ใช้ปิดท้าย URL (ตามหลักเดิม เช่น คำร้อง_v1.docx) — /// Word ตั้งชื่อ copy ตาม URL ตอนเปิด และ browser ใช้เป็น fallback ตอนดาวน์โหลด จึงต้องเป็นชื่อจริง ไม่ใช่ "file" /// เวลาหมดอายุ (UTC) ของลิงก์ที่ได้ string CreateLink(string resource, string format, string fileName, out DateTimeOffset expiresAt); /// validate token ของลิงก์ — true = ผ่าน (ยังไม่หมดอายุ และ HMAC ตรง resource+format) bool Validate(string resource, string format, string token, string expiresUnix); } /// ลิงก์เปิดไฟล์แบบเซ็น HMAC อายุสั้น (แนวคิดเดียวกับ presigned URL ของ S3 แต่ออกเอง /// เพื่อคุม scheme/host ให้เป็น https ของ backend) — payload คือ "resource|format|expiry(unix)" /// เซ็นด้วย HMACSHA256 ของ OpenLinks:Secret แบบ base64url /// /// รูป 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}) public class OpenLinkService : IOpenLinkService { private readonly OpenLinkOptions _options; public OpenLinkService(IOptions options) { _options = options.Value; } 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('/'); string target; if (resource.StartsWith("v:", StringComparison.Ordinal)) { var parts = resource.Split(':'); target = $"v/{parts[1]}/{parts[2]}"; } else if (resource.StartsWith("d:drafts/", StringComparison.Ordinal)) { // 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}/{Uri.EscapeDataString(fileName)}"; } 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 } }