From b976cf31245f66524d0bd20f9b583ce55d4c338a Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Wed, 19 Aug 2026 10:54:55 +0700 Subject: [PATCH] implement Phase 1-2 --- .env.example | 23 ++ CLAUDE.md | 2 +- .../Controllers/DocumentsController.cs | 271 ++++++++++++++++++ .../Controllers/WeatherForecastController.cs | 32 --- .../WordVersioning.Api/Data/AppDbContext.cs | 36 +++ .../20260819034805_InitialCreate.Designer.cs | 113 ++++++++ .../20260819034805_InitialCreate.cs | 70 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 110 +++++++ backend/WordVersioning.Api/Models/Document.cs | 20 ++ .../Models/DocumentVersion.cs | 28 ++ .../WordVersioning.Api/Models/VersionDtos.cs | 28 ++ backend/WordVersioning.Api/Program.cs | 74 ++++- .../Services/CurrentUser.cs | 25 ++ .../Services/HashService.cs | 20 ++ .../Services/MinioOptions.cs | 21 ++ .../Services/ObjectStorageService.cs | 97 +++++++ backend/WordVersioning.Api/WeatherForecast.cs | 12 - .../WordVersioning.Api.http | 6 - .../appsettings.Development.json | 13 +- backend/WordVersioning.Api/appsettings.json | 24 +- docker-compose.yml | 105 +++++++ docs/plans/officejs-implementation-plan.md | 57 ++-- keycloak/realm-export.json | 183 ++++++++++++ 23 files changed, 1291 insertions(+), 79 deletions(-) create mode 100644 .env.example create mode 100644 backend/WordVersioning.Api/Controllers/DocumentsController.cs delete mode 100644 backend/WordVersioning.Api/Controllers/WeatherForecastController.cs create mode 100644 backend/WordVersioning.Api/Data/AppDbContext.cs create mode 100644 backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs create mode 100644 backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.cs create mode 100644 backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/WordVersioning.Api/Models/Document.cs create mode 100644 backend/WordVersioning.Api/Models/DocumentVersion.cs create mode 100644 backend/WordVersioning.Api/Models/VersionDtos.cs create mode 100644 backend/WordVersioning.Api/Services/CurrentUser.cs create mode 100644 backend/WordVersioning.Api/Services/HashService.cs create mode 100644 backend/WordVersioning.Api/Services/MinioOptions.cs create mode 100644 backend/WordVersioning.Api/Services/ObjectStorageService.cs delete mode 100644 backend/WordVersioning.Api/WeatherForecast.cs delete mode 100644 backend/WordVersioning.Api/WordVersioning.Api.http create mode 100644 docker-compose.yml create mode 100644 keycloak/realm-export.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ecd3e5 --- /dev/null +++ b/.env.example @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index c26c866..c675b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ในแผน ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs new file mode 100644 index 0000000..91da2f5 --- /dev/null +++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs @@ -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; + +/// FR-5 (upload version), FR-6 (history), FR-7 (download) + รับ fields สำหรับ FR-11 +[ApiController] +[Authorize] // FR-8: ทุก endpoint ต้องแนบ token +[Route("api/documents")] +public class DocumentsController( + AppDbContext db, + IObjectStorageService storage, + IHashService hasher, + ICurrentUser currentUser, + IOptions minioOptions, + ILogger 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; + + /// FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี) + [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 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)); + } + + /// FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version) + [HttpGet("{documentId:guid}/versions")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> 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()); + } + + /// FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ) + [HttpGet("{documentId:guid}/versions/{versionId:guid}/download")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 }; +} diff --git a/backend/WordVersioning.Api/Controllers/WeatherForecastController.cs b/backend/WordVersioning.Api/Controllers/WeatherForecastController.cs deleted file mode 100644 index 6228dec..0000000 --- a/backend/WordVersioning.Api/Controllers/WeatherForecastController.cs +++ /dev/null @@ -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 _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable 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(); - } -} diff --git a/backend/WordVersioning.Api/Data/AppDbContext.cs b/backend/WordVersioning.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..71ccda6 --- /dev/null +++ b/backend/WordVersioning.Api/Data/AppDbContext.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using WordVersioning.Api.Models; + +namespace WordVersioning.Api.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Documents => Set(); + + public DbSet DocumentVersions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(x => x.Id); + entity.Property(x => x.FileName).HasMaxLength(512); + entity.Property(x => x.AddInSignature).HasMaxLength(64); + }); + + modelBuilder.Entity(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); + }); + } +} diff --git a/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs new file mode 100644 index 0000000..5cd9994 --- /dev/null +++ b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.Designer.cs @@ -0,0 +1,113 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddInSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("FieldsSnapshotJson") + .HasColumnType("text"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("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 + } + } +} diff --git a/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.cs b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.cs new file mode 100644 index 0000000..2ee2568 --- /dev/null +++ b/backend/WordVersioning.Api/Migrations/20260819034805_InitialCreate.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WordVersioning.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Documents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FileName = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + AddInSignature = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + TemplateId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + DocumentId = table.Column(type: "uuid", nullable: false), + VersionNumber = table.Column(type: "integer", nullable: false), + ObjectKey = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CreatedByUserId = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + FieldsSnapshotJson = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DocumentVersions"); + + migrationBuilder.DropTable( + name: "Documents"); + } + } +} diff --git a/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..f4597dc --- /dev/null +++ b/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,110 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddInSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("FieldsSnapshotJson") + .HasColumnType("text"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("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 + } + } +} diff --git a/backend/WordVersioning.Api/Models/Document.cs b/backend/WordVersioning.Api/Models/Document.cs new file mode 100644 index 0000000..a28e98b --- /dev/null +++ b/backend/WordVersioning.Api/Models/Document.cs @@ -0,0 +1,20 @@ +namespace WordVersioning.Api.Models; + +/// เอกสารหนึ่งฉบับที่ถูก track — 1 document : N versions (spec ส่วน 7) +public class Document +{ + public Guid Id { get; set; } + + public string FileName { get; set; } = string.Empty; + + /// ค่า custom property `SavedByAddIn` ที่ stamp ไว้ในไฟล์ (FR-1) + public string AddInSignature { get; set; } = string.Empty; + + /// เอกสารที่สร้างจาก template (FR-10) — null ถ้า track เอกสารเปล่า + /// (Phase 3 จะเติม FK ไป Template เมื่อมี entity) + public Guid? TemplateId { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public List Versions { get; set; } = new(); +} diff --git a/backend/WordVersioning.Api/Models/DocumentVersion.cs b/backend/WordVersioning.Api/Models/DocumentVersion.cs new file mode 100644 index 0000000..ed1af3b --- /dev/null +++ b/backend/WordVersioning.Api/Models/DocumentVersion.cs @@ -0,0 +1,28 @@ +namespace WordVersioning.Api.Models; + +/// Snapshot ของเอกสารหนึ่ง version (spec ส่วน 7) +public class DocumentVersion +{ + public Guid Id { get; set; } + + public Guid DocumentId { get; set; } + + public Document? Document { get; set; } + + /// เริ่มที่ 1 — auto increment ต่อ document (unique index กัน race) + public int VersionNumber { get; set; } + + /// MinIO object key: {documentId}/v{VersionNumber:D4}_{clientVersionId}.docx + public string ObjectKey { get; set; } = string.Empty; + + /// SHA256 hex ของไฟล์ (ตรวจ integrity ภายหลัง) + public string ContentHash { get; set; } = string.Empty; + + /// จาก claim `sub` เท่านั้น — ห้ามรับจาก client + public string CreatedByUserId { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } + + /// Snapshot ค่า fields ของ version นี้ (FR-11) — map tag→text แบบ JSON + public string? FieldsSnapshotJson { get; set; } +} diff --git a/backend/WordVersioning.Api/Models/VersionDtos.cs b/backend/WordVersioning.Api/Models/VersionDtos.cs new file mode 100644 index 0000000..31364aa --- /dev/null +++ b/backend/WordVersioning.Api/Models/VersionDtos.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace WordVersioning.Api.Models; + +/// body ของ POST /api/documents/{documentId}/versions (multipart/form-data) +public class UploadVersionRequest +{ + [Required] + public IFormFile File { get; set; } = null!; + + /// GUID ที่ client generate ต่อ version — ใช้เป็นส่วนหนึ่งของ object key + [Required] + public string ClientVersionId { get; set; } = string.Empty; + + /// JSON object map tag→text จาก content controls (FR-11 — Phase 3 จะใช้ sync เต็มรูปแบบ) + public string? Fields { get; set; } +} + +public record UploadVersionResponse(Guid VersionId, int VersionNumber); + +/// item ของ version history — fields คือ snapshot ที่ parse แล้ว (ถ้ามี) +public record VersionListItem( + Guid VersionId, + int VersionNumber, + DateTimeOffset CreatedAt, + string CreatedByUserId, + string ContentHash, + object? Fields); diff --git a/backend/WordVersioning.Api/Program.cs b/backend/WordVersioning.Api/Program.cs index 48863a6..c65cde4 100644 --- a/backend/WordVersioning.Api/Program.cs +++ b/backend/WordVersioning.Api/Program.cs @@ -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() + }, + }); +}); + +// PostgreSQL — connection string จาก ConnectionStrings:Default (override ผ่าน env: ConnectionStrings__Default) +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +// MinIO / hash / current user +builder.Services.Configure(builder.Configuration.GetSection(MinioOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +// 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(); + 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() ?? []) + .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(); diff --git a/backend/WordVersioning.Api/Services/CurrentUser.cs b/backend/WordVersioning.Api/Services/CurrentUser.cs new file mode 100644 index 0000000..5f70ef5 --- /dev/null +++ b/backend/WordVersioning.Api/Services/CurrentUser.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; + +namespace WordVersioning.Api.Services; + +public interface ICurrentUser +{ + /// userId จาก claim `sub` เท่านั้น (FR-8 — ห้ามรับจาก client) + string UserId { get; } + + /// username จาก claim `preferred_username` (ถ้ามี) + string? UserName { get; } +} + +/// ดึง identity ของผู้เรียกจาก JWT claims — ใช้ตอนบันทึก CreatedByUserId / UpdatedByUserId +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; +} diff --git a/backend/WordVersioning.Api/Services/HashService.cs b/backend/WordVersioning.Api/Services/HashService.cs new file mode 100644 index 0000000..4f8ba4d --- /dev/null +++ b/backend/WordVersioning.Api/Services/HashService.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; + +namespace WordVersioning.Api.Services; + +public interface IHashService +{ + /// คำนวณ SHA256 ระหว่าง stream ผ่าน — คืน hex ตัวเล็ก + /// (อ่านจนจบ stream — caller ต้อง Seek(0) เองถ้าจะใช้ stream ต่อ) + Task ComputeSha256HexAsync(Stream stream, CancellationToken ct = default); +} + +public class HashService : IHashService +{ + public async Task ComputeSha256HexAsync(Stream stream, CancellationToken ct = default) + { + using var sha = SHA256.Create(); + var hash = await sha.ComputeHashAsync(stream, ct); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/backend/WordVersioning.Api/Services/MinioOptions.cs b/backend/WordVersioning.Api/Services/MinioOptions.cs new file mode 100644 index 0000000..87f7048 --- /dev/null +++ b/backend/WordVersioning.Api/Services/MinioOptions.cs @@ -0,0 +1,21 @@ +namespace WordVersioning.Api.Services; + +/// Config การเชื่อม MinIO — bind จาก section "MinIO" ใน appsettings/env +/// (rule: ห้าม hardcode — deploy ได้โดยไม่แก้โค้ด เช่น MinIO__Endpoint) +public class MinioOptions +{ + public const string SectionName = "MinIO"; + + /// host:port ไม่รวม scheme (เช่น localhost:9000 หรือ minio:9000) + 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"; +} diff --git a/backend/WordVersioning.Api/Services/ObjectStorageService.cs b/backend/WordVersioning.Api/Services/ObjectStorageService.cs new file mode 100644 index 0000000..d2613f7 --- /dev/null +++ b/backend/WordVersioning.Api/Services/ObjectStorageService.cs @@ -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 +{ + /// อัปโหลด stream ไป bucket (content ต้อง seek ได้ — จะได้กำหนด ContentLength และ retry ได้) + Task PutAsync(string bucket, string key, Stream content, string contentType, CancellationToken ct = default); + + /// ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย + Task GetStreamAsync(string bucket, string key, CancellationToken ct = default); +} + +/// Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API) +public class ObjectStorageService : IObjectStorageService +{ + private readonly IAmazonS3 _s3; + private readonly ILogger _logger; + + public ObjectStorageService(IOptions options, ILogger 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 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); + } + + /// Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน + /// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง) + 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 ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + _inner.ReadAsync(buffer, offset, count, ct); + public override ValueTask ReadAsync(Memory 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); + } + } +} diff --git a/backend/WordVersioning.Api/WeatherForecast.cs b/backend/WordVersioning.Api/WeatherForecast.cs deleted file mode 100644 index 5c892f5..0000000 --- a/backend/WordVersioning.Api/WeatherForecast.cs +++ /dev/null @@ -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; } -} diff --git a/backend/WordVersioning.Api/WordVersioning.Api.http b/backend/WordVersioning.Api/WordVersioning.Api.http deleted file mode 100644 index 9a85038..0000000 --- a/backend/WordVersioning.Api/WordVersioning.Api.http +++ /dev/null @@ -1,6 +0,0 @@ -@WordVersioning.Api_HostAddress = http://localhost:5075 - -GET {{WordVersioning.Api_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/backend/WordVersioning.Api/appsettings.Development.json b/backend/WordVersioning.Api/appsettings.Development.json index 0c208ae..afd7cb6 100644 --- a/backend/WordVersioning.Api/appsettings.Development.json +++ b/backend/WordVersioning.Api/appsettings.Development.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" } } diff --git a/backend/WordVersioning.Api/appsettings.json b/backend/WordVersioning.Api/appsettings.json index 10f68b8..5fcbc7f 100644 --- a/backend/WordVersioning.Api/appsettings.json +++ b/backend/WordVersioning.Api/appsettings.json @@ -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" ] + } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..515fa9e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index 843898c..056fac6 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -169,18 +169,27 @@ POST /api/documents/generate { templateId, values: { : } } **เป้า:** รัน 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: { : } } **เป้า:** 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 (ใหม่) diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json new file mode 100644 index 0000000..76e496b --- /dev/null +++ b/keycloak/realm-export.json @@ -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 + } + ] + } + ] +}