Skip to content
Open
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
38 changes: 38 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
393 changes: 318 additions & 75 deletions README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/Qwertide.Api/Controllers/ScoresController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Qwertide.Api.Data;
using Qwertide.Api.Models;
Expand Down Expand Up @@ -43,6 +44,7 @@ public async Task<ActionResult<Score>> GetById(int id)
}

[HttpPost]
[EnableRateLimiting("submit")]
public async Task<ActionResult<Score>> Submit([FromBody] ScoreRequest request)
{
var score = new Score
Expand Down
12 changes: 6 additions & 6 deletions src/Qwertide.Api/Data/DbSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
67 changes: 33 additions & 34 deletions src/Qwertide.Api/Migrations/20260605155310_InitialCreate.cs
Original file line number Diff line number Diff line change
@@ -1,45 +1,44 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Qwertide.Api.Migrations
namespace Qwertide.Api.Migrations;

/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
public partial class InitialCreate : Migration
protected override void Up(MigrationBuilder migrationBuilder)
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Scores",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlayerName = table.Column<string>(type: "TEXT", maxLength: 30, nullable: false),
Wpm = table.Column<int>(type: "INTEGER", nullable: false),
Accuracy = table.Column<double>(type: "REAL", nullable: false),
DurationSecs = table.Column<double>(type: "REAL", nullable: false),
PassageId = table.Column<int>(type: "INTEGER", nullable: true),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Scores", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Scores",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlayerName = table.Column<string>(type: "TEXT", maxLength: 30, nullable: false),
Wpm = table.Column<int>(type: "INTEGER", nullable: false),
Accuracy = table.Column<double>(type: "REAL", nullable: false),
DurationSecs = table.Column<double>(type: "REAL", nullable: false),
PassageId = table.Column<int>(type: "INTEGER", nullable: true),
CreatedAtUtc = table.Column<DateTime>(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");
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Scores");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Scores");
}
}
69 changes: 59 additions & 10 deletions src/Qwertide.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Qwertide.Api.Data;

Expand All @@ -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<QwertideDbContext>(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<QwertideDbContext>();

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
Expand All @@ -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();

Expand All @@ -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");

Expand Down
1 change: 1 addition & 0 deletions src/Qwertide.Api/Qwertide.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion src/Qwertide.Client/Qwertide.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.2" PrivateAssets="all" />
<PackageReference Include="MudBlazor" Version="8.0.0" />
<PackageReference Include="MudBlazor" Version="9.5.0" />
</ItemGroup>

</Project>
6 changes: 5 additions & 1 deletion src/Qwertide.Client/Services/PassageLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
}

Expand Down
28 changes: 23 additions & 5 deletions src/Qwertide.Client/Services/TypingSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -73,16 +81,26 @@ public static double AccuracyFor(int correctKeystrokes, int totalKeystrokes)
/// </summary>
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;
var correct = 0;
for (var pos = previousTyped.Length; pos < end; pos++)
{
total++;
if (newValue[pos] == target[pos]) correct++;
if (newValue[pos] == target[pos])
{
correct++;
}
}
return (total, correct);
}
Expand Down
Loading