Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ The API's HTTP surface is `AddApiSurface` plus `MapApiEndpoints`; `Program.cs` a
- **Proxy orphans** (`ProxyOrphans`, a proxy sweep's report) are written only by the Worker; outside it only `EfProxyOrphanReader` depends on the entity (`only-worker-writes-proxy-orphans`). See `docs/architecture/work-queue.md`, "Proxy sweeps".
- Both hosts apply EF migrations at startup, serialised by a PostgreSQL advisory lock (`MigrateWithAdvisoryLockAsync`).

`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite. The API image builds it and serves it on the same origin as `/api/v1`, with immutable hashed assets, an `index.html` fallback that never answers `/api`, and a strict CSP with Trusted Types (ADR 0012, `docs/architecture/ui-serving.md`). It is a pnpm project, not part of the .NET solution; so far it has a translated placeholder page. The work is planned in parent issues #344–#349; see "The UI" below.
`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite. The API image builds it and serves it on the same origin as `/api/v1`, with immutable hashed assets, an `index.html` fallback that never answers `/api`, and a strict CSP with Trusted Types (ADR 0012, `docs/architecture/ui-serving.md`). It is a pnpm project, not part of the .NET solution; so far it has the login screen and a signed-in placeholder (#360). The work is planned in parent issues #344–#349; see "The UI" below.

### Storage Layout

Expand Down Expand Up @@ -167,7 +167,7 @@ Each user has a private set of `UserStorageConfig` entries pointing to NAS roots
- Merging a PR: wait until CI has finished and `CI gate` and `CodeQL` are green (`gh pr checks <n> --watch`), then ask the maintainer to confirm. Never merge before CI passes, and never without that confirmation.
- PostgreSQL connection is never hardcoded — always read from `POSTGRES_CONNECTION__*` env vars or Docker secrets
- CORS allowed origins configured via `CORS__ALLOWEDORIGINS` env var (comma-separated); the same value switches the refresh cookie to `SameSite=None`. Leave empty for same-origin / reverse proxy deployments.
- `X-Forwarded-For` is honoured only from loopback or from addresses listed in `FORWARDEDHEADERS__KNOWNPROXIES` / `FORWARDEDHEADERS__KNOWNNETWORKS` (comma-separated IPs / CIDRs; `ForwardLimit = 1`). Rate limiting partitions on the resulting client IP, so a reverse-proxy deployment must set one of these or all clients share the proxy's bucket. Never trust the Docker bridge network for a directly published port — that lets any client spoof its IP.
- `X-Forwarded-For` is honoured only from loopback or from addresses listed in `FORWARDEDHEADERS__KNOWNPROXIES` / `FORWARDEDHEADERS__KNOWNNETWORKS` (comma-separated IPs / CIDRs; `ForwardLimit = 1`). Rate limiting partitions on the resulting client IP, so a reverse-proxy deployment must set one of these or all clients share the proxy's bucket. Never trust the Docker bridge network for a directly published port — that lets any client spoof its IP. The `Sensitive` policy (login, register, change-password, admin user writes) allows `RATELIMIT__SENSITIVEPERMITLIMIT` requests per minute (default 10); only `src/docker-compose.e2e.yml` raises it.
- Registration requires an admin-issued invite token. The first user (bootstrap admin) is exempt.
- Observability: `GET /api/v1/healthz` (database, proxy storage, originals storage; a missing mount reports Degraded). Logging is the default .NET console logger with `LoggerMessage` source-generated messages (architecture rule `logging-is-source-generated`; `BeginScope` is allowed); structured JSON output and OpenTelemetry are **not** wired up.
- License: AGPL-3.0.
Expand Down
11 changes: 9 additions & 2 deletions docs/architecture/ui-serving.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,12 @@ for the flashback player, say) adds it to `Permissions-Policy` in its own change
- **Seam 4** (`src/Anichron.UI/e2e/`): Playwright against the image built from the checkout,
started by `src/docker-compose.e2e.yml` with PostgreSQL on tmpfs and no Worker. Projects: WebKit
on an iPhone profile, Chromium on a Pixel profile, Firefox at a phone viewport. The smoke test
loads the placeholder and asserts the headers, the assets' caching, no CSP or Trusted Types
violation, no request to another origin, and no axe violations. CI runs it in the `E2E` job.
loads the app (a visitor without a token lands on `/login`) and asserts the headers, the assets'
caching, no CSP or Trusted Types violation, no request to another origin, and no axe violations.
`login.spec.ts` signs in through the screen: success, a wrong password, a disabled account and a
locked account's countdown, each with no CSP violation. Users are seeded only through the public
API (#360): the global setup rotates the bootstrap admin's first password (so it fails on a
database that is not fresh), and each test creates its own user with the admin API and changes
its temporary password. Every request reaches the API from the one Docker bridge address, so the
compose file raises `RateLimit__SensitivePermitLimit`; lockout keeps its production timings. CI
runs both in the `E2E` job.
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static async Task<AuthenticatedTestApp> StartAsync(
builder.Services.AddAuthorizationPolicies();
builder.Services.AddProblemDetails();
if (rateLimited)
builder.Services.AddRateLimiting();
builder.Services.AddRateLimiting(builder.Configuration);
configureServices(builder.Services);
var app = builder.Build();
app.UseApiProblems();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Net;

namespace Anichron.API.Tests.Unit.Infrastructure;
Expand All @@ -22,7 +23,7 @@ private static async Task<WebApplication> StartAsync(Dictionary<string, string?>
builder.Configuration.AddInMemoryCollection(settings);
builder.Services
.AddForwardedHeadersSupport(builder.Configuration)
.AddRateLimiting();
.AddRateLimiting(builder.Configuration);

var app = builder.Build();
app.Use((context, next) =>
Expand Down Expand Up @@ -113,6 +114,33 @@ public async Task RefreshPolicy_AllowsItsPermitLimitThenRejectsAsync()
HttpStatusCode.TooManyRequests);
}

[Fact]
public async Task SensitivePolicy_ConfiguredPermitLimit_AllowsThatManyThenRejectsAsync()
{
await using var app = await StartAsync(new() { ["RateLimit:SensitivePermitLimit"] = "3" });
using var client = app.GetTestClient();
var statuses = new List<HttpStatusCode>();
for (var i = 0; i < 4; i++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/probe");
request.Headers.Add(PeerHeader, UntrustedPeer);
using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
statuses.Add(response.StatusCode);
}

statuses.Should().Equal(HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests);
}

[Theory]
[InlineData("0")]
[InlineData("-1")]
public async Task SensitivePolicy_PermitLimitBelowOne_FailsAtStartupAsync(string limit)
{
var start = () => StartAsync(new() { ["RateLimit:SensitivePermitLimit"] = limit });

await start.Should().ThrowAsync<OptionsValidationException>();
}

[Fact]
public void AddForwardedHeadersSupport_UnparseableNetwork_ThrowsAtRegistration()
{
Expand Down
15 changes: 11 additions & 4 deletions src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Anichron.Infrastructure.Storage;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -31,7 +32,7 @@ public IServiceCollection AddApiSurface(IConfiguration configuration)
.AddAuthServices(configuration)
.AddWorkItemServices()
.AddAuthorizationPolicies()
.AddRateLimiting()
.AddRateLimiting(configuration)
.AddCorsPolicy(configuration)
.AddApiHealthChecks();

Expand Down Expand Up @@ -62,12 +63,16 @@ public IServiceCollection AddForwardedHeadersSupport(IConfiguration configuratio
});
}

public IServiceCollection AddRateLimiting()
public IServiceCollection AddRateLimiting(IConfiguration configuration)
{
return services.AddRateLimiter(options =>
services.AddOptions<RateLimitSettings>()
.Bind(configuration.GetSection("RateLimit"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<RateLimiterOptions>().Configure<IOptions<RateLimitSettings>>((options, settings) =>
{
options.AddPolicy(AuthRateLimitPolicies.Sensitive, new SlidingWindowPolicy(
AppDefaults.RateLimit.Sensitive.PermitLimit,
settings.Value.SensitivePermitLimit,
TimeSpan.FromSeconds(AppDefaults.RateLimit.Sensitive.WindowSeconds),
AppDefaults.RateLimit.Sensitive.Segments));

Expand All @@ -77,6 +82,8 @@ public IServiceCollection AddRateLimiting()
TimeSpan.FromMinutes(AppDefaults.RateLimit.Refresh.WindowMinutes),
AppDefaults.RateLimit.Refresh.Segments));
});

return services.AddRateLimiter(_ => { });
}

public IServiceCollection AddWorkItemServices()
Expand Down
9 changes: 9 additions & 0 deletions src/Anichron.API/Settings/RateLimitSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;

namespace Anichron.API.Settings;

public sealed record RateLimitSettings
{
[Range(1, int.MaxValue)]
public int SensitivePermitLimit { get; init; } = AppDefaults.RateLimit.Sensitive.PermitLimit;
}
83 changes: 83 additions & 0 deletions src/Anichron.UI/e2e/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { randomUUID } from 'node:crypto';
import { expect, type APIRequestContext } from '@playwright/test';

export const bootstrapAdmin = { username: 'admin', password: 'admin' };
export const admin = { username: 'admin', password: 'end-to-end admin passphrase' };

export interface Account {
id: string;
username: string;
password: string;
}

const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });

