diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e5726e8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,38 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml,csproj,props,targets}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.{cs,razor}] +indent_size = 4 + +# ---- C# code style ---- +[*.cs] +# Prefer modern C# idioms already used across the codebase +csharp_style_namespace_declarations = file_scoped:warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_prefer_braces = true:warning +csharp_style_expression_bodied_methods = when_on_single_line:silent +csharp_style_expression_bodied_properties = true:silent + +# Nullable / quality +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_style_readonly_field = true:warning diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..60f3a0d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # .NET NuGet dependencies across the solution + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + microsoft: + patterns: + - "Microsoft.*" + - "System.*" + + # GitHub Actions used in workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a4f42b..6776d6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Restore run: dotnet restore Qwertide.sln + - name: Verify formatting + run: dotnet format Qwertide.sln --verify-no-changes --no-restore + - name: Build run: dotnet build Qwertide.sln --configuration Release --no-restore -warnaserror diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d749d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Manuel Madubugini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c4187d9..b061ae4 100644 --- a/README.md +++ b/README.md @@ -4,109 +4,353 @@ **Live:** https://qwertide.azurewebsites.net -A browser-based typing-speed game built end to end in C# / .NET. A passage -appears, you type it, and Qwertide tracks your words-per-minute and accuracy -live before dropping your run onto a persistent leaderboard. - -The entire front-end and game loop are written in **Blazor WebAssembly** (no -JavaScript game logic), and the leaderboard is served by a separate **ASP.NET -Core Web API** backed by **EF Core**. It is a focused, full-stack, deployed .NET -app built as a portfolio piece for a Junior Software Developer (C#/.NET) role. - -## Features - -- **Live per-keystroke feedback.** Each character is highlighted correct or - incorrect in place as you type, with a blinking caret on the current position. -- **Real-time metrics.** Gross WPM (5 characters = 1 word) and accuracy update - while you type, not just at the end. -- **Type-past-errors model.** Wrong characters are marked and counted, but you - are not forced to stop and fix them, matching common typing-test convention. -- **Three passage lengths.** Short warm-ups, full paragraphs, and real C# code - snippets. -- **Persistent leaderboard.** Top runs are submitted to the API and survive - across sessions, served from a SQLite database through EF Core. -- **Pure, unit-tested scoring engine.** All WPM and accuracy math lives in a - UI-free C# class with xUnit coverage. -- **Accessible, restrained design.** A dark terminal-mono theme, WCAG AA text +A browser-based typing-speed game built end to end in **C# / .NET 8**. A passage +appears, you type it, and Qwertide tracks your words-per-minute and accuracy live — +then drops your run onto a persistent, API-backed leaderboard. + +The entire front-end and game loop are **Blazor WebAssembly** (the scoring engine +is pure C#; the only JavaScript is a 22-line caret-positioning helper). The +leaderboard is served by an **ASP.NET Core Web API** backed by **EF Core + SQLite**. +In production both ship as a **single Azure App Service**. It was built as a +focused, production-minded portfolio piece for a Junior C#/.NET role. + +--- + +## Table of contents + +- [Overview](#overview) +- [Key features](#key-features) +- [Architecture](#architecture) +- [Tech stack](#tech-stack) +- [API documentation](#api-documentation) +- [Security](#security) +- [Testing](#testing) +- [CI/CD](#cicd) +- [Deployment](#deployment) +- [Performance considerations](#performance-considerations) +- [Accessibility](#accessibility) +- [Project structure](#project-structure) +- [Local development](#local-development) +- [Environment variables](#environment-variables) +- [Engineering tradeoffs](#engineering-tradeoffs) +- [Known limitations](#known-limitations) +- [Future improvements](#future-improvements-not-yet-implemented) +- [Project status](#project-status) +- [License](#license) + +--- + +## Overview + +Qwertide is a full-stack, single-page typing test. The interesting engineering is +deliberately *not* in the UI: the words-per-minute and accuracy math is isolated in +a pure, dependency-free domain class so it can be unit-tested directly, and the +leaderboard is a small but properly-hardened REST API rather than a localStorage +hack. The project's goal is to demonstrate the complete C#/.NET stack — WASM +front-end, Web API, ORM with migrations, a tested domain layer, CI, and a public +cloud deployment — in one coherent, honestly-scoped app. + +## Key features + +- **Live per-keystroke feedback** — each character is highlighted correct/incorrect + in place as you type, with a blinking caret on the current position. +- **Real-time metrics** — gross WPM (5 chars = 1 word) and accuracy update *while* + you type, computed by a pure scoring engine. +- **Type-past-errors model** — wrong characters are marked and counted but don't + block you, matching common typing-test convention. +- **Three passage lengths** — short warm-ups, full paragraphs, and real C# snippets. +- **Persistent leaderboard** — top runs are submitted to the API and stored in + SQLite via EF Core, surviving restarts and redeploys. +- **Hardened public API** — input validation, per-IP rate limiting, security + headers, and HSTS (see [Security](#security)). +- **Accessible, restrained design** — dark terminal-mono theme, WCAG AA text contrast, and motion gated behind `prefers-reduced-motion`. -## Tech stack - -| Layer | Choice | -| ------------ | -------------------------------------------------- | -| Language | C# (.NET 8) | -| Front-end | Blazor WebAssembly + MudBlazor | -| Back-end | ASP.NET Core Web API | -| ORM / data | EF Core 8 + SQLite | -| Testing | xUnit + FluentAssertions | -| Hosting | Azure App Service (Linux, single service) | - ## Architecture -One solution, structured so the scoring engine is testable in isolation and the -client depends on the API only through an interface: +One solution, three projects, with the scoring engine isolated so it is testable +without a browser and the client depending on the API only through an interface. ``` -Qwertide.sln -└── src/ - ├── Qwertide.Client Blazor WebAssembly UI + pure scoring engine - ├── Qwertide.Tests xUnit tests for the scoring engine - └── Qwertide.Api ASP.NET Core leaderboard API + EF Core +┌─────────────────────────────────────────────────────────────┐ +│ Azure App Service (single Linux service) │ +│ │ +│ ASP.NET Core Web API ──────────────────────────────────┐ │ +│ • GET/POST /api/scores ┌──────────────────┐ │ │ +│ • rate limiting, validation, │ EF Core + SQLite │ │ │ +│ security headers, HSTS ───────▶│ (/home/qwertide │ │ │ +│ • serves the published WASM │ .db, migrations)│ │ │ +│ bundle + SPA fallback └──────────────────┘ │ │ +│ │ │ +│ Blazor WebAssembly client ◀─── served as static files ──┘ │ +│ • TypingSession (pure scoring engine) │ +│ • ILeaderboardService ──▶ HttpClient ──▶ /api/scores │ +└─────────────────────────────────────────────────────────────┘ ``` -The leaderboard sits behind an `ILeaderboardService` contract. The client talks -to the API through an `HttpClient`-backed implementation, so the UI never depends -on the transport directly. In production the API also hosts the published Blazor -client, so the whole app deploys as a single Azure App Service. +**Key decisions** + +- **Pure domain layer.** All metric math lives in `TypingSession` as static, + UI-free functions, so the test project references it directly and the Blazor + component owns only rendering. +- **Interface-driven leaderboard.** The UI codes against `ILeaderboardService`; + the active implementation (`ApiLeaderboardService`) is swapped in via DI without + any UI changes. A second `localStorage` implementation exists to demonstrate the + abstraction (it is not currently wired in as a runtime fallback). +- **Single-service hosting.** In production the API serves the published WASM + client and falls back to `index.html` for client-side routes, so there is one + deployable, one origin, and no production CORS. ### The scoring engine -`Services/TypingSession.cs` holds every metric calculation as plain, pure C# -with zero UI dependencies, so it can be unit-tested directly: +`Services/TypingSession.cs` holds every calculation as plain, pure C#: ```csharp -// gross WPM, guarding the zero-time edge case +// Gross WPM, guarding the zero-time edge case TypingSession.GrossWpmFor(charsTyped: 50, elapsedSeconds: 60); // -> 10 -// accuracy as correct / total keystrokes, guarding 0/0 +// Accuracy = correct / total keystrokes, guarding 0/0 TypingSession.AccuracyFor(correctKeystrokes: 45, totalKeystrokes: 50); // -> 90 ``` -Keystroke accounting (`CountKeystrokes`) counts every character committed in a -single input event, so fast typing never under-counts the accuracy denominator. +`CountKeystrokes` counts *every* character committed in a single input event +(a fast typist or IME can commit several between ticks), so the accuracy +denominator is never under-counted. + +## Tech stack + +| Layer | Choice | +| ------------ | -------------------------------------------------- | +| Language | C# (.NET 8), nullable reference types enabled | +| Front-end | Blazor WebAssembly + MudBlazor 8 | +| Back-end | ASP.NET Core Web API | +| ORM / data | EF Core 8 + SQLite (code-first migrations) | +| Testing | xUnit + FluentAssertions + coverlet | +| CI | GitHub Actions (build with warnings-as-errors) | +| Code quality | `.editorconfig` + `dotnet format` (CI-enforced) | +| Observability| Health checks (`/health`, EF Core DbContext check) | +| Hosting | Azure App Service (Linux, single service) | +| API docs | Swagger / OpenAPI (Swashbuckle, Development only) | + +## API documentation + +Base path `/api`. In Development, interactive Swagger UI is available at `/swagger`. + +| Method | Route | Description | Success | Notes | +| ------ | ---------------------- | ---------------------------------------- | ------- | ----- | +| `GET` | `/api/scores?top={n}` | Top runs, ordered WPM↓, accuracy↓, time↑ | `200` | `top` clamped to 1–100 | +| `GET` | `/api/scores/{id:int}` | Single run by id | `200` | `404` if not found | +| `POST` | `/api/scores` | Submit a run | `201` | Validated; rate-limited (`429`); returns the persisted row | + +The POST body binds to a dedicated `ScoreRequest` DTO; server-owned fields +(`Id`, `CreatedAtUtc`) are intentionally absent so they cannot be set by the client. + +A `GET /health` endpoint (outside `/api`) returns `Healthy`/`Unhealthy` and +verifies the database connection — suitable for platform health probes. + +## Security + +Implemented in this repository (`Program.cs`, `ScoresController.cs`, `ScoreRequest.cs`): + +- **Over-posting protection** — a request DTO separate from the EF entity prevents + clients from spoofing server-owned fields. +- **Input validation** — `[Required]`, `[StringLength]`, and `[Range]` attributes + enforced automatically by `[ApiController]`. +- **Rate limiting** — fixed-window limiter, **5 submissions/minute per client IP**, + returning `429`; partitioned by IP so one abuser can't lock everyone out. +- **Security response headers** — `X-Content-Type-Options: nosniff`, + `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` on every response. +- **HSTS** in production; HTTPS redirection enabled; HTTPS-only enforced at the + platform. +- **Reverse-proxy awareness** — `X-Forwarded-*` handling for Azure's load balancer, + **gated to Production** so a non-Azure host can't spoof scheme/client IP. +- **CORS** — a named policy with an explicit allow-list from configuration (no + `AllowAnyOrigin`). +- **SPA fallback carve-out** — unmatched `/api/*` routes return a real `404` + instead of the HTML shell. +- **SQL injection** — eliminated by EF Core parameterization. +- **Secrets** — none committed; the connection string is injected via an App + Service setting (see [Environment variables](#environment-variables)). + +> Authentication/authorization is intentionally **not** implemented — see +> [Engineering tradeoffs](#engineering-tradeoffs) and [Known limitations](#known-limitations). + +## Testing + +```bash +dotnet test +``` + +21 xUnit tests (with FluentAssertions) cover the scoring engine and the edge cases +that break naive implementations: zero elapsed time, all-errors, empty input, +multi-character input events, and derived-metric state. Tests target the pure +domain layer directly, so they run fast and need no browser or HTTP host. + +**Scope, stated honestly:** test coverage is the scoring engine. API/controller +integration tests, component tests, and end-to-end tests are not yet present (see +[Future improvements](#future-improvements-not-yet-implemented)). + +## CI/CD + +GitHub Actions (`.github/workflows/ci.yml`) runs on every push and pull request to +`main`: + +1. Restore (`dotnet restore`) +2. **Format check** (`dotnet format --verify-no-changes`) — style violations fail the build +3. **Build in Release with `-warnaserror`** — warnings fail the build +4. Test (`dotnet test`) + +Code style is pinned by an `.editorconfig` and enforced by the format check above. +NuGet packages and GitHub Actions are kept current automatically via **Dependabot** +(`.github/dependabot.yml`, weekly). + +This is **CI only**. Deployment is currently a documented manual step (see below); +there is no automated CD pipeline yet. + +## Deployment + +Live on **Azure App Service** (Linux) as a single service: the ASP.NET Core API +hosts both the leaderboard endpoints and the published Blazor client. The SQLite +file lives on the persistent `/home` share, so scores survive restarts and +redeploys. The complete, reproducible runbook — resource creation, connection +string, HTTPS-only, and the publish/zip/deploy commands — is in +[DEPLOY.md](DEPLOY.md). + +## Performance considerations + +- **Indexed leaderboard reads** — a DB index on `Wpm` backs the primary ordering. +- **Bounded queries** — `top` is clamped to 100 so a request can't ask for an + unbounded result set. +- **Rate limiting** — protects the write path from abuse-driven load. +- **Single origin** — the WASM bundle is served as static files by the same + service, avoiding cross-origin round-trips in production. +- **Release builds** verified in CI. + +## Accessibility + +- WCAG AA text contrast across the theme. +- All motion (caret blink, live counters, in-place colouring) gated behind + `prefers-reduced-motion`. +- Semantic, single-accent design system documented in [DESIGN.md](DESIGN.md). -## Running locally +## Project structure + +``` +Qwertide.sln +├── .github/workflows/ci.yml Build + test on push/PR (warnings-as-errors) +├── .config/dotnet-tools.json Pinned local tool: dotnet-ef +├── DEPLOY.md Azure App Service runbook +├── DESIGN.md Design-system rationale +└── src/ + ├── Qwertide.Client/ Blazor WebAssembly UI + pure scoring engine + │ ├── Services/TypingSession.cs pure, UI-free scoring engine (test surface) + │ ├── Services/ILeaderboardService.cs + ApiLeaderboardService.cs + │ ├── Pages/ Components/ Layout/ game screens, per-glyph render, shell + │ └── Theme/ wwwroot/css/ custom MudBlazor theme + design system + ├── Qwertide.Api/ ASP.NET Core leaderboard API + │ ├── Controllers/ScoresController.cs + │ ├── Models/ (Score entity + ScoreRequest DTO) + │ ├── Data/ (DbContext, seeder) + Migrations/ + │ └── Program.cs pipeline: rate limiting, headers, CORS, SPA host + └── Qwertide.Tests/ xUnit tests for the scoring engine +``` + +## Local development Requires the **.NET 8 SDK**. ```bash git clone https://github.com/ManuelMadu/Qwertide.git cd Qwertide - -# run the game -cd src/Qwertide.Client -dotnet run -# open the printed http://localhost:xxxx URL +dotnet restore ``` -Blazor WebAssembly is served as a static bundle, so changes need a rebuild and a -hard refresh (Cmd/Ctrl + Shift + R) to clear the cached WASM. - -## Tests +**Run the API + client together (production-like single service):** ```bash -dotnet test +dotnet run --project src/Qwertide.Api +# open the printed http://localhost:5229 (https://localhost:7237) URL ``` -Covers the WPM and accuracy math plus the edge cases that break naive -implementations: zero elapsed time, all errors, empty input, and multi-character -input events. +**Run the client standalone against a separately-running API (dev):** -## Deployment +```bash +# terminal 1 +dotnet run --project src/Qwertide.Api # API on http://localhost:5229 +# terminal 2 +dotnet run --project src/Qwertide.Client # client points at the API per appsettings.Development.json +``` + +Blazor WASM is served as a cached static bundle, so after a change rebuild and do a +hard refresh (Cmd/Ctrl + Shift + R) to clear the cached WASM. -The app runs live on Azure App Service as a single Linux service: the ASP.NET -Core API hosts both the leaderboard endpoints and the published Blazor client. -The full step-by-step process is documented in [DEPLOY.md](DEPLOY.md). +**Database:** EF Core applies migrations automatically on startup and seeds a few +entries when empty — no manual `dotnet ef database update` needed for a fresh clone. + +## Environment variables + +Configuration is layered via `appsettings.json` + `appsettings.{Environment}.json`, +overridable by environment variables (double-underscore notation). + +**API (`Qwertide.Api`)** + +| Key | Purpose | Default | +| --- | --- | --- | +| `ConnectionStrings__Qwertide` | SQLite connection string | `Data Source=qwertide.db` (prod: `/home/qwertide.db`) | +| `Cors__AllowedOrigins__0` | Allowed CORS origin(s) | dev localhost origins | +| `ASPNETCORE_ENVIRONMENT` | Environment (gates Swagger, HSTS, proxy trust) | `Production` on Azure | + +**Client (`Qwertide.Client/wwwroot`)** + +| Key | Purpose | Default | +| --- | --- | --- | +| `Api:BaseUrl` | API base URL | empty = same origin (prod); `http://localhost:5229` in dev | + +## Engineering tradeoffs + +- **No authentication.** A typing game's leaderboard doesn't need accounts, so the + API is anonymous. The consequence — accepted deliberately — is that scores are + client-submitted and not server-verified; `[Range]` validation blocks absurd + values but not a plausible fake. Real anti-cheat would require server-side + gameplay validation or auth, which is out of scope for this piece. +- **SQLite over a managed SQL service.** Zero-cost, zero-ops, and ideal for a + single-instance portfolio app, at the cost of horizontal scalability (see below). +- **Migrate-on-startup.** Convenient for a one-instance deploy; a controlled + migration step would be preferable at production scale. +- **Single-service deploy.** Simpler ops and no prod CORS, traded against the + ability to scale the API and static hosting independently. + +## Known limitations + +These are real gaps, listed so the scope is unambiguous: + +- Scores are **not authenticated or server-verified** (spoofable by design). +- **SQLite is single-node** — the app cannot currently scale out to multiple App + Service instances without changing the data store. +- **Tests cover the scoring engine only** — no API integration, component, or E2E + tests yet; coverage is not published or gated. +- **No automated CD, no containerization** — deployment is a manual, documented + zip deploy. +- **Limited observability** — a database-backed `/health` endpoint exists, but + there is no Application Insights, structured logging, metrics, or alerting yet. +- **Fonts are loaded from the Google Fonts CDN** in production (self-hosting is + noted as a TODO for the performance/privacy budget). +- The **`localStorage` leaderboard implementation is not wired in** as a runtime + offline fallback; it exists to demonstrate the `ILeaderboardService` abstraction. + +## Future improvements (not yet implemented) + +Prioritised, and clearly separate from what exists today: + +1. **API integration tests** with `WebApplicationFactory`, plus bUnit component + tests and a Playwright E2E happy-path; publish coverage from CI. +2. **Continuous deployment** — extend the workflow to deploy on green `main`. +3. **Containerization** — a Dockerfile for reproducible builds and portability. +4. **Deeper observability** — Application Insights, structured logging, and + metrics/alerting (a database-backed `/health` endpoint is already in place). +5. **Scale-ready persistence** — move to Azure SQL / PostgreSQL if multi-instance + hosting is needed, with a controlled migration step. +6. **Self-hosted fonts** to remove the third-party CDN dependency. ## Project status @@ -119,10 +363,9 @@ v1 is complete and deployed: - [x] **M5** Connect the client to the API - [x] **M6** Styling, difficulty levels, reduced-motion support - [x] **M7** Deploy to Azure App Service (public URL) +- [x] **M8** API hardening: rate limiting, security headers, HSTS, SPA 404 carve-out +- [x] **M9** Ops & quality: `/health` check, CI format gate, Dependabot, MIT license -## Why this project +## License -Qwertide demonstrates the full C#/.NET stack in one deployed app: a Blazor -WebAssembly front-end, an ASP.NET Core API, EF Core persistence with migrations, -a unit-tested domain layer, and a public Azure deployment, all under version -control with a clean history. +Released under the [MIT License](LICENSE). diff --git a/src/Qwertide.Api/Controllers/ScoresController.cs b/src/Qwertide.Api/Controllers/ScoresController.cs index 96eee7c..f7e7142 100644 --- a/src/Qwertide.Api/Controllers/ScoresController.cs +++ b/src/Qwertide.Api/Controllers/ScoresController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Qwertide.Api.Data; using Qwertide.Api.Models; @@ -43,6 +44,7 @@ public async Task> GetById(int id) } [HttpPost] + [EnableRateLimiting("submit")] public async Task> Submit([FromBody] ScoreRequest request) { var score = new Score diff --git a/src/Qwertide.Api/Data/DbSeeder.cs b/src/Qwertide.Api/Data/DbSeeder.cs index e1a4a95..f4f668a 100644 --- a/src/Qwertide.Api/Data/DbSeeder.cs +++ b/src/Qwertide.Api/Data/DbSeeder.cs @@ -19,12 +19,12 @@ public static void Seed(QwertideDbContext db) var now = DateTime.UtcNow; db.Scores.AddRange( new Score { PlayerName = "kayl_okafor", Wpm = 138, Accuracy = 98.1, DurationSecs = 21.4, CreatedAtUtc = now.AddDays(-2) }, - new Score { PlayerName = "m.santoro", Wpm = 121, Accuracy = 96.7, DurationSecs = 24.9, CreatedAtUtc = now.AddDays(-5) }, - new Score { PlayerName = "ferra", Wpm = 117, Accuracy = 99.2, DurationSecs = 25.8, CreatedAtUtc = now.AddHours(-9) }, - new Score { PlayerName = "noah_si", Wpm = 104, Accuracy = 94.3, DurationSecs = 29.1, CreatedAtUtc = now.AddDays(-1) }, - new Score { PlayerName = "tunde.dev", Wpm = 99, Accuracy = 97.5, DurationSecs = 30.6, CreatedAtUtc = now.AddDays(-3) }, - new Score { PlayerName = "p_renaud", Wpm = 92, Accuracy = 95.0, DurationSecs = 32.8, CreatedAtUtc = now.AddHours(-30) }, - new Score { PlayerName = "isla.k", Wpm = 86, Accuracy = 98.8, DurationSecs = 35.2, CreatedAtUtc = now.AddDays(-6) }); + new Score { PlayerName = "m.santoro", Wpm = 121, Accuracy = 96.7, DurationSecs = 24.9, CreatedAtUtc = now.AddDays(-5) }, + new Score { PlayerName = "ferra", Wpm = 117, Accuracy = 99.2, DurationSecs = 25.8, CreatedAtUtc = now.AddHours(-9) }, + new Score { PlayerName = "noah_si", Wpm = 104, Accuracy = 94.3, DurationSecs = 29.1, CreatedAtUtc = now.AddDays(-1) }, + new Score { PlayerName = "tunde.dev", Wpm = 99, Accuracy = 97.5, DurationSecs = 30.6, CreatedAtUtc = now.AddDays(-3) }, + new Score { PlayerName = "p_renaud", Wpm = 92, Accuracy = 95.0, DurationSecs = 32.8, CreatedAtUtc = now.AddHours(-30) }, + new Score { PlayerName = "isla.k", Wpm = 86, Accuracy = 98.8, DurationSecs = 35.2, CreatedAtUtc = now.AddDays(-6) }); db.SaveChanges(); } diff --git a/src/Qwertide.Api/Migrations/20260605155310_InitialCreate.cs b/src/Qwertide.Api/Migrations/20260605155310_InitialCreate.cs index 7184041..db5c8f8 100644 --- a/src/Qwertide.Api/Migrations/20260605155310_InitialCreate.cs +++ b/src/Qwertide.Api/Migrations/20260605155310_InitialCreate.cs @@ -1,45 +1,44 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Qwertide.Api.Migrations +namespace Qwertide.Api.Migrations; + +/// +public partial class InitialCreate : Migration { /// - public partial class InitialCreate : Migration + protected override void Up(MigrationBuilder migrationBuilder) { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Scores", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - PlayerName = table.Column(type: "TEXT", maxLength: 30, nullable: false), - Wpm = table.Column(type: "INTEGER", nullable: false), - Accuracy = table.Column(type: "REAL", nullable: false), - DurationSecs = table.Column(type: "REAL", nullable: false), - PassageId = table.Column(type: "INTEGER", nullable: true), - CreatedAtUtc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Scores", x => x.Id); - }); + migrationBuilder.CreateTable( + name: "Scores", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerName = table.Column(type: "TEXT", maxLength: 30, nullable: false), + Wpm = table.Column(type: "INTEGER", nullable: false), + Accuracy = table.Column(type: "REAL", nullable: false), + DurationSecs = table.Column(type: "REAL", nullable: false), + PassageId = table.Column(type: "INTEGER", nullable: true), + CreatedAtUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Scores", x => x.Id); + }); - migrationBuilder.CreateIndex( - name: "IX_Scores_Wpm", - table: "Scores", - column: "Wpm"); - } + migrationBuilder.CreateIndex( + name: "IX_Scores_Wpm", + table: "Scores", + column: "Wpm"); + } - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Scores"); - } + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Scores"); } } diff --git a/src/Qwertide.Api/Program.cs b/src/Qwertide.Api/Program.cs index 3daa238..8689f0c 100644 --- a/src/Qwertide.Api/Program.cs +++ b/src/Qwertide.Api/Program.cs @@ -1,4 +1,6 @@ +using System.Threading.RateLimiting; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Qwertide.Api.Data; @@ -16,9 +18,32 @@ .AllowAnyHeader() .AllowAnyMethod())); +// Throttle score submissions per client IP so the public POST endpoint can't be +// scripted to flood the leaderboard. Partitioning by IP means one abuser is +// limited without locking everyone else out. Client IP comes from the forwarded +// headers processed below, so behind Azure's proxy this is the real caller. +const string SubmitRateLimit = "submit"; +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy(SubmitRateLimit, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + })); +}); + builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("Qwertide"))); +// Liveness/readiness probe at /health that also verifies the database connection, +// so a deploy or platform health check fails fast if the data store is unreachable. +builder.Services.AddHealthChecks() + .AddDbContextCheck(); + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -40,16 +65,34 @@ app.UseSwaggerUI(); } -// Trust the X-Forwarded-* headers Azure App Service's load balancer sets, so -// the app sees the original HTTPS scheme instead of looping on redirect behind -// the TLS-terminating proxy. -var forwardedHeaders = new ForwardedHeadersOptions +// Baseline security response headers on every response (incl. static files): +// stop MIME-sniffing, deny framing (clickjacking), and don't leak the referrer. +app.Use(async (context, next) => { - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, -}; -forwardedHeaders.KnownNetworks.Clear(); -forwardedHeaders.KnownProxies.Clear(); -app.UseForwardedHeaders(forwardedHeaders); + var headers = context.Response.Headers; + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["Referrer-Policy"] = "no-referrer"; + await next(); +}); + +if (app.Environment.IsProduction()) +{ + // Behind Azure App Service the TLS-terminating proxy sets X-Forwarded-*; trust + // them so the app sees the original HTTPS scheme (and real client IP) instead + // of looping on redirect. App Service is the only ingress, so clearing the + // known-proxy list is safe here - but only here, hence the production gate. + var forwardedHeaders = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, + }; + forwardedHeaders.KnownNetworks.Clear(); + forwardedHeaders.KnownProxies.Clear(); + app.UseForwardedHeaders(forwardedHeaders); + + // Tell browsers to stay on HTTPS for this host on future visits. + app.UseHsts(); +} app.UseHttpsRedirection(); @@ -58,10 +101,16 @@ app.UseStaticFiles(); app.UseCors(ClientCors); +app.UseRateLimiter(); app.UseAuthorization(); +app.MapHealthChecks("/health"); app.MapControllers(); -// Any non-API route falls through to the SPA entry point so client-side +// Unmatched API routes must return a real 404 instead of falling through to the +// SPA shell below, which would answer 200 with index.html and surface on the +// client as a confusing JSON parse error. +app.MapFallback("api/{**rest}", () => Results.NotFound()); +// Any other (non-API) route falls through to the SPA entry point so client-side // routing (/play, /results, /leaderboard) works on a full-page load. app.MapFallbackToFile("index.html"); diff --git a/src/Qwertide.Api/Qwertide.Api.csproj b/src/Qwertide.Api/Qwertide.Api.csproj index a352bda..54ec9b5 100644 --- a/src/Qwertide.Api/Qwertide.Api.csproj +++ b/src/Qwertide.Api/Qwertide.Api.csproj @@ -13,6 +13,7 @@ all + diff --git a/src/Qwertide.Client/Qwertide.Client.csproj b/src/Qwertide.Client/Qwertide.Client.csproj index 360b4e9..a15d6e4 100644 --- a/src/Qwertide.Client/Qwertide.Client.csproj +++ b/src/Qwertide.Client/Qwertide.Client.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Qwertide.Client/Services/PassageLibrary.cs b/src/Qwertide.Client/Services/PassageLibrary.cs index 1ee508d..3a7cb61 100644 --- a/src/Qwertide.Client/Services/PassageLibrary.cs +++ b/src/Qwertide.Client/Services/PassageLibrary.cs @@ -52,7 +52,11 @@ public sealed class PassageLibrary public Passage Random(Difficulty difficulty) { var pool = _passages.Where(p => p.Difficulty == difficulty).ToArray(); - if (pool.Length == 0) pool = _passages; + if (pool.Length == 0) + { + pool = _passages; + } + return pool[_rng.Next(pool.Length)]; } diff --git a/src/Qwertide.Client/Services/TypingSession.cs b/src/Qwertide.Client/Services/TypingSession.cs index 6a18f58..31357eb 100644 --- a/src/Qwertide.Client/Services/TypingSession.cs +++ b/src/Qwertide.Client/Services/TypingSession.cs @@ -48,14 +48,22 @@ public void Update(int charsTyped, int correctKeystrokes, int totalKeystrokes, d public static double GrossWpmFor(int charsTyped, double elapsedSeconds) { - if (elapsedSeconds <= 0 || charsTyped <= 0) return 0; + if (elapsedSeconds <= 0 || charsTyped <= 0) + { + return 0; + } + var minutes = elapsedSeconds / 60.0; return (charsTyped / CharsPerWord) / minutes; } public static double AccuracyFor(int correctKeystrokes, int totalKeystrokes) { - if (totalKeystrokes <= 0) return 0; + if (totalKeystrokes <= 0) + { + return 0; + } + return (double)correctKeystrokes / totalKeystrokes * 100.0; } @@ -73,8 +81,15 @@ public static double AccuracyFor(int correctKeystrokes, int totalKeystrokes) /// public static (int Total, int Correct) CountKeystrokes(string previousTyped, string newValue, string target) { - if (newValue.Length <= previousTyped.Length) return (0, 0); - if (!newValue.StartsWith(previousTyped, StringComparison.Ordinal)) return (0, 0); + if (newValue.Length <= previousTyped.Length) + { + return (0, 0); + } + + if (!newValue.StartsWith(previousTyped, StringComparison.Ordinal)) + { + return (0, 0); + } var end = Math.Min(newValue.Length, target.Length); var total = 0; @@ -82,7 +97,10 @@ public static (int Total, int Correct) CountKeystrokes(string previousTyped, str for (var pos = previousTyped.Length; pos < end; pos++) { total++; - if (newValue[pos] == target[pos]) correct++; + if (newValue[pos] == target[pos]) + { + correct++; + } } return (total, correct); }