diff --git a/.gitea/workflows/ci-cd-nas.yml b/.gitea/workflows/ci-cd-nas.yml new file mode 100644 index 0000000..33ba373 --- /dev/null +++ b/.gitea/workflows/ci-cd-nas.yml @@ -0,0 +1,52 @@ +name: ci-cd-nas +on: + push: + branches: [main] + +jobs: + # Runs in a normal container on the runner — only needs the .NET SDK. + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { dotnet-version: '8.0.x' } + - run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release + + # Runs on the NAS runner (label `nas`) which has the host Docker socket mounted + # and /volume1/docker/rolac bind-mounted at the same path. Builds, pushes to the + # local Gitea registry, then (re)starts the stack. + deploy: + needs: test + runs-on: nas + env: + REGISTRY: git.golife.love/chrischen + DEPLOY_DIR: /volume1/docker/rolac + steps: + - uses: actions/checkout@v4 + + - name: Registry login + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build images + run: | + docker build -t "$REGISTRY/rolac-api:latest" -t "$REGISTRY/rolac-api:${{ github.sha }}" ./API + docker build -t "$REGISTRY/rolac-app:latest" -t "$REGISTRY/rolac-app:${{ github.sha }}" ./APP + + - name: Push images + run: | + docker push --all-tags "$REGISTRY/rolac-api" + docker push --all-tags "$REGISTRY/rolac-app" + + - name: Sync compose + nginx to deploy dir + run: | + mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage" + cp deploy/nas/docker-compose.yml "$DEPLOY_DIR/docker-compose.yml" + cp deploy/nas/nginx/conf.d/rolac.conf "$DEPLOY_DIR/nginx/conf.d/rolac.conf" + + - name: Deploy + run: | + cd "$DEPLOY_DIR" + docker compose up -d + sleep 5 + curl -fsS http://localhost:8080/api/health diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..101ab7e --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,54 @@ +name: ci-cd +on: + push: + branches: [azure-deploy] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { dotnet-version: '8.0.x' } + - run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release + + build-push: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: git.golife.love + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: ./API + push: true + tags: | + git.golife.love/chrischen/rolac-api:latest + git.golife.love/chrischen/rolac-api:${{ github.sha }} + - uses: docker/build-push-action@v6 + with: + context: ./APP + push: true + tags: | + git.golife.love/chrischen/rolac-app:latest + git.golife.love/chrischen/rolac-app:${{ github.sha }} + + deploy: + needs: build-push + runs-on: ubuntu-latest + steps: + - uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.VM_HOST }} + username: ${{ secrets.VM_USER }} + key: ${{ secrets.VM_SSH_KEY }} + script: | + cd /opt/rolac/deploy + export TAG=${{ github.sha }} + docker compose pull + docker compose up -d + curl -fsS https://app.rolac.org/api/health diff --git a/API/.dockerignore b/API/.dockerignore new file mode 100644 index 0000000..dbf25b4 --- /dev/null +++ b/API/.dockerignore @@ -0,0 +1,7 @@ +**/bin +**/obj +**/appsettings.Development.json +**/appsettings.*.local.json +ROLAC.API.Tests/ +.git +*.user diff --git a/API/Dockerfile b/API/Dockerfile new file mode 100644 index 0000000..bce3160 --- /dev/null +++ b/API/Dockerfile @@ -0,0 +1,25 @@ +# ---- build ---- +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY ROLAC.API/ROLAC.API.csproj ROLAC.API/ +RUN dotnet restore ROLAC.API/ROLAC.API.csproj +COPY ROLAC.API/ ROLAC.API/ +RUN dotnet publish ROLAC.API/ROLAC.API.csproj -c Release -o /app/publish /p:UseAppHost=false + +# ---- runtime ---- +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_HTTP_PORTS=8080 +# curl: used by the HEALTHCHECK (not present in the base image) +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +# storage dir created + owned for the non-root app user +RUN mkdir -p /app/App_Data/storage && chown -R app:app /app/App_Data +USER app +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \ + CMD curl -fsS http://localhost:8080/health || exit 1 +ENTRYPOINT ["dotnet", "ROLAC.API.dll"] diff --git a/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs index 824fd45..24ef989 100644 --- a/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs @@ -7,12 +7,24 @@ using ROLAC.API.Data.Interceptors; using ROLAC.API.DTOs.Giving; using ROLAC.API.Entities; using ROLAC.API.Services; +using ROLAC.API.Services.Storage; using Xunit; namespace ROLAC.API.Tests.Services; public class OfferingSessionServiceTests { + // Proof storage is not exercised by these tests; a no-op keeps the service constructible. + private sealed class NoOpFileStorage : IFileStorage + { + public Task SaveAsync(Stream content, string relativePath, CancellationToken ct = default) + => Task.FromResult(relativePath); + public Task OpenReadAsync(string relativePath, CancellationToken ct = default) + => Task.FromResult(null); + public Task DeleteAsync(string relativePath, CancellationToken ct = default) + => Task.CompletedTask; + } + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; @@ -55,7 +67,7 @@ public class OfferingSessionServiceTests { using var db = BuildDb(); var catId = await SeedCategoryAsync(db); - var svc = new OfferingSessionService(db, BuildAccessor()); + var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage()); var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); @@ -72,7 +84,7 @@ public class OfferingSessionServiceTests { using var db = BuildDb(); var catId = await SeedCategoryAsync(db); - var svc = new OfferingSessionService(db, BuildAccessor()); + var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage()); var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); @@ -86,7 +98,7 @@ public class OfferingSessionServiceTests { using var db = BuildDb(); var catId = await SeedCategoryAsync(db); - var svc = new OfferingSessionService(db, BuildAccessor()); + var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage()); await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); await Assert.ThrowsAsync(() => @@ -98,7 +110,7 @@ public class OfferingSessionServiceTests { using var db = BuildDb(); var catId = await SeedCategoryAsync(db); - var svc = new OfferingSessionService(db, BuildAccessor()); + var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage()); var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); await Assert.ThrowsAsync(() => @@ -110,7 +122,7 @@ public class OfferingSessionServiceTests { using var db = BuildDb(); var catId = await SeedCategoryAsync(db); - var svc = new OfferingSessionService(db, BuildAccessor()); + var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage()); var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); await svc.ReopenAsync(id); @@ -135,7 +147,7 @@ public class OfferingSessionServiceTests { using var db = BuildDb(); var catId = await SeedCategoryAsync(db); - var svc = new OfferingSessionService(db, BuildAccessor()); + var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage()); var req = new CreateOfferingSessionRequest { SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m, diff --git a/API/ROLAC.API/Controllers/OfferingSessionsController.cs b/API/ROLAC.API/Controllers/OfferingSessionsController.cs index b17b556..d9d575d 100644 --- a/API/ROLAC.API/Controllers/OfferingSessionsController.cs +++ b/API/ROLAC.API/Controllers/OfferingSessionsController.cs @@ -56,4 +56,40 @@ public class OfferingSessionsController : ControllerBase catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } + + // ── Paper-proof PDF (merged client-side, one file per session) ─────────── + + [HttpPost("{id:int}/proof")] + [RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt + public async Task UploadProof(int id, IFormFile file) + { + if (file is null || file.Length == 0) return BadRequest(new { message = "No file." }); + if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." }); + try + { + await using var stream = file.OpenReadStream(); + await _svc.SaveProofAsync(id, stream, file.FileName); + return NoContent(); + } + catch (KeyNotFoundException) { return NotFound(); } + } + + [HttpGet("{id:int}/proof")] + public async Task GetProof(int id) + { + try + { + var result = await _svc.OpenProofAsync(id); + if (result is null) return NotFound(); + return File(result.Value.stream, result.Value.contentType); + } + catch (KeyNotFoundException) { return NotFound(); } + } + + [HttpDelete("{id:int}/proof")] + public async Task DeleteProof(int id) + { + try { await _svc.DeleteProofAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } } diff --git a/API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs index 6f65c84..a84456b 100644 --- a/API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs +++ b/API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs @@ -10,5 +10,6 @@ public class OfferingSessionDto public decimal SystemTotal { get; set; } public decimal Difference { get; set; } public string? Notes { get; set; } + public bool HasProof { get; set; } public List Givings { get; set; } = []; } diff --git a/API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs index e2f1fea..92c5888 100644 --- a/API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs +++ b/API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs @@ -10,4 +10,5 @@ public class OfferingSessionListItemDto public decimal SystemTotal { get; set; } public decimal Difference { get; set; } public int LineCount { get; set; } + public bool HasProof { get; set; } } diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 04df4e4..aefba3d 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -119,6 +119,7 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.Difference).HasColumnType("decimal(18,2)"); entity.Property(e => e.SubmittedBy).HasMaxLength(450); entity.Property(e => e.ReconciledBy).HasMaxLength(450); + entity.Property(e => e.ProofPdfPath).HasMaxLength(500); entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.HasIndex(e => e.SessionDate).IsUnique(); diff --git a/API/ROLAC.API/Entities/OfferingSession.cs b/API/ROLAC.API/Entities/OfferingSession.cs index 460e89e..7de4718 100644 --- a/API/ROLAC.API/Entities/OfferingSession.cs +++ b/API/ROLAC.API/Entities/OfferingSession.cs @@ -12,6 +12,7 @@ public class OfferingSession : AuditableEntity public decimal SystemTotal { get; set; } public decimal Difference { get; set; } public string? Notes { get; set; } + public string? ProofPdfPath { get; set; } // merged paper-proof PDF (relative storage path) public DateTimeOffset? SubmittedAt { get; set; } public string? SubmittedBy { get; set; } public DateTimeOffset? ReconciledAt { get; set; } diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index 11190a8..34ccd31 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -911,6 +911,10 @@ namespace ROLAC.API.Migrations b.Property("Notes") .HasColumnType("text"); + b.Property("ProofPdfPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + b.Property("ReconciledAt") .HasColumnType("timestamp with time zone"); diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index fe8dfa3..613ab63 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using System.Security.Claims; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -143,6 +144,7 @@ builder.Services opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter()); }); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddHealthChecks(); builder.Services.AddSwaggerGen(opt => { opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" }); @@ -171,6 +173,12 @@ builder.Services.AddSwaggerGen(opt => // --------------------------------------------------------------------------- var app = builder.Build(); +// Behind a TLS-terminating reverse proxy (nginx), honour the original scheme/client IP. +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}); + // Apply migrations + seed on startup using (var scope = app.Services.CreateScope()) { @@ -185,10 +193,16 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } -app.UseHttpsRedirection(); +// TLS is terminated by nginx in production; only redirect in local dev. +if (app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + app.UseCors("Angular"); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); +app.MapHealthChecks("/health"); app.Run(); diff --git a/API/ROLAC.API/Services/IOfferingSessionService.cs b/API/ROLAC.API/Services/IOfferingSessionService.cs index 20a318b..45ce0f8 100644 --- a/API/ROLAC.API/Services/IOfferingSessionService.cs +++ b/API/ROLAC.API/Services/IOfferingSessionService.cs @@ -12,4 +12,8 @@ public interface IOfferingSessionService Task CreateAsync(CreateOfferingSessionRequest request); Task ReopenAsync(int id); Task ReplaceAsync(int id, CreateOfferingSessionRequest request); + + Task SaveProofAsync(int id, Stream content, string fileName); + Task<(Stream stream, string contentType)?> OpenProofAsync(int id); + Task DeleteProofAsync(int id); } diff --git a/API/ROLAC.API/Services/OfferingSessionService.cs b/API/ROLAC.API/Services/OfferingSessionService.cs index c90b368..8c885c9 100644 --- a/API/ROLAC.API/Services/OfferingSessionService.cs +++ b/API/ROLAC.API/Services/OfferingSessionService.cs @@ -5,6 +5,7 @@ using ROLAC.API.Data; using ROLAC.API.DTOs.Giving; using ROLAC.API.DTOs.Shared; using ROLAC.API.Entities; +using ROLAC.API.Services.Storage; namespace ROLAC.API.Services; @@ -12,11 +13,13 @@ public class OfferingSessionService : IOfferingSessionService { private readonly AppDbContext _db; private readonly IHttpContextAccessor _http; + private readonly IFileStorage _storage; - public OfferingSessionService(AppDbContext db, IHttpContextAccessor http) + public OfferingSessionService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage) { - _db = db; - _http = http; + _db = db; + _http = http; + _storage = storage; } private string CurrentUserId => @@ -48,6 +51,7 @@ public class OfferingSessionService : IOfferingSessionService CashTotal = s.CashTotal, CheckTotal = s.CheckTotal, SystemTotal = s.SystemTotal, Difference = s.Difference, LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0, + HasProof = s.ProofPdfPath != null, }).ToList(); return new PagedResult @@ -81,6 +85,7 @@ public class OfferingSessionService : IOfferingSessionService Id = s.Id, SessionDate = s.SessionDate, Status = s.Status, CashTotal = s.CashTotal, CheckTotal = s.CheckTotal, SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes, + HasProof = s.ProofPdfPath != null, Givings = lines.Select(l => new OfferingGivingLineDto { Id = l.Id, MemberId = l.MemberId, @@ -158,6 +163,41 @@ public class OfferingSessionService : IOfferingSessionService await _db.SaveChangesAsync(); } + // ── Paper-proof PDF (one merged file per session) ──────────────────────── + + public async Task SaveProofAsync(int id, Stream content, string fileName) + { + var s = await _db.OfferingSessions.FindAsync(id) + ?? throw new KeyNotFoundException($"OfferingSession {id} not found."); + + var path = $"finance/offering-proofs/{s.SessionDate.Year}/{s.SessionDate.Month}/{s.Id}-proof.pdf"; + if (s.ProofPdfPath != null && s.ProofPdfPath != path) + await _storage.DeleteAsync(s.ProofPdfPath); + var saved = await _storage.SaveAsync(content, path); + s.ProofPdfPath = saved; + await _db.SaveChangesAsync(); + } + + public async Task<(Stream stream, string contentType)?> OpenProofAsync(int id) + { + var s = await _db.OfferingSessions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"OfferingSession {id} not found."); + if (s.ProofPdfPath is null) return null; + var stream = await _storage.OpenReadAsync(s.ProofPdfPath); + if (stream is null) return null; + return (stream, "application/pdf"); + } + + public async Task DeleteProofAsync(int id) + { + var s = await _db.OfferingSessions.FindAsync(id) + ?? throw new KeyNotFoundException($"OfferingSession {id} not found."); + if (s.ProofPdfPath is null) return; + await _storage.DeleteAsync(s.ProofPdfPath); + s.ProofPdfPath = null; + await _db.SaveChangesAsync(); + } + private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new() { MemberId = line.IsAnonymous ? null : line.MemberId, diff --git a/APP/.dockerignore b/APP/.dockerignore new file mode 100644 index 0000000..c586617 --- /dev/null +++ b/APP/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.angular +.git +*.log diff --git a/APP/Dockerfile b/APP/Dockerfile new file mode 100644 index 0000000..3055cb8 --- /dev/null +++ b/APP/Dockerfile @@ -0,0 +1,13 @@ +# ---- build ---- +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --legacy-peer-deps +COPY . . +RUN npm run build # -> dist/client-bridge/browser (production by default) + +# ---- runtime ---- +FROM nginx:alpine AS final +COPY nginx.app.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/client-bridge/browser /usr/share/nginx/html +EXPOSE 80 diff --git a/APP/nginx.app.conf b/APP/nginx.app.conf new file mode 100644 index 0000000..4593630 --- /dev/null +++ b/APP/nginx.app.conf @@ -0,0 +1,14 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location ~* \.(?:js|css|woff2?|png|jpe?g|svg|ico)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/APP/package-lock.json b/APP/package-lock.json index 3d986fd..8ff4754 100644 --- a/APP/package-lock.json +++ b/APP/package-lock.json @@ -54,6 +54,8 @@ "@progress/kendo-svg-icons": "^4.5.0", "@progress/kendo-theme-default": "^12.0.0", "@progress/kendo-theme-utils": "^12.0.0", + "browser-image-compression": "^2.0.2", + "pdf-lib": "^1.17.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -3196,6 +3198,24 @@ "license": "MIT", "optional": true }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5576,6 +5596,15 @@ "node": ">=8" } }, + "node_modules/browser-image-compression": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz", + "integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==", + "license": "MIT", + "dependencies": { + "uzip": "0.20201231.0" + } + }, "node_modules/browserslist": { "version": "4.25.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", @@ -9789,6 +9818,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -9924,6 +9959,24 @@ "node": ">=16" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11544,6 +11597,12 @@ "node": ">= 0.4.0" } }, + "node_modules/uzip": { + "version": "0.20201231.0", + "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", + "integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==", + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/APP/package.json b/APP/package.json index 9b7c0df..c8b485f 100644 --- a/APP/package.json +++ b/APP/package.json @@ -68,6 +68,8 @@ "@progress/kendo-svg-icons": "^4.5.0", "@progress/kendo-theme-default": "^12.0.0", "@progress/kendo-theme-utils": "^12.0.0", + "browser-image-compression": "^2.0.2", + "pdf-lib": "^1.17.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/APP/src/app/features/giving/models/giving.model.ts b/APP/src/app/features/giving/models/giving.model.ts index 4fd26b3..d095907 100644 --- a/APP/src/app/features/giving/models/giving.model.ts +++ b/APP/src/app/features/giving/models/giving.model.ts @@ -101,6 +101,7 @@ export interface OfferingSessionDto { systemTotal: number; difference: number; notes: string | null; + hasProof: boolean; givings: OfferingGivingLineDto[]; } export interface OfferingSessionListItemDto { @@ -112,6 +113,7 @@ export interface OfferingSessionListItemDto { systemTotal: number; difference: number; lineCount: number; + hasProof: boolean; } /** A row held in the client-side batch buffer before submit. */ diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html index 3e7f0d4..1fbcc0d 100644 --- a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html @@ -50,6 +50,11 @@ + + + 📎 + + @@ -152,6 +157,35 @@ + +
+
+