export async function signIn(
request: APIRequestContext,
usernameOrEmail: string,
password: string,
) {
const response = await request.post('/api/v1/auth/login/mobile', {
data: { usernameOrEmail, password },
});
expect(response.status(), await response.text()).toBe(200);
return ((await response.json()) as { accessToken: string }).accessToken;
}

export async function changePassword(
request: APIRequestContext,
token: string,
currentPassword: string,
newPassword: string,
) {
const response = await request.post('/api/v1/users/me/password', {
headers: bearer(token),
data: { currentPassword, newPassword },
});
expect(response.status(), await response.text()).toBe(204);
}

export async function createAccount(request: APIRequestContext): Promise<Account> {
const adminToken = await signIn(request, admin.username, admin.password);
const username = `user-${randomUUID().slice(0, 8)}`;
const created = await request.post('/api/v1/users', {
headers: bearer(adminToken),
data: { username, email: `${username}@example.com` },
});
expect(created.status(), await created.text()).toBe(201);
const { id, temporaryPassword } = (await created.json()) as {
id: string;
temporaryPassword: string;
};
const password = `${username} passphrase`;
const token = await signIn(request, username, temporaryPassword);
await changePassword(request, token, temporaryPassword, password);
return { id, username, password };
}

export async function disable(request: APIRequestContext, account: Account) {
const adminToken = await signIn(request, admin.username, admin.password);
const response = await request.patch(`/api/v1/users/${account.id}`, {
headers: bearer(adminToken),
data: { isAdmin: null, isDisabled: true },
});
expect(response.status(), await response.text()).toBe(200);
}

