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