Paper Proof

+ 紙本證明 +
+ +

+ Attach photos or PDFs of the count sheets / envelopes. Images are compressed and all files + are merged into a single PDF when you submit. +
附上點算單/信封的照片或 PDF。圖片會自動壓縮,送出時合併為單一 PDF。 +

+ + + +
    +
  • + {{ f.name }} + +
  • +
+ +

+ Selecting files here replaces any existing proof for this session. +
於此選擇檔案會取代此 session 既有的證明。 +

+
+
@@ -230,8 +264,32 @@
+ +
+
+

Paper Proof

+ 紙本證明 +
+ + + +
+ + + + + + + No proof attached · 尚未附上證明 + + + Working… · 處理中… +
+
+ -
+

Notes

備註 diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss index d7551e8..41a890d 100644 --- a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss @@ -182,6 +182,22 @@ .lines-footer { margin-top: 10px; color: var(--ink-soft); font-weight: 600; font-variant-numeric: tabular-nums; } .anon-chip { padding: 0.25rem 0.6rem; background: var(--kendo-color-base-subtle, #e6eaef); border-radius: 999px; font-size: 12px; font-weight: 600; } .notes-text { margin: 0; color: var(--ink); line-height: 1.5; white-space: pre-wrap; } + +/* ---- Paper proof ---- */ +.proof-hint { margin: 0 0 12px; font-size: 13px; line-height: 1.5; color: var(--ink-soft); span { color: #9aa7b6; } } +.proof-input { display: block; font-size: 14px; } +.proof-list { + list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; + li { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 4px 10px; background: var(--kendo-color-base-subtle, #f1f4f8); + border: 1px solid var(--line); border-radius: 8px; font-size: 13px; + } +} +.proof-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.proof-warn { margin: 10px 0 0; font-size: 12px; line-height: 1.5; color: #b45309; span { color: #d97706; } } +.proof-none { font-size: 14px; color: var(--ink-soft); } +.proof-busy { font-size: 13px; color: var(--ink-soft); } .dialog-text { margin: 0; line-height: 1.55; span { color: var(--ink-soft); font-size: 13px; } } .empty { diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts index 7c04f06..d3f57bb 100644 --- a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts @@ -1,7 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Observable } from 'rxjs'; +import { Observable, from, of, map, switchMap } from 'rxjs'; +import { buildProofPdf } from '../../services/proof-pdf.builder'; import { GridModule } from '@progress/kendo-angular-grid'; import { InputsModule } from '@progress/kendo-angular-inputs'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; @@ -56,6 +57,10 @@ export class OfferingSessionPageComponent implements OnInit { showQuickAdd = false; submitting = false; + // Paper-proof attachments staged client-side; merged into one PDF on submit. + pendingProofFiles: File[] = []; + proofBusy = false; + sessions: OfferingSessionListItemDto[] = []; editingSessionId: number | null = null; @@ -99,6 +104,7 @@ export class OfferingSessionPageComponent implements OnInit { this.viewSession = null; this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; + this.pendingProofFiles = []; this.resetEntry(); this.mode = 'workspace'; } @@ -167,6 +173,7 @@ export class OfferingSessionPageComponent implements OnInit { this.cashTotal = dto.cashTotal; this.checkTotal = dto.checkTotal; this.notes = dto.notes; + this.pendingProofFiles = []; this.buffer = dto.givings.map(g => ({ memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount, paymentMethod: g.paymentMethod, checkNumber: g.checkNumber, @@ -254,13 +261,21 @@ export class OfferingSessionPageComponent implements OnInit { notes: l.notes, })), }; - const obs: Observable = this.editingSessionId != null - ? this.api.replace(this.editingSessionId, req) - : this.api.create(req); - obs.subscribe({ + const isEdit = this.editingSessionId != null; + // Save the session first, then resolve to its id so the proof PDF can be attached. + const savedId$: Observable = isEdit + ? this.api.replace(this.editingSessionId!, req).pipe(map(() => this.editingSessionId!)) + : this.api.create(req).pipe(map(r => r.id)); + + savedId$.pipe( + switchMap(id => this.pendingProofFiles.length === 0 + ? of(void 0) + : from(buildProofPdf(this.pendingProofFiles)).pipe( + switchMap(({ blob }) => this.api.uploadProof(id, blob)))), + ).subscribe({ next: () => { this.submitting = false; - alert(this.editingSessionId != null ? 'Offering session updated.' : 'Offering session submitted.'); + alert(isEdit ? 'Offering session updated.' : 'Offering session submitted.'); this.resetSession(); this.mode = 'landing'; this.loadSessions(); @@ -272,6 +287,65 @@ export class OfferingSessionPageComponent implements OnInit { }); } + // ── Paper proof ─────────────────────────────────────────────────────────── + + /** Stage selected attachment files (appends, so multiple picks accumulate). */ + onProofFilesSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + if (files.length) this.pendingProofFiles = [...this.pendingProofFiles, ...files]; + input.value = ''; // allow re-selecting the same file + } + + removeProofFile(i: number): void { + this.pendingProofFiles = this.pendingProofFiles.filter((_, idx) => idx !== i); + } + + /** Open the stored proof PDF in a new tab (authenticated blob fetch). */ + openProof(id: number): void { + this.api.downloadProof(id).subscribe(blob => { + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + }); + } + + /** Replace the stored proof from the read-only view (rebuild + upload + refresh). */ + replaceProof(event: Event): void { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + input.value = ''; + if (!files.length || !this.viewSession) return; + const id = this.viewSession.id; + this.proofBusy = true; + from(buildProofPdf(files)).pipe( + switchMap(({ blob }) => this.api.uploadProof(id, blob)), + switchMap(() => this.api.getById(id)), + ).subscribe({ + next: dto => { this.viewSession = dto; this.proofBusy = false; }, + error: (err: { error?: { message?: string } }) => { + this.proofBusy = false; + alert(err?.error?.message ?? 'Proof upload failed.'); + }, + }); + } + + removeProof(): void { + if (!this.viewSession) return; + if (!confirm('Remove the paper proof for this session? / 移除此 session 的紙本證明?')) return; + const id = this.viewSession.id; + this.proofBusy = true; + this.api.deleteProof(id).pipe( + switchMap(() => this.api.getById(id)), + ).subscribe({ + next: dto => { this.viewSession = dto; this.proofBusy = false; }, + error: (err: { error?: { message?: string } }) => { + this.proofBusy = false; + alert(err?.error?.message ?? 'Remove failed.'); + }, + }); + } + /** Clear the whole working session back to a fresh state (today, empty buffer). */ private resetSession(): void { this.editingSessionId = null; @@ -279,6 +353,7 @@ export class OfferingSessionPageComponent implements OnInit { this.viewSession = null; this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; + this.pendingProofFiles = []; this.sessionDate = new Date(); this.resetEntry(); this.checkDate(); diff --git a/APP/src/app/features/giving/services/offering-session-api.service.ts b/APP/src/app/features/giving/services/offering-session-api.service.ts index 2292b8e..03a8b01 100644 --- a/APP/src/app/features/giving/services/offering-session-api.service.ts +++ b/APP/src/app/features/giving/services/offering-session-api.service.ts @@ -36,4 +36,23 @@ export class OfferingSessionApiService { replace(id: number, request: CreateOfferingSessionRequest): Observable { return this.http.put(`${this.endpoint}/${id}`, request); } + + /** Upload the merged paper-proof PDF (built client-side) for a session. */ + uploadProof(id: number, pdf: Blob): Observable { + const form = new FormData(); + form.append('file', pdf, 'proof.pdf'); + return this.http.post(`${this.endpoint}/${id}/proof`, form); + } + + /** + * Fetch the proof PDF as a Blob via HttpClient so the auth interceptor attaches the JWT. + * A bare window.open on the API URL would be an unauthenticated navigation → 401. + */ + downloadProof(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}/proof`, { responseType: 'blob' }); + } + + deleteProof(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}/proof`); + } } diff --git a/APP/src/app/features/giving/services/proof-pdf.builder.ts b/APP/src/app/features/giving/services/proof-pdf.builder.ts new file mode 100644 index 0000000..42a7ff1 --- /dev/null +++ b/APP/src/app/features/giving/services/proof-pdf.builder.ts @@ -0,0 +1,77 @@ +import imageCompression from 'browser-image-compression'; +import { PDFDocument } from 'pdf-lib'; + +/** + * Builds a single merged PDF from a list of paper-proof attachments (mostly phone photos). + * + * - Images are compressed in the browser (resized + re-encoded as JPEG) so the upload stays + * small — phone photos are 12MP+ and would otherwise be huge. One image per page. + * - Files that are already PDFs are merged through page-by-page, unchanged. + * - Other file types are skipped (and reported in `skipped`). + * + * All work happens client-side; the resulting Blob is uploaded as the session's proof.pdf. + */ + +// Tunables — adjust if proofs look too soft (raise) or files are too large (lower). +const MAX_EDGE_PX = 2000; // longest image edge after compression +const JPEG_QUALITY = 0.72; // 0..1 +const MAX_SIZE_MB = 1; // target ceiling per image + +// US Letter, in PDF points (72pt = 1in). +const PAGE_W = 612; +const PAGE_H = 792; +const MARGIN = 36; + +export interface ProofBuildResult { + blob: Blob; + /** Names of files that were skipped because the type is unsupported. */ + skipped: string[]; +} + +export async function buildProofPdf(files: File[]): Promise { + const doc = await PDFDocument.create(); + const skipped: string[] = []; + + for (const file of files) { + if (file.type === 'application/pdf') { + const bytes = new Uint8Array(await file.arrayBuffer()); + const src = await PDFDocument.load(bytes); + const pages = await doc.copyPages(src, src.getPageIndices()); + pages.forEach(p => doc.addPage(p)); + continue; + } + + if (file.type.startsWith('image/')) { + const compressed = await imageCompression(file, { + maxWidthOrHeight: MAX_EDGE_PX, + maxSizeMB: MAX_SIZE_MB, + initialQuality: JPEG_QUALITY, + fileType: 'image/jpeg', + useWebWorker: true, + }); + const jpgBytes = new Uint8Array(await compressed.arrayBuffer()); + const img = await doc.embedJpg(jpgBytes); + + const page = doc.addPage([PAGE_W, PAGE_H]); + const maxW = PAGE_W - MARGIN * 2; + const maxH = PAGE_H - MARGIN * 2; + const scale = Math.min(maxW / img.width, maxH / img.height, 1); + const w = img.width * scale; + const h = img.height * scale; + page.drawImage(img, { + x: (PAGE_W - w) / 2, + y: (PAGE_H - h) / 2, + width: w, + height: h, + }); + continue; + } + + skipped.push(file.name); + } + + const bytes = await doc.save(); + // Copy into a fresh ArrayBuffer-backed view so the Blob type is unambiguous. + const blob = new Blob([bytes.slice()], { type: 'application/pdf' }); + return { blob, skipped }; +} diff --git a/APP/src/environments/environment.prod.ts b/APP/src/environments/environment.prod.ts index 5e51f5f..564476d 100644 --- a/APP/src/environments/environment.prod.ts +++ b/APP/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - apiUrl: 'https://your-production-api.com/api' + apiUrl: '/api' }; diff --git a/deploy/build-push.ps1 b/deploy/build-push.ps1 new file mode 100644 index 0000000..2f74551 --- /dev/null +++ b/deploy/build-push.ps1 @@ -0,0 +1,58 @@ +# build-push.ps1 — Build both ROLAC images and push them to the Gitea registry. +# +# Usage (from anywhere): +# .\deploy\build-push.ps1 # tags :latest and : +# .\deploy\build-push.ps1 -Tag v1.2.0 # also adds an extra :v1.2.0 tag +# .\deploy\build-push.ps1 -NoPush # build only, don't push +# +# Prereqs: +# - Docker Desktop running +# - docker login git.golife.love (once, with a write:package access token) + +param( + [string]$Tag, # optional extra tag, e.g. a release version + [switch]$NoPush # build only +) + +$ErrorActionPreference = 'Stop' + +# Repo root = parent of this script's folder +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Registry = 'git.golife.love/chrischen' +$Api = "$Registry/rolac-api" +$App = "$Registry/rolac-app" + +# Short git sha for an immutable version tag +$Sha = (git -C $RepoRoot rev-parse --short HEAD).Trim() +Write-Host "Building from commit $Sha" -ForegroundColor Cyan + +# Assemble the -t arguments for each image +$apiTags = @('-t', "${Api}:latest", '-t', "${Api}:$Sha") +$appTags = @('-t', "${App}:latest", '-t', "${App}:$Sha") +if ($Tag) { + $apiTags += @('-t', "${Api}:$Tag") + $appTags += @('-t', "${App}:$Tag") +} + +Write-Host "==> Building API image" -ForegroundColor Green +docker build @apiTags "$RepoRoot\API" +if ($LASTEXITCODE -ne 0) { throw "API build failed" } + +Write-Host "==> Building APP image" -ForegroundColor Green +docker build @appTags "$RepoRoot\APP" +if ($LASTEXITCODE -ne 0) { throw "APP build failed" } + +if ($NoPush) { + Write-Host "Build complete (push skipped)." -ForegroundColor Yellow + return +} + +Write-Host "==> Pushing API image" -ForegroundColor Green +docker push --all-tags $Api +if ($LASTEXITCODE -ne 0) { throw "API push failed" } + +Write-Host "==> Pushing APP image" -ForegroundColor Green +docker push --all-tags $App +if ($LASTEXITCODE -ne 0) { throw "APP push failed" } + +Write-Host "Done. Pushed :latest and :$Sha$(if($Tag){" and :$Tag"})." -ForegroundColor Cyan diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..5b6463f --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,31 @@ +services: + api: + image: git.golife.love/chrischen/rolac-api:${TAG:-latest} + env_file: .env + environment: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__DefaultConnection: ${DB_CONNECTION} + Jwt__SecretKey: ${JWT_SECRET} + Cors__AllowedOrigins__0: https://app.rolac.org + volumes: + - api-storage:/app/App_Data/storage + restart: unless-stopped + expose: ["8080"] + + app: + image: git.golife.love/chrischen/rolac-app:${TAG:-latest} + restart: unless-stopped + expose: ["80"] + + nginx: + image: nginx:alpine + ports: ["80:80", "443:443"] + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - /var/www/certbot:/var/www/certbot:ro + depends_on: [api, app] + restart: unless-stopped + +volumes: + api-storage: diff --git a/deploy/nas/README.md b/deploy/nas/README.md new file mode 100644 index 0000000..4031046 --- /dev/null +++ b/deploy/nas/README.md @@ -0,0 +1,96 @@ +# Deploy to Synology NAS (Container Manager) — LAN / HTTP + +Target: run the ROLAC stack on a Synology NAS, reachable on the LAN at +`http://:8080`, with images built & pushed to the **local Gitea registry** +(`git.golife.love`, same NAS) and auto-deployed by a **Gitea act_runner** on push to `main`. + +``` +browser (LAN) -> http://:8080 + │ nginx edge (container, 8080->80) + ├── / -> app container (Angular static) + └── /api/ -> api container (ASP.NET, :8080) +api ──> existing PostgreSQL @ 192.168.68.55:49154 (not containerized) +``` + +Differences vs the Azure plan: no TLS/certbot, edge on **8080** (DSM owns 80/443), +reuse the LAN database, deploy via the on-NAS runner (no SSH). + +--- + +## One-time NAS setup + +1. **Deploy dir + secrets** (via SSH or File Station): + ```bash + mkdir -p /volume1/docker/rolac/nginx/conf.d /volume1/docker/rolac/data/api-storage + cp /path/to/repo/deploy/nas/.env.example /volume1/docker/rolac/.env + # edit /volume1/docker/rolac/.env -> real DB user/password + JWT_SECRET + APP_ORIGIN + ``` + +2. **Registry token** — in Gitea: Settings → Applications → new token with + `read:package` + `write:package`. Log the NAS Docker in once: + ```bash + docker login git.golife.love -u ChrisChen # paste the token + ``` + +3. **Install the act_runner on the NAS** (Container Manager → Registry → `gitea/act_runner`, + or `docker run`). It must: + - mount the host Docker socket: `-v /var/run/docker.sock:/var/run/docker.sock` + - mount the deploy dir at the same path: `-v /volume1/docker/rolac:/volume1/docker/rolac` + - register against Gitea with the label **`nas`** (this is what `runs-on: nas` targets). + + Get a registration token in Gitea: Site/Repo → Settings → Actions → Runners → + "Create new runner". Example: + ```bash + docker run -d --restart unless-stopped --name rolac-runner \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /volume1/docker/rolac:/volume1/docker/rolac \ + -e GITEA_INSTANCE_URL=https://git.golife.love \ + -e GITEA_RUNNER_REGISTRATION_TOKEN= \ + -e GITEA_RUNNER_LABELS=nas \ + gitea/act_runner:latest + ``` + +4. **Gitea repo secrets** (Settings → Actions → Secrets): + - `REGISTRY_USER` = `ChrisChen` + - `REGISTRY_TOKEN` = the package token from step 2 + +5. **Enable Actions** for the repo if not already (Settings → Advanced → Actions). + +--- + +## Day-to-day + +`git push` to `main` → `.gitea/workflows/ci-cd-nas.yml` runs: +**test → build both images → push to registry → sync compose/nginx → `docker compose up -d` → health check.** + +Open `http://:8080` and log in. + +--- + +## Manual deploy (no runner yet) + +From a machine with Docker + `docker login git.golife.love`: +```powershell +# repo root, build + push (uses deploy/build-push.ps1) +.\deploy\build-push.ps1 +``` +Then on the NAS: +```bash +cd /volume1/docker/rolac +docker compose up -d +curl -fsS http://localhost:8080/api/health +``` + +--- + +## Notes + +- **First boot runs DB migrations** against `192.168.68.55` automatically + (`Program.cs` calls `MigrateAsync()` + seed). Make sure the DB user has DDL rights; + back up before the first run. +- **Bind-mount paths**: the runner deploys by running compose at `/volume1/docker/rolac` + on the host (socket-mounted), so `./nginx/conf.d` and `./data` resolve to real NAS + paths — that's why the runner mounts that dir at the *same* path. +- **Uploaded files** persist under `/volume1/docker/rolac/data/api-storage`. +- To expose beyond the LAN later, put it behind DSM's reverse proxy (Application Portal) + or switch to the Azure `deploy/` files with certbot. diff --git a/deploy/nas/docker-compose.yml b/deploy/nas/docker-compose.yml new file mode 100644 index 0000000..80d2f4f --- /dev/null +++ b/deploy/nas/docker-compose.yml @@ -0,0 +1,29 @@ +services: + api: + image: git.golife.love/chrischen/rolac-api:${TAG:-latest} + env_file: .env + environment: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__DefaultConnection: ${DB_CONNECTION} + Jwt__SecretKey: ${JWT_SECRET} + # Same-origin /api means CORS is not triggered by the browser; this is only + # a safety net for direct cross-origin calls / tools. Set to your NAS URL. + Cors__AllowedOrigins__0: ${APP_ORIGIN:-http://localhost:8080} + volumes: + - ./data/api-storage:/app/App_Data/storage + restart: unless-stopped + expose: ["8080"] + + app: + image: git.golife.love/chrischen/rolac-app:${TAG:-latest} + restart: unless-stopped + expose: ["80"] + + nginx: + image: nginx:alpine + # DSM already uses 80/443, so the edge is published on 8080 (HTTP, LAN only). + ports: ["8080:80"] + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + depends_on: [api, app] + restart: unless-stopped diff --git a/deploy/nas/nginx/conf.d/rolac.conf b/deploy/nas/nginx/conf.d/rolac.conf new file mode 100644 index 0000000..e7ecf46 --- /dev/null +++ b/deploy/nas/nginx/conf.d/rolac.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + + # API -> api container. The SPA calls same-origin /api/... (environment.prod.ts). + location /api/ { + proxy_pass http://api:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Everything else -> the Angular static app (its own nginx does SPA fallback). + location / { + proxy_pass http://app:80; + } +} diff --git a/deploy/nginx/conf.d/rolac.conf b/deploy/nginx/conf.d/rolac.conf new file mode 100644 index 0000000..1dd46f0 --- /dev/null +++ b/deploy/nginx/conf.d/rolac.conf @@ -0,0 +1,24 @@ +server { # HTTP -> HTTPS + listen 80; + server_name app.rolac.org api.rolac.org; + location /.well-known/acme-challenge/ { root /var/www/certbot; } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl; + server_name app.rolac.org; + ssl_certificate /etc/letsencrypt/live/app.rolac.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app.rolac.org/privkey.pem; + + location /api/ { + proxy_pass http://api:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { + proxy_pass http://app:80; + } +}