diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7fe826c --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# ── Hookline local stack (docker compose) ───────────────────────────────── +# Copy to .env and fill real secrets for anything beyond local dev. + +# Postgres role password (shared by the DB + the backend connection string). +POSTGRES_PASSWORD=hookline + +# Backend secrets. Generate strong values for prod: openssl rand -base64 36 +TOKEN_ENCRYPTION_KEY=dev-token-encryption-key-change-me # AES master key — NEVER change after launch +IDENTITY_SIGNING_KEY=dev-identity-signing-key-change-me # HMAC key for the BFF→backend identity assertion +BACKEND_ADMIN_TOKEN=dev-admin-token # proves the BFF is calling (X-Admin-Token) +SESSION_SECRET=dev-session-secret-change-me # signs the BFF session cookie + +# Fast-dev: backend runs as a dev admin without the BFF token/identity. Development-only — +# it is forced off and refused at boot in Production. Flip to true locally to skip auth. +AUTH_DEV_NO_AUTH=false +NEXT_PUBLIC_NO_AUTH=false + +# First-run bootstrap admin (seeded only when the users table is empty). +BOOTSTRAP_ADMIN_EMAIL=admin@hookline.local +BOOTSTRAP_ADMIN_PASSWORD= + +# Client-visible backend origin for OAuth "Connect" redirects (build-arg inlined). +NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..34f6f9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,130 @@ +name: CI + +on: + push: + branches: [main, dev, phase-0] + pull_request: + +jobs: + backend: + name: Backend (build + arch/unit tests) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + - run: dotnet restore Hookline.slnx + - run: dotnet build Hookline.slnx -c Release --no-restore + # The architecture tests run here — an illegal module reference fails this step + # (non-zero exit), which fails the job and blocks the merge. + - name: Test (gates the build on architecture violations) + run: dotnet test Hookline.slnx -c Release --no-build --verbosity normal + + frontend: + name: Frontend (build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - run: bun run build + + # ════════════════════════════════════════════════════════════════════════════ + # DEPLOY GATE — image build/push + server deploy are DISABLED until you flip it. + # + # >>> ONE-LINE FLIP: on the `docker` job below, change + # if: false + # to + # if: github.ref == 'refs/heads/main' + # That enables image build/push on main; the `deploy` job (needs: [docker]) + # then runs automatically on main too. Until you flip it, both are SKIPPED + # and nothing is pushed or deployed — the backend/frontend jobs above still + # run for real on every push/PR and gate correctness. + # + # Before flipping, set repo secrets DEPLOY_HOST + DEPLOY_SSH_KEY, confirm GH_REPO, + # and complete the go-live checklist in deploy/README.md. + # ════════════════════════════════════════════════════════════════════════════ + docker: + name: Docker images (build + push to GHCR) + needs: [backend, frontend] + if: false # ← DEPLOY GATE: flip to `github.ref == 'refs/heads/main'` to enable + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build & push backend + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository }}/backend:latest + ghcr.io/${{ github.repository }}/backend:${{ github.sha }} + - name: Build & push frontend + uses: docker/build-push-action@v6 + with: + context: ./web + file: ./web/Dockerfile + push: true + # NEXT_PUBLIC_BACKEND_URL is inlined into the client bundle at BUILD time + # (the "Connect Slack/Google" buttons navigate the browser to + # ${NEXT_PUBLIC_BACKEND_URL}/{provider}/.../oauth/start). It MUST be the public + # origin, baked here — a runtime env cannot override an inlined value. + build-args: | + NEXT_PUBLIC_BACKEND_URL=https://danielhub.dev + tags: | + ghcr.io/${{ github.repository }}/frontend:latest + ghcr.io/${{ github.repository }}/frontend:${{ github.sha }} + + deploy: + name: Deploy (scp compose + Caddy snippet, pull, up -d, reload Caddy) + needs: [docker] + # Inherits the gate: when `docker` is SKIPPED (if: false above), this is skipped too. + # Its own guard keeps deploy to main even after the docker gate is enabled. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Copy compose + Caddy site block to server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: deploy + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "deploy/docker-compose.prod.yml,deploy/Caddyfile" + target: "/opt/hookline" + strip_components: 1 + - name: Pull, restart, install + reload shared Caddy + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: deploy + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -e + cd /opt/hookline + export IMAGE_TAG="${{ github.sha }}" + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + # Publish this app's Caddy site block into the shared Caddy, then hot-reload. + install -D -m 644 Caddyfile /opt/shared/sites/hookline.caddy + docker exec shared-caddy-1 caddy reload --config /etc/caddy/Caddyfile + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1966a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Design handoff bundle (reference only, not source) +/design/ + +# Dependencies / build artifacts +**/node_modules/ +**/.next/ +**/out/ +**/build/ +*.tsbuildinfo + +# Env +**/.env +**/.env.local +**/.env.*.local +!**/.env.example + +# Local Claude settings +.claude/settings.local.json + +# OS +.DS_Store + +# Local prod-profile smoke harness (holds TEST secrets — never commit) +deploy/local/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..bd98b4f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..8e84a4d --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +**/bin/ +**/obj/ +.git/ +.vs/ +**/*.user +[Tt]est[Rr]esults/ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..1955ec2 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +.vs/ +[Tt]est[Rr]esults/ +*.tsbuildinfo diff --git a/backend/.idea/.idea.Hookline/.idea/.gitignore b/backend/.idea/.idea.Hookline/.idea/.gitignore new file mode 100644 index 0000000..9abb98e --- /dev/null +++ b/backend/.idea/.idea.Hookline/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/.idea.Hookline.iml +/contentModel.xml +/projectSettingsUpdater.xml diff --git a/backend/.idea/.idea.Hookline/.idea/.name b/backend/.idea/.idea.Hookline/.idea/.name new file mode 100644 index 0000000..1a2a76f --- /dev/null +++ b/backend/.idea/.idea.Hookline/.idea/.name @@ -0,0 +1 @@ +Hookline \ No newline at end of file diff --git a/backend/.idea/.idea.Hookline/.idea/encodings.xml b/backend/.idea/.idea.Hookline/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/backend/.idea/.idea.Hookline/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.Hookline/.idea/indexLayout.xml b/backend/.idea/.idea.Hookline/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/backend/.idea/.idea.Hookline/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.Hookline/.idea/vcs.xml b/backend/.idea/.idea.Hookline/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/backend/.idea/.idea.Hookline/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..e80b823 --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + latest + true + false + false + false + + diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props new file mode 100644 index 0000000..1ec474f --- /dev/null +++ b/backend/Directory.Packages.props @@ -0,0 +1,34 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5c61a94 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,30 @@ +# ── build ────────────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Restore first (cache layer) — copy only the project graph + central config. +COPY global.json Directory.Build.props Directory.Packages.props Hookline.slnx ./ +COPY src/Hookline.Host/Hookline.Host.csproj src/Hookline.Host/ +COPY src/Hookline.SharedKernel/Hookline.SharedKernel.csproj src/Hookline.SharedKernel/ +COPY src/Hookline.Infrastructure/Hookline.Infrastructure.csproj src/Hookline.Infrastructure/ +COPY src/Modules/Hookline.Modules.YouTubeUploads/Hookline.Modules.YouTubeUploads.csproj src/Modules/Hookline.Modules.YouTubeUploads/ +COPY src/Modules/Hookline.Modules.YouTubeComments/Hookline.Modules.YouTubeComments.csproj src/Modules/Hookline.Modules.YouTubeComments/ +COPY tests/Hookline.ArchitectureTests/Hookline.ArchitectureTests.csproj tests/Hookline.ArchitectureTests/ +RUN dotnet restore src/Hookline.Host/Hookline.Host.csproj + +COPY . . +RUN dotnet publish src/Hookline.Host/Hookline.Host.csproj -c Release -o /app --no-restore + +# ── runtime ──────────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +# Kerberos/GSSAPI lib so Npgsql doesn't log a (harmless) probe error on startup. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgssapi-krb5-2 \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /app . + +ENV ASPNETCORE_URLS=http://+:8080 \ + DOTNET_gcServer=0 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "Hookline.Host.dll"] diff --git a/backend/Hookline.slnx b/backend/Hookline.slnx new file mode 100644 index 0000000..44bdf65 --- /dev/null +++ b/backend/Hookline.slnx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json new file mode 100644 index 0000000..7dcefc3 --- /dev/null +++ b/backend/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.8", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/backend/global.json b/backend/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/backend/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/backend/src/Hookline.Host/Endpoints/AuthEndpoints.cs b/backend/src/Hookline.Host/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..59012ee --- /dev/null +++ b/backend/src/Hookline.Host/Endpoints/AuthEndpoints.cs @@ -0,0 +1,94 @@ +using Hookline.Infrastructure.Auth; +using Hookline.SharedKernel.Auth; +using Hookline.SharedKernel.Common; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Hookline.Host.Endpoints; + +public static class AuthEndpoints +{ + public static void MapHooklineAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/auth"); + + // Lets the BFF decide whether to show the one-time Create-Owner flow. + group.MapGet("/bootstrap-state", async (UserService users) => + { + var ownerExists = await users.OwnerExistsAsync(); + var all = await users.ListAsync(); + return Results.Ok(new { ownerExists, userCount = all.Count }); + }); + + // The BFF validates here, then mints the session cookie + identity assertion. + group.MapPost("/login", async (LoginRequest request, UserService users) => + { + var user = await users.ValidateCredentialsAsync(request.Email, request.Password); + return user is null + ? Results.Problem(statusCode: 401, title: "invalid_credentials", detail: "Invalid email or password.") + : Results.Ok(ToDto(user)); + }); + + group.MapPost("/logout", () => Results.NoContent()); + + group.MapGet("/me", async (ICurrentUser current, UserService users) => + { + if (!current.IsAuthenticated) + { + return Results.Problem(statusCode: 401, title: "unauthorized"); + } + + // The identity assertion carries id + role; resolve the email from the store. + var email = current.Email; + if (email is null && current.UserId is { } id && !current.IsSystem) + { + email = (await users.FindAsync(id))?.Email; + } + + return Results.Ok(new { id = current.UserId, email, role = current.Role?.ToString(), isSystem = current.IsSystem }); + }); + + group.MapGet("/users", async (ICurrentUser current, UserService users) => + { + if (!current.HasAtLeast(UserRole.Admin)) + { + return Forbidden(); + } + + var all = await users.ListAsync(); + return Results.Ok(all.Select(ToDto)); + }); + + group.MapPost("/users", async (CreateUserRequest request, ICurrentUser current, UserService users) => + { + if (!Enum.TryParse(request.Role, ignoreCase: true, out var role)) + { + return Results.Problem(statusCode: 400, title: "validation", detail: "Unknown role."); + } + + return ToResult(await users.CreateUserAsync(current, request.Email, request.Password, role)); + }); + + // One-time Create-Owner. Creating a second Owner is rejected server-side + // (race-safe partial unique index → 409). + group.MapPost("/owner", async (CreateOwnerRequest request, ICurrentUser current, UserService users) => + ToResult(await users.CreateOwnerAsync(current, request.Email, request.Password))); + } + + private static IResult ToResult(Result result) => + result.IsSuccess + ? Results.Ok(ToDto(result.Value!)) + : Results.Problem(statusCode: result.Error!.Status, title: result.Error.Code, detail: result.Error.Message); + + private static IResult Forbidden() => + Results.Problem(statusCode: 403, title: "forbidden", detail: "You do not have permission to do that."); + + private static UserDto ToDto(User user) => + new(user.Id, user.Email, user.Role.ToString(), user.Status.ToString(), user.LastLoginAt); + + private sealed record LoginRequest(string Email, string Password); + private sealed record CreateUserRequest(string Email, string Password, string Role); + private sealed record CreateOwnerRequest(string Email, string Password); + private sealed record UserDto(Guid Id, string Email, string Role, string Status, DateTimeOffset? LastLoginAt); +} diff --git a/backend/src/Hookline.Host/Endpoints/SystemEndpoints.cs b/backend/src/Hookline.Host/Endpoints/SystemEndpoints.cs new file mode 100644 index 0000000..8f73510 --- /dev/null +++ b/backend/src/Hookline.Host/Endpoints/SystemEndpoints.cs @@ -0,0 +1,253 @@ +using System.Text.RegularExpressions; + +using Hookline.Infrastructure.Settings; +using Hookline.SharedKernel.Audit; +using Hookline.SharedKernel.Auth; +using Hookline.SharedKernel.Common; +using Hookline.SharedKernel.Maintenance; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Hookline.Host.Endpoints; + +/// Body of PATCH /api/system/alerts — any non-null field is applied. +public sealed record UpdateAlertsRequest(bool? UploadFailures, bool? QuotaWarnings, bool? OauthExpiry, bool? WeeklyDigest); + +/// Body of POST /api/system/reset — must carry the literal type-to-confirm phrase. +public sealed record ResetRequest(string? Confirm); + +/// +/// Host-level System endpoints shared by every module: the audit-log read + CSV export, the persisted +/// Alerts preferences, and the cross-module "Danger Zone" actions (pause-all / reset). The destructive +/// actions fan out over every registered — the host never names a module +/// type, so the module-boundary arch tests keep holding. Every state change writes the shared audit trail. +/// +public static class SystemEndpoints +{ + private const string ResetPhrase = "RESET"; + + public static void MapHooklineSystemEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/system"); + + group.MapGet("/logs", async ( + ICurrentUser current, IAuditLogReader reader, string? module, int? page, int? pageSize, CancellationToken ct) => + { + if (!current.IsAuthenticated) + { + return Results.Problem(statusCode: 401, title: "unauthorized"); + } + + var result = await reader.ListAsync(module, page ?? 1, pageSize ?? 50, ct); + return Results.Ok(result); + }); + + // Real filtered CSV export of the Logs page. Applies the module filter server-side and the + // level/search refinements the page uses, over a wider window (up to MaxExportRows). text/csv body; + // the web client names the download (the BFF proxy forwards content-type but not content-disposition). + group.MapGet("/logs/export.csv", async ( + ICurrentUser current, IAuditLogReader reader, string? module, string? level, string? q, CancellationToken ct) => + { + if (!current.IsAuthenticated) + { + return Results.Problem(statusCode: 401, title: "unauthorized"); + } + + const int pageSize = 200, maxExportRows = 5000; + var rows = new List(); + for (var page = 1; rows.Count < maxExportRows; page++) + { + var batch = await reader.ListAsync(module, page, pageSize, ct); + rows.AddRange(batch.Items); + if (batch.Items.Count < pageSize || rows.Count >= batch.Total) + { + break; + } + } + + var csv = Csv.Document( + ["timestamp", "module", "level", "action", "actor", "role", "entityType", "entityId", "detail"], + rows.Where(r => LogMatches(r, level, q)).Take(maxExportRows).Select(r => + { + var (lvl, text) = SplitLevel(r.Detail); + return new string?[] + { + r.Timestamp.ToString("o"), r.Module, lvl, r.Action, r.Actor, r.Role, + r.EntityType, r.EntityId, text, + }; + })); + return Results.Text(csv, "text/csv"); + }); + + // ── Alerts preferences (persisted via the shared settings store; delivery is a separate feature) ── + group.MapGet("/alerts", async (ICurrentUser current, AlertSettingsService alerts, CancellationToken ct) => + { + if (!current.IsAuthenticated) + { + return Results.Problem(statusCode: 401, title: "unauthorized"); + } + + var a = await alerts.GetAsync(ct); + return Results.Ok(new { a.UploadFailures, a.QuotaWarnings, a.OauthExpiry, a.WeeklyDigest }); + }); + + group.MapPatch("/alerts", async ( + UpdateAlertsRequest body, ICurrentUser current, AlertSettingsService alerts, IAuditLog audit, CancellationToken ct) => + { + if (!current.IsAuthenticated) + { + return Results.Problem(statusCode: 401, title: "unauthorized"); + } + + var a = await alerts.UpdateAsync(body.UploadFailures, body.QuotaWarnings, body.OauthExpiry, body.WeeklyDigest, ct); + await audit.WriteAsync("system.alerts-updated", module: "system", + detail: $"failures={a.UploadFailures}, quota={a.QuotaWarnings}, oauth={a.OauthExpiry}, digest={a.WeeklyDigest}", ct: ct); + return Results.Ok(new { a.UploadFailures, a.QuotaWarnings, a.OauthExpiry, a.WeeklyDigest }); + }); + + // ── Danger Zone — cross-module fan-out, each module audits its own slice ── + // The fan-out is NOT one transaction: each module's op is its own commit and may already have + // succeeded when a LATER module throws. So we isolate every module in its own try/catch — one + // failure never aborts the others, the host ALWAYS writes its audit entry (recording a partial + // outcome + which module failed), and the caller gets a visible `partial` signal instead of a + // bare 500 that would hide "some modules were already changed". + group.MapPost("/pause-all", async ( + ICurrentUser current, IEnumerable controls, IAuditLog audit, CancellationToken ct) => + { + if (!current.IsAuthenticated) + { + return Results.Problem(statusCode: 401, title: "unauthorized"); + } + + var (results, failures) = await FanOutAsync(controls, (c, t) => c.PauseAllAsync(t), ct); + var total = results.Sum(r => r.Affected); + await audit.WriteAsync("system.pause-all", module: "system", + detail: failures.Count == 0 + ? $"paused {total} automation(s) across {results.Count} module(s)" + : $"PARTIAL — paused {total} automation(s) across {results.Count} module(s); {FailDetail(failures)}", + ct: ct); + return Results.Ok(new + { + paused = total, + partial = failures.Count > 0, + byModule = results.Select(r => new { r.Module, r.Affected, r.Detail }), + failed = failures.Select(f => new { f.Module, f.Error }), + }); + }); + + group.MapPost("/reset", async ( + ResetRequest body, ICurrentUser current, IEnumerable controls, IAuditLog audit, CancellationToken ct) => + { + if (!current.IsAuthenticated) + { + return Results.Problem(statusCode: 401, title: "unauthorized"); + } + + if (!string.Equals(body?.Confirm, ResetPhrase, StringComparison.Ordinal)) + { + return Results.Problem(statusCode: 400, title: "confirmation_required", + detail: $"Type {ResetPhrase} to confirm — this clears operational data and can't be undone."); + } + + var (results, failures) = await FanOutAsync(controls, (c, t) => c.ResetDataAsync(t), ct); + var total = results.Sum(r => r.Affected); + var perModule = string.Join("; ", results.Select(r => $"{r.Module} → {r.Detail}")); + // Written AFTER the wipe and NEVER cleared (reset keeps audit_logs), so the trail always records it + // — including a partial outcome where one module wiped and another failed. + await audit.WriteAsync("system.reset", module: "system", + detail: failures.Count == 0 + ? $"cleared {total} operational row(s): {perModule}" + : $"PARTIAL — cleared {total} operational row(s) [{perModule}]; {FailDetail(failures)}", + ct: ct); + return Results.Ok(new + { + cleared = total, + partial = failures.Count > 0, + byModule = results.Select(r => new { r.Module, r.Affected, r.Detail }), + failed = failures.Select(f => new { f.Module, f.Error }), + }); + }); + } + + /// + /// Runs a Danger-Zone op across every registered module independently. A module that throws is recorded + /// in 's failure list and the loop CONTINUES — the operation is operational-only, so + /// there is no cross-module rollback; the goal is to apply what we can and report the rest, never to abort + /// silently after a partial change. Returns the per-module successes plus (module, error) failures. + /// + private static async Task<(List Results, List<(string Module, string Error)> Failures)> FanOutAsync( + IEnumerable controls, + Func> op, + CancellationToken ct) + { + var results = new List(); + var failures = new List<(string Module, string Error)>(); + foreach (var control in controls) + { + try + { + results.Add(await op(control, ct)); + } + catch (Exception ex) + { + failures.Add((control.Module, ex.Message)); + } + } + + return (results, failures); + } + + private static string FailDetail(IReadOnlyList<(string Module, string Error)> failures) => + $"{failures.Count} module(s) FAILED: " + string.Join("; ", failures.Select(f => $"{f.Module}: {f.Error}")); + + // ── Mirror the web Logs view's level/search derivation so the CSV matches what the page shows ── + + private static readonly Regex LevelMarker = new(@"^\[(\w+)\]\s*", RegexOptions.Compiled); + + /// Splits the folded [Level] marker off the detail, returning (level, remaining text). + private static (string Level, string Text) SplitLevel(string? detail) + { + if (string.IsNullOrEmpty(detail)) + { + return ("info", ""); + } + + var m = LevelMarker.Match(detail); + if (!m.Success) + { + return ("info", detail); + } + + var level = m.Groups[1].Value.ToLowerInvariant() switch + { + "error" => "error", + "warning" or "warn" => "warn", + "success" => "success", + _ => "info", + }; + return (level, detail[m.Length..]); + } + + private static bool LogMatches(AuditLogRecord r, string? level, string? q) + { + var (lvl, text) = SplitLevel(r.Detail); + if (!string.IsNullOrEmpty(level) && level != "all" && lvl != level) + { + return false; + } + + if (!string.IsNullOrEmpty(q)) + { + var needle = q.Trim().ToLowerInvariant(); + var message = (string.IsNullOrEmpty(text) ? r.Action : text).ToLowerInvariant(); + var target = (r.EntityId ?? r.EntityType ?? r.Actor)?.ToLowerInvariant() ?? ""; + if (!message.Contains(needle) && !target.Contains(needle)) + { + return false; + } + } + + return true; + } +} diff --git a/backend/src/Hookline.Host/Hookline.Host.csproj b/backend/src/Hookline.Host/Hookline.Host.csproj new file mode 100644 index 0000000..1d2d6c0 --- /dev/null +++ b/backend/src/Hookline.Host/Hookline.Host.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/backend/src/Hookline.Host/Program.cs b/backend/src/Hookline.Host/Program.cs new file mode 100644 index 0000000..5e464e6 --- /dev/null +++ b/backend/src/Hookline.Host/Program.cs @@ -0,0 +1,98 @@ +using Hangfire; + +using Hookline.Host.Endpoints; +using Hookline.Infrastructure; +using Hookline.Infrastructure.Jobs; +using Hookline.Modules.YouTubeComments; +using Hookline.Modules.YouTubeUploads; +using Hookline.SharedKernel.Jobs; +using Hookline.SharedKernel.Modules; + +using Serilog; +using Serilog.Context; + +Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddSerilog((_, lc) => lc + .Enrich.FromLogContext() + .Enrich.WithProperty("Application", "Hookline") + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] [{module}] [{CorrelationId}] {Message:lj}{NewLine}{Exception}")); + + builder.Services.AddProblemDetails(); + builder.Services.AddHooklineInfrastructure(builder.Configuration, builder.Environment); + + // The explicit module list — no reflection scanning. Adding a module is one line here. + var modules = new List { new YouTubeUploadsModule(), new YouTubeCommentsModule() }; + foreach (var module in modules) + { + module.RegisterServices(builder.Services, builder.Configuration); + } + + var app = builder.Build(); + + // Migrate every schema under an advisory lock, then seed the bootstrap admin. + // Either throwing fails startup — never a half-migrated boot. + await app.Services.MigrateHooklineAsync(modules); + await app.Services.SeedBootstrapAdminAsync(); + + // Correlation id + module enrichment for every request's logs. + app.Use(async (context, next) => + { + var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault() + ?? Guid.NewGuid().ToString("N")[..8]; + context.Response.Headers["X-Correlation-ID"] = correlationId; + + var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries) ?? []; + var module = segments is ["api", var name, ..] ? name : "host"; + + using (LogContext.PushProperty("CorrelationId", correlationId)) + using (LogContext.PushProperty("module", module)) + { + await next(); + } + }); + + app.UseSerilogRequestLogging(); + app.UseHooklineIdentity(); + + app.MapHealthChecks("/health"); + + app.UseHangfireDashboard("/hangfire", new DashboardOptions + { + Authorization = [new HangfireDashboardAuthorizationFilter()], + }); + + foreach (var module in modules) + { + module.MapEndpoints(app); + } + + var scheduler = app.Services.GetRequiredService(); + foreach (var module in modules) + { + module.RegisterJobs(scheduler); + } + + app.MapHooklineAuthEndpoints(); + app.MapHooklineSystemEndpoints(); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Hookline host terminated unexpectedly."); + Log.CloseAndFlush(); + // Exit non-zero immediately. Rethrowing here leaves the faulted async host spinning + // (99% CPU, container still "Up") instead of dying, which would let an orchestrator or + // healthcheck misread a fail-fast (bad secrets, failed migration, …) as a healthy boot. + Environment.Exit(1); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/backend/src/Hookline.Host/Properties/launchSettings.json b/backend/src/Hookline.Host/Properties/launchSettings.json new file mode 100644 index 0000000..216db34 --- /dev/null +++ b/backend/src/Hookline.Host/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5023", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7124;http://localhost:5023", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/Hookline.Host/appsettings.Development.json b/backend/src/Hookline.Host/appsettings.Development.json new file mode 100644 index 0000000..33e12b0 --- /dev/null +++ b/backend/src/Hookline.Host/appsettings.Development.json @@ -0,0 +1,22 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=hookline;Username=hookline;Password=hookline", + "Redis": "localhost:6379" + }, + "TokenEncryption": { + "Key": "dev-token-encryption-key-change-me" + }, + "Identity": { + "SigningKey": "dev-identity-signing-key-change-me" + }, + "BackendAuth": { + "AdminToken": "dev-admin-token" + }, + "Auth": { + "DevNoAuth": true + }, + "Bootstrap": { + "AdminEmail": "admin@hookline.local", + "AdminPassword": "" + } +} diff --git a/backend/src/Hookline.Host/appsettings.json b/backend/src/Hookline.Host/appsettings.json new file mode 100644 index 0000000..79443f7 --- /dev/null +++ b/backend/src/Hookline.Host/appsettings.json @@ -0,0 +1,31 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Hangfire": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Postgres": "", + "Redis": "" + }, + "TokenEncryption": { + "Key": "" + }, + "Identity": { + "SigningKey": "" + }, + "BackendAuth": { + "AdminToken": "" + }, + "Auth": { + "DevNoAuth": false + }, + "Bootstrap": { + "AdminEmail": "", + "AdminPassword": "" + } +} diff --git a/backend/src/Hookline.Infrastructure/Audit/AuditLog.cs b/backend/src/Hookline.Infrastructure/Audit/AuditLog.cs new file mode 100644 index 0000000..b2c9709 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Audit/AuditLog.cs @@ -0,0 +1,98 @@ +using Hookline.Infrastructure.Persistence; + +using Hookline.SharedKernel.Audit; +using Hookline.SharedKernel.Auth; +using Hookline.SharedKernel.Common; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Infrastructure.Audit; + +/// Writes audit entries, stamping the actor from the current user — unless the caller passes an +/// explicit actor (e.g. the moderating Slack user on the identity-bypassed provider callback, +/// where the current user is anonymous), which then takes precedence. +public sealed class AuditLog(SharedDbContext db, ICurrentUser currentUser) : IAuditLog +{ + public async Task WriteAsync( + string action, + string? module = null, + string? entityType = null, + string? entityId = null, + string? detail = null, + string? actor = null, + CancellationToken ct = default) + { + db.AuditLogs.Add(new AuditLogEntry + { + Timestamp = DateTimeOffset.UtcNow, + // An explicit actor (a provider-callback actor like the Slack user) wins; otherwise fall back + // to the request principal (admin email / "system" for jobs / "anonymous"). + Actor = !string.IsNullOrWhiteSpace(actor) + ? actor + : currentUser.Email ?? (currentUser.IsSystem ? "system" : "anonymous"), + ActorId = currentUser.UserId, + Role = currentUser.Role?.ToString(), + Module = module, + Action = action, + EntityType = entityType, + EntityId = entityId, + Detail = detail, + }); + + await db.SaveChangesAsync(ct); + } +} + +/// Pages over the shared audit_logs table, newest first, optionally filtered to one module. +public sealed class AuditLogReader(SharedDbContext db) : IAuditLogReader +{ + public async Task> ListAsync( + string? module, + int page, + int pageSize, + CancellationToken ct = default) + { + var req = new PageRequest(page, pageSize); + + var query = db.AuditLogs.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(module)) + { + query = query.Where(a => a.Module == module); + } + + var total = await query.LongCountAsync(ct); + var items = await query + .OrderByDescending(a => a.Timestamp) + .ThenByDescending(a => a.Id) + .Skip(req.Skip) + .Take(req.SafePageSize) + .Select(a => new AuditLogRecord( + a.Id, a.Timestamp, a.Actor, a.Role, a.Module, a.Action, a.EntityType, a.EntityId, a.Detail)) + .ToListAsync(ct); + + return new PagedResult(items, req.SafePage, req.SafePageSize, total); + } + + public async Task CountSinceAsync( + string? module, + DateTimeOffset since, + string? detailPrefix = null, + CancellationToken ct = default) + { + var query = db.AuditLogs.AsNoTracking().Where(a => a.Timestamp >= since); + + if (!string.IsNullOrWhiteSpace(module)) + { + query = query.Where(a => a.Module == module); + } + + if (!string.IsNullOrWhiteSpace(detailPrefix)) + { + // The level marker is folded into the detail prefix (e.g. "[Error] ..."). Postgres LIKE + // treats '[' / ']' literally, so StartsWith matches the marker exactly. + query = query.Where(a => a.Detail != null && a.Detail.StartsWith(detailPrefix)); + } + + return await query.CountAsync(ct); + } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/AuthDbContext.cs b/backend/src/Hookline.Infrastructure/Auth/AuthDbContext.cs new file mode 100644 index 0000000..e21fde5 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/AuthDbContext.cs @@ -0,0 +1,34 @@ +using Hookline.SharedKernel.Persistence; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Infrastructure.Auth; + +/// Auth schema — the hub's users. +public sealed class AuthDbContext(DbContextOptions options) : HooklineDbContext(options) +{ + public const string SchemaName = "auth"; + + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + model.HasDefaultSchema(SchemaName); + + var user = model.Entity(); + user.ToTable("users"); + user.HasKey(u => u.Id); + user.Property(u => u.Email).IsRequired().HasMaxLength(320); + user.HasIndex(u => u.Email).IsUnique(); + user.Property(u => u.PasswordHash).IsRequired(); + user.Property(u => u.Role).HasConversion().HasMaxLength(20); + user.Property(u => u.Status).HasConversion().HasMaxLength(20); + + // Race-safe single-Owner guarantee: a partial unique index means two concurrent + // "create Owner" requests can never both succeed (one hits a unique violation). + user.HasIndex(u => u.Role) + .IsUnique() + .HasFilter("role = 'Owner'") + .HasDatabaseName("ux_users_single_owner"); + } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/AuthOptions.cs b/backend/src/Hookline.Infrastructure/Auth/AuthOptions.cs new file mode 100644 index 0000000..ff4f0a9 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/AuthOptions.cs @@ -0,0 +1,24 @@ +namespace Hookline.Infrastructure.Auth; + +/// Backend auth configuration (bound from the Auth / BackendAuth sections). +public sealed class AuthOptions +{ + /// Shared secret proving the request comes from the trusted BFF (header X-Admin-Token). + public string AdminToken { get; set; } = string.Empty; + + /// Dedicated HMAC key for the BFF→backend identity assertion (NOT the AES master key). + public string IdentitySigningKey { get; set; } = string.Empty; + + /// Fast-dev escape hatch: skip the BFF token + identity checks and run as a dev admin. + public bool DevNoAuth { get; set; } + + /// Header carrying the short-TTL signed identity assertion. + public string IdentityHeader { get; set; } = "X-Hookline-Identity"; +} + +/// First-run bootstrap admin seeding (env-only path into a fresh prod DB). +public sealed class BootstrapOptions +{ + public string AdminEmail { get; set; } = string.Empty; + public string AdminPassword { get; set; } = string.Empty; +} diff --git a/backend/src/Hookline.Infrastructure/Auth/CurrentUser.cs b/backend/src/Hookline.Infrastructure/Auth/CurrentUser.cs new file mode 100644 index 0000000..d971878 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/CurrentUser.cs @@ -0,0 +1,56 @@ +using Hookline.SharedKernel.Auth; + +using Microsoft.AspNetCore.Http; + +namespace Hookline.Infrastructure.Auth; + +/// Concrete value. +public sealed record HooklineUser( + bool IsAuthenticated, + Guid? UserId, + string? Email, + UserRole? Role, + bool IsSystem) : ICurrentUser +{ + public bool HasAtLeast(UserRole role) => Role is { } r && r >= role; + + public static readonly HooklineUser Anonymous = new(false, null, null, null, false); + + /// The internal principal used by background jobs (full power, no HTTP context). + public static readonly HooklineUser System = new(true, null, "system", UserRole.Owner, true); + + public static HooklineUser Authenticated(Guid id, string? email, UserRole role) => + new(true, id, email, role, false); +} + +/// +/// Resolves the caller per request from (set by the +/// identity middleware), or the system principal when there is no HTTP context +/// (i.e. inside a background job). +/// +public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + public const string ItemsKey = "hookline.current-user"; + + private ICurrentUser Resolve() + { + var ctx = httpContextAccessor.HttpContext; + if (ctx is null) + { + return HooklineUser.System; + } + + return ctx.Items.TryGetValue(ItemsKey, out var value) && value is ICurrentUser user + ? user + : HooklineUser.Anonymous; + } + + public static void Set(HttpContext context, ICurrentUser user) => context.Items[ItemsKey] = user; + + public bool IsAuthenticated => Resolve().IsAuthenticated; + public Guid? UserId => Resolve().UserId; + public string? Email => Resolve().Email; + public UserRole? Role => Resolve().Role; + public bool IsSystem => Resolve().IsSystem; + public bool HasAtLeast(UserRole role) => Resolve().HasAtLeast(role); +} diff --git a/backend/src/Hookline.Infrastructure/Auth/IdentityMiddleware.cs b/backend/src/Hookline.Infrastructure/Auth/IdentityMiddleware.cs new file mode 100644 index 0000000..4396737 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/IdentityMiddleware.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; +using System.Text; + +using Hookline.SharedKernel.Auth; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Hookline.Infrastructure.Auth; + +/// +/// The single auth gate. Bypass allowlist = exactly /slack/*, /google/*, +/// /linkedin/* and /health (signature/state-verified or public). +/// Everything else must present a valid X-Admin-Token (proves the BFF is calling — +/// it does NOT establish identity), after which the signed identity assertion is verified +/// to resolve . Endpoints then enforce role policies. +/// The DevNoAuth escape hatch is bound off outside Development (see +/// DependencyInjection.GuardSecurityConfig), so this gate is never weakened in prod. +/// +public sealed class IdentityMiddleware( + RequestDelegate next, + IOptions options, + IdentityTokenService identityTokens) +{ + private static readonly string[] BypassPrefixes = + ["/slack", "/google", "/linkedin", "/health"]; + + private readonly AuthOptions _options = options.Value; + private readonly byte[] _adminTokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(options.Value.AdminToken)); + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path; + + if (IsBypassed(path)) + { + CurrentUserAccessor.Set(context, HooklineUser.Anonymous); + await next(context); + return; + } + + // Fast-dev: no BFF token / identity required; run as a dev admin. + if (_options.DevNoAuth) + { + CurrentUserAccessor.Set( + context, + new HooklineUser(true, Guid.Empty, "dev@hookline.local", UserRole.Admin, false)); + await next(context); + return; + } + + // 1) Prove the caller is the trusted BFF (constant-time compare). NOT identity. + if (!IsValidAdminToken(context.Request.Headers["X-Admin-Token"])) + { + await WriteProblem(context, StatusCodes.Status401Unauthorized, "bff_required", + "This endpoint is only reachable through the Hookline BFF."); + return; + } + + // 2) Resolve identity from the short-TTL signed assertion (if present). + var assertion = context.Request.Headers[_options.IdentityHeader].ToString(); + var identity = identityTokens.Verify(assertion); + CurrentUserAccessor.Set( + context, + identity is null + ? HooklineUser.Anonymous + : HooklineUser.Authenticated(identity.UserId, email: null, identity.Role)); + + await next(context); + } + + private static bool IsBypassed(PathString path) => + BypassPrefixes.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase)); + + private bool IsValidAdminToken(string? provided) + { + if (string.IsNullOrEmpty(provided) || _options.AdminToken.Length == 0) + { + return false; + } + + var providedHash = SHA256.HashData(Encoding.UTF8.GetBytes(provided)); + return CryptographicOperations.FixedTimeEquals(providedHash, _adminTokenHash); + } + + private static Task WriteProblem(HttpContext context, int status, string code, string detail) + { + context.Response.StatusCode = status; + context.Response.ContentType = "application/problem+json"; + return context.Response.WriteAsJsonAsync(new + { + type = "about:blank", + title = code, + status, + detail, + }); + } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/IdentityTokenService.cs b/backend/src/Hookline.Infrastructure/Auth/IdentityTokenService.cs new file mode 100644 index 0000000..4a84e5c --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/IdentityTokenService.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Hookline.SharedKernel.Auth; + +namespace Hookline.Infrastructure.Auth; + +/// A verified identity extracted from a BFF assertion. +public sealed record AssertedIdentity(Guid UserId, UserRole Role, DateTimeOffset ExpiresAt); + +/// +/// Mints and verifies the short-TTL identity assertion the BFF forwards to the backend. +/// Format (matched by the Next BFF): base64url(JSON{sub,role,exp})."."base64url(HMACSHA256(part1, key)). +/// Signed with a DEDICATED signing key (Identity__SigningKey) — never the AES master key. +/// X-Admin-Token only proves the BFF is calling; this assertion is what establishes identity. +/// +public sealed class IdentityTokenService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly byte[] _key; + + public IdentityTokenService(string? signingKey) + { + if (string.IsNullOrWhiteSpace(signingKey)) + { + throw new InvalidOperationException( + "Identity__SigningKey is required — refusing to start without an identity signing key."); + } + + _key = Encoding.UTF8.GetBytes(signingKey); + } + + public string Sign(Guid userId, UserRole role, TimeSpan ttl) + { + var payload = new Payload( + userId.ToString("N"), + role.ToString(), + DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds()); + + var part1 = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions)); + var signature = Base64UrlEncode(HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(part1))); + return $"{part1}.{signature}"; + } + + public AssertedIdentity? Verify(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + var dot = token.IndexOf('.'); + if (dot <= 0 || dot == token.Length - 1) + { + return null; + } + + var part1 = token[..dot]; + var providedSig = token[(dot + 1)..]; + + var expectedSig = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(part1)); + byte[] providedSigBytes; + try + { + providedSigBytes = Base64UrlDecode(providedSig); + } + catch (FormatException) + { + return null; + } + + if (!CryptographicOperations.FixedTimeEquals(providedSigBytes, expectedSig)) + { + return null; + } + + Payload? payload; + try + { + payload = JsonSerializer.Deserialize(Base64UrlDecode(part1), JsonOptions); + } + catch (Exception ex) when (ex is JsonException or FormatException) + { + return null; + } + + if (payload is null + || !Guid.TryParse(payload.Sub, out var userId) + || !Enum.TryParse(payload.Role, out var role)) + { + return null; + } + + var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.Exp); + if (expiresAt <= DateTimeOffset.UtcNow) + { + return null; // expired + } + + return new AssertedIdentity(userId, role, expiresAt); + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Base64UrlDecode(string value) + { + var s = value.Replace('-', '+').Replace('_', '/'); + s = (s.Length % 4) switch { 2 => s + "==", 3 => s + "=", _ => s }; + return Convert.FromBase64String(s); + } + + private sealed record Payload( + [property: JsonPropertyName("sub")] string Sub, + [property: JsonPropertyName("role")] string Role, + [property: JsonPropertyName("exp")] long Exp); +} diff --git a/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.Designer.cs b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.Designer.cs new file mode 100644 index 0000000..e5f8c8a --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Hookline.Infrastructure.Auth; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Auth.Migrations +{ + [DbContext(typeof(AuthDbContext))] + [Migration("20260609161211_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("auth") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hookline.Infrastructure.Auth.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("Role") + .IsUnique() + .HasDatabaseName("ux_users_single_owner") + .HasFilter("role = 'Owner'"); + + b.ToTable("users", "auth"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.cs b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.cs new file mode 100644 index 0000000..5c477b9 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/Migrations/20260609161211_Initial.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hookline.Infrastructure.Auth.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "auth"); + + migrationBuilder.CreateTable( + name: "users", + schema: "auth", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), + password_hash = table.Column(type: "text", nullable: false), + role = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: true), + last_login_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_users_email", + schema: "auth", + table: "users", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_users_single_owner", + schema: "auth", + table: "users", + column: "role", + unique: true, + filter: "role = 'Owner'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "users", + schema: "auth"); + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/Migrations/AuthDbContextModelSnapshot.cs b/backend/src/Hookline.Infrastructure/Auth/Migrations/AuthDbContextModelSnapshot.cs new file mode 100644 index 0000000..453124e --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/Migrations/AuthDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// +using System; +using Hookline.Infrastructure.Auth; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Auth.Migrations +{ + [DbContext(typeof(AuthDbContext))] + partial class AuthDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("auth") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hookline.Infrastructure.Auth.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("role"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("Role") + .IsUnique() + .HasDatabaseName("ux_users_single_owner") + .HasFilter("role = 'Owner'"); + + b.ToTable("users", "auth"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/PasswordHasher.cs b/backend/src/Hookline.Infrastructure/Auth/PasswordHasher.cs new file mode 100644 index 0000000..27174f0 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/PasswordHasher.cs @@ -0,0 +1,22 @@ +namespace Hookline.Infrastructure.Auth; + +/// bcrypt password hashing (work factor 12). Passwords are never logged. +public sealed class PasswordHasher +{ + private const int WorkFactor = 12; + + public string Hash(string password) => + BCrypt.Net.BCrypt.HashPassword(password, WorkFactor); + + public bool Verify(string password, string hash) + { + try + { + return BCrypt.Net.BCrypt.Verify(password, hash); + } + catch (BCrypt.Net.SaltParseException) + { + return false; + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/User.cs b/backend/src/Hookline.Infrastructure/Auth/User.cs new file mode 100644 index 0000000..c380177 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/User.cs @@ -0,0 +1,16 @@ +using Hookline.SharedKernel.Auth; + +namespace Hookline.Infrastructure.Auth; + +/// A hub user. Roles: Owner (supreme) / Admin / Member. +public sealed class User +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Email { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public UserRole Role { get; set; } = UserRole.Member; + public UserStatus Status { get; set; } = UserStatus.Active; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid? CreatedBy { get; set; } + public DateTimeOffset? LastLoginAt { get; set; } +} diff --git a/backend/src/Hookline.Infrastructure/Auth/UserService.cs b/backend/src/Hookline.Infrastructure/Auth/UserService.cs new file mode 100644 index 0000000..e2a6ab6 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Auth/UserService.cs @@ -0,0 +1,178 @@ +using System.Security.Cryptography; + +using Hookline.SharedKernel.Auth; +using Hookline.SharedKernel.Common; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Npgsql; + +namespace Hookline.Infrastructure.Auth; + +/// The hub's user lifecycle: bootstrap, credential validation, and role-safe creation. +public sealed class UserService( + AuthDbContext db, + PasswordHasher hasher, + IOptions bootstrap, + ILogger logger) +{ + /// + /// First-run seed: if users is empty, create one bootstrap Admin from env. + /// Idempotent — env creds are inert once any user exists. A blank password is + /// generated and logged once (break-glass) rather than silently skipped. + /// + public async Task BootstrapAsync(CancellationToken ct = default) + { + if (await db.Users.AnyAsync(ct)) + { + return; + } + + var email = bootstrap.Value.AdminEmail; + if (string.IsNullOrWhiteSpace(email)) + { + logger.LogWarning("No users exist and Bootstrap__AdminEmail is unset — cannot seed a bootstrap admin."); + return; + } + + var password = bootstrap.Value.AdminPassword; + if (string.IsNullOrWhiteSpace(password)) + { + password = GenerateBreakGlassPassword(); + logger.LogWarning("BOOTSTRAP_ADMIN_PASSWORD (generated, capture now): {Password}", password); + } + + db.Users.Add(new User + { + Email = email.Trim().ToLowerInvariant(), + PasswordHash = hasher.Hash(password), + Role = UserRole.Admin, + Status = UserStatus.Active, + }); + + await db.SaveChangesAsync(ct); + logger.LogInformation("Seeded bootstrap admin {Email}.", email); + } + + public async Task ValidateCredentialsAsync(string email, string password, CancellationToken ct = default) + { + var user = await db.Users + .FirstOrDefaultAsync(u => u.Email == email.Trim().ToLowerInvariant(), ct); + + if (user is null || user.Status != UserStatus.Active || !hasher.Verify(password, user.PasswordHash)) + { + return null; + } + + user.LastLoginAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + return user; + } + + public Task OwnerExistsAsync(CancellationToken ct = default) => + db.Users.AnyAsync(u => u.Role == UserRole.Owner, ct); + + public Task FindAsync(Guid id, CancellationToken ct = default) => + db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct); + + public async Task> ListAsync(CancellationToken ct = default) => + await db.Users.AsNoTracking().OrderBy(u => u.CreatedAt).ToListAsync(ct); + + /// + /// One-time Create-Owner. While no Owner exists, an Admin may create exactly one. + /// Once an Owner exists, only an Owner may grant the role. The partial unique index + /// ux_users_single_owner is the race-safe backstop: concurrent creates can + /// never both succeed. + /// + public async Task> CreateOwnerAsync(ICurrentUser caller, string email, string password, CancellationToken ct = default) + { + if (!caller.HasAtLeast(UserRole.Admin)) + { + return Error.Forbidden; + } + + var ownerExists = await OwnerExistsAsync(ct); + if (ownerExists && caller.Role != UserRole.Owner) + { + return Error.Forbidden with { Message = "An Owner already exists; only the Owner may grant the Owner role." }; + } + + var user = new User + { + Email = email.Trim().ToLowerInvariant(), + PasswordHash = hasher.Hash(password), + Role = UserRole.Owner, + Status = UserStatus.Active, + CreatedBy = caller.UserId, + }; + db.Users.Add(user); + + try + { + await db.SaveChangesAsync(ct); + return user; + } + catch (DbUpdateException ex) when (IsSingleOwnerViolation(ex)) + { + db.Entry(user).State = EntityState.Detached; + return Error.Conflict("An Owner already exists."); + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + db.Entry(user).State = EntityState.Detached; + return Error.Conflict("A user with that email already exists."); + } + } + + /// Create a Member/Admin. Admins manage Members; only an Owner may create Admins. + public async Task> CreateUserAsync(ICurrentUser caller, string email, string password, UserRole role, CancellationToken ct = default) + { + if (role == UserRole.Owner) + { + return await CreateOwnerAsync(caller, email, password, ct); + } + + var allowed = role switch + { + UserRole.Admin => caller.Role == UserRole.Owner, + UserRole.Member => caller.HasAtLeast(UserRole.Admin), + _ => false, + }; + if (!allowed) + { + return Error.Forbidden; + } + + var user = new User + { + Email = email.Trim().ToLowerInvariant(), + PasswordHash = hasher.Hash(password), + Role = role, + Status = UserStatus.Active, + CreatedBy = caller.UserId, + }; + db.Users.Add(user); + + try + { + await db.SaveChangesAsync(ct); + return user; + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + db.Entry(user).State = EntityState.Detached; + return Error.Conflict("A user with that email already exists."); + } + } + + private static bool IsSingleOwnerViolation(DbUpdateException ex) => + ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "ux_users_single_owner" }; + + private static bool IsUniqueViolation(DbUpdateException ex) => + ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; + + private static string GenerateBreakGlassPassword() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)).Replace('/', '_').Replace('+', '-'); +} diff --git a/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs b/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs new file mode 100644 index 0000000..c1b3040 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs @@ -0,0 +1,51 @@ +using Hookline.SharedKernel.Caching; + +using StackExchange.Redis; + +namespace Hookline.Infrastructure.Caching; + +/// +/// Redis-backed . Walks each connected server endpoint, SCANs the prefix and +/// deletes the matches. Best-effort by contract: a connection/SCAN failure is swallowed (the shared Redis +/// runs noeviction but every app key self-expires, so a missed purge only delays cleanup — it must +/// never fail a data reset). +/// +public sealed class RedisCachePurge(IConnectionMultiplexer redis) : ICachePurge +{ + public async Task PurgeByPrefixAsync(string prefix, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(prefix)) + { + return 0; + } + + long removed = 0; + try + { + var db = redis.GetDatabase(); + foreach (var endpoint in redis.GetEndPoints()) + { + var server = redis.GetServer(endpoint); + if (!server.IsConnected || server.IsReplica) + { + continue; + } + + foreach (var key in server.Keys(pattern: prefix + "*", pageSize: 500)) + { + ct.ThrowIfCancellationRequested(); + if (await db.KeyDeleteAsync(key)) + { + removed++; + } + } + } + } + catch (Exception) when (!ct.IsCancellationRequested) + { + // Best-effort: a cache outage must never fail the surrounding data reset. + } + + return removed; + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/ConnectionStores.cs b/backend/src/Hookline.Infrastructure/Connections/ConnectionStores.cs new file mode 100644 index 0000000..fe1632c --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/ConnectionStores.cs @@ -0,0 +1,255 @@ +using Hookline.SharedKernel.Connections; +using Hookline.SharedKernel.Messaging; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Infrastructure.Connections; + +/// Reads/writes Slack bot tokens (encrypted by the converter) + lists workspaces. +public sealed class SlackConnections(ConnectionsDbContext db, IEventBus events) : ISlackConnections +{ + public async Task GetBotTokenAsync(Guid workspaceId, CancellationToken ct = default) => + await db.SlackWorkspaces.AsNoTracking() + .Where(w => w.Id == workspaceId && w.IsActive) + .Select(w => w.BotTokenEncrypted) + .FirstOrDefaultAsync(ct); + + public async Task GetBotTokenForTeamAsync(string teamId, CancellationToken ct = default) => + await db.SlackWorkspaces.AsNoTracking() + .Where(w => w.TeamId == teamId && w.IsActive) + .Select(w => w.BotTokenEncrypted) + .FirstOrDefaultAsync(ct); + + public async Task> ListAsync(CancellationToken ct = default) => + await db.SlackWorkspaces.AsNoTracking() + .OrderBy(w => w.TeamName) + .Select(w => new SlackWorkspaceSummary(w.Id, w.TeamId, w.TeamName, w.IsActive)) + .ToListAsync(ct); + + public async Task GetByTeamAsync(string teamId, CancellationToken ct = default) => + await db.SlackWorkspaces.AsNoTracking() + .Where(w => w.TeamId == teamId) + .Select(w => new SlackWorkspaceSummary(w.Id, w.TeamId, w.TeamName, w.IsActive)) + .FirstOrDefaultAsync(ct); + + public async Task UpsertWorkspaceAsync(SlackWorkspaceWrite write, CancellationToken ct = default) + { + var existing = await db.SlackWorkspaces.FirstOrDefaultAsync(w => w.TeamId == write.TeamId, ct); + if (existing is null) + { + existing = new SlackWorkspace { TeamId = write.TeamId, InstalledAt = DateTimeOffset.UtcNow }; + db.SlackWorkspaces.Add(existing); + } + + existing.TeamName = write.TeamName; + existing.BotTokenEncrypted = write.BotToken; // encrypted on write by the converter + existing.BotUserId = write.BotUserId; + existing.Scope = write.Scope; + existing.AuthedUserId = write.AuthedUserId; + existing.IsActive = true; + + await db.SaveChangesAsync(ct); + return existing.Id; + } + + public async Task DeactivateAsync(Guid workspaceId, CancellationToken ct = default) + { + var ws = await db.SlackWorkspaces.FirstOrDefaultAsync(w => w.Id == workspaceId, ct); + if (ws is null) + { + return false; + } + + ws.IsActive = false; + await db.SaveChangesAsync(ct); + await events.PublishAsync(new SlackWorkspaceDisconnected(workspaceId), ct); + return true; + } +} + +/// Reads/writes Google accounts in the shared store. The decrypted refresh token is handed to +/// the owning module, which rebuilds credentials with its issuing OAuth client (Projects are module-local). +public sealed class GoogleConnections(ConnectionsDbContext db, IEventBus events) : IGoogleConnections +{ + public async Task> ListAsync(CancellationToken ct = default) => + await db.GoogleAccounts.AsNoTracking() + .OrderBy(g => g.ConnectedAt) + .Select(g => new GoogleAccountSummary(g.Id, g.ChannelTitle, g.IsActive)) + .ToListAsync(ct); + + public async Task GetAsync(Guid accountId, CancellationToken ct = default) => + await db.GoogleAccounts.AsNoTracking() + .Where(g => g.Id == accountId) + .Select(g => new GoogleAccountDetail( + g.Id, g.ChannelId, g.ChannelTitle, g.AccountEmail, g.AvatarUrl, g.Scopes, g.IsActive)) + .FirstOrDefaultAsync(ct); + + // Needs an app-wide OAuth client to mint access tokens; module-local Projects own that, so this + // stays null and callers use GetRefreshTokenAsync + their issuing client instead. + public Task GetCredentialAsync(Guid accountId, CancellationToken ct = default) => + Task.FromResult(null); + + public async Task GetRefreshTokenAsync(Guid accountId, CancellationToken ct = default) => + await db.GoogleAccounts.AsNoTracking() + .Where(g => g.Id == accountId && g.IsActive) + .Select(g => g.RefreshTokenEncrypted) // decrypted on read by the converter + .FirstOrDefaultAsync(ct); + + public async Task CreateAccountAsync(GoogleAccountWrite write, CancellationToken ct = default) + { + var account = new GoogleAccount + { + ChannelId = write.ChannelId, + ChannelTitle = write.ChannelTitle, + AccountEmail = write.AccountEmail, + AvatarUrl = write.AvatarUrl, + RefreshTokenEncrypted = write.RefreshToken, // encrypted on write by the converter + Scopes = write.Scopes, + IsActive = true, + ConnectedAt = DateTimeOffset.UtcNow, + }; + db.GoogleAccounts.Add(account); + await db.SaveChangesAsync(ct); + return account.Id; + } + + public async Task UpdateConsentAsync( + Guid accountId, string refreshToken, string scopes, + string? channelTitle = null, string? accountEmail = null, string? avatarUrl = null, + CancellationToken ct = default) + { + var account = await db.GoogleAccounts.FirstOrDefaultAsync(g => g.Id == accountId, ct); + if (account is null) + { + return false; + } + + account.RefreshTokenEncrypted = refreshToken; // re-encrypted on write by the converter + account.Scopes = scopes; + if (channelTitle is not null) account.ChannelTitle = channelTitle; + if (accountEmail is not null) account.AccountEmail = accountEmail; + if (avatarUrl is not null) account.AvatarUrl = avatarUrl; + account.IsActive = true; // a fresh consent re-activates the record + await db.SaveChangesAsync(ct); + return true; + } + + public async Task DeactivateAsync(Guid accountId, CancellationToken ct = default) + { + var account = await db.GoogleAccounts.FirstOrDefaultAsync(g => g.Id == accountId, ct); + if (account is null) + { + return false; + } + + account.IsActive = false; + await db.SaveChangesAsync(ct); + await events.PublishAsync(new GoogleAccountDisconnected(accountId), ct); + return true; + } +} + +/// Reads/writes YouTube Data API keys (encrypted by the converter) for quota-rotated polling. +public sealed class YouTubeApiKeyConnections(ConnectionsDbContext db, IEventBus events) : IYouTubeApiKeyConnections +{ + public async Task> ListAsync(CancellationToken ct = default) => + await db.YouTubeApiKeys.AsNoTracking() + .OrderBy(k => k.CreatedAt) + .Select(k => new YouTubeApiKeySummary(k.Id, k.Name, k.KeyHint, k.IsActive, k.CreatedAt)) + .ToListAsync(ct); + + public async Task> ListActiveAsync(CancellationToken ct = default) => + await db.YouTubeApiKeys.AsNoTracking() + .Where(k => k.IsActive) + .OrderBy(k => k.CreatedAt) + .Select(k => new YouTubeApiKeySummary(k.Id, k.Name, k.KeyHint, k.IsActive, k.CreatedAt)) + .ToListAsync(ct); + + public async Task GetApiKeyAsync(Guid keyId, CancellationToken ct = default) => + await db.YouTubeApiKeys.AsNoTracking() + .Where(k => k.Id == keyId) + .Select(k => k.ApiKeyEncrypted) // decrypted on read by the converter + .FirstOrDefaultAsync(ct); + + public async Task CreateAsync(string name, string apiKey, string keyHint, CancellationToken ct = default) + { + var key = new YouTubeApiKey + { + Name = name, + ApiKeyEncrypted = apiKey, // encrypted on write by the converter + KeyHint = keyHint, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.YouTubeApiKeys.Add(key); + await db.SaveChangesAsync(ct); + return key.Id; + } + + public async Task ToggleAsync(Guid keyId, bool isActive, CancellationToken ct = default) + { + var key = await db.YouTubeApiKeys.FirstOrDefaultAsync(k => k.Id == keyId, ct); + if (key is null) + { + return false; + } + + key.IsActive = isActive; + await db.SaveChangesAsync(ct); + return true; + } + + public async Task DeleteAsync(Guid keyId, CancellationToken ct = default) + { + var key = await db.YouTubeApiKeys.FirstOrDefaultAsync(k => k.Id == keyId, ct); + if (key is null) + { + return false; + } + + db.YouTubeApiKeys.Remove(key); + await db.SaveChangesAsync(ct); + await events.PublishAsync(new YouTubeApiKeyDisconnected(keyId), ct); + return true; + } +} + +/// Aggregates every connection into a unified catalog for the UI. +public sealed class ConnectionCatalog(ConnectionsDbContext db) : IConnectionCatalog +{ + public async Task> ListAsync( + ConnectionType? type = null, + CancellationToken ct = default) + { + var result = new List(); + + if (type is null or ConnectionType.Slack) + { + result.AddRange(await db.SlackWorkspaces.AsNoTracking() + .Select(w => new ConnectionSummary( + w.Id, ConnectionType.Slack, w.TeamName, + w.IsActive ? "connected" : "disabled", w.TeamId)) + .ToListAsync(ct)); + } + + if (type is null or ConnectionType.Google) + { + result.AddRange(await db.GoogleAccounts.AsNoTracking() + .Select(g => new ConnectionSummary( + g.Id, ConnectionType.Google, g.ChannelTitle, + g.IsActive ? "connected" : "disabled", g.ChannelId)) + .ToListAsync(ct)); + } + + if (type is null or ConnectionType.YouTubeApiKey) + { + result.AddRange(await db.YouTubeApiKeys.AsNoTracking() + .Select(k => new ConnectionSummary( + k.Id, ConnectionType.YouTubeApiKey, k.Name, + k.IsActive ? "active" : "disabled", k.KeyHint)) + .ToListAsync(ct)); + } + + return result; + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/ConnectionsDbContext.cs b/backend/src/Hookline.Infrastructure/Connections/ConnectionsDbContext.cs new file mode 100644 index 0000000..4d0590c --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/ConnectionsDbContext.cs @@ -0,0 +1,51 @@ +using Hookline.SharedKernel.Persistence; +using Hookline.SharedKernel.Secrets; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Infrastructure.Connections; + +/// +/// Connections schema — the shared external-credential store. Every secret column is +/// encrypted at rest through . Multiple credential sets +/// per provider are supported (rows). +/// +public sealed class ConnectionsDbContext( + DbContextOptions options, + ISecretProtector protector) : HooklineDbContext(options) +{ + public const string SchemaName = "connections"; + + public DbSet SlackWorkspaces => Set(); + public DbSet GoogleAccounts => Set(); + public DbSet YouTubeApiKeys => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + model.HasDefaultSchema(SchemaName); + + var slack = model.Entity(); + slack.ToTable("slack_workspaces"); + slack.HasKey(w => w.Id); + slack.HasIndex(w => w.TeamId).IsUnique(); + slack.Property(w => w.TeamId).IsRequired().HasMaxLength(64); + slack.Property(w => w.TeamName).HasMaxLength(200); + slack.Property(w => w.BotTokenEncrypted).IsEncrypted(protector); + + var google = model.Entity(); + google.ToTable("google_accounts"); + google.HasKey(g => g.Id); + google.Property(g => g.ChannelTitle).HasMaxLength(200); + google.Property(g => g.AccountEmail).HasMaxLength(320); + google.Property(g => g.ChannelId).HasMaxLength(64); + google.Property(g => g.RefreshTokenEncrypted).IsEncrypted(protector); + google.HasIndex(g => g.ChannelId); + + var key = model.Entity(); + key.ToTable("api_keys"); + key.HasKey(k => k.Id); + key.Property(k => k.Name).IsRequired().HasMaxLength(120); + key.Property(k => k.KeyHint).HasMaxLength(40); + key.Property(k => k.ApiKeyEncrypted).IsEncrypted(protector); + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/Entities.cs b/backend/src/Hookline.Infrastructure/Connections/Entities.cs new file mode 100644 index 0000000..dd55bbe --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/Entities.cs @@ -0,0 +1,40 @@ +namespace Hookline.Infrastructure.Connections; + +/// A connected Slack workspace (OAuth v2 bot token). Multiple per provider allowed. +public sealed class SlackWorkspace +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string TeamId { get; set; } = string.Empty; + public string TeamName { get; set; } = string.Empty; + public string BotTokenEncrypted { get; set; } = string.Empty; + public string? BotUserId { get; set; } + public string? Scope { get; set; } + public string? AuthedUserId { get; set; } + public bool IsActive { get; set; } = true; + public DateTimeOffset InstalledAt { get; set; } = DateTimeOffset.UtcNow; +} + +/// A connected Google account (OAuth refresh token → YouTube + Drive scopes). +public sealed class GoogleAccount +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? ChannelId { get; set; } + public string ChannelTitle { get; set; } = string.Empty; + public string? AccountEmail { get; set; } + public string? AvatarUrl { get; set; } + public string RefreshTokenEncrypted { get; set; } = string.Empty; + public string Scopes { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow; +} + +/// A YouTube Data API key (comment polling). Multiple per provider, quota-rotated. +public sealed class YouTubeApiKey +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string ApiKeyEncrypted { get; set; } = string.Empty; + public string KeyHint { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.Designer.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.Designer.cs new file mode 100644 index 0000000..43148e0 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.Designer.cs @@ -0,0 +1,164 @@ +// +using System; +using Hookline.Infrastructure.Connections; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Connections.Migrations +{ + [DbContext(typeof(ConnectionsDbContext))] + [Migration("20260609161213_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("connections") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.GoogleAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChannelId") + .HasColumnType("text") + .HasColumnName("channel_id"); + + b.Property("ChannelTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("channel_title"); + + b.Property("ConnectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("RefreshTokenEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("refresh_token_encrypted"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_google_accounts"); + + b.ToTable("google_accounts", "connections"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.SlackWorkspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthedUserId") + .HasColumnType("text") + .HasColumnName("authed_user_id"); + + b.Property("BotTokenEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("bot_token_encrypted"); + + b.Property("BotUserId") + .HasColumnType("text") + .HasColumnName("bot_user_id"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("installed_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("team_id"); + + b.Property("TeamName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("team_name"); + + b.HasKey("Id") + .HasName("pk_slack_workspaces"); + + b.HasIndex("TeamId") + .IsUnique() + .HasDatabaseName("ix_slack_workspaces_team_id"); + + b.ToTable("slack_workspaces", "connections"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.YouTubeApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKeyEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("api_key_encrypted"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("KeyHint") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("key_hint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_api_keys"); + + b.ToTable("api_keys", "connections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.cs new file mode 100644 index 0000000..7d54634 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609161213_Initial.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hookline.Infrastructure.Connections.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "connections"); + + migrationBuilder.CreateTable( + name: "api_keys", + schema: "connections", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + api_key_encrypted = table.Column(type: "text", nullable: false), + key_hint = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_api_keys", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "google_accounts", + schema: "connections", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + channel_id = table.Column(type: "text", nullable: true), + channel_title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + refresh_token_encrypted = table.Column(type: "text", nullable: false), + scopes = table.Column(type: "text", nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + connected_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_google_accounts", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "slack_workspaces", + schema: "connections", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + team_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + team_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + bot_token_encrypted = table.Column(type: "text", nullable: false), + bot_user_id = table.Column(type: "text", nullable: true), + scope = table.Column(type: "text", nullable: true), + authed_user_id = table.Column(type: "text", nullable: true), + is_active = table.Column(type: "boolean", nullable: false), + installed_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_slack_workspaces", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_slack_workspaces_team_id", + schema: "connections", + table: "slack_workspaces", + column: "team_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "api_keys", + schema: "connections"); + + migrationBuilder.DropTable( + name: "google_accounts", + schema: "connections"); + + migrationBuilder.DropTable( + name: "slack_workspaces", + schema: "connections"); + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.Designer.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.Designer.cs new file mode 100644 index 0000000..1864129 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.Designer.cs @@ -0,0 +1,177 @@ +// +using System; +using Hookline.Infrastructure.Connections; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Connections.Migrations +{ + [DbContext(typeof(ConnectionsDbContext))] + [Migration("20260609232123_AddGoogleAccountDisplayFields")] + partial class AddGoogleAccountDisplayFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("connections") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.GoogleAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("account_email"); + + b.Property("AvatarUrl") + .HasColumnType("text") + .HasColumnName("avatar_url"); + + b.Property("ChannelId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("channel_id"); + + b.Property("ChannelTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("channel_title"); + + b.Property("ConnectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("RefreshTokenEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("refresh_token_encrypted"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_google_accounts"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_google_accounts_channel_id"); + + b.ToTable("google_accounts", "connections"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.SlackWorkspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthedUserId") + .HasColumnType("text") + .HasColumnName("authed_user_id"); + + b.Property("BotTokenEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("bot_token_encrypted"); + + b.Property("BotUserId") + .HasColumnType("text") + .HasColumnName("bot_user_id"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("installed_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("team_id"); + + b.Property("TeamName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("team_name"); + + b.HasKey("Id") + .HasName("pk_slack_workspaces"); + + b.HasIndex("TeamId") + .IsUnique() + .HasDatabaseName("ix_slack_workspaces_team_id"); + + b.ToTable("slack_workspaces", "connections"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.YouTubeApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKeyEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("api_key_encrypted"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("KeyHint") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("key_hint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_api_keys"); + + b.ToTable("api_keys", "connections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.cs new file mode 100644 index 0000000..09c92ff --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/20260609232123_AddGoogleAccountDisplayFields.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hookline.Infrastructure.Connections.Migrations +{ + /// + public partial class AddGoogleAccountDisplayFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "channel_id", + schema: "connections", + table: "google_accounts", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "account_email", + schema: "connections", + table: "google_accounts", + type: "character varying(320)", + maxLength: 320, + nullable: true); + + migrationBuilder.AddColumn( + name: "avatar_url", + schema: "connections", + table: "google_accounts", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_google_accounts_channel_id", + schema: "connections", + table: "google_accounts", + column: "channel_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_google_accounts_channel_id", + schema: "connections", + table: "google_accounts"); + + migrationBuilder.DropColumn( + name: "account_email", + schema: "connections", + table: "google_accounts"); + + migrationBuilder.DropColumn( + name: "avatar_url", + schema: "connections", + table: "google_accounts"); + + migrationBuilder.AlterColumn( + name: "channel_id", + schema: "connections", + table: "google_accounts", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/Migrations/ConnectionsDbContextModelSnapshot.cs b/backend/src/Hookline.Infrastructure/Connections/Migrations/ConnectionsDbContextModelSnapshot.cs new file mode 100644 index 0000000..b7da0aa --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/Migrations/ConnectionsDbContextModelSnapshot.cs @@ -0,0 +1,174 @@ +// +using System; +using Hookline.Infrastructure.Connections; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Connections.Migrations +{ + [DbContext(typeof(ConnectionsDbContext))] + partial class ConnectionsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("connections") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.GoogleAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("account_email"); + + b.Property("AvatarUrl") + .HasColumnType("text") + .HasColumnName("avatar_url"); + + b.Property("ChannelId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("channel_id"); + + b.Property("ChannelTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("channel_title"); + + b.Property("ConnectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("RefreshTokenEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("refresh_token_encrypted"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_google_accounts"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_google_accounts_channel_id"); + + b.ToTable("google_accounts", "connections"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.SlackWorkspace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthedUserId") + .HasColumnType("text") + .HasColumnName("authed_user_id"); + + b.Property("BotTokenEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("bot_token_encrypted"); + + b.Property("BotUserId") + .HasColumnType("text") + .HasColumnName("bot_user_id"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("installed_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("TeamId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("team_id"); + + b.Property("TeamName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("team_name"); + + b.HasKey("Id") + .HasName("pk_slack_workspaces"); + + b.HasIndex("TeamId") + .IsUnique() + .HasDatabaseName("ix_slack_workspaces_team_id"); + + b.ToTable("slack_workspaces", "connections"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Connections.YouTubeApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ApiKeyEncrypted") + .IsRequired() + .HasColumnType("text") + .HasColumnName("api_key_encrypted"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("KeyHint") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("key_hint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_api_keys"); + + b.ToTable("api_keys", "connections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Connections/OAuthFlowService.cs b/backend/src/Hookline.Infrastructure/Connections/OAuthFlowService.cs new file mode 100644 index 0000000..6f454cc --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/OAuthFlowService.cs @@ -0,0 +1,75 @@ +using System.Security.Cryptography; + +using Hookline.SharedKernel.Common; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using StackExchange.Redis; + +namespace Hookline.Infrastructure.Connections; + +/// +/// OAuth start/callback skeleton owned by the Connections subsystem. Phase 0 implements +/// the CSRF-safe state lifecycle (generate → store in Redis with a short TTL → verify); +/// the provider-specific code exchange + token storage land when the modules are absorbed. +/// +public sealed class OAuthFlowService( + IConnectionMultiplexer redis, + IConfiguration config, + ILogger logger) +{ + private static readonly TimeSpan StateTtl = TimeSpan.FromMinutes(10); + private static readonly string[] KnownProviders = ["slack", "google"]; + + /// Generate state, persist it, and return the provider's authorize URL. + public async Task> StartAsync(string provider, string? returnUrl, CancellationToken ct = default) + { + provider = provider.ToLowerInvariant(); + if (!KnownProviders.Contains(provider)) + { + return Error.Validation($"Unknown OAuth provider '{provider}'."); + } + + var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + await redis.GetDatabase().StringSetAsync( + RedisKeys.OAuthState(provider, state), + returnUrl ?? "/", + StateTtl); + + var clientId = config[$"{Capitalize(provider)}:ClientId"]; + if (string.IsNullOrWhiteSpace(clientId)) + { + // Skeleton: the provider isn't configured yet (real wiring is Phase 1). + logger.LogInformation("OAuth start requested for {Provider} but no client is configured.", provider); + return Error.Conflict($"The {provider} connection is not configured yet."); + } + + var redirectUri = config[$"{Capitalize(provider)}:RedirectUri"] ?? string.Empty; + var authorizeUrl = provider switch + { + "slack" => $"https://slack.com/oauth/v2/authorize?client_id={clientId}&state={state}&redirect_uri={Uri.EscapeDataString(redirectUri)}", + "google" => $"https://accounts.google.com/o/oauth2/v2/auth?client_id={clientId}&state={state}&redirect_uri={Uri.EscapeDataString(redirectUri)}&response_type=code&access_type=offline&prompt=consent", + _ => string.Empty, + }; + + return authorizeUrl; + } + + /// Verify the returned state (one-shot) before any code exchange. + public async Task> VerifyStateAsync(string provider, string state, CancellationToken ct = default) + { + provider = provider.ToLowerInvariant(); + var key = RedisKeys.OAuthState(provider, state); + var returnUrl = await redis.GetDatabase().StringGetDeleteAsync(key); + if (returnUrl.IsNullOrEmpty) + { + return Error.Validation("Invalid or expired OAuth state."); + } + + // Phase 1 will exchange the code for tokens and persist the connection here. + return returnUrl.ToString(); + } + + private static string Capitalize(string s) => char.ToUpperInvariant(s[0]) + s[1..]; +} diff --git a/backend/src/Hookline.Infrastructure/Connections/RedisKeys.cs b/backend/src/Hookline.Infrastructure/Connections/RedisKeys.cs new file mode 100644 index 0000000..2239fe9 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Connections/RedisKeys.cs @@ -0,0 +1,19 @@ +namespace Hookline.Infrastructure.Connections; + +/// +/// Redis key-prefix registry. One shared Redis; every key is namespaced per owner so +/// modules can't collide. Keep this in sync with docs/redis-key-prefixes.md. +/// +/// conn:* — Connections (OAuth state, transient caches) +/// auth:* — Auth (session/rate-limit helpers) +/// ytu:* — YouTube Uploads module +/// ytc:* — YouTube Comments module +/// +/// +public static class RedisKeys +{ + public const string ConnectionsPrefix = "conn:"; + public const string AuthPrefix = "auth:"; + + public static string OAuthState(string provider, string state) => $"{ConnectionsPrefix}oauth:{provider}:{state}"; +} diff --git a/backend/src/Hookline.Infrastructure/DependencyInjection.cs b/backend/src/Hookline.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..dd7a0ac --- /dev/null +++ b/backend/src/Hookline.Infrastructure/DependencyInjection.cs @@ -0,0 +1,207 @@ +using Hangfire; +using Hangfire.PostgreSql; + +using Hookline.Infrastructure.Audit; +using Hookline.Infrastructure.Auth; +using Hookline.Infrastructure.Caching; +using Hookline.Infrastructure.Connections; +using Hookline.Infrastructure.Health; +using Hookline.Infrastructure.Jobs; +using Hookline.Infrastructure.Messaging; +using Hookline.Infrastructure.Persistence; +using Hookline.Infrastructure.Secrets; +using Hookline.Infrastructure.Settings; + +using Hookline.SharedKernel.Audit; +using Hookline.SharedKernel.Auth; +using Hookline.SharedKernel.Caching; +using Hookline.SharedKernel.Connections; +using Hookline.SharedKernel.Jobs; +using Hookline.SharedKernel.Messaging; +using Hookline.SharedKernel.Modules; +using Hookline.SharedKernel.Secrets; +using Hookline.SharedKernel.Settings; + +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using StackExchange.Redis; + +namespace Hookline.Infrastructure; + +public static class DependencyInjection +{ + /// Registers every shared service the modules and host consume. + public static IServiceCollection AddHooklineInfrastructure(this IServiceCollection services, IConfiguration config, IHostEnvironment env) + { + GuardSecurityConfig(config, env); + + var postgres = config.GetConnectionString("Postgres") + ?? throw new InvalidOperationException("ConnectionStrings:Postgres is required."); + var redis = config.GetConnectionString("Redis") + ?? throw new InvalidOperationException("ConnectionStrings:Redis is required."); + + // DevNoAuth is honored ONLY in Development. Outside it the flag is forced off so a + // stray config value can never disable auth (GuardSecurityConfig also refuses to boot + // if it is set), and IdentityMiddleware therefore never impersonates a dev admin. + var devNoAuth = config.GetValue("Auth:DevNoAuth") && env.IsDevelopment(); + + services.Configure(o => + { + o.AdminToken = config["BackendAuth:AdminToken"] ?? string.Empty; + o.IdentitySigningKey = config["Identity:SigningKey"] ?? string.Empty; + o.DevNoAuth = devNoAuth; + }); + services.Configure(o => + { + o.AdminEmail = config["Bootstrap:AdminEmail"] ?? string.Empty; + o.AdminPassword = config["Bootstrap:AdminPassword"] ?? string.Empty; + }); + + // Security primitives (fail fast on missing keys). + services.AddSingleton(_ => new AesGcmSecretProtector(config["TokenEncryption:Key"])); + services.AddSingleton(_ => new IdentityTokenService(config["Identity:SigningKey"])); + services.AddSingleton(); + + // Redis (lazy, never aborts the app on a transient outage). + services.AddSingleton(_ => + { + var options = ConfigurationOptions.Parse(redis); + options.AbortOnConnectFail = false; + return ConnectionMultiplexer.Connect(options); + }); + + // Prefix purge for the module data-reset path (best-effort; a cache outage never fails a reset). + services.AddSingleton(); + + services.AddHttpContextAccessor(); + services.AddScoped(); + + void ConfigureContext(DbContextOptionsBuilder builder, string schema) => builder + .UseNpgsql(postgres, npgsql => npgsql.MigrationsHistoryTable("__ef_migrations_history", schema)) + .UseSnakeCaseNamingConvention(); + + services.AddDbContext(o => ConfigureContext(o, SharedDbContext.SchemaName)); + services.AddDbContext(o => ConfigureContext(o, AuthDbContext.SchemaName)); + services.AddDbContext(o => ConfigureContext(o, ConnectionsDbContext.SchemaName)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddHangfire(cfg => cfg + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(opt => opt.UseNpgsqlConnection(postgres))); + services.AddHangfireServer(); + + services.AddHealthChecks() + .AddCheck("postgres") + .AddCheck("redis"); + + return services; + } + + /// + /// Fails the boot in any non-Development environment if auth is weakened: DevNoAuth must + /// be off, and the required secrets must not be empty or a known dev placeholder. This is + /// the loud backstop behind the silent DevNoAuth gating in . + /// The required set is the three core auth primitives PLUS each module's Slack signing secret: + /// both modules always map their /slack/.../interactivity callback, and the verifier is + /// fail-closed, so an empty/placeholder signing secret would make every "Reject on YouTube" button + /// press 401 INVISIBLY in prod (boot stays clean, cards keep posting). Guarding it turns that silent + /// misconfig into a fast, loud boot failure. + /// internal (not private) so it can be unit-tested directly without standing up the DB. + /// + internal static void GuardSecurityConfig(IConfiguration config, IHostEnvironment env) + { + if (env.IsDevelopment()) + { + return; + } + + if (config.GetValue("Auth:DevNoAuth")) + { + throw new InvalidOperationException( + $"Auth:DevNoAuth=true is only allowed in Development (environment: {env.EnvironmentName}). Refusing to start."); + } + + (string Key, string? Value)[] secrets = + [ + ("TokenEncryption:Key", config["TokenEncryption:Key"]), + ("Identity:SigningKey", config["Identity:SigningKey"]), + ("BackendAuth:AdminToken", config["BackendAuth:AdminToken"]), + // Slack signing secrets — verify the interactivity callbacks (always mapped, fail-closed). + ("YouTubeUploads:Slack:SigningSecret", config["YouTubeUploads:Slack:SigningSecret"]), + ("YouTubeComments:Slack:SigningSecret", config["YouTubeComments:Slack:SigningSecret"]), + ]; + + foreach (var (key, value) in secrets) + { + if (string.IsNullOrWhiteSpace(value) + || value.Contains("change-me", StringComparison.OrdinalIgnoreCase) + || value == "dev-admin-token") + { + throw new InvalidOperationException( + $"{key} is missing or a known dev placeholder — refusing to start outside Development. " + + "Set a real value (auth primitives: `openssl rand -base64 36`; Slack signing secrets " + + "come from the Slack app's Basic Information page)."); + } + } + } + + /// The single auth gate (BFF token + signed identity). Place early in the pipeline. + public static IApplicationBuilder UseHooklineIdentity(this IApplicationBuilder app) => + app.UseMiddleware(); + + /// + /// Migrate the shared contexts + every module's context under one advisory lock. + /// Throws (host fails to start) if any migration fails. + /// + public static async Task MigrateHooklineAsync(this IServiceProvider rootServices, IReadOnlyList modules) + { + await using var scope = rootServices.CreateAsyncScope(); + var sp = scope.ServiceProvider; + var config = sp.GetRequiredService(); + var postgres = config.GetConnectionString("Postgres")!; + var logger = sp.GetRequiredService().CreateLogger("Hookline.Migrations"); + + var contexts = new List + { + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + }; + foreach (var module in modules) + { + if (module.Migrate(sp) is { } moduleContext) + { + contexts.Add(moduleContext); + } + } + + await DbMigrator.MigrateAsync(postgres, contexts, logger); + } + + /// Seed the first-run bootstrap admin (idempotent) after migrations. + public static async Task SeedBootstrapAdminAsync(this IServiceProvider rootServices) + { + await using var scope = rootServices.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().BootstrapAsync(); + } +} diff --git a/backend/src/Hookline.Infrastructure/Health/HealthChecks.cs b/backend/src/Hookline.Infrastructure/Health/HealthChecks.cs new file mode 100644 index 0000000..f99b745 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Health/HealthChecks.cs @@ -0,0 +1,40 @@ +using Hookline.Infrastructure.Persistence; + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +using StackExchange.Redis; + +namespace Hookline.Infrastructure.Health; + +public sealed class PostgresHealthCheck(SharedDbContext db) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default) + { + try + { + return await db.Database.CanConnectAsync(ct) + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Postgres is unreachable."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Postgres check failed.", ex); + } + } +} + +public sealed class RedisHealthCheck(IConnectionMultiplexer redis) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default) + { + try + { + await redis.GetDatabase().PingAsync(); + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Redis check failed.", ex); + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Hookline.Infrastructure.csproj b/backend/src/Hookline.Infrastructure/Hookline.Infrastructure.csproj new file mode 100644 index 0000000..5805e1d --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Hookline.Infrastructure.csproj @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/Hookline.Infrastructure/Jobs/HangfireDashboardAuthorizationFilter.cs b/backend/src/Hookline.Infrastructure/Jobs/HangfireDashboardAuthorizationFilter.cs new file mode 100644 index 0000000..a5fd7b0 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Jobs/HangfireDashboardAuthorizationFilter.cs @@ -0,0 +1,22 @@ +using Hangfire.Dashboard; + +using Hookline.SharedKernel.Auth; + +using Microsoft.Extensions.DependencyInjection; + +namespace Hookline.Infrastructure.Jobs; + +/// +/// Gates the Hangfire dashboard behind real admin authorization. The identity middleware +/// has already resolved for the request (the dashboard is not +/// in the auth bypass allowlist), so only an Admin+ may view it — never anonymous/local-only. +/// +public sealed class HangfireDashboardAuthorizationFilter : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) + { + var http = context.GetHttpContext(); + var user = http.RequestServices.GetService(); + return user?.HasAtLeast(UserRole.Admin) == true; + } +} diff --git a/backend/src/Hookline.Infrastructure/Jobs/HangfireJobScheduler.cs b/backend/src/Hookline.Infrastructure/Jobs/HangfireJobScheduler.cs new file mode 100644 index 0000000..e77791b --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Jobs/HangfireJobScheduler.cs @@ -0,0 +1,28 @@ +using System.Linq.Expressions; + +using Hangfire; +using Hangfire.Storage; + +using Hookline.SharedKernel.Jobs; + +namespace Hookline.Infrastructure.Jobs; + +/// Hangfire-backed . Jobs are resolved from DI by type. +public sealed class HangfireJobScheduler : IJobScheduler +{ + public void AddOrUpdateRecurring(string id, Expression> methodCall, string cron) + where TJob : notnull => + RecurringJob.AddOrUpdate(id, methodCall, cron); + + public string Enqueue(Expression> methodCall) + where TJob : notnull => + BackgroundJob.Enqueue(methodCall); + + public void RemoveRecurring(string id) => RecurringJob.RemoveIfExists(id); + + public IReadOnlyList ListRecurring() + { + using var connection = JobStorage.Current.GetConnection(); + return connection.GetRecurringJobs().Select(j => j.Id).ToList(); + } +} diff --git a/backend/src/Hookline.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/Hookline.Infrastructure/Messaging/InProcessEventBus.cs new file mode 100644 index 0000000..10576de --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Messaging/InProcessEventBus.cs @@ -0,0 +1,34 @@ +using Hookline.SharedKernel.Messaging; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Hookline.Infrastructure.Messaging; + +/// +/// In-process event bus. Resolves all registered handlers for the event type and +/// invokes them in a fresh scope. A failing handler is logged and does not stop the +/// others. Swapping this for a real broker is how a module gets extracted later. +/// +public sealed class InProcessEventBus(IServiceProvider services, ILogger logger) : IEventBus +{ + public async Task PublishAsync(TEvent @event, CancellationToken ct = default) + where TEvent : IntegrationEvent + { + using var scope = services.CreateScope(); + var handlers = scope.ServiceProvider.GetServices>(); + + foreach (var handler in handlers) + { + try + { + await handler.HandleAsync(@event, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Handler {Handler} failed for {Event}", + handler.GetType().Name, typeof(TEvent).Name); + } + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Persistence/DbMigrator.cs b/backend/src/Hookline.Infrastructure/Persistence/DbMigrator.cs new file mode 100644 index 0000000..4683ddf --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Persistence/DbMigrator.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Npgsql; + +namespace Hookline.Infrastructure.Persistence; + +/// +/// Runs every context's migrations under a single Postgres advisory lock so concurrent +/// host instances serialize and never race. If any migration fails the exception +/// propagates — the host fails to start rather than serving a half-migrated schema. +/// +public static class DbMigrator +{ + // Stable arbitrary key ("HOOKLINE" as ASCII bytes); positive bigint. + private const long AdvisoryLockKey = 0x484F_4F4B_4C49_4E45; + + public static async Task MigrateAsync( + string connectionString, + IEnumerable contexts, + ILogger logger, + CancellationToken ct = default) + { + await using var lockConnection = new NpgsqlConnection(connectionString); + await lockConnection.OpenAsync(ct); + + await using (var acquire = new NpgsqlCommand("SELECT pg_advisory_lock(@key)", lockConnection)) + { + acquire.Parameters.AddWithValue("key", AdvisoryLockKey); + await acquire.ExecuteNonQueryAsync(ct); + } + + logger.LogInformation("Acquired migration advisory lock."); + try + { + foreach (var context in contexts) + { + logger.LogInformation("Applying migrations for {Context}.", context.GetType().Name); + await context.Database.MigrateAsync(ct); + } + } + finally + { + await using var release = new NpgsqlCommand("SELECT pg_advisory_unlock(@key)", lockConnection); + release.Parameters.AddWithValue("key", AdvisoryLockKey); + await release.ExecuteNonQueryAsync(ct); + logger.LogInformation("Released migration advisory lock."); + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Persistence/DesignTimeFactories.cs b/backend/src/Hookline.Infrastructure/Persistence/DesignTimeFactories.cs new file mode 100644 index 0000000..119210b --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Persistence/DesignTimeFactories.cs @@ -0,0 +1,41 @@ +using Hookline.Infrastructure.Auth; +using Hookline.Infrastructure.Connections; +using Hookline.Infrastructure.Secrets; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Hookline.Infrastructure.Persistence; + +// Design-time factories for `dotnet ef migrations add`. The connection string is a +// placeholder — `migrations add` does not connect; it only generates the model diff. + +internal static class DesignTime +{ + public const string PlaceholderConnection = + "Host=localhost;Port=5432;Database=hookline;Username=hookline;Password=design-time"; + + public static DbContextOptionsBuilder Build(string schema) + where TContext : DbContext => + new DbContextOptionsBuilder() + .UseNpgsql(PlaceholderConnection, npgsql => npgsql.MigrationsHistoryTable("__ef_migrations_history", schema)) + .UseSnakeCaseNamingConvention(); +} + +public sealed class SharedDbContextFactory : IDesignTimeDbContextFactory +{ + public SharedDbContext CreateDbContext(string[] args) => + new(DesignTime.Build(SharedDbContext.SchemaName).Options); +} + +public sealed class AuthDbContextFactory : IDesignTimeDbContextFactory +{ + public AuthDbContext CreateDbContext(string[] args) => + new(DesignTime.Build(AuthDbContext.SchemaName).Options); +} + +public sealed class ConnectionsDbContextFactory : IDesignTimeDbContextFactory +{ + public ConnectionsDbContext CreateDbContext(string[] args) => + new(DesignTime.Build(ConnectionsDbContext.SchemaName).Options, new PassthroughSecretProtector()); +} diff --git a/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.Designer.cs b/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.Designer.cs new file mode 100644 index 0000000..63bd5f1 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.Designer.cs @@ -0,0 +1,117 @@ +// +using System; +using Hookline.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(SharedDbContext))] + [Migration("20260609161208_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("shared") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hookline.Infrastructure.Persistence.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_app_settings"); + + b.ToTable("app_settings", "shared"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Persistence.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("actor"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("Detail") + .HasColumnType("text") + .HasColumnName("detail"); + + b.Property("EntityId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("entity_type"); + + b.Property("Module") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("module"); + + b.Property("Role") + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("pk_audit_logs"); + + b.HasIndex("Module") + .HasDatabaseName("ix_audit_logs_module"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_audit_logs_timestamp"); + + b.ToTable("audit_logs", "shared"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.cs b/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.cs new file mode 100644 index 0000000..e28027e --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Persistence/Migrations/20260609161208_Initial.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Persistence.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "shared"); + + migrationBuilder.CreateTable( + name: "app_settings", + schema: "shared", + columns: table => new + { + key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + value = table.Column(type: "text", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_app_settings", x => x.key); + }); + + migrationBuilder.CreateTable( + name: "audit_logs", + schema: "shared", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + timestamp = table.Column(type: "timestamp with time zone", nullable: false), + actor = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), + actor_id = table.Column(type: "uuid", nullable: true), + role = table.Column(type: "text", nullable: true), + module = table.Column(type: "character varying(60)", maxLength: 60, nullable: true), + action = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + entity_type = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + entity_id = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + detail = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_audit_logs", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_audit_logs_module", + schema: "shared", + table: "audit_logs", + column: "module"); + + migrationBuilder.CreateIndex( + name: "ix_audit_logs_timestamp", + schema: "shared", + table: "audit_logs", + column: "timestamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "app_settings", + schema: "shared"); + + migrationBuilder.DropTable( + name: "audit_logs", + schema: "shared"); + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Persistence/Migrations/SharedDbContextModelSnapshot.cs b/backend/src/Hookline.Infrastructure/Persistence/Migrations/SharedDbContextModelSnapshot.cs new file mode 100644 index 0000000..cdd3f75 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Persistence/Migrations/SharedDbContextModelSnapshot.cs @@ -0,0 +1,114 @@ +// +using System; +using Hookline.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Hookline.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(SharedDbContext))] + partial class SharedDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("shared") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hookline.Infrastructure.Persistence.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_app_settings"); + + b.ToTable("app_settings", "shared"); + }); + + modelBuilder.Entity("Hookline.Infrastructure.Persistence.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("actor"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("Detail") + .HasColumnType("text") + .HasColumnName("detail"); + + b.Property("EntityId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("entity_type"); + + b.Property("Module") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("module"); + + b.Property("Role") + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("pk_audit_logs"); + + b.HasIndex("Module") + .HasDatabaseName("ix_audit_logs_module"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_audit_logs_timestamp"); + + b.ToTable("audit_logs", "shared"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Hookline.Infrastructure/Persistence/SharedDbContext.cs b/backend/src/Hookline.Infrastructure/Persistence/SharedDbContext.cs new file mode 100644 index 0000000..f1a9625 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Persistence/SharedDbContext.cs @@ -0,0 +1,37 @@ +using Hookline.SharedKernel.Persistence; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Infrastructure.Persistence; + +/// Cross-cutting shared schema — audit log + hub settings. +public sealed class SharedDbContext(DbContextOptions options) : HooklineDbContext(options) +{ + public const string SchemaName = "shared"; + + public DbSet AuditLogs => Set(); + public DbSet Settings => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + model.HasDefaultSchema(SchemaName); + + var audit = model.Entity(); + audit.ToTable("audit_logs"); + audit.HasKey(a => a.Id); + audit.Property(a => a.Id).ValueGeneratedOnAdd(); + audit.Property(a => a.Actor).IsRequired().HasMaxLength(320); + audit.Property(a => a.Action).IsRequired().HasMaxLength(120); + audit.Property(a => a.Module).HasMaxLength(60); + audit.Property(a => a.EntityType).HasMaxLength(120); + audit.Property(a => a.EntityId).HasMaxLength(200); + audit.HasIndex(a => a.Timestamp); + audit.HasIndex(a => a.Module); + + var setting = model.Entity(); + setting.ToTable("app_settings"); + setting.HasKey(s => s.Key); + setting.Property(s => s.Key).HasMaxLength(200); + setting.Property(s => s.Value).IsRequired(); + } +} diff --git a/backend/src/Hookline.Infrastructure/Persistence/SharedEntities.cs b/backend/src/Hookline.Infrastructure/Persistence/SharedEntities.cs new file mode 100644 index 0000000..65d83db --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Persistence/SharedEntities.cs @@ -0,0 +1,24 @@ +namespace Hookline.Infrastructure.Persistence; + +/// Append-only audit trail row (schema shared). +public sealed class AuditLogEntry +{ + public long Id { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + public string Actor { get; set; } = "system"; + public Guid? ActorId { get; set; } + public string? Role { get; set; } + public string? Module { get; set; } + public string Action { get; set; } = string.Empty; + public string? EntityType { get; set; } + public string? EntityId { get; set; } + public string? Detail { get; set; } +} + +/// Hub-wide key/value setting (DB override layer, schema shared). +public sealed class AppSetting +{ + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/backend/src/Hookline.Infrastructure/Secrets/AesGcmSecretProtector.cs b/backend/src/Hookline.Infrastructure/Secrets/AesGcmSecretProtector.cs new file mode 100644 index 0000000..d78c149 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Secrets/AesGcmSecretProtector.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography; +using System.Text; + +using Hookline.SharedKernel.Secrets; + +namespace Hookline.Infrastructure.Secrets; + +/// +/// AES-256-GCM secret protector. Key = SHA-256(TokenEncryption__Key). +/// Ciphertext layout: [version(1)][nonce(12)][tag(16)][ciphertext(n)], base64-encoded. +/// A fresh 96-bit CSPRNG nonce is used per encryption; the leading version byte keeps key/format +/// rotation possible later. NOTE: that version byte is a deliberate format change — ciphertext from +/// the pre-port app (which had no version prefix) is NOT decodable here. Phase 1 starts fresh via +/// re-OAuth, so there is no legacy ciphertext to import. Fails fast if the master key is missing and +/// never swallows an authentication-tag failure on decrypt. +/// +public sealed class AesGcmSecretProtector : ISecretProtector +{ + private const byte Version = 0x01; + private const int NonceSize = 12; // 96-bit, recommended for GCM + private const int TagSize = 16; // 128-bit + private const int HeaderSize = 1 + NonceSize + TagSize; + + private readonly byte[] _key; + + public AesGcmSecretProtector(string? masterKey) + { + if (string.IsNullOrWhiteSpace(masterKey)) + { + throw new InvalidOperationException( + "TokenEncryption__Key is required — refusing to start without an encryption key."); + } + + // SHA-256 of the configured key → a stable 32-byte AES-256 key. + _key = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey)); + } + + public string Protect(string plaintext) + { + ArgumentNullException.ThrowIfNull(plaintext); + + var plainBytes = Encoding.UTF8.GetBytes(plaintext); + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var cipher = new byte[plainBytes.Length]; + var tag = new byte[TagSize]; + + using (var aes = new AesGcm(_key, TagSize)) + { + aes.Encrypt(nonce, plainBytes, cipher, tag); + } + + var output = new byte[HeaderSize + cipher.Length]; + output[0] = Version; + Buffer.BlockCopy(nonce, 0, output, 1, NonceSize); + Buffer.BlockCopy(tag, 0, output, 1 + NonceSize, TagSize); + Buffer.BlockCopy(cipher, 0, output, HeaderSize, cipher.Length); + return Convert.ToBase64String(output); + } + + public string Unprotect(string ciphertext) + { + ArgumentException.ThrowIfNullOrEmpty(ciphertext); + + var bytes = Convert.FromBase64String(ciphertext); + if (bytes.Length < HeaderSize) + { + throw new CryptographicException("Ciphertext is too short to contain the header."); + } + + var version = bytes[0]; + if (version != Version) + { + throw new CryptographicException($"Unsupported secret version byte: 0x{version:X2}."); + } + + var nonce = bytes.AsSpan(1, NonceSize); + var tag = bytes.AsSpan(1 + NonceSize, TagSize); + var cipher = bytes.AsSpan(HeaderSize); + var plain = new byte[cipher.Length]; + + using (var aes = new AesGcm(_key, TagSize)) + { + // Throws CryptographicException on tag mismatch — never caught here. + aes.Decrypt(nonce, cipher, tag, plain); + } + + return Encoding.UTF8.GetString(plain); + } + + public bool TryUnprotect(string ciphertext, out string plaintext) + { + try + { + plaintext = Unprotect(ciphertext); + return true; + } + catch (Exception ex) when (ex is CryptographicException or FormatException or ArgumentException) + { + plaintext = string.Empty; + return false; + } + } +} + +/// +/// Design-time / no-secret passthrough used only by EF migrations, where the +/// encryption converter doesn't change the column schema. Never registered at runtime. +/// +public sealed class PassthroughSecretProtector : ISecretProtector +{ + public string Protect(string plaintext) => plaintext; + public string Unprotect(string ciphertext) => ciphertext; + public bool TryUnprotect(string ciphertext, out string plaintext) + { + plaintext = ciphertext; + return true; + } +} diff --git a/backend/src/Hookline.Infrastructure/Settings/AlertSettingsService.cs b/backend/src/Hookline.Infrastructure/Settings/AlertSettingsService.cs new file mode 100644 index 0000000..1c9ef39 --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Settings/AlertSettingsService.cs @@ -0,0 +1,64 @@ +using Hookline.SharedKernel.Settings; + +namespace Hookline.Infrastructure.Settings; + +/// Persisted Settings → Alerts preferences. +public sealed record AlertSettings(bool UploadFailures, bool QuotaWarnings, bool OauthExpiry, bool WeeklyDigest); + +/// +/// Reads/writes the Alerts toggles through the shared key/value under the +/// system:alerts:* namespace (no dedicated table — matches how the upload settings are stored). +/// Defaults: upload-failures / quota-warnings / oauth-expiry on, weekly-digest off. +/// Scope: this persists the user PREFERENCE only. Alert delivery (emailing on an upload +/// failure, etc.) is a separate feature that is not built yet — saving a toggle here does not start sending +/// alerts; it records the choice so the UI reflects saved state and a future delivery worker can honour it. +/// +public sealed class AlertSettingsService(ISettingsStore settings) +{ + private const string KeyUploadFailures = "system:alerts:uploadFailures"; + private const string KeyQuotaWarnings = "system:alerts:quotaWarnings"; + private const string KeyOauthExpiry = "system:alerts:oauthExpiry"; + private const string KeyWeeklyDigest = "system:alerts:weeklyDigest"; + + public async Task GetAsync(CancellationToken ct = default) => new( + await GetBoolAsync(KeyUploadFailures, true, ct), + await GetBoolAsync(KeyQuotaWarnings, true, ct), + await GetBoolAsync(KeyOauthExpiry, true, ct), + await GetBoolAsync(KeyWeeklyDigest, false, ct)); + + /// Applies any non-null field, then returns the full saved state. + public async Task UpdateAsync( + bool? uploadFailures, bool? quotaWarnings, bool? oauthExpiry, bool? weeklyDigest, + CancellationToken ct = default) + { + if (uploadFailures.HasValue) + { + await settings.SetAsync(KeyUploadFailures, Str(uploadFailures.Value), ct); + } + + if (quotaWarnings.HasValue) + { + await settings.SetAsync(KeyQuotaWarnings, Str(quotaWarnings.Value), ct); + } + + if (oauthExpiry.HasValue) + { + await settings.SetAsync(KeyOauthExpiry, Str(oauthExpiry.Value), ct); + } + + if (weeklyDigest.HasValue) + { + await settings.SetAsync(KeyWeeklyDigest, Str(weeklyDigest.Value), ct); + } + + return await GetAsync(ct); + } + + private async Task GetBoolAsync(string key, bool fallback, CancellationToken ct) + { + var raw = await settings.GetAsync(key, ct); + return raw is null ? fallback : raw.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + private static string Str(bool value) => value ? "true" : "false"; +} diff --git a/backend/src/Hookline.Infrastructure/Settings/SettingsStore.cs b/backend/src/Hookline.Infrastructure/Settings/SettingsStore.cs new file mode 100644 index 0000000..91fdb4c --- /dev/null +++ b/backend/src/Hookline.Infrastructure/Settings/SettingsStore.cs @@ -0,0 +1,41 @@ +using Hookline.Infrastructure.Persistence; + +using Hookline.SharedKernel.Settings; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace Hookline.Infrastructure.Settings; + +/// Settings resolution: database override → environment/config → caller default. +public sealed class SettingsStore(SharedDbContext db, IConfiguration config) : ISettingsStore +{ + public async Task GetAsync(string key, CancellationToken ct = default) + { + var dbValue = await db.Settings.AsNoTracking() + .Where(s => s.Key == key) + .Select(s => s.Value) + .FirstOrDefaultAsync(ct); + + return dbValue ?? config[key.Replace(':', '_')] ?? config[key]; + } + + public async Task GetAsync(string key, string fallback, CancellationToken ct = default) => + await GetAsync(key, ct) ?? fallback; + + public async Task SetAsync(string key, string value, CancellationToken ct = default) + { + var existing = await db.Settings.FirstOrDefaultAsync(s => s.Key == key, ct); + if (existing is null) + { + db.Settings.Add(new AppSetting { Key = key, Value = value, UpdatedAt = DateTimeOffset.UtcNow }); + } + else + { + existing.Value = value; + existing.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Hookline.SharedKernel/Audit/IAuditLog.cs b/backend/src/Hookline.SharedKernel/Audit/IAuditLog.cs new file mode 100644 index 0000000..bcbbacf --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Audit/IAuditLog.cs @@ -0,0 +1,59 @@ +using Hookline.SharedKernel.Common; + +namespace Hookline.SharedKernel.Audit; + +/// +/// Shared audit trail. Modules write entries through it; the "Logs" page reads +/// them with a per-module filter. The actor defaults to the current user, but a caller may stamp an +/// EXPLICIT for actions whose real actor is not the request principal — e.g. a +/// provider callback (a Slack interactivity button) runs on the identity-bypassed /slack path +/// where the current user is anonymous, so the moderating Slack user is passed through here instead of +/// being lost to "anonymous". +/// +public interface IAuditLog +{ + Task WriteAsync( + string action, + string? module = null, + string? entityType = null, + string? entityId = null, + string? detail = null, + string? actor = null, + CancellationToken ct = default); +} + +/// One audit-trail row as surfaced to the shared System→Logs page (no internal types leak). +public sealed record AuditLogRecord( + long Id, + DateTimeOffset Timestamp, + string Actor, + string? Role, + string? Module, + string Action, + string? EntityType, + string? EntityId, + string? Detail); + +/// +/// Read side of the shared audit trail. The host's System→Logs endpoint pages over it, +/// optionally filtered to one module — every module reuses the same Logs surface. +/// +public interface IAuditLogReader +{ + Task> ListAsync( + string? module, + int page, + int pageSize, + CancellationToken ct = default); + + /// + /// Counts entries since , optionally scoped to one + /// and to rows whose detail begins with (the folded level marker, + /// e.g. [Error]). Lets a dashboard surface an at-a-glance KPI without paging the whole trail. + /// + Task CountSinceAsync( + string? module, + DateTimeOffset since, + string? detailPrefix = null, + CancellationToken ct = default); +} diff --git a/backend/src/Hookline.SharedKernel/Auth/CurrentUser.cs b/backend/src/Hookline.SharedKernel/Auth/CurrentUser.cs new file mode 100644 index 0000000..8d43bed --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Auth/CurrentUser.cs @@ -0,0 +1,37 @@ +namespace Hookline.SharedKernel.Auth; + +/// +/// Hub roles, ordered by power. is supreme; the enum is +/// extensible. Numeric order encodes the hierarchy used by HasAtLeast. +/// +public enum UserRole +{ + Member = 0, + Admin = 1, + Owner = 2, +} + +/// Account state. +public enum UserStatus +{ + Active = 0, + Disabled = 1, +} + +/// +/// The caller resolved per request from the BFF's signed identity assertion +/// (or the system principal for background jobs). Authorization policies key off this. +/// +public interface ICurrentUser +{ + bool IsAuthenticated { get; } + Guid? UserId { get; } + string? Email { get; } + UserRole? Role { get; } + + /// True for the internal system principal that runs background jobs. + bool IsSystem { get; } + + /// True when the caller's role is at least . + bool HasAtLeast(UserRole role); +} diff --git a/backend/src/Hookline.SharedKernel/Caching/ICachePurge.cs b/backend/src/Hookline.SharedKernel/Caching/ICachePurge.cs new file mode 100644 index 0000000..ab6e9b2 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Caching/ICachePurge.cs @@ -0,0 +1,17 @@ +namespace Hookline.SharedKernel.Caching; + +/// +/// Best-effort cache invalidation by key prefix. Backed by Redis in production; lets a module clear its +/// own ytu:* / ytc:* namespace during a data reset without taking a direct +/// StackExchange.Redis dependency (keeping the module → SharedKernel-only boundary). Every key the modules +/// write self-expires, so a missed purge (cache unreachable) is non-fatal — implementations MUST NOT throw +/// for a cache outage. +/// +public interface ICachePurge +{ + /// + /// Delete every key starting with . Returns the number removed (0 if the cache + /// was unreachable). Never throws for a cache outage. + /// + Task PurgeByPrefixAsync(string prefix, CancellationToken ct = default); +} diff --git a/backend/src/Hookline.SharedKernel/Common/Csv.cs b/backend/src/Hookline.SharedKernel/Common/Csv.cs new file mode 100644 index 0000000..f560290 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Common/Csv.cs @@ -0,0 +1,53 @@ +using System.Text; + +namespace Hookline.SharedKernel.Common; + +/// +/// Minimal RFC 4180 CSV writer for the export endpoints (System → Logs, Uploads → History). A field that +/// contains a comma, double-quote, CR or LF is wrapped in double-quotes with inner quotes doubled; +/// everything else is emitted verbatim. Rows are CRLF-terminated (the line ending Excel expects). +/// +public static class Csv +{ + /// Quote a single field iff it contains a delimiter, quote or newline. + public static string Field(string? value) + { + var s = value ?? string.Empty; + if (s.IndexOfAny(['"', ',', '\n', '\r']) < 0) + { + return s; + } + + return $"\"{s.Replace("\"", "\"\"")}\""; + } + + /// Join one row's fields (each quoted as needed) with commas. + public static string Row(params string?[] fields) + { + var sb = new StringBuilder(); + for (var i = 0; i < fields.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + + sb.Append(Field(fields[i])); + } + + return sb.ToString(); + } + + /// Build a full CSV document: a header row followed by data rows, each CRLF-terminated. + public static string Document(IEnumerable header, IEnumerable> rows) + { + var sb = new StringBuilder(); + sb.Append(Row([.. header])).Append("\r\n"); + foreach (var row in rows) + { + sb.Append(Row([.. row])).Append("\r\n"); + } + + return sb.ToString(); + } +} diff --git a/backend/src/Hookline.SharedKernel/Common/PagedResult.cs b/backend/src/Hookline.SharedKernel/Common/PagedResult.cs new file mode 100644 index 0000000..8676b56 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Common/PagedResult.cs @@ -0,0 +1,22 @@ +namespace Hookline.SharedKernel.Common; + +/// The one paging envelope every module's list endpoint returns. +public sealed record PagedResult(IReadOnlyList Items, int Page, int PageSize, long Total) +{ + public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(Total / (double)PageSize); + public bool HasNext => (long)Page * PageSize < Total; + public bool HasPrevious => Page > 1; + + public static PagedResult Empty(int page = 1, int pageSize = 20) => + new([], page, pageSize, 0); +} + +/// Common paging request, clamped to safe bounds. +public readonly record struct PageRequest(int Page, int PageSize) +{ + public const int MaxPageSize = 200; + + public int SafePage => Page < 1 ? 1 : Page; + public int SafePageSize => PageSize is < 1 or > MaxPageSize ? 20 : PageSize; + public int Skip => (SafePage - 1) * SafePageSize; +} diff --git a/backend/src/Hookline.SharedKernel/Common/Result.cs b/backend/src/Hookline.SharedKernel/Common/Result.cs new file mode 100644 index 0000000..8e83817 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Common/Result.cs @@ -0,0 +1,50 @@ +namespace Hookline.SharedKernel.Common; + +/// A machine-readable error, mapped to RFC-7807 ProblemDetails at the edge. +public sealed record Error(string Code, string Message, int Status = 400) +{ + public static readonly Error NotFound = new("not_found", "The requested resource was not found.", 404); + public static readonly Error Unauthorized = new("unauthorized", "Authentication is required.", 401); + public static readonly Error Forbidden = new("forbidden", "You do not have permission to do that.", 403); + public static Error Conflict(string message) => new("conflict", message, 409); + public static Error Validation(string message) => new("validation", message, 400); +} + +/// Outcome of an operation that can fail without throwing. +public readonly record struct Result +{ + public bool IsSuccess { get; } + public Error? Error { get; } + + private Result(bool ok, Error? error) + { + IsSuccess = ok; + Error = error; + } + + public static Result Success() => new(true, null); + public static Result Failure(Error error) => new(false, error); + + public static implicit operator Result(Error error) => Failure(error); +} + +/// Outcome carrying a value on success. +public readonly record struct Result +{ + public bool IsSuccess { get; } + public T? Value { get; } + public Error? Error { get; } + + private Result(bool ok, T? value, Error? error) + { + IsSuccess = ok; + Value = value; + Error = error; + } + + public static Result Success(T value) => new(true, value, null); + public static Result Failure(Error error) => new(false, default, error); + + public static implicit operator Result(T value) => Success(value); + public static implicit operator Result(Error error) => Failure(error); +} diff --git a/backend/src/Hookline.SharedKernel/Connections/Connections.cs b/backend/src/Hookline.SharedKernel/Connections/Connections.cs new file mode 100644 index 0000000..efac634 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Connections/Connections.cs @@ -0,0 +1,157 @@ +using Hookline.SharedKernel.Messaging; + +namespace Hookline.SharedKernel.Connections; + +/// The external connection kinds the hub knows about. +public enum ConnectionType +{ + Slack, + Google, + YouTubeApiKey, +} + +/// A module declares the connections it needs; the host can validate availability. +public sealed record ConnectionRequirement(ConnectionType Type, bool Required = true, string? Note = null); + +// ── Typed accessors (modules resolve a credential at job time; they never touch storage) ── + +public sealed record SlackWorkspaceSummary(Guid Id, string TeamId, string TeamName, bool IsActive); + +/// Payload to upsert a Slack workspace into the shared store (OAuth v2 install callback). +public sealed record SlackWorkspaceWrite( + string TeamId, + string TeamName, + string BotToken, + string? BotUserId = null, + string? Scope = null, + string? AuthedUserId = null); + +public interface ISlackConnections +{ + Task GetBotTokenAsync(Guid workspaceId, CancellationToken ct = default); + + /// Resolve a bot token by Slack team id — the events endpoint maps an inbound team_id. + Task GetBotTokenForTeamAsync(string teamId, CancellationToken ct = default); + + Task> ListAsync(CancellationToken ct = default); + + Task GetByTeamAsync(string teamId, CancellationToken ct = default); + + /// Insert or update (by team id) a workspace + encrypted bot token. Returns the workspace id. + Task UpsertWorkspaceAsync(SlackWorkspaceWrite write, CancellationToken ct = default); + + /// Deactivate a workspace and publish . + Task DeactivateAsync(Guid workspaceId, CancellationToken ct = default); +} + +public sealed record GoogleAccessCredential( + Guid AccountId, + string AccessToken, + DateTimeOffset ExpiresAt, + IReadOnlyList Scopes); + +public sealed record GoogleAccountSummary(Guid Id, string ChannelTitle, bool IsActive); + +/// Full account detail a module needs to drive uploads + display (no secrets). +public sealed record GoogleAccountDetail( + Guid Id, + string? ChannelId, + string ChannelTitle, + string? AccountEmail, + string? AvatarUrl, + string Scopes, + bool IsActive); + +/// Payload to insert a Google account into the shared store (per-consent OAuth callback). +public sealed record GoogleAccountWrite( + string? ChannelId, + string ChannelTitle, + string RefreshToken, + string Scopes, + string? AccountEmail = null, + string? AvatarUrl = null); + +public interface IGoogleConnections +{ + Task> ListAsync(CancellationToken ct = default); + + Task GetAsync(Guid accountId, CancellationToken ct = default); + + /// + /// Short-lived access credential. Implementable only with an app-wide OAuth client; in Phase 1 + /// the YouTube Uploads module owns its OAuth clients ("Projects"), so it resolves the refresh token via + /// and builds the credential with its issuing client itself. + /// + Task GetCredentialAsync(Guid accountId, CancellationToken ct = default); + + /// The account's decrypted refresh token — refreshed only by its issuing OAuth client. + Task GetRefreshTokenAsync(Guid accountId, CancellationToken ct = default); + + /// Insert a new connected account (encrypts the refresh token at rest). Returns the account id. + Task CreateAccountAsync(GoogleAccountWrite write, CancellationToken ct = default); + + /// + /// Update an EXISTING account's refresh token + granted scopes in place. Used on a re-consent that + /// widens scope (e.g. adding youtube.force-ssl for comment moderation) so the SAME account + /// record is reused — no duplicate row — and its scope snapshot reflects the new grant. The owning + /// module decides WHICH account to update (by its channel↔account binding); this is just the write. + /// Returns false when the account is missing. + /// + Task UpdateConsentAsync( + Guid accountId, + string refreshToken, + string scopes, + string? channelTitle = null, + string? accountEmail = null, + string? avatarUrl = null, + CancellationToken ct = default); + + /// Deactivate an account and publish . + Task DeactivateAsync(Guid accountId, CancellationToken ct = default); +} + +// ── YouTube Data API keys (comment polling — API keys, NOT OAuth) ── + +public sealed record YouTubeApiKeySummary(Guid Id, string Name, string KeyHint, bool IsActive, DateTimeOffset CreatedAt); + +/// +/// Typed accessor over the shared api_keys store for the YouTube Comments module's +/// quota-rotated polling. Multiple keys per provider; the module ranks them by remaining +/// Pacific-day quota (tracked module-locally) and resolves the decrypted key at poll time. +/// +public interface IYouTubeApiKeyConnections +{ + /// All keys (active + disabled) for the admin UI. + Task> ListAsync(CancellationToken ct = default); + + /// Only active keys — the rotation candidate set. + Task> ListActiveAsync(CancellationToken ct = default); + + /// The decrypted API key for a given id (resolved at poll time for the lease). + Task GetApiKeyAsync(Guid keyId, CancellationToken ct = default); + + /// Insert a new key (encrypts at rest). Returns the new key id. + Task CreateAsync(string name, string apiKey, string keyHint, CancellationToken ct = default); + + /// Enable/disable a key in the rotation pool. No event (the pool simply shrinks/grows). + Task ToggleAsync(Guid keyId, bool isActive, CancellationToken ct = default); + + /// Hard-delete a key and publish . + Task DeleteAsync(Guid keyId, CancellationToken ct = default); +} + +public sealed record ConnectionSummary(Guid Id, ConnectionType Type, string Label, string Status, string? Detail); + +public interface IConnectionCatalog +{ + Task> ListAsync(ConnectionType? type = null, CancellationToken ct = default); +} + +// ── Events published by the Connections subsystem ── + +public sealed record SlackWorkspaceDisconnected(Guid WorkspaceId) : IntegrationEvent; + +public sealed record GoogleAccountDisconnected(Guid AccountId) : IntegrationEvent; + +/// A YouTube API key was deleted from the shared pool — modules drop any per-key state (e.g. quota rows). +public sealed record YouTubeApiKeyDisconnected(Guid KeyId) : IntegrationEvent; diff --git a/backend/src/Hookline.SharedKernel/Connections/IGoogleChannelCredentials.cs b/backend/src/Hookline.SharedKernel/Connections/IGoogleChannelCredentials.cs new file mode 100644 index 0000000..3b2f54c --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Connections/IGoogleChannelCredentials.cs @@ -0,0 +1,46 @@ +namespace Hookline.SharedKernel.Connections; + +/// Well-known Google OAuth scope strings shared across modules (one source of truth so the +/// consent set, the scope snapshot and the moderation-capability check can never drift). +public static class GoogleScopes +{ + /// Manage a YouTube account — required to moderate (reject) comments on owned channels. + /// Value matches Google.Apis.YouTube.v3.YouTubeService.Scope.YoutubeForceSsl. + public const string YouTubeForceSsl = "https://www.googleapis.com/auth/youtube.force-ssl"; +} + +/// +/// A short-lived, moderation-capable (youtube.force-ssl) access credential for the Google +/// account that OWNS a given YouTube channel. Handed to a caller that performs the actual YouTube call +/// itself — this is an Access primitive (token + expiry + granted scopes), deliberately free of any +/// Google SDK type so it can live in the SharedKernel. +/// +public sealed record GoogleChannelCredential( + Guid AccountId, + string YouTubeChannelId, + string AccessToken, + DateTimeOffset ExpiresAt, + IReadOnlyList Scopes); + +/// +/// Resolves a moderation-capable Google access credential for the account that owns a YouTube channel. +/// This contract is about GOOGLE ACCESS, not comments. The caller (e.g. the YouTube +/// Comments module) performs the actual comments.setModerationStatus call with the returned +/// access token — moderation is the caller's domain, credential-resolution is the implementer's. +/// Ownership of the OAuth client stays with whoever owns Google OAuth. Today the YouTube +/// Uploads module owns the OAuth clients ("Projects") and implements this contract over them, so the +/// refresh token stays issued + refreshed by its own Project client (no token migration, Uploads' +/// credential path untouched). If Google OAuth is later consolidated into the kernel, only the +/// IMPLEMENTER of this interface moves (Uploads → Connections) — the contract and its consumers do +/// not change. That clean swap is the whole reason this is about access, not about Projects. +/// +public interface IGoogleChannelCredentials +{ + /// + /// Resolve a youtube.force-ssl access credential for the ACTIVE account that owns + /// . Returns null when no active account owns that + /// channel, or the owning account has not been granted the force-ssl scope — the caller surfaces an + /// honest "not connected for moderation" error rather than failing silently. + /// + Task GetModerationCredentialAsync(string youtubeChannelId, CancellationToken ct = default); +} diff --git a/backend/src/Hookline.SharedKernel/Hookline.SharedKernel.csproj b/backend/src/Hookline.SharedKernel/Hookline.SharedKernel.csproj new file mode 100644 index 0000000..a67e4d4 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Hookline.SharedKernel.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/backend/src/Hookline.SharedKernel/Jobs/IJobScheduler.cs b/backend/src/Hookline.SharedKernel/Jobs/IJobScheduler.cs new file mode 100644 index 0000000..a644b1c --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Jobs/IJobScheduler.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; + +namespace Hookline.SharedKernel.Jobs; + +/// +/// A thin abstraction over the background-job engine (Hangfire in Infrastructure). +/// Modules schedule recurring work and enqueue one-off work without referencing +/// the engine directly, which keeps "extract this module later" realistic. +/// +public interface IJobScheduler +{ + /// Create or update a recurring job. The method call is resolved from DI at run time. + void AddOrUpdateRecurring(string id, Expression> methodCall, string cron) + where TJob : notnull; + + /// Enqueue a one-off job. Returns the engine's job id. + string Enqueue(Expression> methodCall) + where TJob : notnull; + + /// Remove a recurring job if it exists. + void RemoveRecurring(string id); + + /// + /// The ids of every currently-registered recurring job. Lets a module reconcile its + /// dynamic per-entity jobs against its own table on startup — re-adding active ones and + /// pruning orphans whose owning entity was removed while the host was down. + /// + IReadOnlyList ListRecurring(); +} diff --git a/backend/src/Hookline.SharedKernel/Maintenance/IMaintenanceControl.cs b/backend/src/Hookline.SharedKernel/Maintenance/IMaintenanceControl.cs new file mode 100644 index 0000000..b3181ef --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Maintenance/IMaintenanceControl.cs @@ -0,0 +1,30 @@ +namespace Hookline.SharedKernel.Maintenance; + +/// +/// A module's host-orchestrated maintenance surface. The host resolves every registered +/// and fans a System "Danger Zone" operation out across all modules +/// WITHOUT referencing any module type — so the operation stays module-agnostic and the module-boundary +/// arch tests keep holding (a new module that registers this is picked up automatically). Each module +/// audits its own action through the shared trail. +/// +public interface IMaintenanceControl +{ + /// The owning module id (e.g. "youtube-uploads"), for the response + per-module audit. + string Module { get; } + + /// + /// Pause every active automation the module owns (mappings / routes), tearing down any per-mapping + /// recurring jobs the module schedules. Idempotent. Returns the number of automations paused. + /// + Task PauseAllAsync(CancellationToken ct = default); + + /// + /// Wipe the module's OPERATIONAL data (run history / dedup / quota / retry state) while keeping + /// configuration (mappings/routes), connections and secrets intact. Transactional within the module. + /// Returns a per-table breakdown for the audit detail. + /// + Task ResetDataAsync(CancellationToken ct = default); +} + +/// The outcome of a maintenance op for one module: a total affected count plus a human breakdown. +public sealed record MaintenanceResult(string Module, int Affected, string Detail); diff --git a/backend/src/Hookline.SharedKernel/Messaging/EventBus.cs b/backend/src/Hookline.SharedKernel/Messaging/EventBus.cs new file mode 100644 index 0000000..64b8034 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Messaging/EventBus.cs @@ -0,0 +1,25 @@ +namespace Hookline.SharedKernel.Messaging; + +/// +/// Base for in-process integration events. Events carry only IDs/primitives and +/// live in the SharedKernel so any module can react without referencing another. +/// +public abstract record IntegrationEvent +{ + public Guid EventId { get; init; } = Guid.NewGuid(); + public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow; +} + +/// Handles a specific integration event. Implementations are resolved from DI. +public interface IIntegrationEventHandler + where TEvent : IntegrationEvent +{ + Task HandleAsync(TEvent @event, CancellationToken ct = default); +} + +/// Publishes integration events to all registered handlers (in-process today). +public interface IEventBus +{ + Task PublishAsync(TEvent @event, CancellationToken ct = default) + where TEvent : IntegrationEvent; +} diff --git a/backend/src/Hookline.SharedKernel/Modules/IModule.cs b/backend/src/Hookline.SharedKernel/Modules/IModule.cs new file mode 100644 index 0000000..86a6b0c --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Modules/IModule.cs @@ -0,0 +1,38 @@ +using Hookline.SharedKernel.Connections; +using Hookline.SharedKernel.Jobs; + +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Hookline.SharedKernel.Modules; + +/// +/// The contract every module implements. The host discovers modules from an +/// explicit list (no reflection scanning) and drives them through this surface. +/// Adding a module to the system is one line in that list. +/// +public interface IModule +{ + /// Stable kebab-case identifier, e.g. "youtube-comments". Routes live under /api/{Name}. + string Name { get; } + + /// Register the module's services into the shared container. + void RegisterServices(IServiceCollection services, IConfiguration config); + + /// Map the module's HTTP endpoints (conventionally under /api/{Name}). + void MapEndpoints(IEndpointRouteBuilder endpoints); + + /// Register recurring jobs via the shared scheduler. + void RegisterJobs(IJobScheduler scheduler); + + /// The external connections (Slack / Google / …) this module needs. + IEnumerable RequiredConnections { get; } + + /// + /// Returns the module's so the host can apply its + /// migrations under an advisory lock, or null if the module has no schema. + /// + DbContext? Migrate(IServiceProvider services); +} diff --git a/backend/src/Hookline.SharedKernel/Persistence/Converters.cs b/backend/src/Hookline.SharedKernel/Persistence/Converters.cs new file mode 100644 index 0000000..1c9d13b --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Persistence/Converters.cs @@ -0,0 +1,28 @@ +using Hookline.SharedKernel.Secrets; + +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Hookline.SharedKernel.Persistence; + +/// Normalizes values to UTC on read/write. +public sealed class UtcDateTimeConverter() + : ValueConverter( + v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + +/// +/// Transparently encrypts a string column at rest via . +/// Apply with builder.Property(x => x.Token).IsEncrypted(protector). +/// +public sealed class EncryptedStringConverter(ISecretProtector protector) + : ValueConverter( + plaintext => protector.Protect(plaintext), + ciphertext => protector.Unprotect(ciphertext)); + +public static class EncryptedPropertyExtensions +{ + /// Encrypt this string property at rest through the shared protector. + public static PropertyBuilder IsEncrypted(this PropertyBuilder builder, ISecretProtector protector) + => builder.HasConversion(new EncryptedStringConverter(protector)); +} diff --git a/backend/src/Hookline.SharedKernel/Persistence/HooklineDbContext.cs b/backend/src/Hookline.SharedKernel/Persistence/HooklineDbContext.cs new file mode 100644 index 0000000..1fda0a3 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Persistence/HooklineDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace Hookline.SharedKernel.Persistence; + +/// +/// Base for every module/shared . Each derived context maps +/// to its own schema with its own migration history (configured at registration via +/// UseSnakeCaseNamingConvention() + MigrationsHistoryTable). UTC is +/// enforced here so timestamps are consistent across the hub. +/// +public abstract class HooklineDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + // Persist DateTime as UTC; Npgsql maps DateTimeOffset to timestamptz already. + configurationBuilder.Properties().HaveConversion(); + } +} diff --git a/backend/src/Hookline.SharedKernel/Secrets/ISecretProtector.cs b/backend/src/Hookline.SharedKernel/Secrets/ISecretProtector.cs new file mode 100644 index 0000000..f7591e9 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Secrets/ISecretProtector.cs @@ -0,0 +1,14 @@ +namespace Hookline.SharedKernel.Secrets; + +/// +/// One protector for the whole hub. All external secrets (Slack tokens, Google +/// refresh tokens, API keys) are encrypted at rest through it. The Infrastructure +/// implementation is AES-256-GCM with a 1-byte key-version header so the key can +/// be rotated later without breaking older ciphertext. +/// +public interface ISecretProtector +{ + string Protect(string plaintext); + string Unprotect(string ciphertext); + bool TryUnprotect(string ciphertext, out string plaintext); +} diff --git a/backend/src/Hookline.SharedKernel/Settings/ISettingsStore.cs b/backend/src/Hookline.SharedKernel/Settings/ISettingsStore.cs new file mode 100644 index 0000000..99e24c6 --- /dev/null +++ b/backend/src/Hookline.SharedKernel/Settings/ISettingsStore.cs @@ -0,0 +1,12 @@ +namespace Hookline.SharedKernel.Settings; + +/// +/// Hub-wide key/value settings. The Infrastructure implementation resolves in +/// order: database override → environment → default. +/// +public interface ISettingsStore +{ + Task GetAsync(string key, CancellationToken ct = default); + Task GetAsync(string key, string fallback, CancellationToken ct = default); + Task SetAsync(string key, string value, CancellationToken ct = default); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ChannelMapping.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ChannelMapping.cs new file mode 100644 index 0000000..24cecf2 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ChannelMapping.cs @@ -0,0 +1,42 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// +/// Routes a monitored YouTube channel's new comments to a Slack channel. Both the YouTube channel +/// and the Slack channel are module-local rows (intra-schema FKs). The Slack workspace the +/// channel belongs to lives in the shared connections schema and is referenced only by id. +/// +public class ChannelMapping +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid YouTubeChannelId { get; set; } + public Guid SlackChannelId { get; set; } + public PollingFrequency Frequency { get; set; } = PollingFrequency.FifteenMinutes; + public bool IsActive { get; set; } = true; + + /// When true, replies to top-level comments are also forwarded (threaded under the parent in Slack). + public bool IncludeReplies { get; set; } + + /// + /// Cadence of the deep reply sweep that guarantees full reply coverage (replies on older comments + /// the normal poll can't see). = inline replies only. + /// + public ReplyScanFrequency ReplySweepFrequency { get; set; } = ReplyScanFrequency.Off; + + /// How many days back the deep reply sweep scans for new replies. + public int ReplyWindowDays { get; set; } = 30; + + /// + /// Watermark: only comments published after this instant are forwarded. Set on create and on each + /// reactivation so a long-dormant mapping can't repost old comments once its dedup ledger has aged out. + /// + public DateTimeOffset CommentsSinceUtc { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? LastPolledAt { get; set; } + public string? LastError { get; set; } + + public YouTubeChannel? YouTubeChannel { get; set; } + public SlackChannel? SlackChannel { get; set; } + public ICollection ProcessedComments { get; set; } = new List(); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/CommentModeration.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/CommentModeration.cs new file mode 100644 index 0000000..dbb6afe --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/CommentModeration.cs @@ -0,0 +1,33 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// +/// A durable record that a comment was moderated (rejected) from Slack — the idempotency ledger for the +/// "Reject on YouTube" action. The unique (MappingId, CommentId) key makes a double-click a no-op and +/// records WHO actioned it (the Slack user) for the "rejected by" card update + audit cross-check. +/// Module-local; references the mapping by id only. +/// +public class CommentModeration +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid MappingId { get; set; } + + /// The YouTube comment id that was moderated. + public string CommentId { get; set; } = default!; + + /// The moderation action taken — currently always Rejected (room for future kinds). + public string Action { get; set; } = ActionRejected; + + /// Outcome status: Rejected (we rejected it) or AlreadyGone (404 on YouTube). + public string Status { get; set; } = StatusRejected; + + /// The Slack user who pressed the button (the honest actor — the audit actor is "system"). + public string? SlackUserId { get; set; } + public string? SlackUserName { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public const string ActionRejected = "Rejected"; + public const string StatusRejected = "Rejected"; + public const string StatusAlreadyGone = "AlreadyGone"; +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/PendingDelivery.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/PendingDelivery.cs new file mode 100644 index 0000000..166fe42 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/PendingDelivery.cs @@ -0,0 +1,25 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// +/// A Slack post that failed transiently, persisted so it survives the YouTube fetch window and is +/// retried out-of-band by the delivery-retry job (instead of being lost when the comment scrolls +/// out of the poll's 50-comment view). is the serialized notification. +/// +public class PendingDelivery +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid MappingId { get; set; } + public string CommentId { get; set; } = default!; + public string? ParentCommentId { get; set; } + public string VideoId { get; set; } = default!; + + /// Serialized CommentNotification — the exact message to (re)post. + public string PayloadJson { get; set; } = default!; + + public int AttemptCount { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } = DateTimeOffset.UtcNow; + public string? LastError { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ChannelMapping? Mapping { get; set; } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/PollingFrequency.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/PollingFrequency.cs new file mode 100644 index 0000000..824a0ea --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/PollingFrequency.cs @@ -0,0 +1,30 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// Polling cadence. Underlying value is the interval in minutes. +public enum PollingFrequency +{ + OneMinute = 1, + FiveMinutes = 5, + FifteenMinutes = 15, + ThirtyMinutes = 30, + OneHour = 60, + SixHours = 360, +} + +public static class PollingFrequencyExtensions +{ + /// Cron expression (5-field) for the recurring poll job. + public static string ToCron(this PollingFrequency frequency) => frequency switch + { + PollingFrequency.OneMinute => "* * * * *", + PollingFrequency.FiveMinutes => "*/5 * * * *", + PollingFrequency.FifteenMinutes => "*/15 * * * *", + PollingFrequency.ThirtyMinutes => "*/30 * * * *", + PollingFrequency.OneHour => "0 * * * *", + PollingFrequency.SixHours => "0 */6 * * *", + _ => "*/15 * * * *", + }; + + public static TimeSpan ToInterval(this PollingFrequency frequency) + => TimeSpan.FromMinutes((int)frequency); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ProcessedComment.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ProcessedComment.cs new file mode 100644 index 0000000..1f93f3b --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ProcessedComment.cs @@ -0,0 +1,18 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// Dedup ledger. Composite PK (MappingId, CommentId): a comment is delivered once per mapping. +public class ProcessedComment +{ + public Guid MappingId { get; set; } + public string CommentId { get; set; } = default!; + public string VideoId { get; set; } = default!; + public DateTimeOffset ProcessedAt { get; set; } = DateTimeOffset.UtcNow; + + /// Slack message ts where this comment landed; lets replies thread under it. Null for older rows / failed posts. + public string? SlackMessageTs { get; set; } + + /// The top-level comment id this is a reply to; null when this is itself a top-level comment. + public string? ParentCommentId { get; set; } + + public ChannelMapping? Mapping { get; set; } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/QuotaUsage.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/QuotaUsage.cs new file mode 100644 index 0000000..6be83aa --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/QuotaUsage.cs @@ -0,0 +1,14 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// +/// Daily quota consumption per API key. Composite key (ApiKeyId, UsageDate); date is Pacific Time. +/// is the shared Connections api_keys id (plain value, no cross-schema FK) — +/// the key identity/secret lives in the shared store; the per-key daily accounting lives here. +/// +public class QuotaUsage +{ + public Guid ApiKeyId { get; set; } + public DateOnly UsageDate { get; set; } + public int UnitsUsed { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ReplyScanFrequency.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ReplyScanFrequency.cs new file mode 100644 index 0000000..8ee4dab --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/ReplyScanFrequency.cs @@ -0,0 +1,26 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// +/// Cadence of the deep reply sweep that catches replies the normal poll misses (replies on older +/// comments, or beyond the few the API returns inline). disables the sweep entirely +/// — only the free inline replies from the normal poll are forwarded then. +/// +public enum ReplyScanFrequency +{ + Off = 0, + Hourly = 60, + EverySixHours = 360, + Daily = 1440, +} + +public static class ReplyScanFrequencyExtensions +{ + /// Cron expression (5-field) for the recurring sweep, or null when . + public static string? ToCron(this ReplyScanFrequency frequency) => frequency switch + { + ReplyScanFrequency.Hourly => "0 * * * *", + ReplyScanFrequency.EverySixHours => "0 */6 * * *", + ReplyScanFrequency.Daily => "0 4 * * *", + _ => null, + }; +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/SlackChannel.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/SlackChannel.cs new file mode 100644 index 0000000..0123dbd --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/SlackChannel.cs @@ -0,0 +1,18 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// +/// A channel from a connected Slack workspace (mapping-picker cache). Module-local — +/// channels are a module concern, not a shared connection. is the +/// shared Connections workspace id (plain value, no cross-schema FK). +/// +public class SlackChannel +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid WorkspaceId { get; set; } + public string SlackChannelId { get; set; } = default!; + public string Name { get; set; } = default!; + public bool IsPrivate { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Mappings { get; set; } = new List(); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/YouTubeChannel.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/YouTubeChannel.cs new file mode 100644 index 0000000..81747d9 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Domain/YouTubeChannel.cs @@ -0,0 +1,14 @@ +namespace Hookline.Modules.YouTubeComments.Domain; + +/// A monitored YouTube channel (the poll source). Module-local. +public class YouTubeChannel +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string YouTubeChannelId { get; set; } = default!; + public string Title { get; set; } = default!; + public string? ThumbnailUrl { get; set; } + public string? Handle { get; set; } + public DateTimeOffset AddedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Mappings { get; set; } = new List(); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Endpoints/YouTubeCommentsApiEndpoints.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Endpoints/YouTubeCommentsApiEndpoints.cs new file mode 100644 index 0000000..9727afa --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Endpoints/YouTubeCommentsApiEndpoints.cs @@ -0,0 +1,148 @@ +using Hookline.Modules.YouTubeComments.Infrastructure; +using Hookline.SharedKernel.Auth; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Hookline.Modules.YouTubeComments.Endpoints; + +/// +/// The module's app API under /api/youtube-comments (through the BFF): API keys, monitored +/// YouTube channels, channel→Slack mappings, the Slack workspace/channel reads, and the dashboard +/// stats + 24h comments timeline. Every route is gated to an authenticated caller (the BFF resolves +/// identity behind its admin token). +/// +public static class YouTubeCommentsApiEndpoints +{ + public static void MapYouTubeCommentsApiEndpoints(this IEndpointRouteBuilder app) + { + var g = app.MapGroup("/api/youtube-comments").AddEndpointFilter(async (ctx, next) => + { + var user = ctx.HttpContext.RequestServices.GetRequiredService(); + return user.IsAuthenticated ? await next(ctx) : Results.Unauthorized(); + }); + + MapApiKeys(g); + MapChannels(g); + MapMappings(g); + MapSlack(g); + MapDashboard(g); + } + + // ── YouTube API keys (add/validate/toggle/delete + per-key quota bars) ── + private static void MapApiKeys(RouteGroupBuilder g) + { + g.MapGet("/keys", async (ApiKeyService keys, CancellationToken ct) => + Results.Ok(await keys.ListAsync(ct))); + + g.MapPost("/keys", async (CreateApiKeyRequest request, ApiKeyService keys, CancellationToken ct) => + { + try + { + return Results.Ok(await keys.CreateAsync(request, ct)); + } + catch (ApiKeyValidationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + }); + + g.MapPatch("/keys/{id:guid}/toggle", async (Guid id, ApiKeyService keys, CancellationToken ct) => + { + var dto = await keys.ToggleAsync(id, ct); + return dto is null ? Results.NotFound() : Results.Ok(dto); + }); + + g.MapDelete("/keys/{id:guid}", async (Guid id, ApiKeyService keys, CancellationToken ct) => + await keys.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()); + } + + // ── monitored YouTube channels (by URL / @handle / id) ── + private static void MapChannels(RouteGroupBuilder g) + { + g.MapGet("/youtube/channels", async (ChannelService channels, CancellationToken ct) => + Results.Ok(await channels.ListAsync(ct))); + + g.MapPost("/youtube/channels", async (AddChannelRequest request, ChannelService channels, CancellationToken ct) => + { + try + { + return Results.Ok(await channels.AddAsync(request.Input, ct)); + } + catch (ChannelResolutionException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + }); + + g.MapDelete("/youtube/channels/{id:guid}", async (Guid id, ChannelService channels, CancellationToken ct) => + await channels.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()); + } + + // ── channel → Slack mappings ── + private static void MapMappings(RouteGroupBuilder g) + { + g.MapGet("/mappings", async (MappingService mappings, CancellationToken ct) => + Results.Ok(await mappings.ListAsync(ct))); + + g.MapGet("/mappings/options", async (MappingService mappings, CancellationToken ct) => + Results.Ok(await mappings.GetOptionsAsync(ct))); + + g.MapPost("/mappings", async (CreateMappingRequest request, MappingService mappings, CancellationToken ct) => + { + try + { + var created = await mappings.CreateAsync(request, ct); + return Results.Created($"/api/youtube-comments/mappings/{created.Id}", created); + } + catch (MappingConflictException ex) + { + return Results.Conflict(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + }); + + g.MapPatch("/mappings/{id:guid}", async (Guid id, UpdateMappingRequest request, MappingService mappings, CancellationToken ct) => + { + var updated = await mappings.UpdateAsync(id, request, ct); + return updated is null ? Results.NotFound() : Results.Ok(updated); + }); + + g.MapDelete("/mappings/{id:guid}", async (Guid id, MappingService mappings, CancellationToken ct) => + await mappings.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()); + } + + // ── Slack workspaces + cached channels (connect/disconnect via the shared Connections area) ── + private static void MapSlack(RouteGroupBuilder g) + { + g.MapGet("/slack/workspaces", async (SlackChannelService slack, CancellationToken ct) => + Results.Ok(await slack.ListWorkspacesAsync(ct))); + + g.MapGet("/slack/workspaces/{id:guid}/channels", async (Guid id, SlackChannelService slack, CancellationToken ct) => + Results.Ok(await slack.ListChannelsAsync(id, ct))); + + g.MapPost("/slack/workspaces/{id:guid}/refresh-channels", async (Guid id, SlackChannelService slack, CancellationToken ct) => + { + var channels = await slack.RefreshChannelsAsync(id, ct); + return channels is null ? Results.NotFound() : Results.Ok(channels); + }); + + g.MapDelete("/slack/workspaces/{id:guid}", async (Guid id, SlackChannelService slack, CancellationToken ct) => + await slack.DeleteWorkspaceAsync(id, ct) ? Results.NoContent() : Results.NotFound()); + } + + // ── dashboard KPIs + 24h timeline ── + private static void MapDashboard(RouteGroupBuilder g) + { + g.MapGet("/dashboard/stats", async (DashboardService dashboard, CancellationToken ct) => + Results.Ok(await dashboard.GetStatsAsync(ct))); + + g.MapGet("/dashboard/comments-timeline", async (DashboardService dashboard, CancellationToken ct) => + Results.Ok(await dashboard.GetCommentsTimelineAsync(ct))); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Endpoints/YouTubeCommentsProviderEndpoints.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Endpoints/YouTubeCommentsProviderEndpoints.cs new file mode 100644 index 0000000..c1c42bb --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Endpoints/YouTubeCommentsProviderEndpoints.cs @@ -0,0 +1,197 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +using Hookline.Modules.YouTubeComments.Infrastructure; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Hookline.Modules.YouTubeComments.Endpoints; + +/// +/// Provider endpoints — backend-direct (the identity middleware bypasses /slack); the OAuth +/// install is CSRF-protected by a one-shot state cookie instead. Module-scoped paths per the +/// architecture guide §6; the resulting workspace is stored in the SHARED Connections subsystem. +/// +public static class YouTubeCommentsProviderEndpoints +{ + private const string SlackStateCookie = "ytc_slack_oauth_state"; + private const string InstallScopes = "chat:write,channels:read,groups:read,team:read"; + + public static void MapYouTubeCommentsProviderEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/slack/youtube-comments/oauth/start", StartSlackOAuth); + app.MapGet("/slack/youtube-comments/oauth/callback", HandleSlackCallbackAsync); + app.MapPost("/slack/youtube-comments/interactivity", HandleInteractivityAsync); + } + + /// + /// Slack interactivity callback — handles the "Reject on YouTube" button. Signature-verified over + /// the raw body (replay-guarded), then the block_actions payload is routed to the moderation + /// service. The card is updated in place on success (response_url, replace_original) or an honest + /// ephemeral error is returned. ACKs 200 within Slack's window. + /// + private static async Task HandleInteractivityAsync( + HttpRequest req, SlackSignatureVerifier verifier, IOptions opt, + CommentModerationService moderation, ISlackClient slack, CancellationToken ct) + { + var rawBody = await ReadRawBodyAsync(req); + if (!VerifySignature(req, verifier, opt.Value.Slack.SigningSecret, rawBody)) + return Results.Unauthorized(); + + var payloadJson = ExtractFormField(rawBody, "payload"); + if (string.IsNullOrEmpty(payloadJson)) + return Results.Ok(); + + using var doc = JsonDocument.Parse(payloadJson); + var p = doc.RootElement; + + if (!p.TryGetProperty("type", out var pt) || pt.GetString() != "block_actions") + return Results.Ok(); + if (!p.TryGetProperty("actions", out var actions) || actions.GetArrayLength() == 0) + return Results.Ok(); + + var action = actions[0]; + var actionId = action.TryGetProperty("action_id", out var aid) ? aid.GetString() : null; + if (actionId != SlackActions.RejectComment) + return Results.Ok(); // the open_comment link button needs no server action + + var value = action.TryGetProperty("value", out var v) ? v.GetString() : null; + var responseUrl = p.TryGetProperty("response_url", out var ru) ? ru.GetString() : null; + if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(responseUrl)) + return Results.Ok(); + + // value = "{mappingId}:{commentId}". Split on the FIRST colon — a comment id may contain none, + // but it never contains a colon, and a Guid never does. + var sep = value.IndexOf(':'); + if (sep <= 0 || !Guid.TryParse(value[..sep], out var mappingId)) + return Results.Ok(); + var commentId = value[(sep + 1)..]; + + var actor = ExtractActor(p); + var result = await moderation.RejectAsync(mappingId, commentId, actor, ct); + + if (result.CardShouldShowRejected) + { + JsonElement? blocks = p.TryGetProperty("message", out var msg) + && msg.TryGetProperty("blocks", out var b) ? b : null; + var statusLine = $"🚫 Removed on YouTube · by {actor.Display}"; + var updated = CommentCardUpdater.MarkActioned(blocks, statusLine); + await slack.PostToResponseUrlAsync( + responseUrl, new { replace_original = true, text = "Comment removed on YouTube", blocks = updated }, ct); + } + else + { + // Honest error (no scope / not owner / quota / failure) — ephemeral, card left intact to retry. + await slack.PostToResponseUrlAsync( + responseUrl, new { response_type = "ephemeral", replace_original = false, text = result.Message }, ct); + } + + return Results.Ok(); + } + + private static SlackActor ExtractActor(JsonElement payload) + { + if (!payload.TryGetProperty("user", out var u)) + return new SlackActor(null, null); + + var id = u.TryGetProperty("id", out var uid) ? uid.GetString() : null; + var name = u.TryGetProperty("username", out var un) ? un.GetString() + : u.TryGetProperty("name", out var nm) ? nm.GetString() : null; + return new SlackActor(id, name); + } + + private static bool VerifySignature(HttpRequest req, SlackSignatureVerifier verifier, string? secret, string rawBody) + => verifier.Verify( + secret, + req.Headers["X-Slack-Request-Timestamp"].ToString(), + rawBody, + req.Headers["X-Slack-Signature"].ToString()); + + private static async Task ReadRawBodyAsync(HttpRequest req) + { + req.EnableBuffering(); + using var reader = new StreamReader(req.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + req.Body.Position = 0; + return body; + } + + private static string? ExtractFormField(string body, string name) + { + foreach (var pair in body.Split('&')) + { + var eq = pair.IndexOf('='); + if (eq < 0) continue; + if (pair[..eq] == name) return WebUtility.UrlDecode(pair[(eq + 1)..]); + } + return null; + } + + private static IResult StartSlackOAuth(HttpContext http, IOptions opt) + { + var o = opt.Value.Slack; + var state = GenerateState(); + http.Response.Cookies.Append(SlackStateCookie, state, StateCookie(http)); + + var url = "https://slack.com/oauth/v2/authorize" + + $"?client_id={Uri.EscapeDataString(o.ClientId)}" + + $"&scope={Uri.EscapeDataString(InstallScopes)}" + + $"&redirect_uri={Uri.EscapeDataString(o.RedirectUri)}" + + $"&state={Uri.EscapeDataString(state)}"; + return Results.Redirect(url); + } + + private static async Task HandleSlackCallbackAsync( + HttpContext http, IOptions opt, SlackChannelService slack, + ILoggerFactory loggerFactory, CancellationToken ct) + { + var panel = opt.Value.AdminPanelUrl.TrimEnd('/'); + var code = http.Request.Query["code"].ToString(); + var state = http.Request.Query["state"].ToString(); + var slackError = http.Request.Query["error"].ToString(); + + var expected = http.Request.Cookies[SlackStateCookie]; + http.Response.Cookies.Delete(SlackStateCookie, new CookieOptions { Path = "/" }); + + if (!string.IsNullOrEmpty(slackError)) + return Results.Redirect($"{panel}/connections/slack?error={Uri.EscapeDataString(slackError)}"); + if (string.IsNullOrEmpty(code)) + return Results.Redirect($"{panel}/connections/slack?error=missing_code"); + if (!ValidState(state, expected)) + return Results.Redirect($"{panel}/connections/slack?error=invalid_state"); + + try + { + await slack.HandleOAuthCallbackAsync(code, opt.Value.Slack.RedirectUri, ct); + return Results.Redirect($"{panel}/connections/slack?connected=1"); + } + catch (Exception ex) + { + loggerFactory.CreateLogger("YouTubeComments.Slack.OAuth").LogError(ex, "Slack OAuth callback failed"); + return Results.Redirect($"{panel}/connections/slack?error=oauth_failed"); + } + } + + private static CookieOptions StateCookie(HttpContext http) => new() + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Secure = http.Request.IsHttps, + MaxAge = TimeSpan.FromMinutes(10), + Path = "/", + }; + + private static string GenerateState() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + + private static bool ValidState(string? state, string? expected) => + !string.IsNullOrEmpty(state) && !string.IsNullOrEmpty(expected) + && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(state), Encoding.UTF8.GetBytes(expected)); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Features/CommentsMaintenanceControl.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Features/CommentsMaintenanceControl.cs new file mode 100644 index 0000000..d218c1c --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Features/CommentsMaintenanceControl.cs @@ -0,0 +1,77 @@ +using Hookline.Modules.YouTubeComments.Infrastructure; +using Hookline.SharedKernel.Caching; +using Hookline.SharedKernel.Maintenance; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Modules.YouTubeComments.Features; + +/// +/// The comments module's slice of the System "Danger Zone" fan-out (host-orchestrated via +/// ). Pause = flip every active mapping to paused AND tear down its +/// per-mapping recurring poll + reply sweep (mirrors MappingService's pause). Reset = wipe the +/// operational state (dedup ledger, retry queue, quota counters) and advance every mapping's watermark to +/// now (so a wiped dedup ledger can't replay old comments), keeping the mappings/channels, connections and +/// audit trail intact. +/// +public sealed class CommentsMaintenanceControl( + YouTubeCommentsDbContext db, + IPollingScheduler scheduler, + ICachePurge cache, + ICommentsAudit audit) : IMaintenanceControl +{ + public string Module => "youtube-comments"; + + public async Task PauseAllAsync(CancellationToken ct = default) + { + var active = await db.ChannelMappings.Where(m => m.IsActive).ToListAsync(ct); + var now = DateTimeOffset.UtcNow; + foreach (var m in active) + { + m.IsActive = false; + m.UpdatedAt = now; + } + + await db.SaveChangesAsync(ct); + + // Tear down each paused mapping's recurring jobs (idempotent) — same as a single pause. + foreach (var m in active) + { + scheduler.Remove(m.Id); + scheduler.RemoveReplySweep(m.Id); + } + + await audit.LogAsync(AuditLevel.Warning, "maintenance.pause-all", + $"Paused {active.Count} mapping(s)", "ChannelMapping", ct: ct); + return new MaintenanceResult(Module, active.Count, $"{active.Count} mapping(s) paused"); + } + + public async Task ResetDataAsync(CancellationToken ct = default) + { + var processed = await db.ProcessedComments.ToListAsync(ct); + db.ProcessedComments.RemoveRange(processed); + var pending = await db.PendingDeliveries.ToListAsync(ct); + db.PendingDeliveries.RemoveRange(pending); + var quota = await db.QuotaUsages.ToListAsync(ct); + db.QuotaUsages.RemoveRange(quota); + + // Keep mappings, but advance the watermark to now + clear last-run state so the wiped dedup ledger + // can't replay old comments into Slack. + var now = DateTimeOffset.UtcNow; + var mappings = await db.ChannelMappings.ToListAsync(ct); + foreach (var m in mappings) + { + m.CommentsSinceUtc = now; + m.LastError = null; + m.LastPolledAt = null; + } + + await db.SaveChangesAsync(ct); // one transaction + + var purged = await cache.PurgeByPrefixAsync(RedisKeys.Prefix, ct); + + var detail = $"processed={processed.Count}, pending={pending.Count}, quota={quota.Count}, watermarks={mappings.Count}, cache={purged}"; + await audit.LogAsync(AuditLevel.Warning, "maintenance.reset", detail, ct: ct); + return new MaintenanceResult(Module, processed.Count + pending.Count + quota.Count, detail); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Features/ConnectionEventHandlers.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Features/ConnectionEventHandlers.cs new file mode 100644 index 0000000..6c200da --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Features/ConnectionEventHandlers.cs @@ -0,0 +1,74 @@ +using Hookline.Modules.YouTubeComments.Infrastructure; +using Hookline.SharedKernel.Connections; +using Hookline.SharedKernel.Messaging; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Hookline.Modules.YouTubeComments.Features; + +/// +/// When a shared Slack workspace is disconnected, DEACTIVATE every mapping whose Slack channel belongs +/// to it and tear down its recurring jobs — the mapping rows (and their dedup ledger) survive so a +/// reconnect can reactivate them; the channel cache is left in place (it re-syncs on reconnect). Mirrors +/// the architecture guide's §5 "deactivate on disconnect". The link is a plain workspace id (no +/// cross-schema FK), so this event is how it is severed. Recorded in the shared audit trail. +/// +public sealed class SlackWorkspaceDisconnectedHandler( + YouTubeCommentsDbContext db, + IPollingScheduler scheduler, + ICommentsAudit audit, + ILogger logger) + : IIntegrationEventHandler +{ + public async Task HandleAsync(SlackWorkspaceDisconnected @event, CancellationToken ct = default) + { + var mappings = await db.ChannelMappings + .Include(m => m.SlackChannel) + .Where(m => m.SlackChannel!.WorkspaceId == @event.WorkspaceId && m.IsActive) + .ToListAsync(ct); + + if (mappings.Count > 0) + { + var now = DateTimeOffset.UtcNow; + foreach (var m in mappings) + { + m.IsActive = false; + m.LastError = "Slack workspace disconnected"; + m.UpdatedAt = now; + scheduler.Remove(m.Id); + scheduler.RemoveReplySweep(m.Id); + } + await db.SaveChangesAsync(ct); + } + + await audit.LogAsync(AuditLevel.Warning, "Slack", + "Slack workspace disconnected", "SlackWorkspace", @event.WorkspaceId.ToString(), + details: $"deactivated {mappings.Count} mapping(s)", ct: ct); + logger.LogInformation("Slack workspace {Workspace} disconnected: deactivated {Mappings} mapping(s)", + @event.WorkspaceId, mappings.Count); + } +} + +/// +/// When a shared YouTube API key is deleted from the pool, prune the module-local quota_usage +/// rows that tracked its Pacific-day consumption (the key id is a plain value, no cross-schema FK). +/// Polling keeps working off the remaining keys. +/// +public sealed class YouTubeApiKeyDisconnectedHandler( + YouTubeCommentsDbContext db, + ICommentsAudit audit, + ILogger logger) + : IIntegrationEventHandler +{ + public async Task HandleAsync(YouTubeApiKeyDisconnected @event, CancellationToken ct = default) + { + var pruned = await db.QuotaUsages + .Where(q => q.ApiKeyId == @event.KeyId) + .ExecuteDeleteAsync(ct); + + await audit.LogAsync(AuditLevel.Information, "Quota", + $"API key disconnected: pruned {pruned} quota row(s)", "YouTubeApiKey", @event.KeyId.ToString(), ct: ct); + logger.LogInformation("YouTube API key {Key} disconnected: pruned {Pruned} quota row(s)", @event.KeyId, pruned); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Hookline.Modules.YouTubeComments.csproj b/backend/src/Modules/Hookline.Modules.YouTubeComments/Hookline.Modules.YouTubeComments.csproj new file mode 100644 index 0000000..a05e7c2 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Hookline.Modules.YouTubeComments.csproj @@ -0,0 +1,29 @@ + + + + Hookline.Modules.YouTubeComments + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ApiKeyService.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ApiKeyService.cs new file mode 100644 index 0000000..e1854a5 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ApiKeyService.cs @@ -0,0 +1,139 @@ +using Hookline.SharedKernel.Connections; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// Read model for a stored YouTube API key. Never exposes the secret value, only . +public sealed record ApiKeyDto( + Guid Id, + string Name, + string KeyHint, + int DailyQuotaLimit, + bool IsActive, + DateTimeOffset CreatedAt, + int TodayUnitsUsed, + int RemainingQuota); + +/// Request payload to register a new API key. +public sealed record CreateApiKeyRequest(string Name, string ApiKey); + +/// Thrown when an API key request is invalid or YouTube rejects the key (-> 400). +public sealed class ApiKeyValidationException(string message) : Exception(message); + +/// +/// CRUD + validation for YouTube API keys. The key identity/secret lives in the shared Connections +/// store (); this decorates each key with today's +/// Pacific-Time quota usage (from the module-local quota_usage) so the UI can show consumption. +/// A uniform daily unit limit comes from config (the shared key record has no per-key limit). +/// +public sealed class ApiKeyService( + YouTubeCommentsDbContext db, + IYouTubeClient youtube, + IYouTubeApiKeyConnections keys, + ICommentsAudit audit, + IOptions options) +{ + private readonly int _dailyLimit = options.Value.DailyQuotaUnits; + + /// Lists every stored key with today's quota usage and remaining quota. + public async Task ListAsync(CancellationToken ct = default) + { + var today = PacificTime.Today(); + var all = await keys.ListAsync(ct); + var ids = all.Select(k => k.Id).ToArray(); + + var usageToday = await db.QuotaUsages + .AsNoTracking() + .Where(q => q.UsageDate == today && ids.Contains(q.ApiKeyId)) + .ToDictionaryAsync(q => q.ApiKeyId, q => q.UnitsUsed, ct); + + return all.Select(k => ToDto(k, usageToday.GetValueOrDefault(k.Id))).ToArray(); + } + + /// + /// Validates the request and the key against the YouTube API, then persists it to the shared store. + /// Throws for empty fields or a key YouTube rejects. + /// + public async Task CreateAsync(CreateApiKeyRequest request, CancellationToken ct = default) + { + var name = request.Name?.Trim(); + var apiKey = request.ApiKey?.Trim(); + + if (string.IsNullOrWhiteSpace(name)) + throw new ApiKeyValidationException("Name is required."); + if (string.IsNullOrWhiteSpace(apiKey)) + throw new ApiKeyValidationException("API key is required."); + + var (ok, error) = await youtube.ValidateKeyAsync(apiKey, ct); + if (!ok) + throw new ApiKeyValidationException(error ?? "The API key could not be validated."); + + var hint = Mask(apiKey); + var id = await keys.CreateAsync(name, apiKey, hint, ct); + + await audit.LogAsync(AuditLevel.Information, "ApiKey", + $"Added YouTube API key '{name}' ({hint})", "YouTubeApiKey", id.ToString(), ct: ct); + + // A freshly created key has no usage yet for today. + return new ApiKeyDto(id, name, hint, _dailyLimit, IsActive: true, DateTimeOffset.UtcNow, TodayUnitsUsed: 0, RemainingQuota: _dailyLimit); + } + + /// Deletes the key if present (also prunes its quota rows via the disconnect event). Returns false when no row matched. + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var summary = (await keys.ListAsync(ct)).FirstOrDefault(k => k.Id == id); + if (summary is null) + return false; + + if (!await keys.DeleteAsync(id, ct)) + return false; + + await audit.LogAsync(AuditLevel.Information, "ApiKey", + $"Deleted YouTube API key '{summary.Name}'", "YouTubeApiKey", id.ToString(), ct: ct); + return true; + } + + /// Flips the key's active flag. Returns the updated DTO or null when not found. + public async Task ToggleAsync(Guid id, CancellationToken ct = default) + { + var summary = (await keys.ListAsync(ct)).FirstOrDefault(k => k.Id == id); + if (summary is null) + return null; + + var newActive = !summary.IsActive; + await keys.ToggleAsync(id, newActive, ct); + + await audit.LogAsync(AuditLevel.Information, "ApiKey", + $"{(newActive ? "Enabled" : "Disabled")} YouTube API key '{summary.Name}'", + "YouTubeApiKey", id.ToString(), ct: ct); + + var today = PacificTime.Today(); + var unitsUsed = await db.QuotaUsages + .AsNoTracking() + .Where(q => q.ApiKeyId == id && q.UsageDate == today) + .Select(q => (int?)q.UnitsUsed) + .FirstOrDefaultAsync(ct) ?? 0; + + return ToDto(summary with { IsActive = newActive }, unitsUsed); + } + + private ApiKeyDto ToDto(YouTubeApiKeySummary k, int todayUnitsUsed) => + new(k.Id, k.Name, k.KeyHint, _dailyLimit, k.IsActive, k.CreatedAt, todayUnitsUsed, Math.Max(0, _dailyLimit - todayUnitsUsed)); + + /// + /// Masks a secret for display: first 4 + ellipsis + last 4. For keys of length <= 8 the value + /// is fully hidden and only the length is revealed (e.g. •••• (6)). + /// + internal static string Mask(string apiKey) + { + if (string.IsNullOrEmpty(apiKey)) + return string.Empty; + + if (apiKey.Length <= 8) + return $"•••• ({apiKey.Length})"; + + return $"{apiKey[..4]}…{apiKey[^4..]}"; + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ChannelService.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ChannelService.cs new file mode 100644 index 0000000..533affc --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ChannelService.cs @@ -0,0 +1,114 @@ +using Hookline.Modules.YouTubeComments.Domain; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// A tracked YouTube channel, with a count of the mappings that target it. +public sealed record YouTubeChannelDto( + Guid Id, + string YouTubeChannelId, + string Title, + string? ThumbnailUrl, + string? Handle, + DateTimeOffset AddedAt, + int MappingCount); + +/// Request to add a channel by raw id, @handle, or youtube.com URL. +public sealed record AddChannelRequest(string Input); + +/// Thrown when a channel can't be resolved or no key has quota (-> 400). +public sealed class ChannelResolutionException(string message) : Exception(message); + +/// +/// Manages the tracked set of YouTube channels: listing them with their mapping counts, and adding a +/// channel by resolving a raw id, @handle, or youtube.com URL against the YouTube Data API +/// (consuming a leased key's quota) before upserting it by channel id. +/// +public sealed class ChannelService( + YouTubeCommentsDbContext db, + IYouTubeClient youtube, + IYouTubeApiKeyProvider keys, + ICommentsAudit audit) +{ + /// Lists every tracked channel, newest first, with the number of mappings targeting it. + public async Task ListAsync(CancellationToken ct = default) => + await db.YouTubeChannels + .AsNoTracking() + .OrderByDescending(c => c.AddedAt) + .Select(c => new YouTubeChannelDto( + c.Id, c.YouTubeChannelId, c.Title, c.ThumbnailUrl, c.Handle, c.AddedAt, c.Mappings.Count)) + .ToArrayAsync(ct); + + /// + /// Resolves to a YouTube channel and upserts it. Acquires an API key with + /// available quota, calls the YouTube API, records the quota consumed, then returns the existing or + /// newly-created channel. Throws (-> 400) when no key has + /// quota or the input doesn't resolve to a channel. + /// + public async Task AddAsync(string input, CancellationToken ct = default) + { + var trimmed = input?.Trim() ?? string.Empty; + if (trimmed.Length == 0) + throw new ChannelResolutionException("A channel id, handle, or URL is required."); + + // A /c/CUSTOM URL costs 101 units (search.list + channels.list); everything else costs 1. + // Acquire enough for the worst case so the lookup never aborts mid-flight on quota. + var lease = await keys.AcquireAsync(unitsNeeded: 101, ct) + ?? throw new ChannelResolutionException("No YouTube API key with available quota"); + + var result = await youtube.GetChannelAsync(lease.ApiKey, trimmed, ct); + + // Always record the quota actually consumed, even when the channel didn't resolve. + if (result.UnitsUsed > 0) + await keys.RecordUsageAsync(lease.Id, result.UnitsUsed, ct); + + if (result.Channel is null) + throw new ChannelResolutionException($"Could not resolve channel from '{trimmed}'"); + + var info = result.Channel; + + // Upsert by the immutable YouTube channel id: if we already track it, return as-is. + var existing = await db.YouTubeChannels.FirstOrDefaultAsync(c => c.YouTubeChannelId == info.ChannelId, ct); + if (existing is not null) + return await ToDtoAsync(existing, ct); + + var entity = new YouTubeChannel + { + YouTubeChannelId = info.ChannelId, + Title = info.Title, + ThumbnailUrl = info.ThumbnailUrl, + Handle = info.Handle, + AddedAt = DateTimeOffset.UtcNow, + }; + + db.YouTubeChannels.Add(entity); + await db.SaveChangesAsync(ct); + + await audit.LogAsync(AuditLevel.Information, "Channel", + $"Added YouTube channel '{entity.Title}'", "YouTubeChannel", entity.Id.ToString(), ct: ct); + + return new YouTubeChannelDto(entity.Id, entity.YouTubeChannelId, entity.Title, entity.ThumbnailUrl, entity.Handle, entity.AddedAt, 0); + } + + /// Deletes a channel (and its mappings, via cascade). Returns false when not found. + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var entity = await db.YouTubeChannels.FirstOrDefaultAsync(c => c.Id == id, ct); + if (entity is null) + return false; + + db.YouTubeChannels.Remove(entity); + await db.SaveChangesAsync(ct); + + await audit.LogAsync(AuditLevel.Information, "Channel", + $"Deleted YouTube channel '{entity.Title}'", "YouTubeChannel", id.ToString(), ct: ct); + return true; + } + + private async Task ToDtoAsync(YouTubeChannel channel, CancellationToken ct) + { + var mappingCount = await db.ChannelMappings.CountAsync(m => m.YouTubeChannelId == channel.Id, ct); + return new YouTubeChannelDto(channel.Id, channel.YouTubeChannelId, channel.Title, channel.ThumbnailUrl, channel.Handle, channel.AddedAt, mappingCount); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentCardUpdater.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentCardUpdater.cs new file mode 100644 index 0000000..d18b95b --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentCardUpdater.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// Rewrites the original Block Kit comment card after a moderation action: strips the "Reject" button +/// (leaving the "open on YouTube" link), drops now-empty action rows, and appends a context line such +/// as "🚫 Removed by @user". Pure transform over the message blocks Slack echoes back in the +/// interaction payload, so the card keeps its content instead of being replaced by a bare line. +/// +public static class CommentCardUpdater +{ + /// + /// Returns a new blocks array with the reject button removed and + /// appended as a context block. is the interaction payload's + /// message.blocks; when it is null/absent, returns a single context block with the status. + /// + public static JsonArray MarkActioned(JsonElement? originalBlocks, string statusLine) + { + var result = new JsonArray(); + + if (originalBlocks is { ValueKind: JsonValueKind.Array } blocks) + { + foreach (var block in blocks.EnumerateArray()) + { + var node = JsonNode.Parse(block.GetRawText())!; + + // Strip the reject button from any actions block; keep other elements (e.g. the link button). + if (node["type"]?.GetValue() == "actions" && node["elements"] is JsonArray elements) + { + var kept = new JsonArray(); + foreach (var el in elements) + { + if (el?["action_id"]?.GetValue() == SlackActions.RejectComment) + continue; + kept.Add(el!.DeepClone()); + } + + if (kept.Count == 0) + continue; // drop a now-empty actions row entirely + + node["elements"] = kept; + } + + result.Add(node); + } + } + + result.Add(new JsonObject + { + ["type"] = "context", + ["elements"] = new JsonArray + { + new JsonObject { ["type"] = "mrkdwn", ["text"] = statusLine }, + }, + }); + + return result; + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentModerationService.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentModerationService.cs new file mode 100644 index 0000000..2664969 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentModerationService.cs @@ -0,0 +1,229 @@ +using System.Net; + +using Google; + +using Hookline.Modules.YouTubeComments.Domain; +using Hookline.SharedKernel.Connections; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// The Slack user who pressed the button — the honest moderation actor (the shared audit actor +/// is "system" on a provider callback, so this is recorded explicitly). +public readonly record struct SlackActor(string? UserId, string? UserName) +{ + /// Human label for the Slack card status line ("removed by …"). + public string Display => string.IsNullOrEmpty(UserName) + ? (string.IsNullOrEmpty(UserId) ? "a Slack user" : $"<@{UserId}>") + : $"@{UserName}"; + + /// The value stamped into the shared audit Actor column. Carries BOTH the Slack + /// handle and the immutable user id for non-repudiation, so System→Logs shows who moderated a + /// comment instead of "anonymous" (the /slack callback bypasses identity, so the request principal + /// is anonymous and cannot be the audit actor). + public string AuditActor + { + get + { + var hasId = !string.IsNullOrEmpty(UserId); + var hasName = !string.IsNullOrEmpty(UserName); + if (hasId && hasName) return $"@{UserName} (slack:{UserId})"; + if (hasName) return $"@{UserName}"; + if (hasId) return $"slack:{UserId}"; + return "a Slack user"; + } + } +} + +/// The terminal outcome of a reject action, mapped by the endpoint to a Slack response. +public enum ModerationOutcome +{ + /// Rejected on YouTube just now. + Rejected, + + /// A prior moderation row already exists (double-click / already actioned). + AlreadyDone, + + /// YouTube reported the comment is already gone (404) — treated as success. + AlreadyGoneOnYouTube, + + /// No force-ssl Google account is connected for this channel — honest, not silent. + NotConnected, + + /// YouTube refused (not the channel owner / insufficient permission). + Forbidden, + + /// The OAuth project's quota is exhausted. + QuotaExceeded, + + /// An unexpected failure — logged + audited. + Failed, +} + +/// Outcome + a human message safe to show in Slack. +public sealed record ModerationResult(ModerationOutcome Outcome, string Message) +{ + public bool CardShouldShowRejected => Outcome is ModerationOutcome.Rejected + or ModerationOutcome.AlreadyDone or ModerationOutcome.AlreadyGoneOnYouTube; +} + +/// +/// Performs the "Reject on YouTube" action: resolves the moderation credential for the comment's +/// owning channel (via the shared contract — never a reference +/// to the Uploads module), calls comments.setModerationStatus = rejected, and records a durable +/// idempotency row + an audit entry. Every terminal path is honest — no scope, not owner, already +/// gone, and quota each map to a distinct message rather than a silent failure. +/// +public sealed class CommentModerationService( + YouTubeCommentsDbContext db, + IEnumerable channelCredentialProviders, + IGoogleConnections googleConnections, + IYouTubeModerationClient moderation, + ICommentsAudit audit, + ILogger logger) +{ + // Optional dependency: the credentials contract is implemented by the Uploads module. In a build + // without it (Uploads absent), moderation degrades to an honest "unavailable", never a crash. + private readonly IGoogleChannelCredentials? _channelCredentials = channelCredentialProviders.FirstOrDefault(); + + /// + /// True when an ACTIVE Google account owns AND has been granted + /// the force-ssl scope — i.e. the "Reject" button can act. Reads the shared store's scope snapshot + /// (a contract call, not a join); used for scope-detection surfaces. + /// + public async Task CanModerateAsync(string youtubeChannelId, CancellationToken ct = default) + { + if (_channelCredentials is null || string.IsNullOrEmpty(youtubeChannelId)) + return false; + + foreach (var summary in await googleConnections.ListAsync(ct)) + { + if (!summary.IsActive) + continue; + var detail = await googleConnections.GetAsync(summary.Id, ct); + if (detail is { IsActive: true, ChannelId: { } channelId } + && string.Equals(channelId, youtubeChannelId, StringComparison.Ordinal) + && detail.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Contains(GoogleScopes.YouTubeForceSsl, StringComparer.Ordinal)) + { + return true; + } + } + + return false; + } + + public async Task RejectAsync( + Guid mappingId, string commentId, SlackActor actor, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(commentId)) + return new ModerationResult(ModerationOutcome.Failed, "Missing comment reference."); + + // Resolve the owning YouTube channel id for this mapping (also tells us the mapping exists). + var youtubeChannelId = await db.ChannelMappings.AsNoTracking() + .Where(m => m.Id == mappingId) + .Select(m => m.YouTubeChannel!.YouTubeChannelId) + .FirstOrDefaultAsync(ct); + if (string.IsNullOrEmpty(youtubeChannelId)) + return new ModerationResult(ModerationOutcome.Failed, "This comment's channel mapping no longer exists."); + + // Idempotency: a prior row short-circuits a double-click without touching YouTube. + var existing = await db.CommentModerations.AsNoTracking() + .FirstOrDefaultAsync(x => x.MappingId == mappingId && x.CommentId == commentId, ct); + if (existing is not null) + return new ModerationResult(ModerationOutcome.AlreadyDone, + $"Already removed by {Actor(existing)}."); + + if (_channelCredentials is null) + return new ModerationResult(ModerationOutcome.NotConnected, + "Comment removal is unavailable — the Google credentials provider is not loaded."); + + var credential = await _channelCredentials.GetModerationCredentialAsync(youtubeChannelId, ct); + if (credential is null) + { + await LogAsync(AuditLevel.Warning, commentId, youtubeChannelId, actor, + "Reject skipped: no force-ssl Google account connected for the channel"); + return new ModerationResult(ModerationOutcome.NotConnected, + "No Google account with comment-management permission is connected for this channel. " + + "Re-connect it in Connections → Google (grant the comment-management permission) to enable removal."); + } + + try + { + await moderation.RejectAsync(credential.AccessToken, commentId, ct); + } + catch (GoogleApiException ex) when (ex.HasReason("quotaExceeded")) + { + await LogAsync(AuditLevel.Warning, commentId, youtubeChannelId, actor, "Reject failed: OAuth quota exhausted"); + return new ModerationResult(ModerationOutcome.QuotaExceeded, + "YouTube quota for the connected account is exhausted — try again later."); + } + catch (GoogleApiException ex) when (IsAlreadyGone(ex)) + { + try { await RecordAsync(mappingId, commentId, actor, CommentModeration.StatusAlreadyGone, ct); } + catch (DbUpdateException) { /* a concurrent click already recorded it (unique index) — fine */ } + await LogAsync(AuditLevel.Information, commentId, youtubeChannelId, actor, "Comment already gone on YouTube (treated as removed)"); + return new ModerationResult(ModerationOutcome.AlreadyGoneOnYouTube, "That comment was already gone on YouTube."); + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.Forbidden) + { + await LogAsync(AuditLevel.Warning, commentId, youtubeChannelId, actor, + $"Reject forbidden by YouTube: {ex.Error?.Message ?? ex.Message}"); + return new ModerationResult(ModerationOutcome.Forbidden, + "YouTube refused the removal — the connected account isn't authorized to moderate this comment."); + } + catch (Exception ex) + { + logger.LogError(ex, "Reject failed for comment {CommentId} on mapping {MappingId}", commentId, mappingId); + await LogAsync(AuditLevel.Error, commentId, youtubeChannelId, actor, $"Reject failed: {ex.Message}"); + return new ModerationResult(ModerationOutcome.Failed, "Couldn't remove the comment — an unexpected error occurred."); + } + + try + { + await RecordAsync(mappingId, commentId, actor, CommentModeration.StatusRejected, ct); + } + catch (DbUpdateException) + { + // Lost a race against a concurrent click (unique index). The reject itself succeeded/idempotent. + return new ModerationResult(ModerationOutcome.AlreadyDone, "Already removed."); + } + + await LogAsync(AuditLevel.Information, commentId, youtubeChannelId, actor, "Comment rejected on YouTube"); + return new ModerationResult(ModerationOutcome.Rejected, "🚫 Removed on YouTube."); + } + + private async Task RecordAsync(Guid mappingId, string commentId, SlackActor actor, string status, CancellationToken ct) + { + db.CommentModerations.Add(new CommentModeration + { + MappingId = mappingId, + CommentId = commentId, + Action = CommentModeration.ActionRejected, + Status = status, + SlackUserId = actor.UserId, + SlackUserName = actor.UserName, + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(ct); + } + + private Task LogAsync(string level, string commentId, string channelId, SlackActor actor, string message) => + audit.LogAsync(level, "Moderation", message, "Comment", commentId, + // Stamp the moderating Slack user as the explicit audit ACTOR (the /slack callback is + // identity-bypassed, so the request principal is anonymous). The id/name also stay in the + // detail JSON + the comment_moderations row for cross-referencing. + actor: actor.AuditActor, + details: $"{{\"channel\":\"{channelId}\",\"slackUser\":\"{actor.UserId}\",\"slackUserName\":\"{actor.UserName}\"}}"); + + private static string Actor(CommentModeration row) => + string.IsNullOrEmpty(row.SlackUserName) + ? (string.IsNullOrEmpty(row.SlackUserId) ? "a Slack user" : $"<@{row.SlackUserId}>") + : $"@{row.SlackUserName}"; + + /// A 404 (or commentNotFound) means the comment no longer exists — already gone. + private static bool IsAlreadyGone(GoogleApiException ex) => + ex.HttpStatusCode == HttpStatusCode.NotFound || ex.HasReason("commentNotFound"); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentsAudit.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentsAudit.cs new file mode 100644 index 0000000..21e7574 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/CommentsAudit.cs @@ -0,0 +1,84 @@ +using Hookline.SharedKernel.Audit; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// The closed set of severity levels this module stamps onto audit entries. The shared audit entry has +/// no dedicated severity column by design (see ): the level is folded into +/// the Detail as a leading [Level] marker and queried back through +/// 's detailPrefix. Using these constants instead of +/// bare strings keeps the vocabulary fixed so a typo can't silently fall out of a level-filtered count. +/// +public static class AuditLevel +{ + public const string Information = "Information"; + public const string Warning = "Warning"; + public const string Error = "Error"; +} + +/// +/// Module-local audit facade that maps the legacy level/category/message shape onto the shared +/// . The shared log stamps the actor from ICurrentUser automatically +/// (the admin email for an API request, system inside a background job); when a caller supplies an +/// explicit it is FORWARDED to the shared log and wins over the request +/// principal — this is how the moderating Slack user is recorded on the identity-bypassed /slack +/// interactivity callback (where the current user is anonymous) instead of being lost to "anonymous". +/// The category becomes the audit Action and the level is folded into the detail; every row is +/// tagged module = "youtube-comments" so the shared System→Logs page can filter to it. +/// +/// +/// Severity contract (intentional, queryable). The shared audit entry deliberately carries +/// no separate Level/Severity column. Instead, severity is folded into Detail as a leading +/// [Level] marker (e.g. "[Error] Delivery failed…"). This is the supported query mechanism: +/// filters by detailPrefix via a literal +/// StartsWith (Postgres treats [/] literally), which is how the dashboard's +/// "errors · 24h" KPI is computed. The marker format lives in one place — +/// — and both the writer here and the reader on the dashboard +/// use it, so the two ends cannot drift. Levels come from . +/// +public interface ICommentsAudit +{ + Task LogAsync( + string level, + string category, + string message, + string? entityType = null, + string? entityId = null, + string? actor = null, + string? details = null, + CancellationToken ct = default); +} + +public sealed class CommentsAudit(IAuditLog audit) : ICommentsAudit +{ + public const string ModuleName = "youtube-comments"; + + /// + /// The canonical detail prefix for a severity (e.g. "[Error]"). This + /// is the single source of truth shared by the writer (this facade) and the reader (the dashboard's + /// error-count query), so a level-filtered count can never drift from how the level is stamped. + /// + public static string DetailPrefix(string level) => $"[{level}]"; + + public Task LogAsync( + string level, + string category, + string message, + string? entityType = null, + string? entityId = null, + string? actor = null, + string? details = null, + CancellationToken ct = default) + { + var prefix = DetailPrefix(level); + var detail = details is null ? $"{prefix} {message}" : $"{prefix} {message} {details}"; + return audit.WriteAsync( + action: category, + module: ModuleName, + entityType: entityType, + entityId: entityId, + detail: detail, + actor: actor, + ct: ct); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/DashboardService.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/DashboardService.cs new file mode 100644 index 0000000..2172048 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/DashboardService.cs @@ -0,0 +1,128 @@ +using Hookline.SharedKernel.Audit; +using Hookline.SharedKernel.Connections; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// Aggregated KPIs for the dashboard landing page. +public sealed record DashboardStatsDto( + int ActiveMappings, + int TotalMappings, + int CommentsToday, + int CommentsLast24h, + long TotalQuotaLimit, + long TotalQuotaUsedToday, + double QuotaUsedPercent, + int ErrorsLast24h, + int ConnectedWorkspaces, + int ApiKeyCount, + int ChannelCount); + +/// A single hour bucket in the "comments processed" timeline. is the start of the UTC hour. +public sealed record CommentsTimelinePoint(DateTimeOffset Bucket, int Count); + +/// +/// Read-only aggregation over the operational tables for the dashboard. "Today" boundaries use Pacific +/// Time (matching quota tracking); rolling windows use the last 24 hours from . +/// API keys + Slack workspaces are counted via the shared Connections accessors. +/// +public sealed class DashboardService( + YouTubeCommentsDbContext db, + IYouTubeApiKeyConnections keys, + ISlackConnections slackConnections, + IAuditLogReader auditLog, + IOptions options) +{ + private readonly int _dailyLimit = options.Value.DailyQuotaUnits; + + /// Computes the dashboard KPI snapshot in a handful of aggregate queries. + public async Task GetStatsAsync(CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var since24h = now.AddHours(-24); + var startOfTodayPt = PacificTime.StartOfToday(); + var todayPt = PacificTime.Today(); + + var totalMappings = await db.ChannelMappings.AsNoTracking().CountAsync(ct); + var activeMappings = await db.ChannelMappings.AsNoTracking().CountAsync(m => m.IsActive, ct); + + var commentsToday = await db.ProcessedComments.AsNoTracking().CountAsync(c => c.ProcessedAt >= startOfTodayPt, ct); + var commentsLast24h = await db.ProcessedComments.AsNoTracking().CountAsync(c => c.ProcessedAt >= since24h, ct); + + var allKeys = await keys.ListAsync(ct); + // Meter capacity and usage over the SAME key set (the active keys). The limit is a uniform + // per-key ceiling × active-key count; summing usage over only those keys keeps the numerator and + // denominator consistent, so a disabled/removed key that still has usage recorded for today + // can't push the meter past 100%. Clamp defensively (a final RecordUsage can overshoot a key's + // ceiling by one call's units). + var activeKeyIds = allKeys.Where(k => k.IsActive).Select(k => k.Id).ToList(); + var activeKeyCount = activeKeyIds.Count; + var totalQuotaLimit = (long)activeKeyCount * _dailyLimit; + var totalQuotaUsedToday = activeKeyCount == 0 + ? 0L + : await db.QuotaUsages.AsNoTracking() + .Where(q => q.UsageDate == todayPt && activeKeyIds.Contains(q.ApiKeyId)) + .SumAsync(q => (long)q.UnitsUsed, ct); + var quotaUsedPercent = totalQuotaLimit > 0 + ? Math.Round(Math.Min(100d, (double)totalQuotaUsedToday / totalQuotaLimit * 100), 1) + : 0; + + // Audit lives in the shared trail; count this module's error-level rows (the level is folded + // into the detail as a "[Error] …" marker — see CommentsAudit) in the last 24h so the KPI + // reflects reality instead of a flat 0. The prefix comes from the same builder the writer uses. + var errorsLast24h = await auditLog.CountSinceAsync( + CommentsAudit.ModuleName, since24h, detailPrefix: CommentsAudit.DetailPrefix(AuditLevel.Error), ct); + + var workspaces = await slackConnections.ListAsync(ct); + var connectedWorkspaces = workspaces.Count(w => w.IsActive); + var channelCount = await db.YouTubeChannels.AsNoTracking().CountAsync(ct); + + return new DashboardStatsDto( + ActiveMappings: activeMappings, + TotalMappings: totalMappings, + CommentsToday: commentsToday, + CommentsLast24h: commentsLast24h, + TotalQuotaLimit: totalQuotaLimit, + TotalQuotaUsedToday: totalQuotaUsedToday, + QuotaUsedPercent: quotaUsedPercent, + ErrorsLast24h: errorsLast24h, + ConnectedWorkspaces: connectedWorkspaces, + ApiKeyCount: allKeys.Count, + ChannelCount: channelCount); + } + + /// + /// Returns 24 consecutive hourly buckets covering the last 24 hours, aligned to the start of each + /// UTC hour. Every bucket is present (count 0 where no comments fell in it), ordered ascending. + /// + public async Task GetCommentsTimelineAsync(CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var currentHourStart = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, 0, 0, TimeSpan.Zero); + var earliest = currentHourStart.AddHours(-23); + + var timestamps = await db.ProcessedComments.AsNoTracking() + .Where(c => c.ProcessedAt >= earliest) + .Select(c => c.ProcessedAt) + .ToListAsync(ct); + + var counts = new Dictionary(24); + foreach (var ts in timestamps) + { + var utc = ts.ToUniversalTime(); + var bucket = new DateTimeOffset(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, TimeSpan.Zero); + counts[bucket] = counts.GetValueOrDefault(bucket) + 1; + } + + var points = new CommentsTimelinePoint[24]; + for (var i = 0; i < 24; i++) + { + var bucket = earliest.AddHours(i); + points[i] = new CommentsTimelinePoint(bucket, counts.GetValueOrDefault(bucket)); + } + + return points; + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/GoogleApiExceptionExtensions.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/GoogleApiExceptionExtensions.cs new file mode 100644 index 0000000..ac04503 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/GoogleApiExceptionExtensions.cs @@ -0,0 +1,56 @@ +using System.Net; + +using Google; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// Helpers for classifying s so the polling jobs can branch on +/// specific failure reasons (quota, comments disabled). +/// +public static class GoogleApiExceptionExtensions +{ + /// + /// True for HTTP statuses worth retrying: 5xx (server-side) or 429 (rate limited). A 403 with + /// reason quotaExceeded is deliberately excluded — it is NOT transient, so retrying would + /// only burn more quota; the job rotates to another key instead. Shared by the YouTube client's + /// in-call back-off handler (on the raw response) and (on the exception). + /// + public static bool IsTransientStatus(HttpStatusCode status) => + (int)status >= 500 || status == HttpStatusCode.TooManyRequests; + + /// True when this exception's HTTP status is transient (see ). + public static bool IsTransient(this GoogleApiException ex) => IsTransientStatus(ex.HttpStatusCode); + + /// + /// True for a hard, key/project-level credential failure that will not fix itself: the key was + /// revoked/invalid, expired, restricted (IP/referer), or the YouTube Data API is not enabled for + /// its project. The job auto-disables such a key so it drops out of rotation instead of failing + /// every tick. Deliberately excludes quotaExceeded (recovers at the Pacific-day rollover) + /// and channel-level commentsDisabled. + /// + public static bool IsKeyInvalid(this GoogleApiException ex) => + ex.HasReason("keyInvalid") + || ex.HasReason("keyExpired") + || ex.HasReason("accessNotConfigured") + || ex.HasReason("ipRefererBlocked"); + + /// + /// True when any reported equals + /// (case-insensitive), e.g. quotaExceeded, commentsDisabled. + /// + public static bool HasReason(this GoogleApiException ex, string reason) + { + var errors = ex.Error?.Errors; + if (errors is null) + return false; + + foreach (var e in errors) + { + if (string.Equals(e.Reason, reason, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/HangfirePollingScheduler.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/HangfirePollingScheduler.cs new file mode 100644 index 0000000..aa62c96 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/HangfirePollingScheduler.cs @@ -0,0 +1,112 @@ +using Hookline.Modules.YouTubeComments.Domain; +using Hookline.Modules.YouTubeComments.Jobs; +using Hookline.SharedKernel.Jobs; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// over the shared . Each active mapping +/// maps to one recurring poll job ytc:poll:{id} (and, when replies are swept, one +/// ytc:reply-sweep:{id}) whose cron cadence derives from the mapping's frequency. The deterministic +/// ids keep AddOrUpdate idempotent and let prune orphans by id. +/// +public sealed class HangfirePollingScheduler( + IJobScheduler scheduler, + YouTubeCommentsDbContext db, + ILogger logger) : IPollingScheduler +{ + private const string PollPrefix = "ytc:poll:"; + private const string ReplySweepPrefix = "ytc:reply-sweep:"; + + /// Deterministic recurring-job id for a mapping's poll. + public static string JobId(Guid mappingId) => $"{PollPrefix}{mappingId}"; + + /// Deterministic recurring-job id for a mapping's deep reply sweep. + public static string ReplySweepJobId(Guid mappingId) => $"{ReplySweepPrefix}{mappingId}"; + + public void Schedule(Guid mappingId, PollingFrequency frequency) + { + var id = JobId(mappingId); + scheduler.AddOrUpdateRecurring( + id, job => job.RunAsync(mappingId, CancellationToken.None), frequency.ToCron()); + logger.LogInformation("Scheduled polling job {JobId} ({Cron})", id, frequency.ToCron()); + } + + public void Remove(Guid mappingId) + { + var id = JobId(mappingId); + scheduler.RemoveRecurring(id); + logger.LogInformation("Removed polling job {JobId}", id); + } + + public void ScheduleReplySweep(Guid mappingId, ReplyScanFrequency frequency) + { + var id = ReplySweepJobId(mappingId); + var cron = frequency.ToCron(); + if (cron is null) + { + scheduler.RemoveRecurring(id); + return; + } + + scheduler.AddOrUpdateRecurring( + id, job => job.RunAsync(mappingId, CancellationToken.None), cron); + logger.LogInformation("Scheduled reply sweep {JobId} ({Cron})", id, cron); + } + + public void RemoveReplySweep(Guid mappingId) + { + var id = ReplySweepJobId(mappingId); + scheduler.RemoveRecurring(id); + logger.LogInformation("Removed reply sweep {JobId}", id); + } + + public async Task SyncAllAsync(CancellationToken ct = default) + { + var active = await db.ChannelMappings + .AsNoTracking() + .Where(m => m.IsActive) + .Select(m => new { m.Id, m.Frequency, m.IncludeReplies, m.ReplySweepFrequency }) + .ToListAsync(ct); + + var activeIds = active.Select(m => m.Id).ToHashSet(); + + foreach (var m in active) + { + Schedule(m.Id, m.Frequency); + if (m.IncludeReplies && m.ReplySweepFrequency != ReplyScanFrequency.Off) + ScheduleReplySweep(m.Id, m.ReplySweepFrequency); + else + RemoveReplySweep(m.Id); + } + + // Prune orphan recurring jobs whose mapping was deleted/deactivated while the host was down. + // ListRecurring returns ids across the whole host, so only touch ids in this module's namespace. + var pruned = 0; + foreach (var id in scheduler.ListRecurring()) + { + var mappingId = ParseMappingId(id); + if (mappingId is { } mid && !activeIds.Contains(mid)) + { + scheduler.RemoveRecurring(id); + pruned++; + } + } + + logger.LogInformation("Synced {Active} active polling job(s), pruned {Pruned} orphan(s) on startup", active.Count, pruned); + } + + /// Extracts the mapping id from a ytc:poll:{guid} / ytc:reply-sweep:{guid} id, else null. + private static Guid? ParseMappingId(string id) + { + string? raw = + id.StartsWith(PollPrefix, StringComparison.Ordinal) ? id[PollPrefix.Length..] : + id.StartsWith(ReplySweepPrefix, StringComparison.Ordinal) ? id[ReplySweepPrefix.Length..] : + null; + + return raw is not null && Guid.TryParse(raw, out var g) ? g : null; + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IPollingScheduler.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IPollingScheduler.cs new file mode 100644 index 0000000..f4ca52d --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IPollingScheduler.cs @@ -0,0 +1,33 @@ +using Hookline.Modules.YouTubeComments.Domain; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// Manages the recurring background poll (and deep reply sweep) for each active channel mapping. The +/// concrete implementation drives the shared IJobScheduler; this seam keeps the feature +/// services (e.g. MappingService) free of any scheduler-engine dependency. +/// +public interface IPollingScheduler +{ + /// Registers or refreshes the recurring poll for . Idempotent. + void Schedule(Guid mappingId, PollingFrequency frequency); + + /// Removes the recurring poll for if one exists. Idempotent. + void Remove(Guid mappingId); + + /// + /// Registers or refreshes the recurring deep reply sweep, or removes it when + /// is . Idempotent. + /// + void ScheduleReplySweep(Guid mappingId, ReplyScanFrequency frequency); + + /// Removes the recurring reply sweep for if one exists. Idempotent. + void RemoveReplySweep(Guid mappingId); + + /// + /// Reconciles the scheduler with the database on startup: registers a recurring poll (+ sweep) for + /// every active mapping, and prunes orphan recurring jobs whose mapping was deleted or deactivated + /// while the host was down. Makes jobs survive a restart and self-heal a fresh database. + /// + Task SyncAllAsync(CancellationToken ct = default); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ISlackClient.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ISlackClient.cs new file mode 100644 index 0000000..7f64f22 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/ISlackClient.cs @@ -0,0 +1,82 @@ +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// Result of a successful Slack OAuth v2 token exchange. +public sealed record SlackOAuthResult( + string AccessToken, + string TeamId, + string TeamName, + string? BotUserId, + string? Scope, + string? AuthedUserId); + +/// A Slack conversation (channel) the bot can see. +public sealed record SlackChannelInfo(string Id, string Name, bool IsPrivate); + +/// A YouTube comment to render as a Slack Block Kit notification. +public sealed record CommentNotification( + string AuthorName, + string? AuthorChannelUrl, + string? AuthorImageUrl, + string VideoTitle, + string VideoId, + string CommentText, + long LikeCount, + DateTimeOffset PublishedAt, + string CommentId, + bool IsReply = false, + string? ParentCommentId = null); + +/// Outcome of posting a comment to Slack. +public enum SlackPostStatus +{ + /// Slack accepted the message. + Posted, + + /// A transient failure (rate limit, transport, unknown error) — safe to retry later. + RetryableFailure, + + /// The channel is gone for good (archived / not found / bot removed) — retrying is futile. + ChannelGone, +} + +/// +/// Result of . On , +/// carries the message timestamp used to thread replies under it. +/// +public sealed record SlackPostResult(SlackPostStatus Status, string? MessageTs = null) +{ + public bool Ok => Status == SlackPostStatus.Posted; +} + +/// +/// Thin wrapper over the Slack Web API: OAuth v2 token exchange, listing conversations, and posting +/// comment notifications. Implemented in Infrastructure over a raw HttpClient. +/// +public interface ISlackClient +{ + /// Exchanges an OAuth authorization for a bot token + workspace metadata. + Task ExchangeCodeAsync(string code, string redirectUri, CancellationToken ct = default); + + /// Lists every public and private (non-archived) channel visible to the bot. + Task> ListChannelsAsync(string botToken, CancellationToken ct = default); + + /// + /// Posts a Block Kit comment notification to , optionally threaded + /// under . When is supplied the card carries + /// a moderation action: the active "Reject on YouTube" button when is + /// true (the owning Google account holds the force-ssl scope), otherwise a proactive "Re-consent to + /// enable removal" link to Connections → Google — so an unscoped account never shows a Reject button + /// that would only fail on click. The returned distinguishes a + /// successful post (with its message ts) from a retryable failure and a permanently-gone channel. + /// + Task PostCommentAsync( + string botToken, string channelId, CommentNotification comment, + string? threadTs = null, Guid? mappingId = null, bool canModerate = false, CancellationToken ct = default); + + /// + /// POSTs a payload to a Slack response_url from an interaction (no bot token needed). Used to + /// update the card after a moderation action (replace_original) or to reply ephemerally with + /// an honest error. Best-effort: a non-success response is logged, not thrown. + /// + Task PostToResponseUrlAsync(string responseUrl, object payload, CancellationToken ct = default); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeApiKeyProvider.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeApiKeyProvider.cs new file mode 100644 index 0000000..b2b2df3 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeApiKeyProvider.cs @@ -0,0 +1,39 @@ +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// A leased API key handed to a caller for a unit of work, along with its decrypted key material +/// and the remaining daily quota at the moment it was acquired. +/// +public sealed record ApiKeyLease(Guid Id, string Name, string ApiKey, int RemainingQuota); + +/// +/// Selects an active API key with the most remaining Pacific-Time daily quota and records consumption. +/// The key identity + secret live in the shared Connections store; the per-key daily accounting lives +/// in the module-local quota_usage table, keyed by the Pacific-Time day to match YouTube's reset. +/// +public interface IYouTubeApiKeyProvider +{ + /// + /// Acquires the key with the most remaining quota that still has at least + /// units available today, or null when none qualify. + /// + Task AcquireAsync(int unitsNeeded = 1, CancellationToken ct = default); + + /// Records that quota units were consumed against the given key today. + Task RecordUsageAsync(Guid apiKeyId, int units, CancellationToken ct = default); + + /// + /// Marks the key as exhausted for the rest of the current Pacific-Time day by pinning today's + /// usage to the daily quota limit, so subsequent calls skip it (used + /// when YouTube reports quotaExceeded before our local counter reached the limit). + /// + Task MarkExhaustedAsync(Guid apiKeyId, CancellationToken ct = default); + + /// + /// Permanently disables the key in the shared pool (sets it inactive) after YouTube rejected it + /// with a hard credential error (revoked/invalid/expired/restricted, or API not enabled). It then + /// drops out of rotation until an admin re-enables it — so a dead key + /// no longer fails every tick. Unlike , this does not auto-recover. + /// + Task MarkInvalidAsync(Guid apiKeyId, CancellationToken ct = default); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeClient.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeClient.cs new file mode 100644 index 0000000..8d982fb --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeClient.cs @@ -0,0 +1,93 @@ +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// Resolved metadata for a YouTube channel. +public sealed record YouTubeChannelInfo(string ChannelId, string Title, string? ThumbnailUrl, string? Handle); + +/// +/// The outcome of a channel lookup: the resolved (or null when no +/// channel matched the input) plus the number of quota the lookup consumed. +/// +public sealed record ChannelLookupResult(YouTubeChannelInfo? Channel, int UnitsUsed); + +/// A single YouTube comment fetched for the polling pipeline (top-level or a reply). +public sealed record YouTubeComment( + string CommentId, + string VideoId, + string AuthorName, + string? AuthorChannelUrl, + string? AuthorImageUrl, + string Text, + long LikeCount, + DateTimeOffset PublishedAt, + bool IsReply = false, + string? ParentCommentId = null); + +/// The comments returned by a poll plus the quota the fetch consumed. +public sealed record CommentFetchResult(IReadOnlyList Comments, int UnitsUsed); + +/// +/// A comment thread for the deep reply sweep: the top-level comment, the (few) inline replies the +/// API returned for free, and the authoritative total reply count (used to decide whether a deeper +/// comments.list fetch is needed to reach replies beyond the inline ones). +/// +public sealed record YouTubeThread(YouTubeComment TopLevel, IReadOnlyList InlineReplies, long TotalReplyCount); + +/// The threads scanned within the window plus the quota consumed (1 per page). +public sealed record ThreadFetchResult(IReadOnlyList Threads, int UnitsUsed); + +/// All replies for a single parent comment plus the quota consumed (1 per page). +public sealed record RepliesResult(IReadOnlyList Replies, int UnitsUsed); + +/// A map of videoId -> title plus the quota the lookups consumed. +public sealed record VideoTitlesResult(IReadOnlyDictionary Titles, int UnitsUsed); + +/// +/// Thin wrapper over the YouTube Data API. Polling uses API KEYS (not OAuth) — that is correct for +/// comment monitoring. A transient/quota failure surfaces as the underlying +/// Google.GoogleApiException so the caller can inspect the reason (e.g. quotaExceeded, +/// commentsDisabled) and react. +/// +public interface IYouTubeClient +{ + /// + /// Verifies that is a working YouTube Data API key by issuing a + /// minimal request (videos.list?chart=mostPopular&maxResults=1). + /// + Task<(bool Ok, string? Error)> ValidateKeyAsync(string apiKey, CancellationToken ct = default); + + /// + /// Resolves a channel from a raw channel id, an @handle, or a youtube.com URL + /// (/channel/UC…, /@handle, /user/USERNAME, /c/CUSTOM). Units used: + /// 1 for a direct channels.list lookup, 101 when a search.list fallback was required. + /// + Task GetChannelAsync(string apiKey, string idOrHandleOrUrl, CancellationToken ct = default); + + /// + /// Fetches up to of the most recent comment threads across all + /// videos of (commentThreads.list with + /// allThreadsRelatedToChannelId, part=snippet,replies, newest-first; 1 quota unit). Inline replies + /// are flattened in with set. + /// + Task GetRecentCommentsAsync(string apiKey, string youtubeChannelId, int maxResults = 50, CancellationToken ct = default); + + /// + /// Resolves titles for via videos.list (part=snippet) in batches of + /// 50 (1 quota unit per batch). Missing/private videos are simply absent from the result map. + /// + Task GetVideoTitlesAsync(string apiKey, IEnumerable videoIds, CancellationToken ct = default); + + /// + /// Pages through the channel's comment threads (commentThreads.list, order=time, part=snippet,replies; + /// 1 unit per page of up to 100) newest-first, stopping once a thread is older than + /// or is reached. Used by the deep reply sweep. + /// + Task GetCommentThreadsSinceAsync( + string apiKey, string youtubeChannelId, DateTimeOffset since, int maxPages, CancellationToken ct = default); + + /// + /// Fetches every reply for via comments.list (1 unit per page of + /// up to 100). stamps the replies' video id (the API omits it here). + /// + Task GetRepliesAsync( + string apiKey, string parentCommentId, string parentVideoId, int maxPages, CancellationToken ct = default); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeModerationClient.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeModerationClient.cs new file mode 100644 index 0000000..fd0bfcd --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/IYouTubeModerationClient.cs @@ -0,0 +1,49 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Services; +using Google.Apis.Util; +using Google.Apis.YouTube.v3; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// The write side of the YouTube Data API used by comment moderation — distinct from the read-only, +/// API-key-based (polling). Authorized per call with a short-lived OAuth +/// access token resolved through the shared IGoogleChannelCredentials contract. A failure +/// surfaces as the underlying Google.GoogleApiException so the caller can branch on the reason +/// (already-gone / forbidden / quota). +/// +public interface IYouTubeModerationClient +{ + /// + /// Sets moderationStatus = rejected on (hides it on YouTube), + /// authorized by . This is a moderation status change, not a hard + /// delete — reversible in YouTube Studio. Throws GoogleApiException on an API error. + /// + Task RejectAsync(string accessToken, string commentId, CancellationToken ct = default); +} + +/// +/// over the Google.Apis.YouTube.v3 client library. Builds a +/// per-call authorized from the supplied access token and issues +/// comments.setModerationStatus. +/// +public sealed class YouTubeModerationClient : IYouTubeModerationClient +{ + private const string ApplicationName = "Hookline.YouTubeComments"; + + public async Task RejectAsync(string accessToken, string commentId, CancellationToken ct = default) + { + var credential = GoogleCredential.FromAccessToken(accessToken); + using var service = new YouTubeService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = ApplicationName, + }); + + var request = service.Comments.SetModerationStatus( + new Repeatable(new[] { commentId }), + CommentsResource.SetModerationStatusRequest.ModerationStatusEnum.Rejected); + + await request.ExecuteAsync(ct); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/MappingService.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/MappingService.cs new file mode 100644 index 0000000..165cb54 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/MappingService.cs @@ -0,0 +1,260 @@ +using Hookline.Modules.YouTubeComments.Domain; +using Hookline.SharedKernel.Connections; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// A channel mapping flattened with the display names of its endpoints. +public sealed record MappingDto( + Guid Id, + Guid YouTubeChannelId, + string YouTubeChannelTitle, + string? YouTubeChannelThumbnailUrl, + Guid SlackChannelId, + string SlackChannelName, + string SlackWorkspaceName, + PollingFrequency Frequency, + bool IsActive, + bool IncludeReplies, + ReplyScanFrequency ReplySweepFrequency, + int ReplyWindowDays, + DateTimeOffset? LastPolledAt, + string? LastError, + DateTimeOffset CreatedAt); + +/// Request to create a mapping between a tracked YouTube channel and a Slack channel. +public sealed record CreateMappingRequest( + Guid YouTubeChannelId, + Guid SlackChannelId, + PollingFrequency Frequency, + bool IncludeReplies = false, + ReplyScanFrequency ReplySweepFrequency = ReplyScanFrequency.Off, + int ReplyWindowDays = 30); + +/// Partial update of a mapping: any non-null field is applied. +public sealed record UpdateMappingRequest( + PollingFrequency? Frequency, + bool? IsActive, + bool? IncludeReplies, + ReplyScanFrequency? ReplySweepFrequency, + int? ReplyWindowDays); + +/// The selectable endpoints for building a mapping in the UI. +public sealed record MappingOptionsDto( + IReadOnlyList YouTubeChannels, + IReadOnlyList SlackChannels); + +/// A YouTube channel option for the mapping form. +public sealed record ChannelOption(Guid Id, string Title); + +/// A Slack channel option for the mapping form, qualified by its workspace. +public sealed record SlackChannelOption(Guid Id, string Name, string WorkspaceName, bool IsPrivate); + +/// Thrown when a mapping already exists for the same (YouTube, Slack) pair (-> 409). +public sealed class MappingConflictException(string message) : Exception(message); + +/// +/// CRUD over channel mappings (the link between a tracked YouTube channel and a Slack channel, with a +/// polling cadence). Slack workspace display names are resolved from the shared Connections store — +/// the module-local rows carry only the workspace id (no cross-schema join). +/// +public sealed class MappingService( + YouTubeCommentsDbContext db, + IPollingScheduler scheduler, + ICommentsAudit audit, + ISlackConnections slackConnections) +{ + /// Lists every mapping, newest first, flattened with its endpoint display names. + public async Task ListAsync(CancellationToken ct = default) + { + var rows = await db.ChannelMappings + .AsNoTracking() + .OrderByDescending(m => m.CreatedAt) + .Select(Projection) + .ToListAsync(ct); + + var names = await WorkspaceNamesAsync(ct); + return rows.Select(r => ToDto(r, names)).ToArray(); + } + + /// Returns the selectable YouTube channels and Slack channels for the mapping form. + public async Task GetOptionsAsync(CancellationToken ct = default) + { + var youtubeChannels = await db.YouTubeChannels + .AsNoTracking() + .OrderBy(c => c.Title) + .Select(c => new ChannelOption(c.Id, c.Title)) + .ToListAsync(ct); + + var slackRows = await db.SlackChannels + .AsNoTracking() + .OrderBy(c => c.Name) + .Select(c => new { c.Id, c.Name, c.WorkspaceId, c.IsPrivate }) + .ToListAsync(ct); + + var names = await WorkspaceNamesAsync(ct); + var slackChannels = slackRows + .Select(c => new SlackChannelOption(c.Id, c.Name, names.GetValueOrDefault(c.WorkspaceId, "—"), c.IsPrivate)) + .ToList(); + + return new MappingOptionsDto(youtubeChannels, slackChannels); + } + + /// + /// Creates a mapping after validating both foreign keys exist (-> 400) and that no mapping already + /// exists for the same pair (-> 409 via ). + /// + public async Task CreateAsync(CreateMappingRequest req, CancellationToken ct = default) + { + if (!await db.YouTubeChannels.AnyAsync(c => c.Id == req.YouTubeChannelId, ct)) + throw new InvalidOperationException($"YouTube channel {req.YouTubeChannelId} not found."); + if (!await db.SlackChannels.AnyAsync(c => c.Id == req.SlackChannelId, ct)) + throw new InvalidOperationException($"Slack channel {req.SlackChannelId} not found."); + + var duplicate = await db.ChannelMappings.AnyAsync( + m => m.YouTubeChannelId == req.YouTubeChannelId && m.SlackChannelId == req.SlackChannelId, ct); + if (duplicate) + throw new MappingConflictException("A mapping for this YouTube channel and Slack channel already exists."); + + var now = DateTimeOffset.UtcNow; + var entity = new ChannelMapping + { + YouTubeChannelId = req.YouTubeChannelId, + SlackChannelId = req.SlackChannelId, + Frequency = req.Frequency, + IncludeReplies = req.IncludeReplies, + ReplySweepFrequency = req.ReplySweepFrequency, + ReplyWindowDays = ClampWindow(req.ReplyWindowDays), + IsActive = true, + // Watermark: only forward comments published from now on, so a newly mapped channel + // doesn't flood Slack with its entire recent backlog. + CommentsSinceUtc = now, + CreatedAt = now, + }; + + db.ChannelMappings.Add(entity); + await db.SaveChangesAsync(ct); + + if (entity.IsActive) + { + scheduler.Schedule(entity.Id, entity.Frequency); + ReconcileReplySweep(entity); + } + + await audit.LogAsync(AuditLevel.Information, "Mapping", + $"Created mapping ({entity.Frequency}, replies: {entity.IncludeReplies})", + "ChannelMapping", entity.Id.ToString(), ct: ct); + + return await GetDtoAsync(entity.Id, ct) + ?? throw new InvalidOperationException("Mapping vanished immediately after creation."); + } + + /// Applies any provided fields to a mapping. Returns the updated DTO, or null when not found. + public async Task UpdateAsync(Guid id, UpdateMappingRequest req, CancellationToken ct = default) + { + var entity = await db.ChannelMappings.FirstOrDefaultAsync(m => m.Id == id, ct); + if (entity is null) + return null; + + if (req.Frequency.HasValue) entity.Frequency = req.Frequency.Value; + if (req.IncludeReplies.HasValue) entity.IncludeReplies = req.IncludeReplies.Value; + if (req.ReplySweepFrequency.HasValue) entity.ReplySweepFrequency = req.ReplySweepFrequency.Value; + if (req.ReplyWindowDays.HasValue) entity.ReplyWindowDays = ClampWindow(req.ReplyWindowDays.Value); + if (req.IsActive.HasValue) + { + // Reactivating a dormant mapping advances the watermark to now so it can't repost old + // comments whose dedup rows the retention job may have already pruned. + if (req.IsActive.Value && !entity.IsActive) + entity.CommentsSinceUtc = DateTimeOffset.UtcNow; + entity.IsActive = req.IsActive.Value; + } + + entity.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + if (entity.IsActive) + { + scheduler.Schedule(entity.Id, entity.Frequency); + ReconcileReplySweep(entity); + } + else + { + scheduler.Remove(entity.Id); + scheduler.RemoveReplySweep(entity.Id); + } + + await audit.LogAsync(AuditLevel.Information, "Mapping", + $"Updated mapping (active: {entity.IsActive}, {entity.Frequency}, replies: {entity.IncludeReplies})", + "ChannelMapping", entity.Id.ToString(), ct: ct); + + return await GetDtoAsync(id, ct); + } + + /// Deletes a mapping. Returns false when not found. + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var entity = await db.ChannelMappings.FirstOrDefaultAsync(m => m.Id == id, ct); + if (entity is null) + return false; + + db.ChannelMappings.Remove(entity); + await db.SaveChangesAsync(ct); + + scheduler.Remove(id); + scheduler.RemoveReplySweep(id); + + await audit.LogAsync(AuditLevel.Information, "Mapping", "Deleted mapping", "ChannelMapping", id.ToString(), ct: ct); + return true; + } + + private async Task GetDtoAsync(Guid id, CancellationToken ct) + { + var row = await db.ChannelMappings + .AsNoTracking() + .Where(m => m.Id == id) + .Select(Projection) + .FirstOrDefaultAsync(ct); + + if (row is null) + return null; + + var names = await WorkspaceNamesAsync(ct); + return ToDto(row, names); + } + + private async Task> WorkspaceNamesAsync(CancellationToken ct) + { + var workspaces = await slackConnections.ListAsync(ct); + return workspaces.ToDictionary(w => w.Id, w => w.TeamName); + } + + private static MappingDto ToDto(MappingRow r, IReadOnlyDictionary names) => new( + r.Id, r.YouTubeChannelId, r.YtTitle, r.YtThumb, r.SlackChannelId, r.SlackName, + names.GetValueOrDefault(r.WorkspaceId, "—"), r.Frequency, r.IsActive, r.IncludeReplies, + r.ReplySweepFrequency, r.ReplyWindowDays, r.LastPolledAt, r.LastError, r.CreatedAt); + + private static readonly System.Linq.Expressions.Expression> Projection = + m => new MappingRow( + m.Id, m.YouTubeChannelId, m.YouTubeChannel!.Title, m.YouTubeChannel.ThumbnailUrl, + m.SlackChannelId, m.SlackChannel!.Name, m.SlackChannel.WorkspaceId, + m.Frequency, m.IsActive, m.IncludeReplies, m.ReplySweepFrequency, m.ReplyWindowDays, + m.LastPolledAt, m.LastError, m.CreatedAt); + + private void ReconcileReplySweep(ChannelMapping m) + { + if (m.IncludeReplies && m.ReplySweepFrequency != ReplyScanFrequency.Off) + scheduler.ScheduleReplySweep(m.Id, m.ReplySweepFrequency); + else + scheduler.RemoveReplySweep(m.Id); + } + + private static int ClampWindow(int days) => Math.Clamp(days, 1, 90); + + private sealed record MappingRow( + Guid Id, Guid YouTubeChannelId, string YtTitle, string? YtThumb, + Guid SlackChannelId, string SlackName, Guid WorkspaceId, + PollingFrequency Frequency, bool IsActive, bool IncludeReplies, + ReplyScanFrequency ReplySweepFrequency, int ReplyWindowDays, + DateTimeOffset? LastPolledAt, string? LastError, DateTimeOffset CreatedAt); +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/PacificTime.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/PacificTime.cs new file mode 100644 index 0000000..1652794 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/PacificTime.cs @@ -0,0 +1,29 @@ +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// Single source of truth for the Pacific-Time "quota day". YouTube's Data API quota resets at +/// midnight Pacific, so every per-key daily counter buckets on this date. One TZ computation for the +/// whole module (the provider, the dashboard, and the API-key read all key off this). +/// +public static class PacificTime +{ + private static readonly TimeZoneInfo Zone = Resolve(); + + /// Today's Pacific calendar date — the partition of the quota_usage composite key. + public static DateOnly Today() + => DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, Zone).DateTime); + + /// The exact UTC instant the current Pacific day began (for ProcessedAt >= start "today" filters). + public static DateTimeOffset StartOfToday() + { + var midnight = Today().ToDateTime(TimeOnly.MinValue); // unspecified-kind PT wall clock + var offset = Zone.GetUtcOffset(midnight); + return new DateTimeOffset(midnight, offset).ToUniversalTime(); + } + + private static TimeZoneInfo Resolve() + { + try { return TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles"); } + catch (TimeZoneNotFoundException) { return TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); } + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/RedisKeys.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/RedisKeys.cs new file mode 100644 index 0000000..c1ba952 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/RedisKeys.cs @@ -0,0 +1,16 @@ +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// Central registry of the module's Redis key shapes (prefix ytc: — see +/// docs/redis-key-prefixes.md). The shared Redis runs --maxmemory-policy noeviction, so every +/// key here MUST self-expire. Durable dedup + quota live in Postgres (processed_comments / quota_usage); +/// Redis holds only ephemeral fast-path state. +/// +public static class RedisKeys +{ + public const string Prefix = "ytc:"; + + /// Fast "this key is exhausted for today" cache, sparing a DB read in the hot acquire path. + /// Authoritative state is still quota_usage; TTL bounded to Pacific midnight. + public static string QuotaExhausted(Guid apiKeyId, string ptDate) => $"{Prefix}quota:exhausted:{apiKeyId}:{ptDate}"; +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackActions.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackActions.cs new file mode 100644 index 0000000..76590ef --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackActions.cs @@ -0,0 +1,16 @@ +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// action_id values used on Block Kit buttons (matched in the interactivity handler). +public static class SlackActions +{ + /// The existing link button that deep-links to the comment on YouTube (no interactivity). + public const string OpenComment = "open_comment"; + + /// The "Reject on YouTube" button — moderates (hides) the comment via the OAuth account. + public const string RejectComment = "reject_comment"; + + /// The proactive "Re-consent to enable removal" link button shown INSTEAD of Reject when the + /// owning Google account lacks the force-ssl scope. It deep-links to Connections → Google; like + /// it is a URL button, so the interactivity handler takes no server action. + public const string ReConsentGoogle = "reconsent_google"; +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackChannelService.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackChannelService.cs new file mode 100644 index 0000000..c26fda2 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackChannelService.cs @@ -0,0 +1,143 @@ +using Hookline.Modules.YouTubeComments.Domain; +using Hookline.SharedKernel.Connections; + +using Microsoft.EntityFrameworkCore; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// A connected Slack workspace (from the shared store), with a count of channels synced for it. +public sealed record SlackWorkspaceDto(Guid Id, string TeamId, string TeamName, bool IsActive, int ChannelCount); + +/// A Slack channel cached for a workspace (the mapping picker reads these). +public sealed record SlackChannelDto(Guid Id, string SlackChannelId, string Name, bool IsPrivate); + +/// +/// Bridges the module to the shared Slack Connections store. Workspaces (bot tokens) live in the +/// shared connections schema; this owns only the module-local channel cache (the mapping +/// picker). Connect goes through the provider OAuth callback (upsert into the shared store); disconnect +/// deactivates the shared workspace, which fans out a SlackWorkspaceDisconnected event. +/// +public sealed class SlackChannelService( + YouTubeCommentsDbContext db, + ISlackClient slack, + ISlackConnections slackConnections, + ICommentsAudit audit) +{ + /// Lists every connected workspace with the number of channels cached for it. + public async Task ListWorkspacesAsync(CancellationToken ct = default) + { + var workspaces = await slackConnections.ListAsync(ct); + var counts = await db.SlackChannels + .AsNoTracking() + .GroupBy(c => c.WorkspaceId) + .Select(g => new { WorkspaceId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.WorkspaceId, x => x.Count, ct); + + return workspaces + .Select(w => new SlackWorkspaceDto(w.Id, w.TeamId, w.TeamName, w.IsActive, counts.GetValueOrDefault(w.Id))) + .ToArray(); + } + + /// + /// Completes the OAuth flow: exchanges for a bot token, upserts the + /// workspace into the shared store (keyed by Slack team id), then syncs its channels into the + /// module-local cache. Returns the shared workspace id. + /// + public async Task HandleOAuthCallbackAsync(string code, string redirectUri, CancellationToken ct = default) + { + var oauth = await slack.ExchangeCodeAsync(code, redirectUri, ct); + + var workspaceId = await slackConnections.UpsertWorkspaceAsync(new SlackWorkspaceWrite( + TeamId: oauth.TeamId, + TeamName: oauth.TeamName, + BotToken: oauth.AccessToken, + BotUserId: oauth.BotUserId, + Scope: oauth.Scope, + AuthedUserId: oauth.AuthedUserId), ct); + + // Best-effort channel sync immediately after connect, using the token we just obtained. + await SyncChannelsAsync(workspaceId, oauth.AccessToken, ct); + + await audit.LogAsync(AuditLevel.Information, "Slack", + $"Connected Slack workspace '{oauth.TeamName}'", "SlackWorkspace", workspaceId.ToString(), ct: ct); + + return workspaceId; + } + + /// Lists the channels cached for a workspace. + public async Task ListChannelsAsync(Guid workspaceId, CancellationToken ct = default) => + await db.SlackChannels + .AsNoTracking() + .Where(c => c.WorkspaceId == workspaceId) + .OrderBy(c => c.Name) + .Select(c => new SlackChannelDto(c.Id, c.SlackChannelId, c.Name, c.IsPrivate)) + .ToArrayAsync(ct); + + /// + /// Re-fetches channels from Slack for a workspace and reconciles the cached set. Returns the + /// resulting channels, or null when the workspace has no bot token (gone/disconnected). + /// + public async Task RefreshChannelsAsync(Guid workspaceId, CancellationToken ct = default) + { + var botToken = await slackConnections.GetBotTokenAsync(workspaceId, ct); + if (string.IsNullOrEmpty(botToken)) + return null; + + await SyncChannelsAsync(workspaceId, botToken, ct); + return await ListChannelsAsync(workspaceId, ct); + } + + /// Disconnects a workspace from the shared store (fans out SlackWorkspaceDisconnected). + /// Returns false when not found. + public async Task DeleteWorkspaceAsync(Guid id, CancellationToken ct = default) + { + if (!await slackConnections.DeactivateAsync(id, ct)) + return false; + + await audit.LogAsync(AuditLevel.Information, "Slack", + "Disconnected Slack workspace", "SlackWorkspace", id.ToString(), ct: ct); + return true; + } + + /// Reconciles the module-local channel cache for a workspace against the live list from Slack. + private async Task SyncChannelsAsync(Guid workspaceId, string botToken, CancellationToken ct) + { + var live = await slack.ListChannelsAsync(botToken, ct); + + var existing = await db.SlackChannels + .Where(c => c.WorkspaceId == workspaceId) + .ToDictionaryAsync(c => c.SlackChannelId, c => c, StringComparer.Ordinal, ct); + + var seen = new HashSet(StringComparer.Ordinal); + var now = DateTimeOffset.UtcNow; + + foreach (var info in live) + { + seen.Add(info.Id); + if (existing.TryGetValue(info.Id, out var channel)) + { + channel.Name = info.Name; + channel.IsPrivate = info.IsPrivate; + channel.UpdatedAt = now; + } + else + { + db.SlackChannels.Add(new SlackChannel + { + WorkspaceId = workspaceId, + SlackChannelId = info.Id, + Name = info.Name, + IsPrivate = info.IsPrivate, + UpdatedAt = now, + }); + } + } + + // Drop channels the bot can no longer see. + var gone = existing.Values.Where(c => !seen.Contains(c.SlackChannelId)).ToList(); + if (gone.Count > 0) + db.SlackChannels.RemoveRange(gone); + + await db.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackClient.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackClient.cs new file mode 100644 index 0000000..56a7e61 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackClient.cs @@ -0,0 +1,339 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// over a raw (the bot token is passed per call, +/// so one instance serves any workspace). chat.postMessage carries the Block Kit comment card with a +/// deep link to the comment; conversations.list is form-encoded (a JSON body silently drops +/// types). Retries HTTP 429 honoring Retry-After. App client id/secret (for the OAuth code +/// exchange) come from . +/// +public sealed class SlackClient( + HttpClient http, + IOptions options, + ILogger logger) : ISlackClient +{ + private const string ApiBase = "https://slack.com/api/"; + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + private readonly YouTubeCommentsOptions.SlackSettings _opt = options.Value.Slack; + + // Web panel origin — the proactive "Re-consent to enable removal" button deep-links to its + // Connections → Google page when the owning account lacks the force-ssl moderation scope. + private readonly string _panelUrl = options.Value.AdminPanelUrl; + + // Slack error codes that mean the channel is permanently unusable: retrying will never succeed, + // so the mapping is deactivated rather than left to fail on every poll. + private static readonly HashSet ChannelGoneCodes = new(StringComparer.Ordinal) + { + "channel_not_found", + "is_archived", + "not_in_channel", + }; + + /// + public async Task ExchangeCodeAsync(string code, string redirectUri, CancellationToken ct = default) + { + var form = new Dictionary + { + ["code"] = code, + ["client_id"] = _opt.ClientId, + ["client_secret"] = _opt.ClientSecret, + ["redirect_uri"] = redirectUri, + }; + + using var res = await http.PostAsync(ApiBase + "oauth.v2.access", new FormUrlEncodedContent(form), ct); + var body = await res.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (!root.TryGetProperty("ok", out var ok) || !ok.GetBoolean()) + throw new InvalidOperationException( + $"oauth.v2.access failed: {(root.TryGetProperty("error", out var e) ? e.GetString() : "unknown")}"); + + var team = root.GetProperty("team"); + return new SlackOAuthResult( + AccessToken: root.GetProperty("access_token").GetString()!, + TeamId: team.GetProperty("id").GetString()!, + TeamName: team.TryGetProperty("name", out var tn) ? tn.GetString() ?? "" : "", + BotUserId: root.TryGetProperty("bot_user_id", out var bu) ? bu.GetString() : null, + Scope: root.TryGetProperty("scope", out var sc) ? sc.GetString() : null, + AuthedUserId: root.TryGetProperty("authed_user", out var au) && au.TryGetProperty("id", out var auid) + ? auid.GetString() + : null); + } + + /// + public async Task> ListChannelsAsync(string botToken, CancellationToken ct = default) + { + var result = new List(); + string? cursor = null; + do + { + // conversations.list reads its params from the FORM/query string, NOT a JSON body. Sent as + // JSON, `types` is silently dropped and Slack falls back to public_channel only — so private + // channels never come back regardless of scopes/membership. Always call it form-encoded. + var form = new Dictionary + { + ["types"] = "public_channel,private_channel", + ["exclude_archived"] = "true", + ["limit"] = "200", + }; + if (!string.IsNullOrEmpty(cursor)) form["cursor"] = cursor; + + var root = await CallFormAsync(botToken, "conversations.list", form, ct); + if (root is null || !root.Value.GetProperty("ok").GetBoolean()) break; + + if (root.Value.TryGetProperty("channels", out var channels)) + { + foreach (var c in channels.EnumerateArray()) + { + result.Add(new SlackChannelInfo( + c.GetProperty("id").GetString()!, + c.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "", + c.TryGetProperty("is_private", out var p) && p.GetBoolean())); + } + } + + // Stop when next_cursor is an EMPTY STRING (the last page still has channels). + cursor = root.Value.TryGetProperty("response_metadata", out var meta) + && meta.TryGetProperty("next_cursor", out var nc) ? nc.GetString() : null; + } while (!string.IsNullOrEmpty(cursor)); + + return result; + } + + /// + public async Task PostCommentAsync( + string botToken, string channelId, CommentNotification comment, + string? threadTs = null, Guid? mappingId = null, bool canModerate = false, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(botToken)) + return new SlackPostResult(SlackPostStatus.RetryableFailure); + + var payload = new Dictionary + { + ["channel"] = channelId, + ["text"] = Fallback(comment), + // Suppress Slack's auto link/media unfurl so the YouTube preview card doesn't appear + // beneath the comment card (the links live inside the blocks + the button). + ["unfurl_links"] = false, + ["unfurl_media"] = false, + ["blocks"] = BuildBlocks(comment, mappingId, canModerate, ReConsentUrl()), + }; + if (threadTs is not null) payload["thread_ts"] = threadTs; + + try + { + for (var attempt = 0; attempt < 3; attempt++) + { + using var req = new HttpRequestMessage(HttpMethod.Post, ApiBase + "chat.postMessage") + { + Content = JsonContent.Create(payload, options: JsonOpts), + }; + req.Headers.Authorization = new("Bearer", botToken); + + using var res = await http.SendAsync(req, ct); + if (res.StatusCode == HttpStatusCode.TooManyRequests) + { + var wait = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); + logger.LogWarning("Slack chat.postMessage to {ChannelId} rate-limited; waiting {Wait}s", channelId, wait.TotalSeconds); + await Task.Delay(wait, ct); + continue; + } + + var body = await res.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("ok", out var ok) && ok.GetBoolean()) + return new SlackPostResult(SlackPostStatus.Posted, root.TryGetProperty("ts", out var ts) ? ts.GetString() : null); + + var error = root.TryGetProperty("error", out var er) ? er.GetString() : null; + var status = error is not null && ChannelGoneCodes.Contains(error) + ? SlackPostStatus.ChannelGone + : SlackPostStatus.RetryableFailure; + logger.LogWarning("Slack chat.postMessage to {ChannelId} failed: {Error} ({Status})", channelId, error, status); + return new SlackPostResult(status); + } + + // 429-retry budget exhausted — treat as transient so the delivery-retry job tries again. + return new SlackPostResult(SlackPostStatus.RetryableFailure); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Slack chat.postMessage to {ChannelId} threw", channelId); + return new SlackPostResult(SlackPostStatus.RetryableFailure); + } + } + + private static string Fallback(CommentNotification c) => c.IsReply + ? $"Reply from {c.AuthorName} on \"{c.VideoTitle}\"" + : $"New comment from {c.AuthorName} on \"{c.VideoTitle}\""; + + /// The Connections → Google deep link for the proactive "re-consent" button. + private string ReConsentUrl() => $"{_panelUrl.TrimEnd('/')}/connections/google"; + + /// + /// Builds the Block Kit comment card: a header context (avatar + linked author and video), the + /// quoted comment, and a primary button that deep links straight to the comment under the video. + /// When a mapping id is present, a moderation action is added: an active Reject button if + /// , else a re-consent link to . + /// + private static object[] BuildBlocks(CommentNotification c, Guid? mappingId, bool canModerate, string reConsentUrl) + { + // Canonical www host (matches YouTube's own canonical link) so the comment deep link resolves + // in one hop. The lc (linked comment) param is YouTube's official comment permalink: on desktop + // web it highlights the comment and scrolls to it. A top-level comment links by its own id; + // a reply uses the "parentId.replyId" form. + var videoUrl = $"https://www.youtube.com/watch?v={c.VideoId}"; + var lc = c is { IsReply: true, ParentCommentId: { } parent } ? $"{parent}.{c.CommentId}" : c.CommentId; + var commentUrl = $"{videoUrl}&lc={lc}"; + + var headerElements = new List(); + if (!string.IsNullOrWhiteSpace(c.AuthorImageUrl)) + headerElements.Add(new { type = "image", image_url = c.AuthorImageUrl, alt_text = c.AuthorName }); + + var authorLink = string.IsNullOrWhiteSpace(c.AuthorChannelUrl) + ? $"*{EscapeMrkdwn(c.AuthorName)}*" + : $"*<{c.AuthorChannelUrl}|{EscapeMrkdwn(c.AuthorName)}>*"; + var replyPrefix = c.IsReply ? "↳ " : string.Empty; + headerElements.Add(new { type = "mrkdwn", text = $"{replyPrefix}{authorLink} · ▶ *<{videoUrl}|{EscapeMrkdwn(c.VideoTitle)}>*" }); + + var buttonText = c.IsReply ? "▶ Go to reply" : "▶ Go to comment"; + + var actionElements = new List + { + new { type = "button", text = new { type = "plain_text", text = buttonText, emoji = true }, url = commentUrl, action_id = SlackActions.OpenComment, style = "primary" }, + }; + + // A moderation action is only added when a mapping id is supplied (so the callback can route to + // the comment + owning channel). When the owning Google account holds the force-ssl scope, render + // the active "Reject on YouTube" button (an irreversible-via-Slack action, so it carries an inline + // confirm; the label says "Reject/Hide" NOT "Delete" — it sets moderationStatus=rejected, which is + // reversible in YouTube Studio; the value is "{mappingId}:{commentId}", the raw comment id / API + // target, not the lc deep-link form). When it does NOT hold the scope, render a proactive + // "Re-consent to enable removal" link to Connections → Google INSTEAD — so an unscoped account + // never shows a Reject button that would only fail on click (the click-time NotConnected error + // remains as a backstop). The re-consent button is a URL button ⇒ the handler takes no action. + if (mappingId is { } id) + { + if (canModerate) + { + actionElements.Add(new + { + type = "button", + text = new { type = "plain_text", text = "🚫 Reject on YouTube", emoji = true }, + action_id = SlackActions.RejectComment, + value = $"{id}:{c.CommentId}", + style = "danger", + confirm = new + { + title = new { type = "plain_text", text = "Reject this comment?" }, + text = new { type = "mrkdwn", text = "This *hides* the comment on YouTube (moderation status → rejected). It can be restored in YouTube Studio, but not from here." }, + confirm = new { type = "plain_text", text = "Reject" }, + deny = new { type = "plain_text", text = "Cancel" }, + style = "danger", + }, + }); + } + else + { + actionElements.Add(new + { + type = "button", + text = new { type = "plain_text", text = "⚠️ Re-consent to enable removal", emoji = true }, + action_id = SlackActions.ReConsentGoogle, + url = reConsentUrl, + }); + } + } + + return + [ + new { type = "context", elements = headerElements.ToArray() }, + new { type = "section", text = new { type = "mrkdwn", text = BlockQuote(c.CommentText) } }, + new { type = "actions", elements = actionElements.ToArray() }, + ]; + } + + /// + public async Task PostToResponseUrlAsync(string responseUrl, object payload, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(responseUrl)) + return; + + try + { + using var res = await http.PostAsync(responseUrl, JsonContent.Create(payload, options: JsonOpts), ct); + if (!res.IsSuccessStatusCode) + logger.LogWarning("Slack response_url POST returned {Status}", (int)res.StatusCode); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Slack response_url POST threw"); + } + } + + /// Escapes the three characters Slack mrkdwn treats specially (&, <, >). + private static string EscapeMrkdwn(string text) => + string.IsNullOrEmpty(text) + ? string.Empty + : text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + + /// Renders as a Slack block quote: every line prefixed with ">". + private static string BlockQuote(string text) + { + if (string.IsNullOrEmpty(text)) + return ">"; + + var lines = text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + return string.Join("\n", lines.Select(line => $">{EscapeMrkdwn(line)}")); + } + + // ---- core form-encoded call with 429 retry (read methods whose params Slack reads from the form) ---- + private async Task CallFormAsync( + string botToken, string method, Dictionary form, CancellationToken ct) + { + if (string.IsNullOrEmpty(botToken)) + { + logger.LogWarning("No Slack bot token available — skipping {Method}", method); + return null; + } + + for (var attempt = 0; attempt < 3; attempt++) + { + using var req = new HttpRequestMessage(HttpMethod.Post, ApiBase + method) + { + Content = new FormUrlEncodedContent(form), + }; + req.Headers.Authorization = new("Bearer", botToken); + + using var res = await http.SendAsync(req, ct); + if (res.StatusCode == HttpStatusCode.TooManyRequests) + { + var wait = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); + logger.LogWarning("Slack {Method} rate-limited; waiting {Wait}s", method, wait.TotalSeconds); + await Task.Delay(wait, ct); + continue; + } + + var body = await res.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (!root.TryGetProperty("ok", out var ok) || !ok.GetBoolean()) + { + logger.LogWarning("Slack {Method} returned error: {Error}", method, + root.TryGetProperty("error", out var er) ? er.GetString() : "unknown"); + } + return root.Clone(); + } + + return null; + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackSignatureVerifier.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackSignatureVerifier.cs new file mode 100644 index 0000000..ea61f86 --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/SlackSignatureVerifier.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// Verifies Slack's X-Slack-Signature over the RAW request body: +/// base = "v0:{timestamp}:{rawBody}", expected = "v0=" + lowercase-hex(HMAC_SHA256(secret, base)). +/// Rejects requests older than 5 minutes (replay guard) and uses a constant-time compare. +/// Module-local (mirrors the YouTube Uploads verifier) — the modules don't share a Slack app yet. +/// +public sealed class SlackSignatureVerifier +{ + private const int MaxSkewSeconds = 300; + + public bool Verify(string? signingSecret, string? timestamp, string rawBody, string? signature) + { + if (string.IsNullOrEmpty(signingSecret) || + string.IsNullOrEmpty(timestamp) || + string.IsNullOrEmpty(signature)) + return false; + + if (!long.TryParse(timestamp, out var tsVal)) + return false; + if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - tsVal) > MaxSkewSeconds) + return false; + + var baseStr = $"v0:{timestamp}:{rawBody}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret)); + var expected = "v0=" + Convert.ToHexString( + hmac.ComputeHash(Encoding.UTF8.GetBytes(baseStr))).ToLowerInvariant(); + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(signature)); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/YouTubeApiKeyProvider.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/YouTubeApiKeyProvider.cs new file mode 100644 index 0000000..16e37ce --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/YouTubeApiKeyProvider.cs @@ -0,0 +1,97 @@ +using Hookline.Modules.YouTubeComments.Domain; +using Hookline.SharedKernel.Connections; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// Picks the active API key with the most remaining Pacific-Time daily quota and records usage. +/// Keys come from the shared Connections store (); the daily +/// counter lives in the module-local quota_usage table. All keys share a uniform daily unit +/// limit from config () — the shared key record +/// carries no per-key limit (the YouTube Data API default is 10000/day for every key). +/// +public sealed class YouTubeApiKeyProvider( + YouTubeCommentsDbContext db, + IYouTubeApiKeyConnections keys, + IOptions options) : IYouTubeApiKeyProvider +{ + private readonly int _dailyLimit = options.Value.DailyQuotaUnits; + + public async Task AcquireAsync(int unitsNeeded = 1, CancellationToken ct = default) + { + var today = PacificTime.Today(); + + var active = await keys.ListActiveAsync(ct); + if (active.Count == 0) + return null; + + var ids = active.Select(k => k.Id).ToList(); + var usedToday = await db.QuotaUsages + .AsNoTracking() + .Where(q => q.UsageDate == today && ids.Contains(q.ApiKeyId)) + .ToDictionaryAsync(q => q.ApiKeyId, q => q.UnitsUsed, ct); + + var best = active + .Select(k => new { k.Id, k.Name, Remaining = _dailyLimit - usedToday.GetValueOrDefault(k.Id) }) + .Where(c => c.Remaining >= unitsNeeded) + .OrderByDescending(c => c.Remaining) + .FirstOrDefault(); + + if (best is null) + return null; + + // Resolve the decrypted key only for the winner. + var apiKey = await keys.GetApiKeyAsync(best.Id, ct); + if (string.IsNullOrEmpty(apiKey)) + return null; + + return new ApiKeyLease(best.Id, best.Name, apiKey, best.Remaining); + } + + public async Task RecordUsageAsync(Guid apiKeyId, int units, CancellationToken ct = default) + { + var today = PacificTime.Today(); + var now = DateTimeOffset.UtcNow; + + var usage = await db.QuotaUsages.FirstOrDefaultAsync(q => q.ApiKeyId == apiKeyId && q.UsageDate == today, ct); + if (usage is null) + { + db.QuotaUsages.Add(new QuotaUsage { ApiKeyId = apiKeyId, UsageDate = today, UnitsUsed = units, UpdatedAt = now }); + } + else + { + usage.UnitsUsed += units; + usage.UpdatedAt = now; + } + + await db.SaveChangesAsync(ct); + } + + public Task MarkInvalidAsync(Guid apiKeyId, CancellationToken ct = default) => + // Disable the key in the shared pool; ListActiveAsync then excludes it from rotation. + keys.ToggleAsync(apiKeyId, isActive: false, ct); + + public async Task MarkExhaustedAsync(Guid apiKeyId, CancellationToken ct = default) + { + var today = PacificTime.Today(); + var now = DateTimeOffset.UtcNow; + + // Pin today's usage at (or above) the daily limit so AcquireAsync's remaining-quota filter + // skips this key until the Pacific-Time day rolls over. + var usage = await db.QuotaUsages.FirstOrDefaultAsync(q => q.ApiKeyId == apiKeyId && q.UsageDate == today, ct); + if (usage is null) + { + db.QuotaUsages.Add(new QuotaUsage { ApiKeyId = apiKeyId, UsageDate = today, UnitsUsed = _dailyLimit, UpdatedAt = now }); + } + else if (usage.UnitsUsed < _dailyLimit) + { + usage.UnitsUsed = _dailyLimit; + usage.UpdatedAt = now; + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/YouTubeClient.cs b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/YouTubeClient.cs new file mode 100644 index 0000000..a5db69c --- /dev/null +++ b/backend/src/Modules/Hookline.Modules.YouTubeComments/Infrastructure/YouTubeClient.cs @@ -0,0 +1,450 @@ +using System.Text.RegularExpressions; + +using Google; +using Google.Apis.Http; +using Google.Apis.Services; +using Google.Apis.Util; +using Google.Apis.YouTube.v3; +using Google.Apis.YouTube.v3.Data; + +using Microsoft.Extensions.Logging; + +namespace Hookline.Modules.YouTubeComments.Infrastructure; + +/// +/// backed by the Google.Apis.YouTube.v3 client library. Each service is +/// configured (see ) with an exponential back-off handler that retries +/// transient 5xx/429 responses in-call; quotaExceeded (403) is excluded so it bubbles as a +/// for the job to rotate keys, as do commentsDisabled and +/// other non-transient reasons. There is no circuit breaker — the durable retry queue and per-tick +/// scheduling absorb sustained outages. +/// +public sealed partial class YouTubeClient(ILogger logger) : IYouTubeClient +{ + private const string ApplicationName = "YouTubeComments"; + + // channels.list / search.list / commentThreads.list / videos.list quota costs + // (YouTube Data API v3 quota table — all reads are 1 unit except search.list at 100). + private const int ChannelsListCost = 1; + private const int SearchListCost = 100; + private const int CommentThreadsListCost = 1; + private const int VideosListCost = 1; + + // videos.list accepts up to 50 ids per call. + private const int VideosBatchSize = 50; + + public async Task<(bool Ok, string? Error)> ValidateKeyAsync(string apiKey, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(apiKey)) + return (false, "API key is required."); + + using var service = CreateService(apiKey); + + // Cheapest representative read (1 quota unit): fetch one most-popular video. + var request = service.Videos.List("id"); + request.Chart = VideosResource.ListRequest.ChartEnum.MostPopular; + request.MaxResults = 1; + + try + { + await request.ExecuteAsync(ct); + return (true, null); + } + catch (GoogleApiException ex) + { + // Surface the API-provided message (e.g. "API key not valid", "quota exceeded"). + var message = ex.Error?.Message ?? ex.Message; + logger.LogWarning(ex, "YouTube API key validation failed: {Message}", message); + return (false, message); + } + } + + /// + public async Task GetChannelAsync(string apiKey, string idOrHandleOrUrl, CancellationToken ct = default) + { + var input = idOrHandleOrUrl?.Trim() ?? string.Empty; + if (input.Length == 0) + return new ChannelLookupResult(null, 0); + + var query = ParseInput(input); + using var service = CreateService(apiKey); + + // Track quota precisely as each request is issued so a mid-lookup failure still reports the + // units already spent (the search.list fallback charges before the follow-up channels.list). + var unitsUsed = 0; + try + { + if (query.Kind == LookupKind.Search) + { + // No direct lookup for /c/CUSTOM URLs: search for the channel (100 units), then read + // the matched id via channels.list (1 unit) for a precise, complete snippet. + var search = service.Search.List("snippet"); + search.Q = query.Value; + search.Type = "channel"; + search.MaxResults = 1; + + var searchResponse = await search.ExecuteAsync(ct); + unitsUsed += SearchListCost; + + var matchedId = searchResponse.Items?.FirstOrDefault()?.Id?.ChannelId; + if (string.IsNullOrEmpty(matchedId)) + return new ChannelLookupResult(null, unitsUsed); + + var byId = service.Channels.List("snippet"); + byId.Id = matchedId; + var byIdResponse = await byId.ExecuteAsync(ct); + unitsUsed += ChannelsListCost; + + return new ChannelLookupResult(MapChannel(byIdResponse.Items?.FirstOrDefault()), unitsUsed); + } + + var request = service.Channels.List("snippet"); + switch (query.Kind) + { + case LookupKind.Id: + request.Id = query.Value; + break; + case LookupKind.Handle: + request.ForHandle = query.Value; + break; + case LookupKind.Username: + request.ForUsername = query.Value; + break; + } + + var response = await request.ExecuteAsync(ct); + unitsUsed += ChannelsListCost; + + return new ChannelLookupResult(MapChannel(response.Items?.FirstOrDefault()), unitsUsed); + } + catch (GoogleApiException ex) + { + // Treat API failures (bad key, quota, invalid filter) as "not resolved": the service maps + // a null channel to a 400. Report whatever quota was already consumed before the failure. + var message = ex.Error?.Message ?? ex.Message; + logger.LogWarning(ex, "YouTube channel lookup failed for '{Input}': {Message}", input, message); + return new ChannelLookupResult(null, unitsUsed); + } + } + + /// + public async Task GetRecentCommentsAsync( + string apiKey, string youtubeChannelId, int maxResults = 50, CancellationToken ct = default) + { + using var service = CreateService(apiKey); + + // part=snippet,replies: commentThreads.list is 1 unit regardless of parts, so the inline + // replies (the few most recent the API ships per thread) come for free. + var request = service.CommentThreads.List("snippet,replies"); + request.AllThreadsRelatedToChannelId = youtubeChannelId; + request.Order = CommentThreadsResource.ListRequest.OrderEnum.Time; // newest first + request.MaxResults = Math.Clamp(maxResults, 1, 100); + request.TextFormat = CommentThreadsResource.ListRequest.TextFormatEnum.PlainText; + + // Let GoogleApiException bubble (quotaExceeded / commentsDisabled) so the job can branch. + var response = await request.ExecuteAsync(ct); + + var comments = new List(response.Items?.Count ?? 0); + foreach (var thread in response.Items ?? Enumerable.Empty()) + { + var topLevel = MapComment(thread); + if (topLevel is null) + continue; + + comments.Add(topLevel); + + // Flatten any inline replies, tagged with their parent so the caller can thread them. + foreach (var reply in thread.Replies?.Comments ?? Enumerable.Empty()) + { + var mappedReply = MapReply(reply, topLevel.CommentId, topLevel.VideoId); + if (mappedReply is not null) + comments.Add(mappedReply); + } + } + + return new CommentFetchResult(comments, CommentThreadsListCost); + } + + /// + public async Task GetVideoTitlesAsync( + string apiKey, IEnumerable videoIds, CancellationToken ct = default) + { + var distinct = videoIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var titles = new Dictionary(StringComparer.Ordinal); + if (distinct.Count == 0) + return new VideoTitlesResult(titles, 0); + + using var service = CreateService(apiKey); + + var unitsUsed = 0; + for (var offset = 0; offset < distinct.Count; offset += VideosBatchSize) + { + var batch = distinct.Skip(offset).Take(VideosBatchSize).ToList(); + + var request = service.Videos.List("snippet"); + request.Id = string.Join(',', batch); + request.MaxResults = VideosBatchSize; + + var response = await request.ExecuteAsync(ct); + unitsUsed += VideosListCost; + + foreach (var video in response.Items ?? Enumerable.Empty