async function failSignIn(request: APIRequestContext, account: Account) {
const response = await request.post('/api/v1/auth/login/mobile', {
data: { usernameOrEmail: account.username, password: 'not the password' },
});
expect(response.status()).toBe(401);
}

const pause = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

// Failures 4, 5 and 6 lock for 2, 4 and 8 s; one during a lock is not counted.
export async function lockOut(request: APIRequestContext, account: Account) {
for (let attempt = 0; attempt < 4; attempt++) await failSignIn(request, account);
await pause(2.5);
await failSignIn(request, account);
await pause(4.5);
await failSignIn(request, account);
}
17 changes: 17 additions & 0 deletions src/Anichron.UI/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { request, type FullConfig } from '@playwright/test';
import { admin, bootstrapAdmin, changePassword } from './accounts';

export default async function globalSetup(config: FullConfig) {
const context = await request.newContext({ baseURL: config.projects[0]?.use.baseURL });
const response = await context.post('/api/v1/auth/login/mobile', {
data: { usernameOrEmail: bootstrapAdmin.username, password: bootstrapAdmin.password },
});
if (response.status() !== 200)
throw new Error(
'The bootstrap admin cannot sign in with its first password, so the database is not fresh. ' +
'Restart it: docker compose -f src/docker-compose.e2e.yml down, then up.',
);
const { accessToken } = (await response.json()) as { accessToken: string };
await changePassword(context, accessToken, bootstrapAdmin.password, admin.password);
await context.dispose();
}
93 changes: 93 additions & 0 deletions src/Anichron.UI/e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import { createAccount, disable, lockOut } from './accounts';

const signedIn = "You're signed in. Your photo library is on its way.";
const pageErrors: string[] = [];

test.beforeEach(async ({ page }) => {
pageErrors.length = 0;
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.addInitScript(() => {
const violations: string[] = [];
Object.defineProperty(window, 'cspViolations', { value: violations });
document.addEventListener('securitypolicyviolation', (event) => {
violations.push(`${event.effectiveDirective} ${event.blockedURI}`);
});
});
});

test.afterEach(async ({ page }) => {
expect(await page.evaluate(() => Reflect.get(window, 'cspViolations') as string[])).toEqual([]);
expect(pageErrors).toEqual([]);
});

async function signIn(page: Page, usernameOrEmail: string, password: string) {
await page.goto('/login');
await page.getByLabel('Username or email').fill(usernameOrEmail);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
}

async function expectAccessible(page: Page) {
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
}

test('a user signs in and lands on the signed-in page', async ({ page, request }) => {
const account = await createAccount(request);

await signIn(page, account.username, account.password);

await expect(page.getByText(signedIn)).toBeVisible();
await expect(page).toHaveURL('/');
await expectAccessible(page);

expect(await page.evaluate(() => Reflect.get(window, 'cspViolations') as string[])).toEqual([]);
await page.reload();
await expect(page).toHaveURL('/login');
});

test('a wrong password is refused in the user’s language', async ({ page, request }) => {
const account = await createAccount(request);

await signIn(page, account.username, 'not the password');

await expect(page.getByRole('alert')).toHaveText('The username, email or password is wrong.');
await expect(page).toHaveURL('/login');
await expectAccessible(page);
});

test('a disabled account is told to ask the administrator', async ({ page, request }) => {
const account = await createAccount(request);
await disable(request, account);

await signIn(page, account.username, account.password);

await expect(page.getByRole('alert')).toHaveText(
'This account is disabled. Ask your administrator to enable it.',
);
await expect(page).toHaveURL('/login');
await expectAccessible(page);
});

test('a locked account counts down, then signs in', async ({ page, request }) => {
test.slow();
const account = await createAccount(request);
await lockOut(request, account);

await signIn(page, account.username, account.password);

const alert = page.getByRole('alert');
await expect(alert).toContainText('Too many failed attempts. This account is locked for now.');
await expect(page.getByText(/You can try again in \d+ seconds?\./)).toBeVisible();
const submit = page.getByRole('button', { name: 'Sign in' });
await expect(submit).toBeDisabled();
await expectAccessible(page);

await expect(submit).toBeEnabled({ timeout: 10_000 });
await expect(page.getByText(/You can try again/)).toBeHidden();
await submit.click();

await expect(page.getByText(signedIn)).toBeVisible();
});
6 changes: 3 additions & 3 deletions src/Anichron.UI/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const appPolicy =
"font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; " +
"frame-ancestors 'none'; require-trusted-types-for 'script'";

test('the placeholder loads with its headers and requests nothing but its own origin', async ({
test('the app loads with its headers and requests nothing but its own origin', async ({
page,
context,
baseURL,
Expand All @@ -32,8 +32,8 @@ test('the placeholder loads with its headers and requests nothing but its own or

const response = await page.goto('/');

await expect(page.getByRole('heading', { level: 1, name: 'Anichron' })).toBeVisible();
await expect(page.getByText('Your photo library is on its way.')).toBeVisible();
await expect(page.getByRole('heading', { level: 1, name: 'Sign in to Anichron' })).toBeVisible();
await expect(page).toHaveURL('/login');
const headers = response?.headers() ?? {};
expect(headers['content-security-policy']).toBe(appPolicy);
expect(headers['referrer-policy']).toBe('same-origin');
Expand Down
7 changes: 7 additions & 0 deletions src/Anichron.UI/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export default defineConfig([
'error',
{ allowInterfaces: 'with-single-extends' },
],
'@typescript-eslint/only-throw-error': [
'error',
{
allow: [{ from: 'package', package: '@tanstack/router-core', name: 'Redirect' }],
allowRethrowing: true,
},
],
},
},
{
Expand Down
5 changes: 4 additions & 1 deletion src/Anichron.UI/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@
"last 2 Edge versions"
],
"dependencies": {
"@hookform/resolvers": "6.0.2",
"@tanstack/react-router": "1.170.46",
"i18next": "26.5.11",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-i18next": "17.1.9"
"react-hook-form": "7.91.0",
"react-i18next": "17.1.9",
"zod": "4.6.7"
},
"devDependencies": {
"@axe-core/playwright": "4.14.0",
Expand Down
Loading
Loading