implement Phase 1-2
This commit is contained in:
parent
41573b3c4f
commit
b976cf3124
23 changed files with 1291 additions and 79 deletions
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" ]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue