การทดสอบ 6 รอบบนเครื่องจริง (Mac, Word 16.101) พิสูจน์ว่า ms-word:ofe
ถูกเมินแบบไม่ deterministic ทุกรูปแบบ (http/https/query/ไม่มี query/
file:// — Word ไม่ยิง request ถึง server เลย เด้งหน้าเลือกไฟล์แทน)
open -a Word <local file> ใช้ได้แต่เรียกจากเว็บไม่ได้ (sandbox +
backend รัน server กลางใน production) → สรุปทางเดียวที่ deterministic
คือ download-first
- Backend: OpenLinkService (HMAC อายุสั้น ValidateOnStart) +
OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.docx และ /d/{objectKey}
— ทุกอย่างอยู่ใน path ไม่มี query string (Word เมิน URL มี query),
[AllowAnonymous] ด้วยลายเซ็นแทน Bearer, stream จาก MinIO + disposition
ชื่อไทยเหมือนเดิม · download-link + generate-draft ออก open-link
แทน presigned ของ MinIO (http) · ลบ dead code presigned ทิ้ง
- Web: openDocxUrl = ดาวน์โหลดทันทีทุกครั้ง + ยิง ms-word ควบคู่แบบ
best-effort + notice "ไฟล์อยู่ Downloads" — เอา heuristic สลับแอป
ออก (โดนหน้าเลือกไฟล์ของ Word หลอกจนไม่ดาวน์โหลด)
- Spec v9.2 + ตารางทดสอบเครื่องจริงใน acceptance-review + CLAUDE.md
กฎ 13 (ms-word = best-effort เท่านั้น) + ทางเลือก helper อนาคต
- backend 86 / web 58 / add-in 66 + build ครบ; QA reviewer PASS
(1 round, minors 4 แก้ครบ)
หมายเหตุ deploy: ต้องตั้ง OpenLinks__Secret (ค่าลับจริง) และ
OpenLinks__BaseUrl (https domain ของ backend) — fail ดังตั้งแต่สตาร์ทถ้าไม่ตั้ง
Co-Authored-By: Claude Code <noreply@anthropic.com>
156 lines
7.8 KiB
C#
156 lines
7.8 KiB
C#
using Amazon.Runtime;
|
|
using Amazon.S3;
|
|
using Amazon.S3.Model;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace WordVersioning.Api.Services;
|
|
|
|
public interface IObjectStorageService
|
|
{
|
|
/// <summary>อัปโหลด stream ไป bucket (content ต้อง seek ได้ — จะได้กำหนด ContentLength และ retry ได้)</summary>
|
|
Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default);
|
|
|
|
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
|
|
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
|
|
|
|
/// <summary>FR-16 — ตั้ง lifecycle rule ให้ object ใต้ prefix หนึ่งหมดอายุเองตามจำนวนวัน
|
|
/// (idempotent ตาม rule id และ sync ค่า days ใหม่ถ้า config เปลี่ยนหลัง boot แรก — days < 1 คือ throw)
|
|
/// ใช้กับไฟล์ร่างที่เก็บชั่วคราวใน bucket versions ใต้ `drafts/`
|
|
/// คืน true เมื่อเขียน config ใหม่, false เมื่อ rule ถูกต้องอยู่แล้ว (ใช้ assert ใน test)</summary>
|
|
Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
|
public class ObjectStorageService : IObjectStorageService
|
|
{
|
|
private readonly IAmazonS3 _s3;
|
|
private readonly bool _useHttps;
|
|
private readonly ILogger<ObjectStorageService> _logger;
|
|
|
|
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger)
|
|
{
|
|
var opt = options.Value;
|
|
_useHttps = opt.UseSSL;
|
|
var config = new AmazonS3Config
|
|
{
|
|
ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint,
|
|
ForcePathStyle = true, // จำเป็นสำหรับ MinIO (path-style bucket)
|
|
};
|
|
_s3 = new AmazonS3Client(new BasicAWSCredentials(opt.AccessKey, opt.SecretKey), config);
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default)
|
|
{
|
|
// จับความยาวไว้ก่อน — AWSSDK 4.x dispose input stream ให้หลัง upload เสร็จ อ่าน .Length ตอนหลังไม่ได้
|
|
long? length = content.CanSeek ? content.Length : null;
|
|
|
|
var request = new PutObjectRequest
|
|
{
|
|
BucketName = bucket,
|
|
Key = key,
|
|
InputStream = content,
|
|
ContentType = contentType,
|
|
};
|
|
if (length.HasValue)
|
|
{
|
|
// ระบุความยาวชัดๆ ไม่ต้องให้ SDK ไป buffer ทั้ง stream
|
|
request.Headers.ContentLength = length.Value;
|
|
}
|
|
|
|
await _s3.PutObjectAsync(request, ct);
|
|
_logger.LogInformation("Uploaded object {Bucket}/{Key} ({Length} bytes)", bucket, key, length);
|
|
}
|
|
|
|
public async Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default)
|
|
{
|
|
GetObjectResponse response;
|
|
try
|
|
{
|
|
response = await _s3.GetObjectAsync(new GetObjectRequest { BucketName = bucket, Key = key }, ct);
|
|
}
|
|
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
throw new KeyNotFoundException($"Object {bucket}/{key} not found");
|
|
}
|
|
|
|
_logger.LogInformation("Fetching object {Bucket}/{Key} ({Length} bytes)", bucket, key, response.ContentLength);
|
|
return new S3ResponseStream(response);
|
|
}
|
|
|
|
public async Task<bool> EnsurePrefixExpiryAsync(string bucket, string prefix, int days, string ruleId, CancellationToken ct = default)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(days, 1);
|
|
|
|
var rules = new List<LifecycleRule>();
|
|
try
|
|
{
|
|
var existing = await _s3.GetLifecycleConfigurationAsync(
|
|
new GetLifecycleConfigurationRequest { BucketName = bucket }, ct);
|
|
// Rules เป็น null ได้เมื่อ bucket มี lifecycle config แต่ยังไม่มี rule เลย
|
|
rules = existing.Configuration.Rules ?? [];
|
|
}
|
|
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
// ยังไม่มี lifecycle เลย — เริ่มชุดใหม่
|
|
}
|
|
|
|
var rule = rules.FirstOrDefault(r => r.Id == ruleId);
|
|
if (rule is null)
|
|
{
|
|
rules.Add(new LifecycleRule
|
|
{
|
|
Id = ruleId,
|
|
Filter = new LifecycleFilter { Prefix = prefix },
|
|
Status = LifecycleRuleStatus.Enabled,
|
|
Expiration = new LifecycleRuleExpiration { Days = days },
|
|
});
|
|
}
|
|
else if (rule.Expiration?.Days == days)
|
|
{
|
|
_logger.LogDebug("Lifecycle rule {RuleId} มีอยู่แล้วที่ {Bucket} (expire={Days}d)", ruleId, bucket, days);
|
|
return false;
|
|
}
|
|
else
|
|
{
|
|
// config เปลี่ยนหลัง boot แรก — sync ค่าใหม่ ไม่ใช่ปล่อยให้เป็น no-op เงียบๆ
|
|
rule.Expiration = new LifecycleRuleExpiration { Days = days };
|
|
}
|
|
|
|
await _s3.PutLifecycleConfigurationAsync(new PutLifecycleConfigurationRequest
|
|
{
|
|
BucketName = bucket,
|
|
Configuration = new LifecycleConfiguration { Rules = rules },
|
|
}, ct);
|
|
_logger.LogInformation("Lifecycle rule {RuleId} ตั้งแล้ว: {Bucket}/{Prefix} expire={Days}d", ruleId, bucket, prefix, days);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
|
|
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
|
|
private sealed class S3ResponseStream(GetObjectResponse response) : Stream
|
|
{
|
|
private readonly Stream _inner = response.ResponseStream;
|
|
|
|
public override bool CanRead => _inner.CanRead;
|
|
public override bool CanSeek => _inner.CanSeek;
|
|
public override bool CanWrite => _inner.CanWrite;
|
|
public override long Length => _inner.Length;
|
|
public override long Position { get => _inner.Position; set => _inner.Position = value; }
|
|
|
|
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
|
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
|
|
_inner.ReadAsync(buffer, offset, count, ct);
|
|
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) => _inner.ReadAsync(buffer, ct);
|
|
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
|
|
public override void Flush() => _inner.Flush();
|
|
public override void SetLength(long value) => _inner.SetLength(value);
|
|
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing) response.Dispose();
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
}
|