implement Phase 1-2
This commit is contained in:
parent
41573b3c4f
commit
b976cf3124
23 changed files with 1291 additions and 79 deletions
23
.env.example
Normal file
23
.env.example
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# ------------------------------------------------------------------
|
||||
# Local dev infra (docker-compose.yml) — template เท่านั้น
|
||||
# คัดลอกเป็น `.env` แล้วเปลี่ยนค่าก่อนใช้: cp .env.example .env
|
||||
# ค่าในไฟล์นี้เป็นค่า dev ที่ใช้เฉพาะเครื่อง local — ห้ามเอาไปใช้ production
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# --- PostgreSQL (service: db) ---
|
||||
POSTGRES_USER=wordversioning
|
||||
POSTGRES_PASSWORD=dev-local-pass
|
||||
POSTGRES_DB=wordversioning
|
||||
|
||||
# --- MinIO (service: minio) ---
|
||||
# password ต้องยาว >= 8 ตัวอักษร
|
||||
MINIO_ROOT_USER=wordversioning-minio
|
||||
MINIO_ROOT_PASSWORD=dev-local-pass
|
||||
|
||||
# --- Keycloak bootstrap admin (service: keycloak) ---
|
||||
# ใช้ล็อกอิน admin console ครั้งแรกที่ http://localhost:8080/admin/
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass
|
||||
|
||||
# --- Keycloak test user (define ใน keycloak/realm-export.json ฝั่ง realm) ---
|
||||
# username: testuser / password: Test1234! (แก้ที่ realm-export.json แล้ว re-import)
|
||||
|
|
@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||
|
||||
**สถานะปัจจุบัน: pre-implementation — ยังไม่มีโค้ด** งานคือ implement ตาม phase 0–8 ในแผน
|
||||
**สถานะปัจจุบัน: Phase 0–2 เสร็จ** (scaffold + infra + backend core FR-5–8) — งานต่อคือ implement phase 3–8 ในแผน
|
||||
|
||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||
|
||||
|
|
|
|||
271
backend/WordVersioning.Api/Controllers/DocumentsController.cs
Normal file
271
backend/WordVersioning.Api/Controllers/DocumentsController.cs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
using System.Text.Json;
|
||||
using Amazon.Runtime;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using WordVersioning.Api.Data;
|
||||
using WordVersioning.Api.Models;
|
||||
using WordVersioning.Api.Services;
|
||||
|
||||
namespace WordVersioning.Api.Controllers;
|
||||
|
||||
/// <summary>FR-5 (upload version), FR-6 (history), FR-7 (download) + รับ fields สำหรับ FR-11</summary>
|
||||
[ApiController]
|
||||
[Authorize] // FR-8: ทุก endpoint ต้องแนบ token
|
||||
[Route("api/documents")]
|
||||
public class DocumentsController(
|
||||
AppDbContext db,
|
||||
IObjectStorageService storage,
|
||||
IHashService hasher,
|
||||
ICurrentUser currentUser,
|
||||
IOptions<MinioOptions> minioOptions,
|
||||
ILogger<DocumentsController> logger) : ControllerBase
|
||||
{
|
||||
public const string DocxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
private const long MaxUploadBytes = 100L * 1024 * 1024; // 100MB ตาม spec
|
||||
private const int MaxVersionInsertRetries = 5;
|
||||
|
||||
/// <summary>FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี)</summary>
|
||||
[HttpPost("{documentId:guid}/versions")]
|
||||
[RequestSizeLimit(MaxUploadBytes)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)]
|
||||
[ProducesResponseType(typeof(UploadVersionResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||
public async Task<IActionResult> UploadVersion(Guid documentId, [FromForm] UploadVersionRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.File is null || request.File.Length == 0)
|
||||
return BadRequest("ต้องแนบไฟล์ `file` (docx)");
|
||||
if (!Guid.TryParse(request.ClientVersionId, out _))
|
||||
return BadRequest("`clientVersionId` ต้องเป็น GUID");
|
||||
if (request.File.Length > MaxUploadBytes)
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB");
|
||||
if (!IsValidDocx(request.File))
|
||||
return BadRequest("รองรับเฉพาะไฟล์ .docx (Word document)");
|
||||
|
||||
// parse fields map (FR-11) — optional, ผิด format คือ 400
|
||||
string? fieldsSnapshotJson = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.Fields))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var fieldsDoc = JsonDocument.Parse(request.Fields);
|
||||
if (fieldsDoc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return BadRequest("`fields` ต้องเป็น JSON object รูป { tag: text }");
|
||||
foreach (var prop in fieldsDoc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!IsScalar(prop.Value.ValueKind))
|
||||
return BadRequest($"`fields.{prop.Name}` ต้องเป็นค่า scalar (string/number/bool/null)");
|
||||
}
|
||||
fieldsSnapshotJson = fieldsDoc.RootElement.GetRawText();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BadRequest("`fields` ไม่ใช่ JSON ที่ถูกต้อง");
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var fileName = request.File.FileName;
|
||||
|
||||
// hash ระหว่าง stream ผ่าน (pass แรก) แล้ว rewind ส่ง MinIO (pass สอง)
|
||||
await using var fileStream = request.File.OpenReadStream();
|
||||
Stream uploadStream = fileStream;
|
||||
if (!fileStream.CanSeek)
|
||||
{
|
||||
// IFormFile ปกติ seek ได้ — กันเหลือ: buffer ก่อน
|
||||
var buffered = new MemoryStream();
|
||||
await fileStream.CopyToAsync(buffered, ct);
|
||||
uploadStream = buffered;
|
||||
}
|
||||
var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct);
|
||||
uploadStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
// สร้าง Document ถ้ายังไม่มี (FR-5)
|
||||
var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct);
|
||||
var createdDoc = false;
|
||||
if (doc is null)
|
||||
{
|
||||
doc = new Document
|
||||
{
|
||||
Id = documentId,
|
||||
FileName = fileName,
|
||||
AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง
|
||||
CreatedAt = now,
|
||||
};
|
||||
db.Documents.Add(doc);
|
||||
createdDoc = true;
|
||||
}
|
||||
|
||||
// จอง versionNumber = max+1 — ถ้าชน unique index แปลว่ามี request คู่แข่ง ให้คำนวณใหม่
|
||||
DocumentVersion version = null!;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var nextNumber = await db.DocumentVersions
|
||||
.Where(v => v.DocumentId == documentId)
|
||||
.Select(v => (int?)v.VersionNumber)
|
||||
.MaxAsync(ct) ?? 0;
|
||||
nextNumber++;
|
||||
|
||||
version = new DocumentVersion
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DocumentId = documentId,
|
||||
VersionNumber = nextNumber,
|
||||
ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx",
|
||||
ContentHash = contentHash,
|
||||
CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น
|
||||
CreatedAt = now,
|
||||
FieldsSnapshotJson = fieldsSnapshotJson,
|
||||
};
|
||||
db.DocumentVersions.Add(version);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
break;
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex) && attempt < MaxVersionInsertRetries)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Version number conflict documentId={DocumentId} attempt={Attempt} userId={UserId} — retry",
|
||||
documentId, attempt, currentUser.UserId);
|
||||
|
||||
db.Entry(version).State = EntityState.Detached;
|
||||
if (createdDoc)
|
||||
{
|
||||
// document อาจถูกสร้างโดย request คู่แข่งไปแล้ว — ถอดออกจาก tracker แล้วอ่านใหม่
|
||||
var existing = await db.Documents.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == documentId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
db.Entry(doc).State = EntityState.Detached;
|
||||
doc = existing;
|
||||
createdDoc = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// upload ลง MinIO หลังจองเลข version สำเร็จ (key มี versionNumber อยู่ข้างใน)
|
||||
try
|
||||
{
|
||||
await storage.PutAsync(minioOptions.Value.VersionsBucket, version.ObjectKey, uploadStream, DocxContentType, ct);
|
||||
}
|
||||
catch (AmazonServiceException ex)
|
||||
{
|
||||
logger.LogError(ex, "MinIO upload failed documentId={DocumentId} versionId={VersionId} userId={UserId}",
|
||||
documentId, version.Id, currentUser.UserId);
|
||||
|
||||
// ถอน record ที่เพิ่ง insert — ไม่ทิ้ง row ที่ชี้ไฟล์ที่ไม่มีจริง
|
||||
db.DocumentVersions.Remove(version);
|
||||
if (createdDoc)
|
||||
db.Documents.Remove(doc);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ");
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Version uploaded documentId={DocumentId} versionId={VersionId} versionNumber={VersionNumber} userId={UserId} bytes={Size} hash={Hash}",
|
||||
documentId, version.Id, version.VersionNumber, currentUser.UserId, request.File.Length, contentHash);
|
||||
|
||||
return CreatedAtAction(nameof(GetVersions), new { documentId },
|
||||
new UploadVersionResponse(version.Id, version.VersionNumber));
|
||||
}
|
||||
|
||||
/// <summary>FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version)</summary>
|
||||
[HttpGet("{documentId:guid}/versions")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<VersionListItem>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<VersionListItem>>> GetVersions(Guid documentId, CancellationToken ct)
|
||||
{
|
||||
var exists = await db.Documents.AnyAsync(d => d.Id == documentId, ct);
|
||||
if (!exists)
|
||||
return NotFound($"ไม่พบ document {documentId}");
|
||||
|
||||
var rows = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId)
|
||||
.OrderByDescending(v => v.VersionNumber)
|
||||
.Select(v => new
|
||||
{
|
||||
v.Id,
|
||||
v.VersionNumber,
|
||||
v.CreatedAt,
|
||||
v.CreatedByUserId,
|
||||
v.ContentHash,
|
||||
v.FieldsSnapshotJson,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(rows.Select(r => new VersionListItem(
|
||||
r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash,
|
||||
ParseSnapshot(r.FieldsSnapshotJson))).ToList());
|
||||
}
|
||||
|
||||
/// <summary>FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ)</summary>
|
||||
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.DocumentVersions
|
||||
.AsNoTracking()
|
||||
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||
.Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, d.FileName })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (row is null)
|
||||
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||
|
||||
Stream stream;
|
||||
try
|
||||
{
|
||||
stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, row.ObjectKey, ct);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}",
|
||||
documentId, versionId, row.ObjectKey);
|
||||
return NotFound("ไฟล์ของ version นี้ไม่พบใน storage");
|
||||
}
|
||||
|
||||
logger.LogInformation("Version download documentId={DocumentId} versionId={VersionId} userId={UserId}",
|
||||
documentId, versionId, currentUser.UserId);
|
||||
|
||||
var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
? row.FileName
|
||||
: row.FileName + ".docx";
|
||||
return File(stream, DocxContentType, downloadName);
|
||||
}
|
||||
|
||||
private static bool IsValidDocx(IFormFile file) =>
|
||||
file.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
&& IsAcceptableContentType(file.ContentType);
|
||||
|
||||
// spec: reject content-type ที่ไม่ใช่ docx — ยอมรับ octet-stream/zip ด้วยเพราะ client บางตัว
|
||||
// (เช่น curl -F) ไม่ set ชนิดไฟล์ให้ ส่วนชื่อไฟล์ต้องลง .docx เสมอ (เช็คข้างบน)
|
||||
private static bool IsAcceptableContentType(string contentType) =>
|
||||
string.IsNullOrWhiteSpace(contentType)
|
||||
|| contentType.Equals(DocxContentType, StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase)
|
||||
|| contentType.Equals("application/zip", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsScalar(JsonValueKind kind) =>
|
||||
kind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null;
|
||||
|
||||
private static object? ParseSnapshot(string? json)
|
||||
{
|
||||
if (json is null)
|
||||
return null;
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.Clone(); // clone ก่อน dispose JsonDocument เพื่อให้ serialize ต่อได้
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException ex) =>
|
||||
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace WordVersioning.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
public class WeatherForecastController : ControllerBase
|
||||
{
|
||||
private static readonly string[] Summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
private readonly ILogger<WeatherForecastController> _logger;
|
||||
|
||||
public WeatherForecastController(ILogger<WeatherForecastController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet(Name = "GetWeatherForecast")]
|
||||
public IEnumerable<WeatherForecast> Get()
|
||||
{
|
||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
36
backend/WordVersioning.Api/Data/AppDbContext.cs
Normal file
36
backend/WordVersioning.Api/Data/AppDbContext.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using WordVersioning.Api.Models;
|
||||
|
||||
namespace WordVersioning.Api.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Document> Documents => Set<Document>();
|
||||
|
||||
public DbSet<DocumentVersion> DocumentVersions => Set<DocumentVersion>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Document>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.FileName).HasMaxLength(512);
|
||||
entity.Property(x => x.AddInSignature).HasMaxLength(64);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DocumentVersion>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
|
||||
// กัน versionNumber ซ้ำตอน upload พร้อมกัน (retry ที่ controller เมื่อชน constraint)
|
||||
entity.HasIndex(x => new { x.DocumentId, x.VersionNumber }).IsUnique();
|
||||
|
||||
entity.Property(x => x.ObjectKey).HasMaxLength(1024);
|
||||
entity.Property(x => x.ContentHash).HasMaxLength(64);
|
||||
|
||||
entity.HasOne(x => x.Document)
|
||||
.WithMany(d => d.Versions)
|
||||
.HasForeignKey(x => x.DocumentId);
|
||||
});
|
||||
}
|
||||
}
|
||||
113
backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs
generated
Normal file
113
backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using WordVersioning.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace WordVersioning.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260819034805_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AddInSignature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid?>("TemplateId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedByUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("DocumentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FieldsSnapshotJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ObjectKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DocumentVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
|
||||
{
|
||||
b.HasOne("WordVersioning.Api.Models.Document", "Document")
|
||||
.WithMany("Versions")
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
|
||||
{
|
||||
b.Navigation("Versions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace WordVersioning.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Documents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
AddInSignature = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
TemplateId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Documents", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DocumentVersions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DocumentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
VersionNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
ObjectKey = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
CreatedByUserId = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
FieldsSnapshotJson = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DocumentVersions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DocumentVersions_Documents_DocumentId",
|
||||
column: x => x.DocumentId,
|
||||
principalTable: "Documents",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DocumentVersions_DocumentId_VersionNumber",
|
||||
table: "DocumentVersions",
|
||||
columns: new[] { "DocumentId", "VersionNumber" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DocumentVersions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Documents");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using WordVersioning.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace WordVersioning.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AddInSignature")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid?>("TemplateId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedByUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("DocumentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FieldsSnapshotJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ObjectKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DocumentVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
|
||||
{
|
||||
b.HasOne("WordVersioning.Api.Models.Document", "Document")
|
||||
.WithMany("Versions")
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
|
||||
{
|
||||
b.Navigation("Versions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
20
backend/WordVersioning.Api/Models/Document.cs
Normal file
20
backend/WordVersioning.Api/Models/Document.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
namespace WordVersioning.Api.Models;
|
||||
|
||||
/// <summary>เอกสารหนึ่งฉบับที่ถูก track — 1 document : N versions (spec ส่วน 7)</summary>
|
||||
public class Document
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>ค่า custom property `SavedByAddIn` ที่ stamp ไว้ในไฟล์ (FR-1)</summary>
|
||||
public string AddInSignature { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>เอกสารที่สร้างจาก template (FR-10) — null ถ้า track เอกสารเปล่า
|
||||
/// (Phase 3 จะเติม FK ไป Template เมื่อมี entity)</summary>
|
||||
public Guid? TemplateId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public List<DocumentVersion> Versions { get; set; } = new();
|
||||
}
|
||||
28
backend/WordVersioning.Api/Models/DocumentVersion.cs
Normal file
28
backend/WordVersioning.Api/Models/DocumentVersion.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace WordVersioning.Api.Models;
|
||||
|
||||
/// <summary>Snapshot ของเอกสารหนึ่ง version (spec ส่วน 7)</summary>
|
||||
public class DocumentVersion
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid DocumentId { get; set; }
|
||||
|
||||
public Document? Document { get; set; }
|
||||
|
||||
/// <summary>เริ่มที่ 1 — auto increment ต่อ document (unique index กัน race)</summary>
|
||||
public int VersionNumber { get; set; }
|
||||
|
||||
/// <summary>MinIO object key: {documentId}/v{VersionNumber:D4}_{clientVersionId}.docx</summary>
|
||||
public string ObjectKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>SHA256 hex ของไฟล์ (ตรวจ integrity ภายหลัง)</summary>
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>จาก claim `sub` เท่านั้น — ห้ามรับจาก client</summary>
|
||||
public string CreatedByUserId { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Snapshot ค่า fields ของ version นี้ (FR-11) — map tag→text แบบ JSON</summary>
|
||||
public string? FieldsSnapshotJson { get; set; }
|
||||
}
|
||||
28
backend/WordVersioning.Api/Models/VersionDtos.cs
Normal file
28
backend/WordVersioning.Api/Models/VersionDtos.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace WordVersioning.Api.Models;
|
||||
|
||||
/// <summary>body ของ POST /api/documents/{documentId}/versions (multipart/form-data)</summary>
|
||||
public class UploadVersionRequest
|
||||
{
|
||||
[Required]
|
||||
public IFormFile File { get; set; } = null!;
|
||||
|
||||
/// <summary>GUID ที่ client generate ต่อ version — ใช้เป็นส่วนหนึ่งของ object key</summary>
|
||||
[Required]
|
||||
public string ClientVersionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JSON object map tag→text จาก content controls (FR-11 — Phase 3 จะใช้ sync เต็มรูปแบบ)</summary>
|
||||
public string? Fields { get; set; }
|
||||
}
|
||||
|
||||
public record UploadVersionResponse(Guid VersionId, int VersionNumber);
|
||||
|
||||
/// <summary>item ของ version history — fields คือ snapshot ที่ parse แล้ว (ถ้ามี)</summary>
|
||||
public record VersionListItem(
|
||||
Guid VersionId,
|
||||
int VersionNumber,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedByUserId,
|
||||
string ContentHash,
|
||||
object? Fields);
|
||||
|
|
@ -1,25 +1,83 @@
|
|||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using WordVersioning.Api.Data;
|
||||
using WordVersioning.Api.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
builder.Services.AddControllers();
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "วาง access token จาก Keycloak (realm word-versioning)",
|
||||
});
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" },
|
||||
},
|
||||
Array.Empty<string>()
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// PostgreSQL — connection string จาก ConnectionStrings:Default (override ผ่าน env: ConnectionStrings__Default)
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
|
||||
|
||||
// MinIO / hash / current user
|
||||
builder.Services.Configure<MinioOptions>(builder.Configuration.GetSection(MinioOptions.SectionName));
|
||||
builder.Services.AddSingleton<IObjectStorageService, ObjectStorageService>();
|
||||
builder.Services.AddSingleton<IHashService, HashService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
|
||||
// JWT — validate ผ่าน JWKS ของ Keycloak อัตโนมัติ (FR-8)
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = builder.Configuration["Authentication:Authority"];
|
||||
options.RequireHttpsMetadata = false; // ⚠️ dev เท่านั้น — production ต้องเป็น true
|
||||
options.MapInboundClaims = false; // คงชื่อ claim ดิบ: "sub", "preferred_username"
|
||||
|
||||
// รับ token ได้ทั้ง 2 frontend client (add-in + web portal) — แต่ละตัว issue token ที่ aud ต่างกัน
|
||||
var audiences = builder.Configuration.GetSection("Authentication:ValidAudiences").Get<string[]>();
|
||||
if (audiences is { Length: > 0 })
|
||||
options.TokenValidationParameters.ValidAudiences = audiences;
|
||||
else
|
||||
options.TokenValidationParameters.ValidateAudience = false; // realm-level: ตรวจ issuer + signature อย่างเดียว
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// CORS — add-in task pane (https://localhost:3000) + web portal (http://localhost:5173) เท่านั้น
|
||||
const string corsPolicy = "frontends";
|
||||
builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy
|
||||
.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseCors(corsPolicy);
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
namespace WordVersioning.Api;
|
||||
|
||||
public class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
@WordVersioning.Api_HostAddress = http://localhost:5075
|
||||
|
||||
GET {{WordVersioning.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
|
@ -2,7 +2,18 @@
|
|||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
},
|
||||
|
||||
"//": "ค่า local dev ตรงกับ .env.example (docker compose) — ถ้าจะ override ด้วย env ใช้ตัวแปร เช่น MinIO__Endpoint, ConnectionStrings__Default, Authentication__Authority",
|
||||
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Port=5432;Database=wordversioning;Username=wordversioning;Password=dev-local-pass"
|
||||
},
|
||||
|
||||
"MinIO": {
|
||||
"AccessKey": "wordversioning-minio",
|
||||
"SecretKey": "dev-local-pass"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,27 @@
|
|||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Port=5432;Database=wordversioning;Username=wordversioning;Password="
|
||||
},
|
||||
|
||||
"MinIO": {
|
||||
"Endpoint": "localhost:9000",
|
||||
"AccessKey": "",
|
||||
"SecretKey": "",
|
||||
"UseSSL": false,
|
||||
"VersionsBucket": "docx-versions",
|
||||
"TemplatesBucket": "docx-templates"
|
||||
},
|
||||
|
||||
"Authentication": {
|
||||
"Authority": "http://localhost:8080/realms/word-versioning",
|
||||
"ValidAudiences": [ "word-addin-officejs", "word-versioning-web" ]
|
||||
},
|
||||
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "https://localhost:3000", "http://localhost:5173" ]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
docker-compose.yml
Normal file
105
docker-compose.yml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Local dev infrastructure — Word Versioning
|
||||
# ใช้ร่วมกับ .env (สร้างจาก .env.example) — ทุก credential อยู่ใน env เท่านั้น ห้าม hardcode
|
||||
#
|
||||
# docker compose up -d # รัน infra: db + minio (+ init buckets) + keycloak
|
||||
# docker compose --profile api up -d # เพิ่ม backend API (หลัง Phase 2 มี Dockerfile)
|
||||
|
||||
name: word-versioning
|
||||
|
||||
services:
|
||||
# --- PostgreSQL (EF Core / Npgsql) ---------------------------------
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
ports:
|
||||
- "5432:5432" # เปิดให้ backend ที่รันด้วย dotnet run บน host เชื่อมได้
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
|
||||
# --- MinIO (S3-compatible สำหรับไฟล์ .docx) -----------------------
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
# ให้ healthcheck (mc ready) เรียกผ่าน alias "local" ได้
|
||||
MC_HOST_local: http://${MINIO_ROOT_USER}:${MINIO_ROOT_PASSWORD}@localhost:9000
|
||||
ports:
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # MinIO console
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
# สร้าง bucket docx-versions + docx-templates แบบ private (anonymous = none)
|
||||
# idempotent — รันซ้ำได้ด้วย --ignore-existing
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
set -e;
|
||||
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD;
|
||||
mc mb --ignore-existing local/docx-versions;
|
||||
mc mb --ignore-existing local/docx-templates;
|
||||
mc anonymous set none local/docx-versions;
|
||||
mc anonymous set none local/docx-templates;
|
||||
echo 'MinIO buckets ready: docx-versions, docx-templates (private)';
|
||||
"
|
||||
|
||||
# --- Keycloak (OIDC) -----------------------------------------------
|
||||
# realm word-versioning import อัตโนมัติตอน startup จาก /keycloak/realm-export.json
|
||||
# ไม่ mount volume เก็บข้อมูล realm โดยตั้งใจ: ตั้งแต่ compose down ขึ้นไป realm จะถูก
|
||||
# import ใหม่จาก realm-export.json (ไฟล์นี้คือ source of truth ของ realm config ฝั่ง dev)
|
||||
# — แก้อะไรใน realm ให้แก้ใน realm-export.json แล้ว docker compose down && up
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.2
|
||||
command: start-dev --import-realm
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
|
||||
ports:
|
||||
- "8080:8080" # admin console + OIDC endpoints
|
||||
volumes:
|
||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||
|
||||
# --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) -----
|
||||
# รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:9000
|
||||
# หรือรันใน network นี้: docker compose --profile api up -d --build
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: WordVersioning.Api/Dockerfile
|
||||
profiles: ["api"]
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "8081:8080"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
|
|
@ -169,18 +169,27 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
|
||||
**เป้า:** รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8)
|
||||
|
||||
- [ ] `docker-compose.yml` กำหนด services:
|
||||
- [x] `docker-compose.yml` กำหนด services:
|
||||
- `db` — `postgres:16` (volume สำหรับข้อมูล)
|
||||
- `minio` — `minio/minio` (console เปิดด้วย) + init service สร้าง bucket **`docx-versions`** และ **`docx-templates`** แบบ **private** ทั้งคู่
|
||||
- `keycloak` — `quay.io/keycloak/keycloak` start-dev + import realm อัตโนมัติ
|
||||
- `api` — build จาก `/backend` (รอ Phase 2 เสร็จค่อย enable ก็ได้)
|
||||
- [ ] `/keycloak/realm-export.json`:
|
||||
- [x] `/keycloak/realm-export.json`:
|
||||
- realm `word-versioning`
|
||||
- client `word-addin-officejs` — public, Standard Flow, **PKCE S256 บังคับ**, ไม่มี secret, redirect URI `https://localhost:3000/auth-callback.html`
|
||||
- client `word-versioning-web` — public, Standard Flow, PKCE S256, redirect URI `http://localhost:5173/*` (web ทุก route เพราะใช้ redirect ปกติ)
|
||||
- client `word-addin-vsto` — public + PKCE (pre-register ตาม spec)
|
||||
- สร้าง test user แรกไว้ใน realm export เลยก็ได้
|
||||
- [ ] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
|
||||
- [x] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
|
||||
|
||||
หมายเหตุจากการทำจริง (2026-08-19):
|
||||
- Keycloak = `26.2.5` (tag `26.2`); admin bootstrap ใช้ env `KC_BOOTSTRAP_ADMIN_USERNAME/PASSWORD` (ชุดใหม่แทน `KEYCLOAK_ADMIN` ที่ deprecated)
|
||||
- Realm import strategy ของ Keycloak = `IGNORE_EXISTING` และ compose **ไม่ mount volume เก็บข้อมูล realm โดยตั้งใจ** → หลัง `docker compose down` realm จะถูก import ใหม่จาก `realm-export.json` เสมอ (ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev — แก้ realm ให้แก้ที่ไฟล์นี้)
|
||||
- ทุก client ตั้ง `webOrigins` ด้วย (add-in: `https://localhost:3000`, web: `http://localhost:5173`) — จำเป็นเพื่อให้ browser เรียก token endpoint ผ่าน CORS จาก task pane / เว็บได้ (ไม่ใช่แค่ redirectUris)
|
||||
- ปิด `directAccessGrantsEnabled` ทุก client ตาม spec (บังคับ PKCE); ตอน dev อยากได้ token สำหรับทดสอบใช้ client `admin-cli` ของ realm (ROPC) หรือเดิน auth code flow จริง
|
||||
- redirect URI ของ `word-addin-vsto` ใช้ loopback `http://localhost/*` + `http://127.0.0.1/*` ตาม RFC 8252 (spec-vsto.md ยังไม่มี — pre-register ไว้ก่อน)
|
||||
- `api` service ใส่ไว้ใน compose แบบ `profiles: ["api"]` — ไม่ start ตอน `docker compose up` ปกติ จนกว่าจะมี Dockerfile ใน Phase 2 (`docker compose --profile api up -d --build`)
|
||||
- ตรวจ DoD ผ่านหมด: realm + 3 clients (public/standardFlow/PKCE S256/redirect ถูกทุกตัว ผ่าน admin REST), `testuser/Test1234!` ขอ token ได้, bucket 2 ตัวเป็น private (`mc anonymous get` = private, anonymous GET = 403)
|
||||
|
||||
**DoD:** `docker compose up` แล้ว (1) เข้า Keycloak admin console เห็น realm `word-versioning` + clients ครบ 3 ตัว (2) เข้า MinIO console เห็น bucket `docx-versions` + `docx-templates` ที่เป็น private
|
||||
|
||||
|
|
@ -191,32 +200,44 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
**เป้า:** API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5)
|
||||
|
||||
### 2.1 Data layer
|
||||
- [ ] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
|
||||
- [ ] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
|
||||
- [ ] EF migration แรก
|
||||
- [x] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
|
||||
- [x] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
|
||||
- [x] EF migration แรก
|
||||
|
||||
### 2.2 Services
|
||||
- [ ] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
|
||||
- [ ] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
|
||||
- [x] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
|
||||
- [x] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
|
||||
|
||||
### 2.3 Auth (FR-8 ฝั่ง backend)
|
||||
- [ ] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
|
||||
- [ ] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
|
||||
- [ ] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
|
||||
- [x] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
|
||||
- [x] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
|
||||
- [x] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
|
||||
|
||||
### 2.4 Endpoints (`DocumentsController`)
|
||||
- [ ] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
|
||||
- [ ] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
|
||||
- [ ] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
|
||||
- [ ] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
|
||||
- [x] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
|
||||
- [x] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
|
||||
- [x] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
|
||||
- [x] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
|
||||
|
||||
### 2.5 Cross-cutting
|
||||
- [ ] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
|
||||
- [ ] Logging ทุก upload/download/generate: documentId, versionId, userId
|
||||
- [ ] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
|
||||
- [x] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
|
||||
- [x] Logging ทุก upload/download/generate: documentId, versionId, userId
|
||||
- [x] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
|
||||
|
||||
หมายเหตุจากการทำจริง (2026-08-19):
|
||||
- **Keycloak public client ไม่ใส่ claim `aud` ใน access token อัตโนมัติ** (ทดสอบจริงด้วย PKCE flow) → เพิ่ม `oidc-audience-mapper` ("audience-self") ให้ทุก client ใน `realm-export.json` เพื่อให้ token มี `aud` = clientId ตัวเอง แล้ว backend จึง validate `ValidAudiences = [word-addin-officejs, word-versioning-web]` ได้จริง (realm ต้อง re-import: `docker compose rm -sf keycloak && docker compose up -d`)
|
||||
- ตรวจ token ได้โดยไม่ต้องเปิด browser: PKCE auth-code flow ทั้งเส้นด้วย curl (GET login form → POST credentials ตาม cookie → ตาม 302 เอา `code` → exchange กับ verifier) — ใช้ซ้ำได้ตอน dev/test
|
||||
- `MapInboundClaims = false` เพื่ออ่าน claim ชื่อดิบ `sub` / `preferred_username` (ไม่โดนแปลงเป็น `http://schemas.xmlsoap.org/...`)
|
||||
- download ใช้ stream wrapper ที่ครอบ `GetObjectResponse` (dispose stream = dispose response) — controller คืน `File(stream, ...)` ได้เลยไม่ต้องโหลดทั้งไฟล์ใน memory ไม่ต้องจัดการ lifetime ใน controller
|
||||
- upload คำนวณ SHA256 จาก stream pass แรกแล้ว `Seek(0)` ส่ง MinIO pass สอง (IFormFile ของ Kestrel seek ได้เพราะ spool ลง disk แล้ว); ถ้า MinIO ล้มเหลวหลัง insert row จะถอน row กลับ (ไม่ทิ้ง record ชี้ไฟล์ที่ไม่มีจริง)
|
||||
- content-type ยอมรับ docx mime ตรงๆ และ `application/octet-stream`/`application/zip` (client อย่าง curl ไม่ set ชนิดให้) แต่ชื่อไฟล์ต้องลงท้าย `.docx` เสมอ
|
||||
- เลข version จองผ่าน insert + unique index — ชน constraint (request พร้อมกัน) แล้ว detach แล้วคำนวณใหม่สูงสุด 5 ครั้ง
|
||||
- API dev URL = `http://localhost:5075` (run แบบ `dotnet run --no-launch-profile` + `ASPNETCORE_URLS` เพื่อไม่เปิด browser)
|
||||
|
||||
**DoD:** `dotnet run` + Swagger: (1) เรียก endpoint ไหนก็ได้โดยไม่แนบ token → **401** (2) ขอ token จาก Keycloak ด้วย test user แล้ว upload ไฟล์ docx จริง → มี record ใน Postgres + object ใน MinIO + download กลับมาเปิดได้ (AC-3 ผ่าน)
|
||||
|
||||
> ✅ ตรวจผ่านจริง (2026-08-19): 401 ทั้ง 3 endpoints; token จาก PKCE flow จริง (client `word-versioning-web`); upload v1 (พร้อม `fields`) + v2 → 201 versionNumber 1, 2; Postgres มี Document + 2 DocumentVersions (ContentHash ตรง sha256 ต้นทาง, CreatedByUserId = `sub`); MinIO มี object 2 ไฟล์ตาม key format; download กลับมาตรง byte-for-byte + content-type ถูก; GET history เรียงใหม่→เก่าพร้อม fields ที่ parse แล้ว; .txt → 400; CORS preflight จาก `https://localhost:3000` → 204 + allow-origin ถูก
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Backend template & generation — FR-9, FR-10 (ใหม่)
|
||||
|
|
|
|||
183
keycloak/realm-export.json
Normal file
183
keycloak/realm-export.json
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
{
|
||||
"realm": "word-versioning",
|
||||
"enabled": true,
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"resetPasswordAllowed": false,
|
||||
"bruteForceProtected": false,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "word-addin-officejs",
|
||||
"name": "Word Versioning Add-in (Office.js task pane)",
|
||||
"description": "Public client สำหรับ task pane — OIDC Authorization Code + PKCE S256 ผ่าน Office Dialog API (redirect กลับ auth-callback.html)",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": true,
|
||||
"bearerOnly": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"fullScopeAllowed": true,
|
||||
"surrogateAuthRequired": false,
|
||||
"redirectUris": [
|
||||
"https://localhost:3000/auth-callback.html"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://localhost:3000"
|
||||
],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-self",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "word-addin-officejs",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"basic",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "word-versioning-web",
|
||||
"name": "Word Versioning Web Portal (Nuxt)",
|
||||
"description": "Public client สำหรับเว็บ portal — OIDC browser redirect ปกติ (top-level page) + PKCE S256",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": true,
|
||||
"bearerOnly": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"fullScopeAllowed": true,
|
||||
"surrogateAuthRequired": false,
|
||||
"redirectUris": [
|
||||
"http://localhost:5173/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:5173"
|
||||
],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-self",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "word-versioning-web",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"basic",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "word-addin-vsto",
|
||||
"name": "Word Versioning Add-in (VSTO desktop)",
|
||||
"description": "Pre-register ไว้ตาม spec (spec-vsto.md ยังไม่มีงาน implement) — public + PKCE, redirect เป็น loopback ตาม RFC 8252",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": true,
|
||||
"bearerOnly": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"fullScopeAllowed": true,
|
||||
"surrogateAuthRequired": false,
|
||||
"redirectUris": [
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"attributes": {
|
||||
"pkce.code.challenge.method": "S256"
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "audience-self",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "word-addin-vsto",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"profile",
|
||||
"roles",
|
||||
"basic",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "testuser",
|
||||
"enabled": true,
|
||||
"email": "testuser@word-versioning.local",
|
||||
"emailVerified": true,
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "Test1234!",
|
||||
"temporary": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue