implement Phase 1-2
This commit is contained in:
parent
41573b3c4f
commit
b976cf3124
23 changed files with 1291 additions and 79 deletions
25
backend/WordVersioning.Api/Services/CurrentUser.cs
Normal file
25
backend/WordVersioning.Api/Services/CurrentUser.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Security.Claims;
|
||||
|
||||
namespace WordVersioning.Api.Services;
|
||||
|
||||
public interface ICurrentUser
|
||||
{
|
||||
/// <summary>userId จาก claim `sub` เท่านั้น (FR-8 — ห้ามรับจาก client)</summary>
|
||||
string UserId { get; }
|
||||
|
||||
/// <summary>username จาก claim `preferred_username` (ถ้ามี)</summary>
|
||||
string? UserName { get; }
|
||||
}
|
||||
|
||||
/// <summary>ดึง identity ของผู้เรียกจาก JWT claims — ใช้ตอนบันทึก CreatedByUserId / UpdatedByUserId</summary>
|
||||
public class CurrentUser(IHttpContextAccessor accessor) : ICurrentUser
|
||||
{
|
||||
private readonly ClaimsPrincipal _principal =
|
||||
accessor.HttpContext?.User ?? throw new InvalidOperationException("CurrentUser ต้องเรียกภายใน request ที่มี HttpContext");
|
||||
|
||||
public string UserId =>
|
||||
_principal.FindFirst("sub")?.Value
|
||||
?? throw new UnauthorizedAccessException("token ไม่มี claim 'sub'");
|
||||
|
||||
public string? UserName => _principal.FindFirst("preferred_username")?.Value;
|
||||
}
|
||||
20
backend/WordVersioning.Api/Services/HashService.cs
Normal file
20
backend/WordVersioning.Api/Services/HashService.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using System.Security.Cryptography;
|
||||
|
||||
namespace WordVersioning.Api.Services;
|
||||
|
||||
public interface IHashService
|
||||
{
|
||||
/// <summary>คำนวณ SHA256 ระหว่าง stream ผ่าน — คืน hex ตัวเล็ก
|
||||
/// (อ่านจนจบ stream — caller ต้อง Seek(0) เองถ้าจะใช้ stream ต่อ)</summary>
|
||||
Task<string> ComputeSha256HexAsync(Stream stream, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class HashService : IHashService
|
||||
{
|
||||
public async Task<string> ComputeSha256HexAsync(Stream stream, CancellationToken ct = default)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = await sha.ComputeHashAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
21
backend/WordVersioning.Api/Services/MinioOptions.cs
Normal file
21
backend/WordVersioning.Api/Services/MinioOptions.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace WordVersioning.Api.Services;
|
||||
|
||||
/// <summary>Config การเชื่อม MinIO — bind จาก section "MinIO" ใน appsettings/env
|
||||
/// (rule: ห้าม hardcode — deploy ได้โดยไม่แก้โค้ด เช่น MinIO__Endpoint)</summary>
|
||||
public class MinioOptions
|
||||
{
|
||||
public const string SectionName = "MinIO";
|
||||
|
||||
/// <summary>host:port ไม่รวม scheme (เช่น localhost:9000 หรือ minio:9000)</summary>
|
||||
public string Endpoint { get; set; } = "localhost:9000";
|
||||
|
||||
public string AccessKey { get; set; } = string.Empty;
|
||||
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
public bool UseSSL { get; set; }
|
||||
|
||||
public string VersionsBucket { get; set; } = "docx-versions";
|
||||
|
||||
public string TemplatesBucket { get; set; } = "docx-templates";
|
||||
}
|
||||
97
backend/WordVersioning.Api/Services/ObjectStorageService.cs
Normal file
97
backend/WordVersioning.Api/Services/ObjectStorageService.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
||||
public class ObjectStorageService : IObjectStorageService
|
||||
{
|
||||
private readonly IAmazonS3 _s3;
|
||||
private readonly ILogger<ObjectStorageService> _logger;
|
||||
|
||||
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger)
|
||||
{
|
||||
var opt = options.Value;
|
||||
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)
|
||||
{
|
||||
var request = new PutObjectRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = key,
|
||||
InputStream = content,
|
||||
ContentType = contentType,
|
||||
};
|
||||
if (content.CanSeek)
|
||||
{
|
||||
// ระบุความยาวชัดๆ ไม่ต้องให้ SDK ไป buffer ทั้ง stream
|
||||
request.Headers.ContentLength = content.Length;
|
||||
}
|
||||
|
||||
await _s3.PutObjectAsync(request, ct);
|
||||
_logger.LogInformation("Uploaded object {Bucket}/{Key} ({Length} bytes)", bucket, key, content.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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue