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:{objectKey}" (ไฟล์ร่าง)
/// docx | pdf — อยู่ในชื่อไฟล์ท้าย path และผูกกับลายเซ็น
/// เวลาหมดอายุ (UTC) ของลิงก์ที่ได้
string CreateLink(string resource, string format, 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):
/// version: {BaseUrl}/api/open/{t}/{e}/v/{documentId}/{versionId}/v{เลขอะไรก็ได้ปลอดภัย}.{ext}
/// draft: {BaseUrl}/api/open/{t}/{e}/d/{objectKey} (objectKey = drafts/…/….docx จบ .docx อยู่แล้ว)
public class OpenLinkService : IOpenLinkService
{
private readonly OpenLinkOptions _options;
public OpenLinkService(IOptions options)
{
_options = options.Value;
}
public string CreateLink(string resource, string format, 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}";
}
else if (resource.StartsWith("d:", StringComparison.Ordinal))
{
target = $"d/{resource["d:".Length..]}";
}
else
throw new ArgumentException($"รูปแบบ resource ไม่รองรับ: {resource}", nameof(resource));
return $"{baseUrl}/api/open/{token}/{expiresUnix}/{target}";
}
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
}
}