Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copy to .env and fill in values. Never commit .env to git.
ASPNETCORE_ENVIRONMENT=Production

# PostgreSQL (docker-compose)
POSTGRES_PASSWORD=change-me
ConnectionStrings__fastgeography-db=Host=postgres;Port=5432;Database=fastgeography;Username=postgres;Password=change-me

# Destination AI — Auto tries OpenAI chat completions first, then Ollama
DestinationAi__Provider=Auto
DestinationAi__Model=gpt-4o-mini
DestinationAi__ApiKey=
OPENAI_API_KEY=

# Ollama fallback (docker compose --profile ollama up)
DestinationAi__OllamaModel=llama3.2:3b
DestinationAi__OllamaBaseUrl=http://ollama:11434/v1

# Optional explicit providers
# DestinationAi__Provider=OpenAI
# DestinationAi__Provider=Grok
# GROK_API_KEY=
# DestinationAi__Provider=Claude
# ANTHROPIC_API_KEY=

# Geocoding (optional)
Geocoding__BingMaps__ApiKey=
Geocoding__GeoNames__Username=
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build-and-test:
name: Build, test, and security scan
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore dependencies
run: dotnet restore

- name: Build (Release)
run: dotnet build --no-restore --configuration Release

- name: Unit tests
run: |
dotnet test tst/FastGeography.Tests.Unit \
--no-build \
--configuration Release \
--logger "trx;LogFileName=unit-results.trx" \
--collect:"XPlat Code Coverage"

- name: Integration tests
run: |
dotnet test tst/FastGeography.Tests.Integration \
--no-build \
--configuration Release \
--logger "trx;LogFileName=integration-results.trx"
env:
# Empty credentials — the fake geocoding service is substituted in tests
# so no real API calls are made regardless of which provider is configured.
Geocoding__Provider: "Nominatim"
Geocoding__GeoNames__Username: ""
Geocoding__BingMaps__ApiKey: ""

- name: Publish test results
if: always()
uses: dorny/test-reporter@v1
with:
name: Test results
path: "**/*.trx"
reporter: dotnet-trx

- name: Security scan — vulnerable packages
run: dotnet list package --vulnerable --include-transitive
continue-on-error: true
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ paket-files/
.idea/
*.sln.iml

# Environment secrets (use .env.example as template)
.env
.env.local
.env.*.local

# Kentico Required files - In-built Debug logging
!Systems/CMS/Propay.LoggedOut/CMS/CMSModules/System/Debug/
!Systems/CMS/Propay.LoggedOut/CMS/CMSAdminControls/Debug/
Expand Down
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore src/FastGeography.Server/FastGeography.Server.csproj
RUN dotnet publish src/FastGeography.Server/FastGeography.Server.csproj -c Release -o /app/publish --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "FastGeography.Server.dll"]
49 changes: 49 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: fastgeography
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5

server:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__fastgeography-db: Host=postgres;Port=5432;Database=fastgeography;Username=postgres;Password=${POSTGRES_PASSWORD:-change-me}
DestinationAi__Provider: ${DestinationAi__Provider:-Auto}
DestinationAi__Model: ${DestinationAi__Model:-gpt-4o-mini}
DestinationAi__ApiKey: ${DestinationAi__ApiKey:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
DestinationAi__OllamaModel: ${DestinationAi__OllamaModel:-llama3.2:3b}
DestinationAi__OllamaBaseUrl: ${DestinationAi__OllamaBaseUrl:-http://ollama:11434/v1}
DestinationAi__BaseUrl: ${DestinationAi__BaseUrl:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GROK_API_KEY: ${GROK_API_KEY:-}
depends_on:
postgres:
condition: service_healthy

# Optional local LLM. First run: docker compose exec ollama ollama pull llama3.2:3b
# Requires ~8 GB RAM for llama3.2:3b on CPU. Do not expose port 11434 publicly in production.
ollama:
image: ollama/ollama:latest
profiles: ["ollama"]
volumes:
- ollama-data:/root/.ollama
# Internal only — server reaches ollama via Docker network when BaseUrl=http://ollama:11434/v1

volumes:
postgres-data:
ollama-data:
2 changes: 1 addition & 1 deletion src/FastGeography.AppHost/FastGeography.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.0.0" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\FastGeography.Client\FastGeography.Client.csproj" />
<ProjectReference Include="..\FastGeography.Server\FastGeography.Server.csproj" />
</ItemGroup>

Expand Down
46 changes: 42 additions & 4 deletions src/FastGeography.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,45 @@
var builder = DistributedApplication.CreateBuilder(args);

var server = builder.AddProject<Projects.FastGeography_Server>("fastgeography-api");
var client = builder.AddProject<Projects.FastGeography_Client>("fastgeography-client")
.WithReference(server);
// Hosted Blazor WASM: the Server serves both the UI and the API on one origin.
// Do not AddProject the Client — that would start a second WASM host and break same-origin HttpClient.

builder.Build().Run();
// Optional secrets — set via AppHost user-secrets (never commit keys).
// dotnet user-secrets set "Parameters:openai-apikey" "sk-..." --project src/FastGeography.AppHost
var geoNamesUsername = builder.AddParameter("geonames-username", secret: true);
var bingMapsApiKey = builder.AddParameter("bingmaps-apikey", secret: true);
var openAiApiKey = builder.AddParameter("openai-apikey", secret: true);
var anthropicApiKey = builder.AddParameter("anthropic-apikey", secret: true);
var grokApiKey = builder.AddParameter("grok-apikey", secret: true);

// Default chain: OpenAI chat completions first, Ollama second.
// Override with Parameters:destination-ai-provider.
var destinationAiProvider = builder.AddParameter("destination-ai-provider", "Auto");

// Local LLM fallback. First run: docker exec -it <ollama> ollama pull llama3.2:3b
// Needs ~8 GB RAM for llama3.2:3b on CPU.
var ollama = builder.AddContainer("ollama", "ollama/ollama", tag: "latest")
.WithHttpEndpoint(port: 11434, targetPort: 11434, name: "http")
.WithVolume("ollama-models", "/root/.ollama");

var postgres = builder.AddPostgres("postgres")
.WithPgAdmin();

var db = postgres.AddDatabase("fastgeography-db");

var server = builder.AddProject<Projects.FastGeography_Server>("fastgeography-server")
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/alive")
.WithEnvironment("Geocoding__GeoNames__Username", geoNamesUsername)
.WithEnvironment("Geocoding__BingMaps__ApiKey", bingMapsApiKey)
.WithEnvironment("DestinationAi__Provider", destinationAiProvider)
.WithEnvironment("DestinationAi__ApiKey", openAiApiKey)
.WithEnvironment("DestinationAi__OllamaBaseUrl", $"{ollama.GetEndpoint("http")}/v1")
.WithEnvironment("OpenAI__ApiKey", openAiApiKey)
.WithEnvironment("OPENAI_API_KEY", openAiApiKey)
.WithEnvironment("ANTHROPIC_API_KEY", anthropicApiKey)
.WithEnvironment("GROK_API_KEY", grokApiKey)
.WithReference(db)
.WaitFor(db)
.WaitFor(ollama);

builder.Build().Run();
7 changes: 7 additions & 0 deletions src/FastGeography.AppHost/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,12 @@
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
},
"Parameters": {
"geonames-username": "",
"bingmaps-apikey": ""
},
"ConnectionStrings": {
"fastgeography-db": "Host=localhost;Port=5432;Database=fastgeography;Username=postgres;Password=postgres"
}
}
38 changes: 26 additions & 12 deletions src/FastGeography.Client/App.razor
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@inject IStringLocalizer<UiStrings> L

<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<Authorizing>
@* Show nothing while the async auth check is in flight so we don't
prematurely redirect authenticated users to the login page. *@
</Authorizing>
<NotAuthorized>
<LayoutView Layout="@typeof(MainLayout)">
<RedirectToLogin />
</LayoutView>
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">@L["App_NotFound"]</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
<ApplicationInsightsComponent />
92 changes: 92 additions & 0 deletions src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
namespace FastGeography.Client.Auth;

using System.Net.Http.Json;
using System.Security.Claims;

using FastGeography.Shared.Dtos;

using Microsoft.AspNetCore.Components.Authorization;

public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvider
{
private static readonly AuthenticationState Anonymous =
new(new ClaimsPrincipal(new ClaimsIdentity()));

private readonly HttpClient _http;
private UserInfoResponse? _cachedUser;

public CookieAuthenticationStateProvider(HttpClient http) => _http = http;

public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
// Return cached state when available to avoid an HTTP round-trip on every
// component render and to prevent the auth state from appearing as
// "anonymous" while an async check is in flight.
if (_cachedUser is not null)
return BuildState(_cachedUser);

try
{
_cachedUser = await _http.GetFromJsonAsync<UserInfoResponse>("api/auth/userinfo");
}
catch
{
_cachedUser = null;
}

return _cachedUser is null ? Anonymous : BuildState(_cachedUser);
}

public async Task<bool> LoginAsync(string email, string password)
{
var response = await _http.PostAsJsonAsync(
"api/auth/login", new LoginRequest(email, password));

if (!response.IsSuccessStatusCode) return false;

// Clear the cache so GetAuthenticationStateAsync fetches fresh data.
_cachedUser = null;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return true;
}

public async Task<(bool Success, string? Error)> RegisterAsync(
string email, string password, string displayName)
{
var response = await _http.PostAsJsonAsync(
"api/auth/register", new RegisterRequest(email, password, displayName));

if (response.IsSuccessStatusCode)
{
// Auto-login after registration by calling login
await LoginAsync(email, password);
return (true, null);
}

var body = await response.Content.ReadFromJsonAsync<ErrorBody>();
return (false, string.Join("; ", body?.Errors ?? ["Registration failed."]));
}

public async Task LogoutAsync()
{
await _http.PostAsync("api/auth/logout", null);
_cachedUser = null;
NotifyAuthenticationStateChanged(Task.FromResult(Anonymous));
}

public UserInfoResponse? CurrentUser => _cachedUser;

private static AuthenticationState BuildState(UserInfoResponse user)
{
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, user.UserId),
new Claim(ClaimTypes.Name, user.DisplayName),
new Claim(ClaimTypes.Email, user.Email),
], "cookie");

return new AuthenticationState(new ClaimsPrincipal(identity));
}

private sealed record ErrorBody(List<string>? Errors);
}
14 changes: 14 additions & 0 deletions src/FastGeography.Client/Auth/CookieHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace FastGeography.Client.Auth;

using Microsoft.AspNetCore.Components.WebAssembly.Http;

public sealed class CookieHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest");
return base.SendAsync(request, cancellationToken);
}
}
Loading