diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml new file mode 100644 index 0000000..6f5f76a --- /dev/null +++ b/.woodpecker/release.yml @@ -0,0 +1,30 @@ +when: + - event: push + branch: main + +depends_on: + - test + +steps: + - name: build-image + image: gcr.io/kaniko-project/executor:v1.23.2-debug + environment: + GITEA_TOKEN: + from_secret: registry_token + commands: + - mkdir -p /kaniko/.docker + - REGISTRY=$${CI_FORGE_URL#*://} + - IMAGE="$$REGISTRY/$$CI_REPO" + - AUTH=$(printf "%s" "$CI_REPO_OWNER:$GITEA_TOKEN" | base64 -w0) + - printf '{"auths":{"%s":{"auth":"%s"}}}' "$$REGISTRY" "$AUTH" > /kaniko/.docker/config.json + - BUILD_TAG="build.$(date +%s)" + - >- + /kaniko/executor + --context=. + --dockerfile=Dockerfile + --destination=$$IMAGE:${CI_COMMIT_SHA} + --destination=$$IMAGE:${CI_COMMIT_SHA:0:7} + --destination=$$IMAGE:$${BUILD_TAG} + --destination=$$IMAGE:main + --snapshot-mode=redo + --use-new-run diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..9fff114 --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,10 @@ +when: + - event: [pull_request, push] + +steps: + - name: test + image: mcr.microsoft.com/dotnet/sdk:10.0 + commands: + - dotnet restore Wiki.slnx + - dotnet build Wiki.slnx --no-restore -c Release + - dotnet test Wiki.slnx --no-build -c Release --logger "console;verbosity=minimal" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91f31b1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY Wiki.slnx Directory.Build.props ./ +COPY src/Wiki.Core/Wiki.Core.csproj src/Wiki.Core/ +COPY src/Wiki.Application/Wiki.Application.csproj src/Wiki.Application/ +COPY src/Wiki.Adapters.Gitea/Wiki.Adapters.Gitea.csproj src/Wiki.Adapters.Gitea/ +COPY src/Wiki.Adapters.LlamaCpp/Wiki.Adapters.LlamaCpp.csproj src/Wiki.Adapters.LlamaCpp/ +COPY src/Wiki.Adapters.TypeSafe/Wiki.Adapters.TypeSafe.csproj src/Wiki.Adapters.TypeSafe/ +COPY src/Wiki.Adapters.FileIndex/Wiki.Adapters.FileIndex.csproj src/Wiki.Adapters.FileIndex/ +COPY src/Wiki.Adapters.Anthropic/Wiki.Adapters.Anthropic.csproj src/Wiki.Adapters.Anthropic/ +COPY src/Wiki.Host/Wiki.Host.csproj src/Wiki.Host/ +RUN dotnet restore src/Wiki.Host/Wiki.Host.csproj + +COPY . . +# No --no-restore: publishing a Blazor Web App with it skips the static-asset +# setup and blazor.web.js never lands in the output (dotnet/aspnetcore#65291). +RUN dotnet publish src/Wiki.Host/Wiki.Host.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish ./ + +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 \ + DOTNET_RUNNING_IN_CONTAINER=true \ + DOTNET_NOLOGO=true + +# Index snapshots and the catalog live here; a mounted volume in the cluster. +RUN mkdir -p /data +VOLUME ["/data"] + +ENTRYPOINT ["dotnet", "Wiki.Host.dll"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..300624c --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Wiki + +Web interface and agent API for the Notes vault (`k.renner/notes`), running in the homelab +cluster. Design: `kubernetes/PROPOSAL-wiki-web-api.md` in the homelab repository. + +## Layout + +``` +src/Wiki.Core domain types, ports, vault parsing — references nothing +src/Wiki.Application use cases (sync, rank, status, …) — depends on Core only +src/Wiki.Adapters.* one project per external thing, each references Core only +src/Wiki.Host ASP.NET Core API + MudBlazor UI, composition root +test/Wiki.Contracts one abstract suite per port; every adapter passes its suite +``` + +Adapters are selected in `appsettings.json` under `Wiki:`; secrets come from environment +variables (`Wiki__NoteStore__Gitea__Token`, `Wiki__Webhook__Secret`, `Auth__ApiKeys__`). + +## Run + +``` +dotnet test Wiki.slnx +dotnet run --project src/Wiki.Host +``` + +Endpoints: `/health` (liveness), `/ready` (readiness), `/api/v1/status`, `POST /rank`, +`POST /webhook`. diff --git a/src/Wiki.Host/Api/ApiV1.cs b/src/Wiki.Host/Api/ApiV1.cs new file mode 100644 index 0000000..546db0f --- /dev/null +++ b/src/Wiki.Host/Api/ApiV1.cs @@ -0,0 +1,24 @@ +using Wiki.Application.Status; +using Wiki.Application.Sync; + +namespace Wiki.Host.Api; + +/// /api/v1: the read API grows here phase by phase. Phase 1 exposes status and a manual sync. +public static class ApiV1 +{ + public static void MapApiV1(this WebApplication app) + { + var v1 = app.MapGroup("/api/v1"); + + v1.MapGet("/status", async (StatusQuery status, CancellationToken ct) => + Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).AllowAnonymous(); + + v1.MapPost("/sync", async (SyncCoordinator sync, bool? force, CancellationToken ct) => + { + var summary = await sync.RunAsync(force ?? false, ct); + return summary is null + ? Results.Json(new { error = sync.LastError }, statusCode: StatusCodes.Status502BadGateway) + : Results.Ok(summary); + }).RequireAuthorization(Auth.Policies.Write); + } +} diff --git a/src/Wiki.Host/Api/HealthEndpoints.cs b/src/Wiki.Host/Api/HealthEndpoints.cs new file mode 100644 index 0000000..b95ce56 --- /dev/null +++ b/src/Wiki.Host/Api/HealthEndpoints.cs @@ -0,0 +1,22 @@ +using Wiki.Application.Status; + +namespace Wiki.Host.Api; + +public static class HealthEndpoints +{ + /// + /// /health: the process serves, always 200 (liveness). + /// /ready: catalog synced once and the active index loaded (readiness and startup). + /// Adapter checks belong to /api/v1/status and never gate readiness. + /// + public static void MapHealthEndpoints(this WebApplication app) + { + app.MapGet("/health", () => Results.Ok(new { ok = true })).AllowAnonymous(); + app.MapGet("/ready", async (StatusQuery status, CancellationToken ct) => + { + var ready = await status.IsReadyAsync(ct); + var body = await status.GetAsync(includeAdapterChecks: false, ct); + return ready ? Results.Ok(body) : Results.Json(body, statusCode: StatusCodes.Status503ServiceUnavailable); + }).AllowAnonymous(); + } +} diff --git a/src/Wiki.Host/Api/IndexEndpoints.cs b/src/Wiki.Host/Api/IndexEndpoints.cs new file mode 100644 index 0000000..fe821b4 --- /dev/null +++ b/src/Wiki.Host/Api/IndexEndpoints.cs @@ -0,0 +1,77 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Wiki.Application.Retrieval; +using Wiki.Application.Status; +using Wiki.Core.Ports; +using Wiki.Host.Composition; + +namespace Wiki.Host.Api; + +public sealed record RankRequest(string? Query, string? Question, int? K, string? Model); + +/// +/// The endpoints the Python wiki-index served, kept shape-compatible so bin/wiki_rank.py works +/// unchanged during the migration: POST /rank {query, k} → {rows: [{slug, title, score}], index}, +/// POST /webhook (Gitea push, HMAC-SHA256 in X-Gitea-Signature). +/// +public static class IndexEndpoints +{ + public static void MapIndexEndpoints(this WebApplication app) + { + app.MapPost("/rank", RankAsync).AllowAnonymous(); + app.MapPost("/api/v1/rank", RankAsync).AllowAnonymous(); + + app.MapPost("/webhook", async (HttpRequest request, IConfiguration configuration, WikiOptions options, SyncBackgroundService sync, CancellationToken ct) => + { + var secret = configuration["Wiki:Webhook:Secret"]; + if (string.IsNullOrEmpty(secret)) + return Results.Json(new { error = "webhook secret not configured" }, statusCode: StatusCodes.Status503ServiceUnavailable); + using var ms = new MemoryStream(); + await request.Body.CopyToAsync(ms, ct); + var body = ms.ToArray(); + var expected = Convert.ToHexStringLower(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body)); + var presented = (request.Headers["X-Gitea-Signature"].FirstOrDefault() ?? string.Empty).Trim(); + if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(presented))) + return Results.Json(new { error = "bad signature" }, statusCode: StatusCodes.Status403Forbidden); + + string? @ref = null; + try + { + using var doc = JsonDocument.Parse(body.Length == 0 ? "{}"u8.ToArray() : body); + if (doc.RootElement.TryGetProperty("ref", out var r)) @ref = r.GetString(); + } + catch (JsonException) + { + return Results.BadRequest(new { error = "bad json" }); + } + if (!string.IsNullOrEmpty(@ref) && @ref != $"refs/heads/{options.NoteStore.Gitea.Branch}") + return Results.Ok(new { queued = false, @ref }); + sync.Trigger(); + return Results.Ok(new { queued = true }); + }).AllowAnonymous(); + } + + private static async Task RankAsync(RankRequest body, RankUseCase rank, StatusQuery status, IServiceProvider services, IEmbeddingModel active, CancellationToken ct) + { + var question = (body.Query ?? body.Question ?? string.Empty).Trim(); + if (question.Length == 0) return Results.BadRequest(new { error = "query required" }); + var model = active; + if (!string.IsNullOrEmpty(body.Model)) + { + model = services.GetKeyedService(body.Model); + if (model is null) return Results.BadRequest(new { error = $"unknown model '{body.Model}'" }); + } + try + { + var rows = await rank.RankAsync(model, question, body.K ?? 60, ct); + var index = await status.GetAsync(includeAdapterChecks: false, ct); + return Results.Ok(new { rows = rows.Select(r => new { r.Slug, r.Title, r.Score }), model = model.ModelId, index }); + } + catch (HttpRequestException e) + { + return Results.Json(new { error = e.Message }, statusCode: StatusCodes.Status502BadGateway); + } + } +} diff --git a/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs b/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..c44d491 --- /dev/null +++ b/src/Wiki.Host/Auth/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Wiki.Host.Auth; + +/// +/// Machine callers present `X-Api-Key: ` or `Authorization: Bearer `. Keys are named, +/// so a write carries the identity behind it: Auth:ApiKeys:{name} = key. Fail closed: no keys +/// configured means no key authenticates. +/// +public sealed class ApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "ApiKey"; + + protected override Task HandleAuthenticateAsync() + { + var presented = Request.Headers["X-Api-Key"].FirstOrDefault(); + if (presented is null) + { + var authorization = Request.Headers.Authorization.FirstOrDefault(); + if (authorization?.StartsWith("Bearer ", StringComparison.Ordinal) == true) + presented = authorization["Bearer ".Length..]; + } + if (presented is null) + return Task.FromResult(AuthenticateResult.NoResult()); + + var presentedBytes = Encoding.UTF8.GetBytes(presented); + foreach (var child in configuration.GetSection("Auth:ApiKeys").GetChildren()) + { + var configured = child.Value; + if (string.IsNullOrEmpty(configured)) continue; + if (CryptographicOperations.FixedTimeEquals(presentedBytes, Encoding.UTF8.GetBytes(configured))) + { + var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, child.Key), new Claim(Claims.Kind, "machine")], Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name))); + } + } + return Task.FromResult(AuthenticateResult.Fail("invalid API key")); + } +} + +public static class Policies +{ + public const string Write = "write"; +} diff --git a/src/Wiki.Host/Auth/ProxyHeaderAuthenticationHandler.cs b/src/Wiki.Host/Auth/ProxyHeaderAuthenticationHandler.cs new file mode 100644 index 0000000..e1a503b --- /dev/null +++ b/src/Wiki.Host/Auth/ProxyHeaderAuthenticationHandler.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Wiki.Host.Auth; + +/// +/// Humans arrive through the Authentik proxy outpost, which sets X-Authentik-Username after +/// validating the session. The header is trusted only from the peers listed in +/// Auth:ReverseProxy:TrustedProxies; with none configured the scheme authenticates nobody. +/// +public sealed class ProxyHeaderAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "ProxyHeader"; + public const string DefaultHeaderName = "X-Authentik-Username"; + + protected override Task HandleAuthenticateAsync() + { + var headerName = configuration["Auth:ReverseProxy:HeaderName"]; + if (string.IsNullOrWhiteSpace(headerName)) headerName = DefaultHeaderName; + + var user = Request.Headers[headerName].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(user)) + return Task.FromResult(AuthenticateResult.NoResult()); + + var peer = Context.Connection.RemoteIpAddress; + if (peer is null || !IsTrustedProxy(peer)) + { + Logger.LogWarning("ignoring {Header} from untrusted peer {Peer}", headerName, peer); + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user), new Claim(Claims.Kind, "human")], Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name))); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + + private bool IsTrustedProxy(IPAddress peer) + { + if (peer.IsIPv4MappedToIPv6) peer = peer.MapToIPv4(); + var trusted = configuration.GetSection("Auth:ReverseProxy:TrustedProxies").Get() ?? []; + foreach (var entry in trusted) + { + if (IPAddress.TryParse(entry, out var single)) + { + if (single.Equals(peer)) return true; + continue; + } + if (IPNetwork.TryParse(entry, out var network) && network.Contains(peer)) + return true; + } + return false; + } +} + +public static class Claims +{ + public const string Kind = "wiki:kind"; +} diff --git a/src/Wiki.Host/Components/App.razor b/src/Wiki.Host/Components/App.razor index fd5bc2b..d0935ad 100644 --- a/src/Wiki.Host/Components/App.razor +++ b/src/Wiki.Host/Components/App.razor @@ -1,4 +1,4 @@ - + @@ -6,6 +6,7 @@ + @@ -16,6 +17,7 @@ + diff --git a/src/Wiki.Host/Components/Layout/MainLayout.razor b/src/Wiki.Host/Components/Layout/MainLayout.razor index 96fbbe6..35310bf 100644 --- a/src/Wiki.Host/Components/Layout/MainLayout.razor +++ b/src/Wiki.Host/Components/Layout/MainLayout.razor @@ -1,6 +1,22 @@ -@inherits LayoutComponentBase +@inherits LayoutComponentBase -@Body + + + + + + + + Wiki + + Status + + + + @Body + + +
An unhandled error has occurred. diff --git a/src/Wiki.Host/Components/Pages/Home.razor b/src/Wiki.Host/Components/Pages/Home.razor index 9001e0b..eb503f3 100644 --- a/src/Wiki.Host/Components/Pages/Home.razor +++ b/src/Wiki.Host/Components/Pages/Home.razor @@ -1,7 +1,78 @@ -@page "/" +@page "/" +@rendermode InteractiveServer +@using Wiki.Application.Status +@inject StatusQuery Status -Home +Wiki status -

Hello, world!

+Status -Welcome to your new app. +@if (_status is null) +{ + +} +else +{ + + + + Index + + + Ready@(_status.Ready ? "yes" : "no") + Commit@(_status.Commit is { Length: > 8 } c ? c[..8] : _status.Commit) + Synced@(_status.SyncedAt?.ToString("u") ?? "never") (@(_status.AgeSeconds?.ToString("0") ?? "-") s ago) + Syncing@(_status.Syncing ? "yes" : "no") + Notes@_status.Notes + Raw queue@_status.RawQueue + Outputs@_status.Outputs + @if (_status.LastError is not null) + { + Last error@_status.LastError + } + + + + + + + Embedding models + + + @foreach (var i in _status.Indexes) + { + @i.ModelId @(i.Active ? "(active)" : "")@i.Notes vectors + } + + + + + + + Adapters + + + @foreach (var a in _status.Adapters) + { + + @a.Adapter + @(a.Ok ? "ok" : "failing") + @a.Latency.TotalMilliseconds.ToString("0") ms + @a.Detail + + } + + + + + +} + +@code { + private ServiceStatus? _status; + + protected override async Task OnInitializedAsync() + { + _status = await Status.GetAsync(includeAdapterChecks: true, CancellationToken.None); + } +} diff --git a/src/Wiki.Host/Components/_Imports.razor b/src/Wiki.Host/Components/_Imports.razor index 1b19ea5..5f50cd4 100644 --- a/src/Wiki.Host/Components/_Imports.razor +++ b/src/Wiki.Host/Components/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @@ -6,6 +6,7 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop +@using MudBlazor @using Wiki.Host @using Wiki.Host.Components @using Wiki.Host.Components.Layout diff --git a/src/Wiki.Host/Composition/SyncBackgroundService.cs b/src/Wiki.Host/Composition/SyncBackgroundService.cs new file mode 100644 index 0000000..0dc94c7 --- /dev/null +++ b/src/Wiki.Host/Composition/SyncBackgroundService.cs @@ -0,0 +1,32 @@ +using Wiki.Application.Sync; + +namespace Wiki.Host.Composition; + +/// Syncs at start, on every webhook trigger, and every interval; retries sooner after a failure. +public sealed class SyncBackgroundService(SyncCoordinator coordinator, WikiOptions options, ILogger log) : BackgroundService +{ + private readonly SemaphoreSlim _wake = new(0); + + public void Trigger() => _wake.Release(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var summary = await coordinator.RunAsync(force: false, stoppingToken); + var wait = summary is null ? options.Sync.RetrySeconds : options.Sync.IntervalSeconds; + if (summary is null) + log.LogWarning("sync failed; retrying in {Seconds}s", wait); + try + { + await _wake.WaitAsync(TimeSpan.FromSeconds(wait), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + // drain extra triggers that arrived during the run; one sync covers them + while (_wake.CurrentCount > 0) await _wake.WaitAsync(0, stoppingToken); + } + } +} diff --git a/src/Wiki.Host/Composition/WikiOptions.cs b/src/Wiki.Host/Composition/WikiOptions.cs new file mode 100644 index 0000000..2b39673 --- /dev/null +++ b/src/Wiki.Host/Composition/WikiOptions.cs @@ -0,0 +1,55 @@ +namespace Wiki.Host.Composition; + +/// The `Wiki` configuration section: which adapters exist and which one is active. Secrets are read separately from configuration keys, never bound here. +public sealed class WikiOptions +{ + public NoteStoreOptions NoteStore { get; set; } = new(); + public EmbeddingsOptions Embeddings { get; set; } = new(); + public IndexOptions Index { get; set; } = new(); + public SyncScheduleOptions Sync { get; set; } = new(); +} + +public sealed class NoteStoreOptions +{ + public string Active { get; set; } = "gitea"; + public GiteaStoreOptions Gitea { get; set; } = new(); +} + +public sealed class GiteaStoreOptions +{ + public string BaseUrl { get; set; } = "https://gitea.renner.uno"; + public string Repository { get; set; } = "k.renner/notes"; + public string Branch { get; set; } = "main"; +} + +public sealed class EmbeddingsOptions +{ + public string Active { get; set; } = "bge-m3"; + public Dictionary Models { get; set; } = new(); +} + +public sealed class EmbeddingModelOptions +{ + public string Adapter { get; set; } = "LlamaCpp"; + public string Url { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public int Dimension { get; set; } = 1024; +} + +public sealed class IndexOptions +{ + public string Active { get; set; } = "file"; + public FileIndexStoreOptions File { get; set; } = new(); +} + +public sealed class FileIndexStoreOptions +{ + public string Directory { get; set; } = "/data"; +} + +public sealed class SyncScheduleOptions +{ + /// Reconcile interval; the webhook is the fast path. + public int IntervalSeconds { get; set; } = 1800; + public int RetrySeconds { get; set; } = 60; +} diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index f643367..42bcf46 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -1,27 +1,101 @@ +using Microsoft.AspNetCore.Authentication; +using MudBlazor.Services; +using Wiki.Adapters.FileIndex; +using Wiki.Adapters.Gitea; +using Wiki.Adapters.LlamaCpp; +using Wiki.Application.Retrieval; +using Wiki.Application.Status; +using Wiki.Application.Sync; +using Wiki.Core.Ports; +using Wiki.Host.Api; +using Wiki.Host.Auth; using Wiki.Host.Components; +using Wiki.Host.Composition; var builder = WebApplication.CreateBuilder(args); +var wiki = builder.Configuration.GetSection("Wiki").Get() ?? new WikiOptions(); +builder.Services.AddSingleton(wiki); +builder.Services.AddSingleton(TimeProvider.System); -// Add services to the container. -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); +// ---- adapters, selected by configuration ------------------------------------------------ +builder.Services.AddGiteaAdapters(o => +{ + o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl; + o.Repository = wiki.NoteStore.Gitea.Repository; + o.Branch = wiki.NoteStore.Gitea.Branch; + o.Token = builder.Configuration["Wiki:NoteStore:Gitea:Token"] ?? string.Empty; +}); +builder.Services.AddFileIndexAdapters(o => o.Directory = wiki.Index.File.Directory); +foreach (var (name, model) in wiki.Embeddings.Models) +{ + switch (model.Adapter) + { + case "LlamaCpp": + builder.Services.AddLlamaCppEmbeddings(name, o => + { + o.Url = model.Url; + o.Model = model.Model; + o.ModelId = name; + o.Dimension = model.Dimension; + o.BasicAuth = builder.Configuration[$"Wiki:Embeddings:Models:{name}:BasicAuth"]; + }); + break; + default: + throw new InvalidOperationException($"unknown embeddings adapter '{model.Adapter}' for model '{name}'"); + } +} +builder.Services.AddSingleton>(sp => + wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService).ToList()); +builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.Embeddings.Active)); +builder.Services.AddSingleton>(sp => +[ + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + .. sp.GetRequiredService>(), +]); + +// ---- use cases --------------------------------------------------------------------------- +builder.Services.AddSingleton(new SyncOptions()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// ---- auth: humans arrive through the Authentik outpost, machines with an API key --------- +builder.Services.AddAuthentication(ProxyHeaderAuthenticationHandler.SchemeName) + .AddScheme(ProxyHeaderAuthenticationHandler.SchemeName, null) + .AddScheme(ApiKeyAuthenticationHandler.SchemeName, null); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(Policies.Write, p => p.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName).RequireAuthenticatedUser()); +}); + +// ---- UI ---------------------------------------------------------------------------------- +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddMudServices(); var app = builder.Build(); -// Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); } app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); -app.UseHttpsRedirection(); - +app.UseAuthentication(); +app.UseAuthorization(); app.UseAntiforgery(); +app.MapHealthEndpoints(); +app.MapIndexEndpoints(); +app.MapApiV1(); + app.MapStaticAssets(); -app.MapRazorComponents() - .AddInteractiveServerRenderMode(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); app.Run(); + +public partial class Program; diff --git a/src/Wiki.Host/Wiki.Host.csproj b/src/Wiki.Host/Wiki.Host.csproj index 90d0b37..2a9ccc1 100644 --- a/src/Wiki.Host/Wiki.Host.csproj +++ b/src/Wiki.Host/Wiki.Host.csproj @@ -9,6 +9,10 @@ + + + + net10.0 enable diff --git a/src/Wiki.Host/appsettings.json b/src/Wiki.Host/appsettings.json index 10f68b8..3617d2d 100644 --- a/src/Wiki.Host/appsettings.json +++ b/src/Wiki.Host/appsettings.json @@ -5,5 +5,23 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Wiki": { + "NoteStore": { + "Active": "gitea", + "Gitea": { "BaseUrl": "https://gitea.renner.uno", "Repository": "k.renner/notes", "Branch": "main" } + }, + "Embeddings": { + "Active": "bge-m3", + "Models": { + "bge-m3": { "Adapter": "LlamaCpp", "Url": "http://wiki-embed.ai.svc.cluster.local:8080", "Model": "bge-m3", "Dimension": 1024 } + } + }, + "Index": { "Active": "file", "File": { "Directory": "/data" } }, + "Sync": { "IntervalSeconds": 1800, "RetrySeconds": 60 } + }, + "Auth": { + "ReverseProxy": { "HeaderName": "X-Authentik-Username", "TrustedProxies": [] }, + "ApiKeys": {} + } }