diff --git a/.env.example b/.env.example index ec682e9..e7344be 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ YOUTUBE_CHANNEL_ID=UC74q2pzSGuoaikYwnavcGMg # Google client secrets belong in .NET user-secrets or the backend host's secret configuration. # See backend/README.md for Google OAuth, database setup, and deployment. # DOTNET_API_URL=http://localhost:5100 + +# Required for direct contact-form submissions: same server-only secret as backend Contact__ProxyKey. +# CONTACT_PROXY_KEY= diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e6c500f..8d80262 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,6 +3,7 @@ version: 2 # updates, when enabled in the repository settings, do not follow this schedule. updates: - package-ecosystem: npm + target-branch: dev directory: / schedule: interval: monthly @@ -38,6 +39,7 @@ updates: update-types: - version-update:semver-major - package-ecosystem: nuget + target-branch: dev # Dependabot expands ProCoderJi.slnx here, covering the API and test projects # together so shared .NET packages can move in one reviewed pull request. directory: /backend @@ -56,6 +58,7 @@ updates: - minor - patch - package-ecosystem: github-actions + target-branch: dev directory: / schedule: interval: monthly diff --git a/.github/scripts/branch-policy.test.mjs b/.github/scripts/branch-policy.test.mjs new file mode 100644 index 0000000..1ed88bf --- /dev/null +++ b/.github/scripts/branch-policy.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { branchPolicy } from "./check-branch-policy.mjs"; + +const repository = "procoderji-com/procoderji"; +const check = (base, head, sourceRepository = repository) => branchPolicy({ base, head, sourceRepository, repository }); + +test("contributors and bots can send changes to dev", () => { + assert.equal(check("dev", "feat/student-experience", "contributor/procoderji"), null); + assert.equal(check("dev", "dependabot/npm_and_yarn/example"), null); +}); + +test("main accepts only the canonical dev branch", () => { + assert.equal(check("main", "dev"), null); + assert.ok(check("main", "feat/student-experience")); + assert.ok(check("main", "dependabot/npm_and_yarn/example")); + assert.ok(check("main", "dev", "contributor/procoderji")); +}); + +test("unknown or absent targets do not silently pass", () => { + assert.ok(check("release/1.0", "dev")); + assert.ok(check(undefined, undefined)); + assert.ok(branchPolicy({ base: "main", head: "dev" })); +}); diff --git a/.github/scripts/check-branch-policy.mjs b/.github/scripts/check-branch-policy.mjs new file mode 100644 index 0000000..e2b468a --- /dev/null +++ b/.github/scripts/check-branch-policy.mjs @@ -0,0 +1,26 @@ +import { pathToFileURL } from "node:url"; + +export function branchPolicy({ base, head, sourceRepository, repository }) { + if (!base || !head || !sourceRepository || !repository) return "Pull-request branch metadata is incomplete."; + if (base === "dev") return null; + if (base === "main" && head === "dev" && sourceRepository === repository) return null; + if (base === "main") { + return "Production PRs must promote this repository's dev branch. Send feature and dependency PRs to dev first."; + } + return "Contributions target dev; production promotions target main."; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const failure = branchPolicy({ + base: process.env.PR_BASE, + head: process.env.PR_HEAD, + sourceRepository: process.env.PR_SOURCE_REPOSITORY, + repository: process.env.TARGET_REPOSITORY, + }); + if (failure) { + console.error(failure); + process.exitCode = 1; + } else { + console.log("Branch policy passed."); + } +} diff --git a/.github/workflows/backend-containers.yml b/.github/workflows/backend-containers.yml index 6433440..c414780 100644 --- a/.github/workflows/backend-containers.yml +++ b/.github/workflows/backend-containers.yml @@ -12,7 +12,7 @@ on: - 'vercel.backend.json' - '.github/workflows/backend-containers.yml' push: - branches: [main] + branches: [dev, main] paths: - 'backend/**' - 'shared/**' diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 0000000..4e01a3d --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,30 @@ +name: Branch policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + +jobs: + branch-policy: + name: Branch policy + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check out proposed policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Test branch policy + run: node --test .github/scripts/branch-policy.test.mjs + + - name: Enforce contribution and promotion targets + env: + PR_BASE: ${{ github.event.pull_request.base.ref }} + PR_HEAD: ${{ github.event.pull_request.head.ref }} + PR_SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + TARGET_REPOSITORY: ${{ github.repository }} + run: node .github/scripts/check-branch-policy.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 536ef43..0489dd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main] + branches: [dev, main] workflow_dispatch: permissions: @@ -11,7 +11,8 @@ permissions: concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Cancel stale PR checks, but never interrupt a push run during a deployment. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: verify: @@ -40,6 +41,9 @@ jobs: - name: Unit tests run: npm test + - name: Deployment gate tests + run: node --test scripts/*.test.mjs + - name: Production build run: npm run build @@ -148,3 +152,43 @@ jobs: ConnectionStrings__Platform: 'Host=localhost;Port=5432;Database=procoderji_test;Username=postgres;Password=postgres' PublicOrigin: http://localhost:3000 run: dotnet run --project backend/src/ProCoderJi.Api --configuration Release --no-build --no-launch-profile -- --migrate + + deploy: + name: Deploy checked branch + permissions: + contents: read + deployments: write + needs: [verify, site-quality, backend] + if: github.event_name == 'push' && github.repository == 'procoderji-com/procoderji' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: + name: ${{ github.ref_name == 'main' && 'production' || 'staging' }} + url: ${{ vars.PUBLIC_ORIGIN }} + concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: false + steps: + - name: Check out checked commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: 24 + + - name: Install deployment CLI + run: npm install --global vercel@65.14.0 + + - name: Deploy backend, verify storage, then deploy frontend + env: + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + VERCEL_FRONTEND_PROJECT_ID: ${{ vars.VERCEL_FRONTEND_PROJECT_ID }} + VERCEL_BACKEND_PROJECT_ID: ${{ vars.VERCEL_BACKEND_PROJECT_ID }} + PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }} + BACKEND_ORIGIN: ${{ vars.BACKEND_ORIGIN }} + VERCEL_FRONTEND_TOKEN: ${{ secrets.VERCEL_FRONTEND_TOKEN }} + VERCEL_BACKEND_TOKEN: ${{ secrets.VERCEL_BACKEND_TOKEN }} + run: node scripts/deploy-vercel.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc5a1a9..a895501 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,13 +53,13 @@ fork as `origin`, and continue from the same checkout. ## Start a branch -Update your local `main` before starting a new change: +Update your local `dev` before starting a new change: ```bash -git switch main git fetch upstream -git merge --ff-only upstream/main -git push origin main +git switch dev +git merge --ff-only upstream/dev +git push origin dev git switch -c feat/describe-the-change ``` @@ -114,17 +114,26 @@ any that could not run; maintainers and CI complete the remaining validation. ## Submit the pull request +Keep the existing yellow/black visual identity while core student features are being +completed. Improve responsiveness, usability and consistency within that design. +A broad website redesign is deferred until the core student journeys are working. + ```bash git add git commit -m "Describe the change" git push -u origin ``` -Open a pull request from your fork branch to `procoderji-com/procoderji:main`. Fill in the +Open a pull request from your fork branch to `procoderji-com/procoderji:dev`. Fill in the template, link a related issue when one exists, explain the resulting behavior, and list the checks you ran. Include screenshots for visible UI changes and sources or permissions for learning content. +`dev` is the integration/staging branch; `main` is production. Feature and dependency changes +go through `dev`. Maintainers promote the canonical `dev` branch to `main` after staging checks; +contributors do not send feature branches directly to `main`. See the +[branch and deployment workflow](docs/branching-and-releases.md). + Respond to review feedback and keep the branch up to date when needed. Maintainers review student value, correctness, accessibility, and maintenance cost before merging. A proposed feature may need a smaller scope or further discussion. diff --git a/README.md b/README.md index 8e1dee3..a96667b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,17 @@ A student learning workspace with original practice questions, explanations, and > [!NOTE] > This is an early student-product foundation. Four practice topics contain 24 original multiple-choice questions. Practice and browser-saved progress work without login. An optional ASP.NET Core backend provides Google sign-in, account progress, and private portfolio drafts. The portfolio editor, two themes, Markdown import, and public-page support are implemented; public publishing is off by default pending deployment configuration. Billing and checkout are not enabled. Course notes, guides, and job listings are awaiting published content. -![Guest learning workspace before the first session](docs/images/learning-workspace.png) +![ProCoderJii homepage with the current yellow brand and shared navigation](docs/images/homepage-desktop.png) + +
+Learning workspace and mobile previews + +![Guest learning workspace with the shared navigation and dashboard sidebar](docs/images/learning-workspace.png) + +ProCoderJii homepage at 360px +Shared mobile navigation at 360px + +
See the [product roadmap](docs/product-roadmap.md) for student validation, content growth, and future monetization work. diff --git a/backend/README.md b/backend/README.md index be7c78b..d9d1381 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,6 +4,10 @@ ASP.NET Core 10 provides Google sign-in, server-managed cookie sessions, durable and portfolio drafts/publication. Next.js forwards `/api/platform/*` through its server-only BFF. Guest practice and the in-memory portfolio preview work without backend credentials. +The anonymous [contact form](../docs/contact-form.md) saves private messages after server-side +Cloudflare Turnstile verification. It requires separate CAPTCHA and proxy credentials; Google +sign-in is not required. Apply its migration before enabling submission. + ## Run locally Install a supported .NET 10 SDK and keep its runtime on the latest servicing patch. From the repository root: diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs index 50e41b7..005a6a8 100644 --- a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs +++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs @@ -4,6 +4,7 @@ using ProCoderJi.Api.Features.Practice; using ProCoderJi.Api.Features.Progress; using ProCoderJi.Api.Features.Portfolio; +using ProCoderJi.Api.Features.Contact; namespace ProCoderJi.Api.Data.Mongo; @@ -16,10 +17,16 @@ public sealed class MongoPlatformDatabase(IMongoDatabase database) public IMongoCollection Users => Database.GetCollection("users"); public IMongoCollection Progress => Database.GetCollection("progress"); public IMongoCollection Keys => Database.GetCollection("dataProtectionKeys"); + public IMongoCollection ContactMessages => Database.GetCollection("contactMessages"); /// MongoDB has no EF migrations; provisioning creates the identity and username boundaries. public async Task InitializeAsync(CancellationToken cancellationToken = default) { + await ContactMessages.Indexes.CreateManyAsync([ + new CreateIndexModel(Builders.IndexKeys.Ascending(message => message.CaptchaTokenHash), + new CreateIndexOptions { Unique = true, Name = "IX_contactMessages_CaptchaTokenHash" }), + new CreateIndexModel(Builders.IndexKeys.Ascending(message => message.CreatedAtUtc)) + ], cancellationToken: cancellationToken); await Users.Indexes.CreateManyAsync([ new CreateIndexModel(Builders.IndexKeys.Ascending(user => user.GoogleSubject), new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }), diff --git a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs index eeb0834..edc110d 100644 --- a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs +++ b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ProCoderJi.Api.Features.Portfolio; +using ProCoderJi.Api.Features.Contact; namespace ProCoderJi.Api.Data; @@ -10,10 +11,22 @@ public sealed class PlatformDbContext(DbContextOptions option public DbSet ProgressStates => Set(); public DbSet Attempts => Set(); public DbSet Portfolios => Set(); + public DbSet ContactMessages => Set(); public DbSet DataProtectionKeys { get; set; } = null!; protected override void OnModelCreating(ModelBuilder model) { + model.Entity(entity => + { + entity.HasKey(message => message.Id); + entity.Property(message => message.Name).HasMaxLength(100); + entity.Property(message => message.Email).HasMaxLength(254); + entity.Property(message => message.Subject).HasMaxLength(160); + entity.Property(message => message.Message).HasMaxLength(4000); + entity.Property(message => message.CaptchaTokenHash).HasMaxLength(64); + entity.HasIndex(message => message.CaptchaTokenHash).IsUnique(); + entity.HasIndex(message => message.CreatedAtUtc); + }); model.Entity(entity => { entity.HasKey(user => user.Id); diff --git a/backend/src/ProCoderJi.Api/Features/Contact/ContactConfiguration.cs b/backend/src/ProCoderJi.Api/Features/Contact/ContactConfiguration.cs new file mode 100644 index 0000000..a9bf22e --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Contact/ContactConfiguration.cs @@ -0,0 +1,51 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading.RateLimiting; +using ProCoderJi.Api.Configuration; + +namespace ProCoderJi.Api.Features.Contact; + +public sealed class ContactConfiguration(IConfiguration configuration, PlatformConfiguration platform) +{ + public string SiteKey => configuration["Contact:TurnstileSiteKey"]?.Trim() ?? ""; + public string SecretKey => configuration["Contact:TurnstileSecretKey"]?.Trim() ?? ""; + private string ProxyKey => configuration["Contact:ProxyKey"] ?? ""; + public string Hostname => platform.PublicOrigin.Host; + public bool Configured => SiteKey.Length > 0 && SecretKey.Length > 0 && ProxyKey.Length >= 32 && + !System.Text.RegularExpressions.Regex.IsMatch(SiteKey, "^[123]x0{20}[A-Z]{2}$") && + !System.Text.RegularExpressions.Regex.IsMatch(SecretKey, "^[123]x0{31}AA$"); + + // Only the trusted BFF may supply a client address. Browser headers never select a limiter partition. + public bool IsTrusted(HttpContext context) + { + var supplied = context.Request.Headers["X-Contact-Proxy-Key"].ToString(); + return Configured && supplied.Length == ProxyKey.Length && CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(supplied), Encoding.UTF8.GetBytes(ProxyKey)); + } + + public string? ClientIp(HttpContext context) => IsTrusted(context) && + IPAddress.TryParse(context.Request.Headers["X-Contact-Client-IP"].ToString(), out var address) + ? address.ToString() : null; +} + +public static class ContactRegistration +{ + public const string RatePolicy = "contact-submit"; + public static IServiceCollection AddContactApi(this IServiceCollection services) + { + services.AddSingleton(); + services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(8)); + services.AddRateLimiter(options => options.AddPolicy(RatePolicy, context => + { + var ip = context.RequestServices.GetRequiredService().ClientIp(context); + return ip is null ? RateLimitPartition.GetNoLimiter("untrusted-contact") : + RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, + AutoReplenishment = true + }); + })); + return services; + } +} diff --git a/backend/src/ProCoderJi.Api/Features/Contact/ContactEndpoints.cs b/backend/src/ProCoderJi.Api/Features/Contact/ContactEndpoints.cs new file mode 100644 index 0000000..b87de07 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Contact/ContactEndpoints.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ProCoderJi.Api.Configuration; + +namespace ProCoderJi.Api.Features.Contact; + +public static class ContactEndpoints +{ + public const int BodyLimit = 32 * 1024; + public static void MapContactEndpoints(this WebApplication app) + { + app.MapGet("/api/platform/contact/config", (ContactConfiguration configuration) => + Results.Json(new { configured = configuration.Configured, siteKey = configuration.Configured ? configuration.SiteKey : null })); + app.MapPost("/api/platform/contact", Submit).RequireRateLimiting(ContactRegistration.RatePolicy); + } + + private static async Task Submit(HttpContext context, ContactConfiguration configuration, + PlatformConfiguration platform, ITurnstileVerifier captcha, IContactStore store) + { + if (!configuration.Configured) return Error("Message submission is temporarily unavailable. Please try later.", 503); + if (!configuration.IsTrusted(context) || context.Request.Headers.Origin != platform.PublicOrigin.GetLeftPart(UriPartial.Authority)) + return Error("Submit your message from this website.", 403); + var ip = configuration.ClientIp(context); + if (ip is null) return Error("Your connection could not be verified. Please try again.", 400); + if (!context.Request.HasJsonContentType()) return Error("Send a JSON message.", 415); + if (context.Request.ContentLength > BodyLimit) return Error("Your message is too large.", 413); + ContactRequest? request; + try + { + using var body = new MemoryStream(); + var buffer = new byte[4096]; + int read; + while ((read = await context.Request.Body.ReadAsync(buffer, context.RequestAborted)) > 0) + { + if (body.Length + read > BodyLimit) return Error("Your message is too large.", 413); + body.Write(buffer, 0, read); + } + request = JsonSerializer.Deserialize(body.ToArray(), new JsonSerializerOptions(JsonSerializerDefaults.Web) + { UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow }); + } + catch (JsonException) { return Error("Check your message and try again.", 400); } + catch (BadHttpRequestException) { return Error("Your message could not be read.", 400); } + if (request is null || !Valid(request.Name, 100) || !Valid(request.Email, 254) || + !new EmailAddressAttribute().IsValid(request.Email!.Trim()) || !Valid(request.Subject, 160) || + !Valid(request.Message, 4000) || !Valid(request.CaptchaToken, 2048) || !string.IsNullOrEmpty(request.Website)) + return Error("Check the required fields and complete the security check.", 400); + + var token = request.CaptchaToken!.Trim(); + var verified = await captcha.Verify(token, ip, context.RequestAborted); + if (verified == CaptchaResult.Unavailable) return Error("The security check is temporarily unavailable. Please try again.", 503); + if (verified != CaptchaResult.Valid) return Error("The security check expired or failed. Please try again.", 400); + var saved = await store.Save(new ContactMessage + { + Id = Guid.NewGuid(), Name = request.Name!.Trim(), Email = request.Email!.Trim(), + Subject = request.Subject!.Trim(), Message = request.Message!.Trim(), + CreatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + CaptchaTokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))) + }, context.RequestAborted); + return saved ? Results.Json(new { message = "Your message has been received. Our team will review it." }, statusCode: 201) + : Error("This security check was already used. Please complete a new one.", 400); + } + + private static bool Valid(string? value, int max) => !string.IsNullOrWhiteSpace(value) && value.Length <= max && + !value.Any(character => char.IsControl(character) && character is not ('\r' or '\n' or '\t')); + private static IResult Error(string message, int status) => Results.Json(new { error = message }, statusCode: status); + private sealed record ContactRequest(string? Name, string? Email, string? Subject, string? Message, string? CaptchaToken, string? Website); +} diff --git a/backend/src/ProCoderJi.Api/Features/Contact/ContactStore.cs b/backend/src/ProCoderJi.Api/Features/Contact/ContactStore.cs new file mode 100644 index 0000000..9c022b2 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Contact/ContactStore.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Data.Mongo; + +namespace ProCoderJi.Api.Features.Contact; + +public sealed class ContactMessage +{ + [BsonRepresentation(MongoDB.Bson.BsonType.String)] + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Email { get; set; } + public required string Subject { get; set; } + public required string Message { get; set; } + public required string CaptchaTokenHash { get; set; } + public long CreatedAtUtc { get; set; } +} + +public interface IContactStore +{ + /// False if this CAPTCHA token has already been used. + Task Save(ContactMessage message, CancellationToken cancellationToken); +} + +public sealed class RelationalContactStore(PlatformDbContext database) : IContactStore +{ + public async Task Save(ContactMessage message, CancellationToken cancellationToken) + { + database.ContactMessages.Add(message); + try { await database.SaveChangesAsync(cancellationToken); return true; } + catch (DbUpdateException) + { + database.Entry(message).State = EntityState.Detached; + if (await database.ContactMessages.AnyAsync(row => row.CaptchaTokenHash == message.CaptchaTokenHash, cancellationToken)) return false; + throw; + } + } +} + +public sealed class MongoContactStore(MongoPlatformDatabase database) : IContactStore +{ + public async Task Save(ContactMessage message, CancellationToken cancellationToken) + { + try { await database.ContactMessages.InsertOneAsync(message, cancellationToken: cancellationToken); return true; } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { return false; } + } +} diff --git a/backend/src/ProCoderJi.Api/Features/Contact/TurnstileVerifier.cs b/backend/src/ProCoderJi.Api/Features/Contact/TurnstileVerifier.cs new file mode 100644 index 0000000..d53f583 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Contact/TurnstileVerifier.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace ProCoderJi.Api.Features.Contact; + +public enum CaptchaResult { Valid, Invalid, Unavailable } +public interface ITurnstileVerifier +{ + Task Verify(string token, string ip, CancellationToken cancellationToken); +} + +public sealed class TurnstileVerifier(HttpClient client, ContactConfiguration configuration) : ITurnstileVerifier +{ + public async Task Verify(string token, string ip, CancellationToken cancellationToken) + { + try + { + using var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", + new FormUrlEncodedContent(new Dictionary + { + ["secret"] = configuration.SecretKey, ["response"] = token, ["remoteip"] = ip + }), cancellationToken); + if (!response.IsSuccessStatusCode) return CaptchaResult.Unavailable; + var result = await response.Content.ReadFromJsonAsync(cancellationToken); + return result is { Success: true, Action: "contact" } && + string.Equals(result.Hostname, configuration.Hostname, StringComparison.OrdinalIgnoreCase) + ? CaptchaResult.Valid : CaptchaResult.Invalid; + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException) + { + // Never log the request body, secret, token, or visitor's message. + return CaptchaResult.Unavailable; + } + } + + private sealed record Verification( + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("hostname")] string? Hostname, + [property: JsonPropertyName("action")] string? Action); +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioApiConfiguration.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioApiConfiguration.cs index 96e3bf4..2095bb5 100644 --- a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioApiConfiguration.cs +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioApiConfiguration.cs @@ -25,7 +25,9 @@ public static IServiceCollection AddPortfolioApi(this IServiceCollection service await context.HttpContext.Response.WriteAsJsonAsync(new { code = "rate-limited", - error = "Too many portfolio requests. Wait before trying again." + error = context.HttpContext.Request.Path.StartsWithSegments("/api/platform/contact") + ? "Too many messages. Wait a minute before trying again." + : "Too many portfolio requests. Wait before trying again." }, cancellationToken); }; }); diff --git a/backend/src/ProCoderJi.Api/Migrations/20261113130000_ContactMessages.cs b/backend/src/ProCoderJi.Api/Migrations/20261113130000_ContactMessages.cs new file mode 100644 index 0000000..a305a8b --- /dev/null +++ b/backend/src/ProCoderJi.Api/Migrations/20261113130000_ContactMessages.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using ProCoderJi.Api.Data; + +namespace ProCoderJi.Api.Migrations; + +[DbContext(typeof(PlatformDbContext))] +[Migration("20261113130000_ContactMessages")] +public sealed class ContactMessagesMigration : Migration +{ + protected override void Up(MigrationBuilder migration) + { + migration.CreateTable("ContactMessages", columns: table => new + { + Id = table.Column(nullable: false), + Name = table.Column(maxLength: 100, nullable: false), + Email = table.Column(maxLength: 254, nullable: false), + Subject = table.Column(maxLength: 160, nullable: false), + Message = table.Column(maxLength: 4000, nullable: false), + CaptchaTokenHash = table.Column(maxLength: 64, nullable: false), + CreatedAtUtc = table.Column(nullable: false) + }, constraints: table => table.PrimaryKey("PK_ContactMessages", message => message.Id)); + migration.CreateIndex("IX_ContactMessages_CaptchaTokenHash", "ContactMessages", "CaptchaTokenHash", unique: true); + migration.CreateIndex("IX_ContactMessages_CreatedAtUtc", "ContactMessages", "CreatedAtUtc"); + } + protected override void Down(MigrationBuilder migration) => migration.DropTable("ContactMessages"); +} diff --git a/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs index 205d535..878a4b6 100644 --- a/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs +++ b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs @@ -13,6 +13,20 @@ protected override void BuildModel(ModelBuilder model) { model.HasAnnotation("ProductVersion", "10.0.14"); model.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + model.Entity("ProCoderJi.Api.Features.Contact.ContactMessage", entity => + { + entity.Property("Id").ValueGeneratedOnAdd(); + entity.Property("Name").IsRequired().HasMaxLength(100); + entity.Property("Email").IsRequired().HasMaxLength(254); + entity.Property("Subject").IsRequired().HasMaxLength(160); + entity.Property("Message").IsRequired().HasMaxLength(4000); + entity.Property("CaptchaTokenHash").IsRequired().HasMaxLength(64); + entity.Property("CreatedAtUtc"); + entity.HasKey("Id"); + entity.HasIndex("CaptchaTokenHash").IsUnique(); + entity.HasIndex("CreatedAtUtc"); + entity.ToTable("ContactMessages"); + }); model.Entity("ProCoderJi.Api.Data.LearnerUser", entity => { entity.Property("Id").ValueGeneratedOnAdd(); diff --git a/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs index 2d75f0e..267f623 100644 --- a/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs +++ b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs @@ -14,6 +14,20 @@ protected override void BuildModel(ModelBuilder model) { model.HasAnnotation("ProductVersion", "10.0.14"); model.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + model.Entity("ProCoderJi.Api.Features.Contact.ContactMessage", entity => + { + entity.Property("Id").ValueGeneratedOnAdd(); + entity.Property("Name").IsRequired().HasMaxLength(100); + entity.Property("Email").IsRequired().HasMaxLength(254); + entity.Property("Subject").IsRequired().HasMaxLength(160); + entity.Property("Message").IsRequired().HasMaxLength(4000); + entity.Property("CaptchaTokenHash").IsRequired().HasMaxLength(64); + entity.Property("CreatedAtUtc"); + entity.HasKey("Id"); + entity.HasIndex("CaptchaTokenHash").IsUnique(); + entity.HasIndex("CreatedAtUtc"); + entity.ToTable("ContactMessages"); + }); model.Entity("ProCoderJi.Api.Data.LearnerUser", entity => { entity.Property("Id").ValueGeneratedOnAdd(); diff --git a/backend/src/ProCoderJi.Api/Program.cs b/backend/src/ProCoderJi.Api/Program.cs index ff67354..f5a5b23 100644 --- a/backend/src/ProCoderJi.Api/Program.cs +++ b/backend/src/ProCoderJi.Api/Program.cs @@ -12,6 +12,7 @@ using ProCoderJi.Api.Features.Practice; using ProCoderJi.Api.Features.Progress; using ProCoderJi.Api.Features.Portfolio; +using ProCoderJi.Api.Features.Contact; var operatorCommand = PortfolioModerationCommand.IsRequested(args); var builder = WebApplication.CreateBuilder(operatorCommand ? [] : args); @@ -33,6 +34,10 @@ builder.Services.AddScoped(services => IsMongo(services.GetRequiredService()) ? services.GetRequiredService() : services.GetRequiredService()); builder.Services.AddPortfolioApi(); +builder.Services.AddContactApi(); +builder.Services.AddScoped(services => IsMongo(services.GetRequiredService()) + ? new MongoContactStore(services.GetRequiredService()) + : new RelationalContactStore(services.GetRequiredService())); builder.Services.AddDataProtection().SetApplicationName("ProCoderJi.Platform"); builder.Services.AddOptions().Configure((options, services) => options.XmlRepository = IsMongo(services.GetRequiredService()) @@ -121,7 +126,9 @@ app.UseExceptionHandler(handler => handler.Run(async context => { context.Response.StatusCode = 503; - await context.Response.WriteAsJsonAsync(new { error = "The learning account service is temporarily unavailable. Your browser practice remains available." }); + await context.Response.WriteAsJsonAsync(new { error = context.Request.Path.StartsWithSegments("/api/platform/contact") + ? "Your message could not be saved right now. Please try again later." + : "The learning account service is temporarily unavailable. Your browser practice remains available." }); })); app.Use(async (context, next) => { @@ -140,6 +147,7 @@ app.MapAccountEndpoints(); app.MapProgressEndpoints(); app.MapPortfolioEndpoints(); +app.MapContactEndpoints(); app.MapGet("/api/platform/health", () => Results.Json(new { status = "ok" })); app.Run(); diff --git a/backend/tests/ProCoderJi.Api.Tests/ContactIntegrationTests.cs b/backend/tests/ProCoderJi.Api.Tests/ContactIntegrationTests.cs new file mode 100644 index 0000000..74b940d --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/ContactIntegrationTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Data.Mongo; +using ProCoderJi.Api.Features.Contact; + +namespace ProCoderJi.Api.Tests; + +public sealed class ContactIntegrationTests +{ + internal const string TestProxyKey = "test-only-internal-contact-proxy-key-123456789"; + internal static Dictionary Settings => new() + { + ["Contact:TurnstileSiteKey"] = "test-site-key", ["Contact:TurnstileSecretKey"] = "test-secret-key", + ["Contact:ProxyKey"] = TestProxyKey + }; + + [Fact] + public async Task Accepted_message_is_durable_private_and_captcha_replay_is_rejected() + { + using var factory = new PlatformFactory(overrides: Settings); + using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + services.AddSingleton(new FakeCaptcha(CaptchaResult.Valid)))); + using var client = app.CreateClient(); + var config = await client.GetStringAsync("/api/platform/contact/config"); + Assert.Contains("test-site-key", config); + Assert.DoesNotContain("test-secret-key", config); + Assert.DoesNotContain(TestProxyKey, config); + Trust(client); + var concurrent = await Task.WhenAll(client.PostAsJsonAsync("/api/platform/contact", Payload()), + client.PostAsJsonAsync("/api/platform/contact", Payload())); + Assert.Single(concurrent, response => response.StatusCode == HttpStatusCode.Created); + Assert.Single(concurrent, response => response.StatusCode == HttpStatusCode.BadRequest); + // A fresh request scope proves that this was persisted rather than kept in endpoint memory. + var rows = await Messages(app.Services); + var saved = Assert.Single(rows); + Assert.Equal("Student", saved.Name); + Assert.Equal("student@example.test", saved.Email); + Assert.Equal("Please add notes", saved.Message); + Assert.Equal(64, saved.CaptchaTokenHash.Length); + Assert.DoesNotContain("captcha-token", saved.CaptchaTokenHash); + Assert.True(saved.CreatedAtUtc > 0); + // Even a successful provider response on a reused token cannot create another row. + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/platform/contact", Payload())).StatusCode); + var spacedToken = Payload(); spacedToken["captchaToken"] = " captcha-token "; + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/platform/contact", spacedToken)).StatusCode); + Assert.Single(await Messages(app.Services)); + Assert.Equal(HttpStatusCode.MethodNotAllowed, (await client.GetAsync("/api/platform/contact")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/platform/contact/messages")).StatusCode); + } + + [Theory] + [InlineData(CaptchaResult.Invalid, HttpStatusCode.BadRequest)] + [InlineData(CaptchaResult.Unavailable, HttpStatusCode.ServiceUnavailable)] + public async Task Failed_security_check_never_saves(CaptchaResult result, HttpStatusCode status) + { + using var factory = new PlatformFactory(overrides: Settings); + using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + services.AddSingleton(new FakeCaptcha(result)))); + using var client = app.CreateClient(); Trust(client); + Assert.Equal(status, (await client.PostAsJsonAsync("/api/platform/contact", Payload())).StatusCode); + Assert.Empty(await Messages(app.Services)); + } + + [Fact] + public async Task Origin_proxy_validation_bounds_honeypot_and_rate_limit_are_enforced_before_captcha() + { + using var factory = new PlatformFactory(overrides: Settings); + var captcha = new FakeCaptcha(CaptchaResult.Valid); + using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + services.AddSingleton(captcha))); + using var client = app.CreateClient(); + Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsJsonAsync("/api/platform/contact", Payload())).StatusCode); + Trust(client); + client.DefaultRequestHeaders.Remove("Origin"); client.DefaultRequestHeaders.Add("Origin", "https://other.example"); + Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsJsonAsync("/api/platform/contact", Payload())).StatusCode); + client.DefaultRequestHeaders.Remove("Origin"); client.DefaultRequestHeaders.Add("Origin", "http://localhost:3000"); + var invalid = Payload(); invalid["name"] = new string('x', 101); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/platform/contact", invalid)).StatusCode); + invalid = Payload(); invalid["website"] = "https://spam.example"; + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/platform/contact", invalid)).StatusCode); + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.PostAsync("/api/platform/contact", + new StringContent(new string('x', 33000), System.Text.Encoding.UTF8, "application/json"))).StatusCode); + invalid = Payload(); invalid["email"] = "invalid"; + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/api/platform/contact", invalid)).StatusCode); + var limited = await client.PostAsJsonAsync("/api/platform/contact", Payload()); + Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode); + Assert.NotNull(limited.Headers.RetryAfter); + Assert.Equal(0, captcha.Calls); + Assert.Empty(await Messages(app.Services)); + } + + [Fact] + public async Task Missing_configuration_fails_closed() + { + using var app = new PlatformFactory(overrides: new Dictionary { ["Contact:ProxyKey"] = "" }); + using var client = app.Browser(); + Assert.Contains("\"configured\":false", await client.GetStringAsync("/api/platform/contact/config")); + Assert.Equal(HttpStatusCode.ServiceUnavailable, (await client.PostAsJsonAsync("/api/platform/contact", Payload())).StatusCode); + } + + [Fact] + public async Task Storage_failure_never_reports_success_or_echoes_private_content() + { + using var factory = new PlatformFactory(overrides: Settings); + using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + { + services.AddSingleton(new FakeCaptcha(CaptchaResult.Valid)); + services.AddSingleton(); + })); + using var client = app.CreateClient(); Trust(client); + var response = await client.PostAsJsonAsync("/api/platform/contact", Payload()); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + var text = await response.Content.ReadAsStringAsync(); + Assert.Contains("could not be saved", text); + Assert.DoesNotContain("student@example.test", text); + Assert.Empty(await Messages(app.Services)); + } + + private static Dictionary Payload() => new() + { + ["name"] = " Student ", ["email"] = "student@example.test", ["subject"] = "Notes request", + ["message"] = "Please add notes", ["captchaToken"] = "captcha-token", ["website"] = "" + }; + private static void Trust(HttpClient client) + { + client.DefaultRequestHeaders.Add("Origin", "http://localhost:3000"); + client.DefaultRequestHeaders.Add("X-Contact-Proxy-Key", TestProxyKey); + client.DefaultRequestHeaders.Add("X-Contact-Client-IP", "192.0.2.1"); + } + private static async Task> Messages(IServiceProvider services) + { + using var scope = services.CreateScope(); + if (scope.ServiceProvider.GetRequiredService()["Database:Provider"] == "MongoDb") + return await scope.ServiceProvider.GetRequiredService().ContactMessages.Find(_ => true).ToListAsync(); + var database = scope.ServiceProvider.GetRequiredService(); + if (database.Database.IsNpgsql()) + { + var connection = database.Database.GetDbConnection(); + var searchPath = new Npgsql.NpgsqlConnectionStringBuilder(connection.ConnectionString).SearchPath; + Assert.StartsWith("procoderji_test_", searchPath); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); command.CommandText = "SELECT current_schema()"; + Assert.Equal(searchPath, await command.ExecuteScalarAsync()); + } + return await database.ContactMessages.AsNoTracking().ToListAsync(); + } + private sealed class FakeCaptcha(CaptchaResult result) : ITurnstileVerifier + { + public int Calls { get; private set; } + public Task Verify(string token, string ip, CancellationToken cancellationToken) + { Calls++; return Task.FromResult(result); } + } + private sealed class UnavailableStore : IContactStore + { + public Task Save(ContactMessage message, CancellationToken cancellationToken) => + throw new InvalidOperationException("Test-only unavailable store"); + } +} diff --git a/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs index ad16719..1bc7187 100644 --- a/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs +++ b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs @@ -42,7 +42,19 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) admin.Open(); using var command = new NpgsqlCommand($"CREATE SCHEMA \"{schema}\"", admin); command.ExecuteNonQuery(); - connection = new NpgsqlConnectionStringBuilder(postgresConnection) { SearchPath = schema }.ConnectionString; + connection = new NpgsqlConnectionStringBuilder(postgresConnection) + { + SearchPath = schema, + Options = $"-c search_path={schema}", + Pooling = false + }.ConnectionString; + // Verify isolation before migrations or fixtures can write. Some hosted proxies + // ignore the startup search_path parameter; the options form must also be honored. + using var isolated = new NpgsqlConnection(connection); + isolated.Open(); + using var currentSchema = new NpgsqlCommand("SELECT current_schema()", isolated); + if (!string.Equals(currentSchema.ExecuteScalar() as string, schema, StringComparison.Ordinal)) + throw new InvalidOperationException("The test connection did not honor its isolated schema. No fixtures were written."); } else if (!string.IsNullOrWhiteSpace(sqlServerConnection)) { @@ -69,6 +81,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["Database:Name"] = mongoDatabase, ["Authentication:Google:ClientId"] = googleConfigured ? "test-client.apps.googleusercontent.com" : "", ["Authentication:Google:ClientSecret"] = googleConfigured ? "test-only-not-a-real-secret" : "", + ["Contact:TurnstileSiteKey"] = "", + ["Contact:TurnstileSecretKey"] = "", + ["Contact:ProxyKey"] = "", }; if (overrides is not null) foreach (var (key, value) in overrides) settings[key] = value; diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs index 211f973..feba812 100644 --- a/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs @@ -86,13 +86,17 @@ public async Task Upgrading_an_existing_account_database_keeps_its_records() // This disposable database remains scoped to this test's factory throughout. var migrations = db.GetService(); var portfolioMigration = migrations.Migrations.Single(item => item.Key.EndsWith("_StudentPortfolios", StringComparison.Ordinal)); - var migration = migrations.CreateMigration(portfolioMigration.Value, db.Database.ProviderName!); - foreach (var command in db.GetService().Generate(migration.DownOperations)) - await db.Database.ExecuteSqlRawAsync(command.CommandText); - // Later portfolio migrations add metadata/indexes to the same table. Its removal - // restores the original account-only schema; remove their history together. - foreach (var applied in migrations.Migrations.Keys.Where(key => string.CompareOrdinal(key, portfolioMigration.Key) >= 0)) + foreach (var applied in migrations.Migrations.Keys.Where(key => string.CompareOrdinal(key, portfolioMigration.Key) >= 0).OrderDescending()) + { + var migration = migrations.CreateMigration(migrations.Migrations[applied], db.Database.ProviderName!); + // Drop feature tables as a whole. Reversing intermediate column changes needs + // historical target models on SQLite, and is unnecessary for this baseline test. + var drops = migration.DownOperations.OfType() + .Cast().ToArray(); + foreach (var command in db.GetService().Generate(drops)) + await db.Database.ExecuteSqlRawAsync(command.CommandText); await db.Database.ExecuteSqlRawAsync(db.GetService().GetDeleteScript(applied)); + } var userId = Guid.NewGuid(); var generation = Guid.NewGuid(); db.Users.Add(new LearnerUser { Id = userId, GoogleSubject = "existing-subject", Email = "existing@example.test", Name = "Existing", CreatedAtUtc = 1 }); diff --git a/backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs b/backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs index 7794816..838ce0a 100644 --- a/backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs +++ b/backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs @@ -25,6 +25,8 @@ public void SqlServerMigrationUsesIdentityAndSqlServerLengthConstraints() using var db = Context("SqlServer", "Server=localhost;Database=script_only;User Id=sa;Password=unused"); var sql = db.GetService().GenerateScript(); Assert.Contains("CREATE TABLE [Users]", sql); + Assert.Contains("CREATE TABLE [ContactMessages]", sql); + Assert.Contains("CREATE UNIQUE INDEX [IX_ContactMessages_CaptchaTokenHash]", sql); Assert.Contains("IDENTITY", sql); Assert.Contains("DATALENGTH([QuestionIdsJson]) <= 8192", sql); Assert.Contains("DATALENGTH([ResponsesJson]) <= 16384", sql); diff --git a/backend/tests/ProCoderJi.Api.Tests/TurnstileVerifierTests.cs b/backend/tests/ProCoderJi.Api.Tests/TurnstileVerifierTests.cs new file mode 100644 index 0000000..4daa4f9 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/TurnstileVerifierTests.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.Configuration; +using ProCoderJi.Api.Configuration; +using ProCoderJi.Api.Features.Contact; + +namespace ProCoderJi.Api.Tests; + +public sealed class TurnstileVerifierTests +{ + [Theory] + [InlineData(true, "localhost", "contact", CaptchaResult.Valid)] + [InlineData(false, "localhost", "contact", CaptchaResult.Invalid)] + [InlineData(true, "evil.example", "contact", CaptchaResult.Invalid)] + [InlineData(true, "localhost", "signin", CaptchaResult.Invalid)] + [InlineData(true, "localhost", null, CaptchaResult.Invalid)] + public async Task Checks_provider_success_hostname_and_action(bool success, string hostname, string? action, CaptchaResult expected) + { + using var handler = new ProviderHandler(new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new { success, hostname, action }) }); + using var client = new HttpClient(handler); + var verifier = new TurnstileVerifier(client, Configuration()); + Assert.Equal(expected, await verifier.Verify("visitor-token", "192.0.2.1", default)); + Assert.Equal("https://challenges.cloudflare.com/turnstile/v0/siteverify", handler.Destination); + Assert.Contains("response=visitor-token", handler.Body); + Assert.Contains("remoteip=192.0.2.1", handler.Body); + Assert.Contains("secret=test-secret-key", handler.Body); + } + + [Fact] + public async Task Malformed_or_unavailable_provider_fails_closed() + { + foreach (var response in new[] { new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("not json") } }) + { + using var handler = new ProviderHandler(response); + using var client = new HttpClient(handler); + Assert.Equal(CaptchaResult.Unavailable, await new TurnstileVerifier(client, Configuration()).Verify("token", "192.0.2.1", default)); + } + } + + [Theory] + [InlineData("1x00000000000000000000AA", "test-secret-key")] + [InlineData("test-site-key", "1x0000000000000000000000000000000AA")] + public void Official_dummy_keys_cannot_enable_public_submission(string site, string secret) + { + var values = ContactIntegrationTests.Settings; + values["Contact:TurnstileSiteKey"] = site; values["Contact:TurnstileSecretKey"] = secret; + Assert.False(new ContactConfiguration(new ConfigurationBuilder().AddInMemoryCollection(values).Build(), + new PlatformConfiguration(new Uri("https://procoderji.com"), false)).Configured); + } + + private static ContactConfiguration Configuration() => new(new ConfigurationBuilder() + .AddInMemoryCollection(ContactIntegrationTests.Settings).Build(), new PlatformConfiguration(new Uri("http://localhost:3000"), false)); + private sealed class ProviderHandler(HttpResponseMessage response) : HttpMessageHandler + { + public string? Destination { get; private set; } + public string? Body { get; private set; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Destination = request.RequestUri?.AbsoluteUri; + Body = await request.Content!.ReadAsStringAsync(cancellationToken); + return response; + } + } +} diff --git a/backend/tests/ProCoderJi.BrowserHost/BrowserCaptchaVerifier.cs b/backend/tests/ProCoderJi.BrowserHost/BrowserCaptchaVerifier.cs new file mode 100644 index 0000000..5090473 --- /dev/null +++ b/backend/tests/ProCoderJi.BrowserHost/BrowserCaptchaVerifier.cs @@ -0,0 +1,11 @@ +using ProCoderJi.Api.Features.Contact; + +namespace ProCoderJi.BrowserHost; + +// Only the loopback test executable registers this verifier. It is never published with the API. +internal sealed class BrowserCaptchaVerifier : ITurnstileVerifier +{ + public Task Verify(string token, string ip, CancellationToken cancellationToken) => + Task.FromResult(token.StartsWith("browser-fixture-valid-", StringComparison.Ordinal) + ? CaptchaResult.Valid : CaptchaResult.Invalid); +} diff --git a/backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs b/backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs index ce76ef0..7f124b2 100644 --- a/backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs +++ b/backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Options; using ProCoderJi.Api.Data; using ProCoderJi.Api.Features.Portfolio; +using ProCoderJi.Api.Features.Contact; using ProCoderJi.Api.Tests; namespace ProCoderJi.BrowserHost; @@ -31,6 +32,9 @@ public static async Task Main(string[] args) { ["Portfolio:PublishingEnabled"] = "true", ["Logging:LogLevel:Default"] = "Warning", + ["Contact:TurnstileSiteKey"] = "browser-fixture-site-key", + ["Contact:TurnstileSecretKey"] = "browser-fixture-secret-key", + ["Contact:ProxyKey"] = "browser-fixture-contact-proxy-key-123456789", }; if (options.DataDirectory is not null) settings["ConnectionStrings:Platform"] = new SqliteConnectionStringBuilder @@ -47,6 +51,7 @@ public static async Task Main(string[] args) builder.ConfigureTestServices(services => { services.AddSingleton(state); + services.AddSingleton(); services.AddSingleton(); services.AddAuthentication(authentication => { diff --git a/compose.yml b/compose.yml index 45800cc..44e047f 100644 --- a/compose.yml +++ b/compose.yml @@ -10,6 +10,9 @@ x-api-environment: &api-environment ConnectionStrings__Platform: ${PLATFORM_CONNECTION_STRING:-} Authentication__Google__ClientId: ${GOOGLE_CLIENT_ID:-} Authentication__Google__ClientSecret: ${GOOGLE_CLIENT_SECRET:-} + Contact__TurnstileSiteKey: ${TURNSTILE_SITE_KEY:-} + Contact__TurnstileSecretKey: ${TURNSTILE_SECRET_KEY:-} + Contact__ProxyKey: ${CONTACT_PROXY_KEY:-} x-api-image: &api-image image: procoderji-api:local diff --git a/deploy/container.env.example b/deploy/container.env.example index db1783a..626a7d9 100644 --- a/deploy/container.env.example +++ b/deploy/container.env.example @@ -21,6 +21,11 @@ MSSQL_PID=Developer GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Contact form: create a Managed Cloudflare Turnstile widget. See docs/contact-form.md. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +CONTACT_PROXY_KEY= + # For the base compose.yml with an EXTERNAL database, instead of a DB override: DATABASE_PROVIDER=Postgres DATABASE_NAME=procoderji diff --git a/deploy/neon.env.example b/deploy/neon.env.example index 7e66655..9327e4c 100644 --- a/deploy/neon.env.example +++ b/deploy/neon.env.example @@ -13,3 +13,8 @@ NEON_MIGRATION_CONNECTION_STRING=Host=ep-your-project.REGION.aws.neon.tech;Datab # Google callback: https://procoderji.com/api/platform/signin-google GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= + +# Contact form; see docs/contact-form.md. Same proxy secret goes in the Next.js host. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +CONTACT_PROXY_KEY= diff --git a/docs/README.md b/docs/README.md index 6d82303..c82e3f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,8 +22,10 @@ synchronization is a separate capability; paid billing is future work. | Import a portfolio from a text template | [Markdown import](portfolio-markdown.md), [template](../public/templates/portfolio.md) | | Work on portfolio fields, policy, publication, or storage | [Shared rules](../shared/portfolio/README.md), [Portfolio API](portfolio-api.md) | | Configure production hosting and the site domain | [Deployment](deployment.md) | +| Contribute to dev, test staging and promote a production release | [Branches and releases](branching-and-releases.md) | | Run the API and choose a database with Docker | [Containers](containers.md) | | Configure the selected hosted PostgreSQL database | [Neon setup](neon.md) | +| Configure private contact submissions and CAPTCHA | [Contact form](contact-form.md) | | Report a vulnerability privately | [Security policy](../SECURITY.md) | | Review implementation security notes | [Security notes](security.md) | | Understand optional coding-agent tools | [Agent tools](agent-tools.md) | diff --git a/docs/branching-and-releases.md b/docs/branching-and-releases.md new file mode 100644 index 0000000..1e03a3b --- /dev/null +++ b/docs/branching-and-releases.md @@ -0,0 +1,95 @@ +# Branches and deployments + +ProCoderJii uses two long-lived branches: + +| Branch | Purpose | Deployment | +| --- | --- | --- | +| `dev` | Default contribution branch; reviewed features and dependency updates integrate here. | Staging, after CI passes. | +| `main` | Reviewed production releases. | Production, after CI passes. | + +The flow is `feature/* -> PR -> dev -> staging checks -> PR -> main`. There is no permanent +`release` branch. Version tags can identify releases without creating another integration branch. + +## Contributions + +Create a feature branch from current `dev` in your fork and open the PR against canonical `dev`. +Both branches reject deletion and force pushes and require PR review and passing checks. +The Branch policy check rejects a feature/fork PR directly into `main`; only this repository's +`dev` branch can be promoted. A fork branch named `dev` is not a production promotion. + +Commits and contribution PRs use the contributor account. For the project owner's two-account +workflow, contributions use `Coding-Professional`; reviews and merges use `procoder-ji`. + +## Promote a release + +1. Make sure `dev` includes current `main`. If production has advanced independently, open a + `main -> dev` synchronization PR and run checks before testing staging again. +2. Wait for the exact `dev` commit's CI and staging deployment to pass. Exercise the changed + student journey on staging with sample data. Check migrations, account/portfolio privacy, + mobile and keyboard behavior when affected. +3. Open a PR from **canonical `dev` to `main`**, describing the behavior being released and + the staging checks. Review as `procoder-ji` and satisfy all required checks. +4. Use a **merge commit** for this promotion, preserving ancestry between the long-lived + branches. Squash merges remain suitable for individual feature PRs into `dev`. +5. Production deploys after the new `main` commit passes CI. Confirm the deployment summary, + health/session checks, canonical redirects and affected public routes. + +Do not deploy unreviewed feature commits to production. Urgent fixes follow the same route; +there is no permanent bypass branch. Database schema changes remain deliberate release +operations: test the migrations on staging and apply approved production migrations with the +direct connection before rolling out code that requires them. The runtime keeps migration on +startup disabled. Never use a production database to run browser fixtures. + +## Deployment environments + +Each GitHub environment has branch restrictions and separate project-scoped deployment tokens. +The CI deployment job runs only on canonical `dev`/`main` pushes, after frontend, browser/SEO +and backend checks pass. PRs and fork workflows receive no deployment credentials. + +| Environment | Allowed branch | Frontend project | Backend project | +| --- | --- | --- | --- | +| `staging` | `dev` | `procoderji-dev` | `procoderji-api-dev` | +| `production` | `main` | `procoderji` | `procoderji-api` | + +Environment variables: `VERCEL_ORG_ID`, `VERCEL_FRONTEND_PROJECT_ID`, +`VERCEL_BACKEND_PROJECT_ID`, `PUBLIC_ORIGIN`, and `BACKEND_ORIGIN`. +Environment secrets: `VERCEL_FRONTEND_TOKEN` and `VERCEL_BACKEND_TOKEN`. Scope each token to +its project, give it an expiration date and rotate it before expiry. Tokens are never printed +or committed. The script passes them directly to the CLI child process without a shell or +command echo; it never dumps provider output into the workflow logs. +Database and Google credentials stay in the appropriate Vercel project's secret settings. + +Staging uses a schema-only Neon branch and a fresh `procoderji_dev` database, with a separate +runtime role. Production student records and Data Protection keys are not copied into it. +Use a separate Google OAuth client with the staging frontend callback. Until configured, +staging supports guest practice and editor previews while account saving is unavailable. + +Set `DEPLOYMENT_ENVIRONMENT=staging` in the staging frontend. Staging and Vercel previews send +`noindex, nofollow` and disallow crawling; a dedicated staging project is still a production +*build*, so `NODE_ENV` alone is not an environment boundary. Indexing directives are not access +control. Use sample data and configure hosting access protection when private QA is needed. + +The deployment script copies tracked source only. It prepares the backend's service configuration +in an isolated directory, deploys/verifies the API first, then deploys the frontend. The live +summary records the checked source commit and both origins. A failure stops the rollout; inspect +the relevant Vercel project and restore its previous deployment if a rollback is needed. + +Each build is created without assigning domains. The pipeline discovers it by its source commit +and unique run metadata, waits for provider readiness, promotes that exact build, and verifies +the project's current production target. A previously rolled-back domain cannot silently keep +serving an older build while the workflow reports success. + +Disable a previous independent Git deployment connection after this pipeline has been verified; +otherwise a second deployment path can publish before CI finishes. The canonical GitHub Actions +pipeline does not require granting Vercel's GitHub integration access to an organization. + +## Operator recovery + +For a manual staging check from a trusted checkout, set the staging project IDs and origins in +the current process, authenticate Vercel CLI, and run +`node scripts/deploy-vercel.mjs --local-staging`. This mode is unavailable in GitHub Actions and +cannot select production. Source files and the operator's existing auth remain separate. + +For a production incident, use the hosting dashboard's rollback to the previous verified +deployment, then send the fix through `dev`. Do not delete student records or blindly reverse a +schema migration as part of an application rollback. diff --git a/docs/contact-form.md b/docs/contact-form.md new file mode 100644 index 0000000..a1f2c19 --- /dev/null +++ b/docs/contact-form.md @@ -0,0 +1,86 @@ +# Contact messages and bot protection + +The home-page form sends messages to the ASP.NET Core API through the same-origin Next.js +proxy. A successful response means the message was committed to the database. It does not +send an email or open the visitor's mail app. Google sign-in is not required. + +## Configuration + +Create a **Managed** widget in [Cloudflare Turnstile](https://dash.cloudflare.com/). +Allow only the frontend hostnames you use, for example `procoderji.com` and +`procoderji-dev.vercel.app`. Use separate widgets/credentials when you need environment isolation. +The widget action is `contact`; the API also checks the hostname against `PublicOrigin`. + +| Service | Environment variable | Value | +| --- | --- | --- | +| Next.js | `DOTNET_API_URL` | Trusted backend origin | +| Next.js | `CONTACT_PROXY_KEY` | Random secret, at least 32 characters | +| ASP.NET Core | `Contact__ProxyKey` | Same proxy secret | +| ASP.NET Core | `Contact__TurnstileSiteKey` | Widget's public site key | +| ASP.NET Core | `Contact__TurnstileSecretKey` | Widget's private secret | + +Store secrets in your host's encrypted environment configuration or local ignored files. +Never use `NEXT_PUBLIC_` for either secret. `GET /api/platform/contact/config` exposes only +the public site key and availability. Missing keys disable submission; there is no CAPTCHA bypass. +Cloudflare's official dummy keys are rejected by this app. Browser tests inject a fake verifier +only in the separate, loopback-only test executable, never in the published API. + +The Next.js proxy uses Vercel's overwritten `x-vercel-forwarded-for` header for the client IP +and authenticates its forwarded address using the shared secret. It ignores browser-supplied +contact headers. Outside Vercel, requests share a loopback rate-limit allowance. If deploying to +another platform, configure a trusted ingress adapter before expecting per-visitor rate limits. + +## Persistence and operations + +Apply `20261113130000_ContactMessages` using the existing migration procedure before deploying +the API. Test against an isolated database first; Neon migrations use the direct connection, +while the runtime uses the pooled connection. PostgreSQL, SQL Server and SQLite use the EF +migration; MongoDB provisioning creates the contact collection's indexes. + +Messages are stored in `ContactMessages` (MongoDB: `contactMessages`). There is deliberately no +public message-reading endpoint. Authorized operators can inspect submissions in their database +console using read-only access. For PostgreSQL, select only what you need: + +```sql +SELECT "Id", "CreatedAtUtc", "Name", "Email", "Subject", "Message" +FROM "ContactMessages" +ORDER BY "CreatedAtUtc" DESC +LIMIT 50; +``` + +`CreatedAtUtc` is Unix time in milliseconds. Treat messages as untrusted plain text; never render +them as raw HTML. Do not paste message contents into CI logs, issues or public screenshots. +Delete messages when they are no longer needed, including verified deletion requests. No automatic +retention period is claimed or implemented. + +## Abuse controls and failure behavior + +- CAPTCHA tokens are verified server-side at Cloudflare's Siteverify endpoint. Failed, expired, + wrong-host and wrong-action challenges cannot save a message. Provider failures fail closed. +- A unique SHA-256 token hash prevents concurrent/repeated tokens from creating duplicate messages. + Raw CAPTCHA tokens and client IP addresses are not stored with the message. +- Each trusted client IP can attempt five submissions per minute per API process; rejected requests + return `429` with `Retry-After`. Multi-instance hosting still needs provider-level abuse limits or + a shared limiter. CAPTCHA verification and the unique database constraint remain active on every instance. +- Names, email addresses, subjects, messages and tokens have server-side length bounds; the JSON + request is limited to 32 KiB. A hidden honeypot catches basic form fillers. +- Validation, security-service and storage failures retain the visitor's text. The browser clears + fields only after confirmed success and always requires a fresh security check for the next attempt. + +See [Cloudflare's validation guide](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) +and [the privacy policy](../src/app/privacy/page.tsx). If you enforce CSP, allow the Turnstile script +and frames from `https://challenges.cloudflare.com` and re-test the complete form. + +## Verification + +```bash +dotnet test backend/tests/ProCoderJi.Api.Tests +npm test +npm run lint +npm run build +``` + +`E2E_DOTNET=1 npm run e2e` additionally tests the real proxy/API/database with a test-only +CAPTCHA provider, including successful storage, failed checks, retry and narrow-screen layout. +It never uses production credentials or sends test messages to the live database. +Set `E2E_CONTACT_ONLY=1` as well to run just the contact browser flows through the same isolated harness. diff --git a/docs/images/homepage-desktop.png b/docs/images/homepage-desktop.png new file mode 100644 index 0000000..28eb944 Binary files /dev/null and b/docs/images/homepage-desktop.png differ diff --git a/docs/images/learning-workspace.png b/docs/images/learning-workspace.png index a450fc4..91dcb9d 100644 Binary files a/docs/images/learning-workspace.png and b/docs/images/learning-workspace.png differ diff --git a/docs/images/responsive-home-mobile.png b/docs/images/responsive-home-mobile.png new file mode 100644 index 0000000..0eccfe7 Binary files /dev/null and b/docs/images/responsive-home-mobile.png differ diff --git a/docs/images/responsive-menu-mobile.png b/docs/images/responsive-menu-mobile.png new file mode 100644 index 0000000..1dc56d7 Binary files /dev/null and b/docs/images/responsive-menu-mobile.png differ diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 197b481..f14e1e3 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -19,7 +19,8 @@ These steps do not need repeated owner permission. Existing decisions, including backend and selected Neon PostgreSQL deployment, remain valid. A person implementing that approved scope should continue through a reviewable change and its relevant checks. -Use the pull-request workflow for changes to `main`, and satisfy the repository's active review +Use feature PRs into `dev`, then a reviewed promotion PR from canonical `dev` into `main`. +Follow [branching and releases](branching-and-releases.md), and satisfy the active review and status-check rules before merging. Maintainers can merge routine changes within their authorised scope once the relevant checks and review are complete. Production rollout and private-credential access follow the separately agreed operating scope. diff --git a/docs/security.md b/docs/security.md index fea88c7..9f5b9b5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -120,7 +120,9 @@ Security Policy" messages. - A contributor with merge rights could still publish misleading content; review pull requests. - Links in notes may point to any https site (external links get `noopener noreferrer`). -- The contact form is a `mailto:` link, so spam is handled by the owner's mailbox, not the site. +- Contact submissions require server-verified Turnstile, a trusted proxy, bounded JSON, a honeypot, + token replay protection and a per-process IP limit. See [contact operations](contact-form.md) + for provisioning and the multi-instance rate-limit boundary. There is no public message-read API. - Request bounds are not a distributed rate limiter. Configure provider limits and infrastructure abuse controls before a public cloud-account rollout. Backend user-secrets are for development; use the hosting platform's secret store for Google and database credentials. diff --git a/e2e/checks.mjs b/e2e/checks.mjs index 44e9479..9626814 100644 --- a/e2e/checks.mjs +++ b/e2e/checks.mjs @@ -2,6 +2,7 @@ import { chromium, devices } from "playwright-core"; import fs from "node:fs"; import path from "node:path"; import { runPortfolioChecks } from "./portfolio-checks.mjs"; +import { installCaptchaFixture, runContactChecks } from "./contact-checks.mjs"; const BASE = process.env.BASE || "http://localhost:3150"; const CHANNEL = process.env.E2E_CHANNEL || "chrome"; @@ -34,6 +35,7 @@ const browser = await chromium.launch({ channel: CHANNEL, headless: true }); for (const [vpName, opts] of Object.entries(VIEWPORTS)) { const context = await browser.newContext(opts); + if (FULLSTACK) await installCaptchaFixture(context); for (const url of PAGES) { const page = await context.newPage(); const errors = []; @@ -103,6 +105,7 @@ for (const [vpName, opts] of Object.entries(VIEWPORTS)) { // ---------- interactions ---------- async function interaction(name, vpName, fn) { const context = await browser.newContext(VIEWPORTS[vpName]); + if (FULLSTACK) await installCaptchaFixture(context); const page = await context.newPage(); const errs = []; page.on("pageerror", (e) => errs.push(e.message)); @@ -167,16 +170,28 @@ await interaction("home: mobile menu opens, lists links, closes", "mobile", asyn await btn.click(); await page.locator("#mobile-menu").waitFor({ state: "visible" }); expectTrue((await page.getByRole("button", { name: /close menu/i }).getAttribute("aria-expanded")) === "true", "aria-expanded should be true when open"); - for (const t of ["About", "Games", "Contact", "Guides", "Jobs", "Browse Notes"]) { + for (const t of ["Browse Notes", "Placement practice", "Games", "About", "Contact"]) { expectTrue(await page.locator("#mobile-menu").getByText(t, { exact: true }).isVisible(), `menu should list ${t}`); } await page.getByRole("button", { name: /close menu/i }).click(); await page.locator("#mobile-menu").waitFor({ state: "hidden" }); }); +await interaction("home: original mobile menu links remain reachable on short screens", "mobile", async (page) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto(BASE + "/", { waitUntil: "load" }); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + const notes = page.locator("#mobile-menu").getByRole("link", { name: "Browse Notes", exact: true }); + await notes.scrollIntoViewIfNeeded(); + const bounds = await notes.boundingBox(); + expectTrue(bounds.y + bounds.height <= 568, "last menu action stays within the viewport"); + await page.getByRole("button", { name: "Close menu", exact: true }).click(); + await page.locator("#mobile-menu").waitFor({ state: "hidden" }); +}); + await interaction("home: desktop nav has working links", "desktop", async (page) => { await page.goto(BASE + "/", { waitUntil: "networkidle" }); - for (const [t, href] of [["Games", "/games"], ["Guides", "/guides"], ["Jobs", "/jobs"], ["Browse Notes", "/dashboard"]]) { + for (const [t, href] of [["Browse Notes", "/dashboard"], ["Placement practice", "/practice"], ["Games", "/games"], ["About", "/about"], ["Contact", "/#contact"]]) { const a = page.locator("nav").getByRole("link", { name: t, exact: true }).first(); expectTrue((await a.getAttribute("href")) === href, `${t} should link to ${href}`); } @@ -272,7 +287,7 @@ await interaction("skip link is first tab stop and works", "desktop", async (pag expectTrue(page.url().endsWith("#main-content"), "skip link jumps to main"); }); -await interaction("contact form: required validation, labels, mailto target", "desktop", async (page) => { +await interaction("contact form: required fields and CAPTCHA gate", "desktop", async (page) => { await page.goto(BASE + "/", { waitUntil: "networkidle" }); const form = page.locator("form").first(); expectTrue(!(await form.evaluate((f) => f.checkValidity())), "empty form should be invalid"); @@ -282,6 +297,7 @@ await interaction("contact form: required validation, labels, mailto target", "d await page.fill("#contact-name", "QA"); await page.fill("#contact-email", "qa@example.com"); await page.fill("#contact-subject", "Hi"); await page.fill("#contact-message", "Hello"); expectTrue(await form.evaluate((f) => f.checkValidity()), "filled form valid"); + expectTrue(await page.getByRole("button", { name: "Send message", exact: true }).isDisabled(), "sending requires a verified CAPTCHA"); }); await interaction("dashboard: mobile drawer opens, Escape closes", "mobile", async (page) => { @@ -290,8 +306,12 @@ await interaction("dashboard: mobile drawer opens, Escape closes", "mobile", asy const dlg = page.getByRole("dialog"); await dlg.waitFor({ state: "visible" }); expectTrue(await dlg.getByRole("link", { name: /Browse Notes/ }).isVisible(), "drawer has Browse Notes"); + await page.getByRole("button", { name: "Close navigation menu", exact: true }).focus(); + await page.keyboard.press("Shift+Tab"); + expectTrue(await dlg.evaluate(el => el.contains(document.activeElement)), "keyboard focus stays inside the drawer"); await page.keyboard.press("Escape"); await dlg.waitFor({ state: "hidden" }); + expectTrue(await page.getByRole("button", { name: /open navigation menu/i }).evaluate(el => el === document.activeElement), "drawer closes back to its toggle"); }); await interaction("dashboard: sidebar expands to semester and navigates (desktop)", "desktop", async (page) => { @@ -449,6 +469,7 @@ await interaction("privacy page discloses every third-party host the site contac }); if (FULLSTACK) { + findings.push(...await runContactChecks({ browser, base: BASE })); try { findings.push(...await runPortfolioChecks({ browser, base: BASE, backendOrigin: process.env.E2E_BACKEND_ORIGIN || "http://127.0.0.1:5151" })); } catch (error) { diff --git a/e2e/contact-checks.mjs b/e2e/contact-checks.mjs new file mode 100644 index 0000000..48f1add --- /dev/null +++ b/e2e/contact-checks.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Route the external widget only inside browser tests. The real BFF, API and SQLite remain active. +export async function installCaptchaFixture(context) { + await context.route("https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", route => route.fulfill({ + contentType: "application/javascript", + body: `window.turnstile = { + render(container, options) { + const button = document.createElement('button'); + button.type = 'button'; button.textContent = 'Complete test security check'; + button.style.cssText = 'width:100%;min-height:65px;border:1px solid #ddd;border-radius:8px'; + button.onclick = () => options.callback('browser-fixture-valid-' + crypto.randomUUID()); + container.append(button); + window.__contactWidget = { + expire: () => options['expired-callback'](), + invalid: () => options.callback('browser-fixture-invalid') + }; + return 'test-widget'; + }, + remove() { document.querySelector('[aria-label="Security check"]')?.replaceChildren(); } + };`, + })); +} + +export async function runContactChecks({ browser, base }) { + const findings = []; + for (const width of [320, 1280]) { + const context = await browser.newContext({ viewport: { width, height: 800 } }); + await installCaptchaFixture(context); + const page = await context.newPage(); + try { + await page.goto(base + "/", { waitUntil: "networkidle" }); + const send = page.getByRole("button", { name: "Send message", exact: true }); + const solve = () => page.getByRole("button", { name: "Complete test security check", exact: true }).click(); + const fill = async () => { + await page.fill("#contact-name", "Browser test student"); + await page.fill("#contact-email", "browser@example.test"); + await page.fill("#contact-subject", "Notes request"); + await page.fill("#contact-message", "Please add more notes."); + }; + await fill(); + assert(await send.isDisabled(), "CAPTCHA required"); + await solve(); + await page.evaluate(() => window.__contactWidget.expire()); + await page.waitForFunction(() => document.querySelector('#contact button[type="submit"]')?.disabled); + assert(await send.isDisabled(), "expiry disables sending"); + await solve(); + const accepted = page.waitForResponse(response => response.url().endsWith("/api/platform/contact") && response.request().method() === "POST"); + await send.click(); + assert.equal((await accepted).status(), 201, "real backend confirms database commit"); + await page.getByRole("status").filter({ hasText: "Your message has been received" }).waitFor(); + assert.equal(await page.inputValue("#contact-message"), "", "confirmed success clears fields"); + assert(await send.isDisabled(), "a fresh CAPTCHA is required for another message"); + assert.equal(new URL(page.url()).origin, base, "submission stays on the website"); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "no horizontal overflow"); + + if (width === 1280) { + await fill(); + await page.evaluate(() => window.__contactWidget.invalid()); + await send.click(); + await page.getByRole("alert").filter({ hasText: "expired or failed" }).waitFor(); + assert.equal(await page.inputValue("#contact-message"), "Please add more notes."); + assert(await send.isDisabled(), "failed token must be replaced"); + // Exercise an unavailable service without modifying the production API's behavior. + await page.route("**/api/platform/contact", route => route.fulfill({ status: 503, + contentType: "application/json", body: JSON.stringify({ error: "Your message could not be saved right now. Please try again later." }) })); + await solve(); await send.click(); + await page.getByRole("alert").filter({ hasText: "could not be saved" }).waitFor(); + assert.equal(await page.inputValue("#contact-message"), "Please add more notes."); + } + } catch (error) { + findings.push({ vp: String(width), url: "/#contact", kind: "contact", detail: String(error.message ?? error) }); + } finally { await context.close(); } + } + return findings; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { chromium } = await import("playwright-core"); + const browser = await chromium.launch({ channel: process.env.E2E_CHANNEL || "chrome", headless: true }); + try { + const findings = await runContactChecks({ browser, base: process.env.BASE || "http://localhost:3150" }); + console.log(JSON.stringify({ contactFindings: findings }, null, 2)); + if (findings.length) process.exitCode = 1; + } finally { await browser.close(); } +} diff --git a/e2e/portfolio-checks.mjs b/e2e/portfolio-checks.mjs index acc9f56..d341d3b 100644 --- a/e2e/portfolio-checks.mjs +++ b/e2e/portfolio-checks.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const CONTACT = "procoderji@gmail.com"; const EDITOR = "/learn/portfolio"; @@ -92,7 +93,10 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { const url = new URL(request.url()); if (/^https?:$/.test(url.protocol) && url.origin !== new URL(base).origin) foreignRequests.add(url.origin); }); - const response = await page.goto(base + "/u/" + username, { waitUntil: "networkidle" }); + // Next.js prefetch requests can outlive rendering; assert the loaded page's + // status, content and privacy boundaries instead of waiting for network idle. + const response = await page.goto(base + "/u/" + username, { waitUntil: "load" }); + await page.evaluate(() => document.fonts.ready); assert.equal(response?.status(), expectedStatus, `Public ${username} should return ${expectedStatus}`); if (expectedStatus === 200) { assert.equal(await page.locator("h1").count(), 1); @@ -109,7 +113,7 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { } async function editor(page) { - const response = await page.goto(base + EDITOR, { waitUntil: "networkidle" }); + const response = await page.goto(base + EDITOR, { waitUntil: "load" }); assert.equal(response?.status(), 200); await page.getByRole("heading", { name: "Portfolio builder", exact: true }).waitFor(); await until(() => page.getByRole("button", { name: "Save draft", exact: true }).isEnabled(), "Authenticated editor did not become ready"); @@ -317,13 +321,13 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { const page = await context.newPage(); await editor(page); await page.getByLabel("Name", { exact: true }).fill("Keep these unsaved edits"); page.once("dialog", dialog => dialog.dismiss()); - await page.getByRole("link", { name: "Placement practice", exact: true }).click(); + await page.locator("[data-site-header]").getByRole("link", { name: "Placement practice", exact: true }).click(); assert.equal(new URL(page.url()).pathname, EDITOR); assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), "Keep these unsaved edits"); page.once("dialog", dialog => dialog.accept()); await Promise.all([ page.waitForURL(url => url.pathname === "/practice"), - page.getByRole("link", { name: "Placement practice", exact: true }).click(), + page.locator("[data-site-header]").getByRole("link", { name: "Placement practice", exact: true }).click(), ]); assert.equal(new URL(page.url()).pathname, "/practice"); }); @@ -403,7 +407,7 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { await run("guest", "guests can preview while private APIs and unpublished pages stay protected", async contexts => { const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); contexts.push(context); - const page = await context.newPage(); await page.goto(base + EDITOR, { waitUntil: "networkidle" }); + const page = await context.newPage(); await page.goto(base + EDITOR, { waitUntil: "load" }); await page.getByRole("heading", { name: "Try the editor, then sign in to save", exact: true }).waitFor(); await page.getByLabel("Name", { exact: true }).fill("Guest preview"); assert.equal(await page.getByRole("button", { name: "Save draft", exact: true }).isDisabled(), true); @@ -416,3 +420,14 @@ export async function runPortfolioChecks({ browser, base, backendOrigin }) { } return findings; } + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { chromium } = await import("playwright-core"); + const browser = await chromium.launch({ channel: process.env.E2E_CHANNEL || "chrome", headless: true }); + try { + const findings = await runPortfolioChecks({ browser, base: process.env.BASE || "http://localhost:3150", + backendOrigin: process.env.E2E_BACKEND_ORIGIN || "http://127.0.0.1:5151" }); + console.log(JSON.stringify({ portfolioFindings: findings }, null, 2)); + if (findings.length) process.exitCode = 1; + } finally { await browser.close(); } +} diff --git a/e2e/run.mjs b/e2e/run.mjs index 3376ca1..a9065cc 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -20,7 +20,8 @@ const BACKEND_PROJECT = path.join(ROOT, "backend", "tests", "ProCoderJi.BrowserH const BACKEND_DLL = path.join(ROOT, "backend", "tests", "ProCoderJi.BrowserHost", "bin", "Release", "net10.0", "ProCoderJi.BrowserHost.dll"); const NEXT = path.join(ROOT, "node_modules", "next", "dist", "bin", "next"); const TEMP_ROOT = fs.realpathSync(os.tmpdir()); -const runEnv = { ...process.env, DOTNET_API_URL: FULLSTACK ? BACKEND_ORIGIN : "" }; +const runEnv = { ...process.env, DOTNET_API_URL: FULLSTACK ? BACKEND_ORIGIN : "", + CONTACT_PROXY_KEY: FULLSTACK ? "browser-fixture-contact-proxy-key-123456789" : "", VERCEL: "" }; let server = null; let backend = null; @@ -111,9 +112,11 @@ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => { let code = 1; try { // If the cleanliness check fails, cleanup must never check out real content. - fixtures.assertClean(); - fixturesApplied = true; - fixtures.apply(); + if (process.env.E2E_HEADER_ONLY !== "1") { + fixtures.assertClean(); + fixturesApplied = true; + fixtures.apply(); + } if (FULLSTACK) { if (PORT === BACKEND_PORT) throw new Error("E2E_PORT and E2E_BACKEND_PORT must differ"); @@ -140,7 +143,10 @@ try { await run(process.execPath, [NEXT, "build"]); server = start(process.execPath, [NEXT, "start", "-p", PORT], runEnv); await waitUntilReady(server, BASE, "Next.js", 40); - await run(process.execPath, [path.join(ROOT, "e2e", "checks.mjs")], { ...runEnv, BASE, E2E_BACKEND_ORIGIN: BACKEND_ORIGIN }); + const checks = process.env.E2E_HEADER_ONLY === "1" ? "site-header-checks.mjs" + : process.env.E2E_PORTFOLIO_ONLY === "1" ? "portfolio-checks.mjs" + : process.env.E2E_CONTACT_ONLY === "1" ? "contact-checks.mjs" : "checks.mjs"; + await run(process.execPath, [path.join(ROOT, "e2e", checks)], { ...runEnv, BASE, E2E_BACKEND_ORIGIN: BACKEND_ORIGIN }); code = 0; } catch (error) { console.error(String(error.message ?? error)); diff --git a/e2e/site-header-checks.mjs b/e2e/site-header-checks.mjs new file mode 100644 index 0000000..5461a0b --- /dev/null +++ b/e2e/site-header-checks.mjs @@ -0,0 +1,161 @@ +// Focused browser coverage and a review gallery; no content fixtures or database writes. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { chromium } from "playwright-core"; + +const base = process.env.BASE || "http://127.0.0.1:3180"; +const out = path.resolve("e2e/.out/site-header"); +fs.mkdirSync(out, { recursive: true }); +const pages = [ + ["Home", "/"], ["Practice", "/practice"], ["My learning", "/learn"], + ["Games", "/games"], ["Notes", "/dashboard"], ["About", "/about"], ["Contact", "/#contact"], + ["Portfolio", "/learn/portfolio"], +]; +const expected = [ + ["Browse Notes", "/dashboard"], ["Placement practice", "/practice"], + ["Games", "/games"], ["About", "/about"], ["Contact", "/#contact"], +]; +const widths = [360, 768, 1440]; +const browser = await chromium.launch({ channel: process.env.E2E_CHANNEL || "chrome", headless: true }); +const findings = []; +const screenshots = []; + +async function noOverflow(page, label) { + const dimensions = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth })); + assert(dimensions.document <= dimensions.viewport, `${label}: document overflows by ${dimensions.document - dimensions.viewport}px`); +} + +try { + for (const width of widths) { + const context = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: "reduce" }); + for (const [name, route] of pages) { + const page = await context.newPage(); + try { + await page.goto(base + route, { waitUntil: "domcontentloaded" }); + const header = page.locator("[data-site-header]"); + await header.getByRole("button", { name: "Guest learner" }).waitFor(); + await page.evaluate(() => document.fonts.ready); + if (["/practice", "/learn", "/learn/portfolio"].includes(route)) { + const workspace = await page.locator("main").evaluate(el => { + const bounds = el.parentElement.getBoundingClientRect(); + return { left: bounds.left, width: bounds.width, available: document.documentElement.clientWidth }; + }); + assert.equal(workspace.left, 0, "workspace sidebar starts at the viewport edge"); + assert.equal(workspace.width, workspace.available, "workspace uses the full available width"); + } + if (route === "/learn/portfolio") { + await page.locator("#portfolio-theme").selectOption("developer"); + if (width >= 1024) { + const sidebar = page.locator("[data-learning-sidebar]"); + await page.getByRole("button", { name: "Collapse learning sidebar", exact: true }).click(); + assert((await sidebar.boundingBox()).width < 100, "collapsed sidebar becomes a compact rail"); + assert(await sidebar.getByRole("link", { name: "My portfolio", exact: true }).isVisible(), "rail keeps accessible navigation"); + await page.getByRole("button", { name: "Expand learning sidebar", exact: true }).click(); + await page.evaluate(() => window.scrollTo(0, 800)); + const top = await sidebar.evaluate(el => el.getBoundingClientRect().top); + const bottom = await page.locator("[data-site-header]").evaluate(el => el.getBoundingClientRect().bottom); + assert(Math.abs(top - bottom) < 2, "sidebar stays below the shared header when scrolling"); + await page.evaluate(() => window.scrollTo(0, 0)); + } + } + assert.equal(await header.count(), 1, "one shared header"); + const brand = header.getByRole("link", { name: "ProCoderJii", exact: true }); + assert.equal(await brand.count(), 1, "same brand label"); + assert.equal(await brand.locator("img").count(), 1, "brand includes the logo"); + await noOverflow(page, name); + if (route !== "/" && route !== "/#contact") { + const appearance = await header.evaluate(el => ({ background: getComputedStyle(el).backgroundColor, border: getComputedStyle(el).borderBottomWidth })); + assert.equal(appearance.background, "rgb(255, 255, 255)", "non-hero header is white"); + assert.equal(appearance.border, "1px", "non-hero header has subtle border"); + } + + if (width < 768) await header.getByRole("button", { name: "Open menu", exact: true }).click(); + else assert.equal(await header.getByRole("button", { name: "Open menu", exact: true }).isVisible(), false, "hamburger ends at 768px"); + const nav = header.getByRole("navigation", { name: "Main", exact: true }); + assert.deepEqual(await nav.getByRole("link").evaluateAll(elements => elements.map(el => [el.textContent.trim(), el.getAttribute("href")])), expected, "identical links and order"); + for (const link of await nav.getByRole("link").all()) { + const bounds = await link.boundingBox(); + assert(bounds.width >= 44 && bounds.height >= 44, "44px navigation tap targets"); + } + const active = route === "/practice" ? "Placement practice" : route === "/games" ? "Games" : route === "/dashboard" ? "Browse Notes" : route === "/about" ? "About" : route === "/#contact" ? "Contact" : null; + if (active) assert.equal(await nav.getByRole("link", { name: active, exact: true }).getAttribute("aria-current"), "page"); + if (width < 768) { + if (route === "/") await page.screenshot({ path: path.join(out, `home-menu-${width}.png`) }); + await page.keyboard.press("Escape"); + assert.equal(await header.getByRole("button", { name: "Open menu", exact: true }).evaluate(el => el === document.activeElement), true, "Escape restores focus"); + } + const user = header.getByRole("button", { name: "Guest learner" }); + await user.click(); + await header.getByRole("navigation", { name: "Learner", exact: true }).getByRole("link", { name: "My learning", exact: true }).waitFor(); + await page.keyboard.press("Escape"); + assert.equal(await user.evaluate(el => el === document.activeElement), true); + if (route === "/learn" && width === 360) { + const open = page.getByRole("button", { name: "Open learning navigation", exact: true }); + await open.click(); + await page.getByRole("dialog", { name: "Learning navigation" }).waitFor({ state: "visible" }); + await page.keyboard.press("Escape"); + await page.getByRole("dialog", { name: "Learning navigation" }).waitFor({ state: "hidden" }); + assert.equal(await open.evaluate(el => el === document.activeElement), true); + const cards = await page.locator("main section").filter({ has: page.getByRole("heading", { name: /^(Completed sessions|Questions answered|Practice accuracy)$/ }) }).evaluateAll(elements => elements.map(el => ({ x: el.getBoundingClientRect().x, y: el.getBoundingClientRect().y }))); + assert.equal(cards.length, 3); + assert(cards.every(card => card.x === cards[0].x), "mobile stat cards stack"); + assert(cards[0].y < cards[1].y && cards[1].y < cards[2].y); + } + if (route === "/games" && width === 360) { + const cards = await page.locator("main article").evaluateAll(elements => elements.map(el => el.getBoundingClientRect().x)); + assert.equal(cards.length, 2); assert.equal(cards[0], cards[1], "mobile games stack"); + } + // Reveal existing while-in-view sections before the full-page capture. + await page.evaluate(async () => { + for (let y = 0; y < document.documentElement.scrollHeight; y += 600) { + window.scrollTo(0, y); await new Promise(resolve => setTimeout(resolve, 40)); + } + }); + if (route === "/#contact") { + await page.locator("#contact").scrollIntoViewIfNeeded(); + await page.evaluate(() => document.querySelector("#contact").scrollIntoView()); + } else await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(350); + await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); }); + await noOverflow(page, name); + if (route === "/") assert.equal(await header.evaluate(el => getComputedStyle(el).backgroundColor), "rgba(0, 0, 0, 0)", "home is transparent at the top"); + if (route === "/#contact") { + const top = await page.locator("#contact").evaluate(el => el.getBoundingClientRect().top); + const bottom = await header.evaluate(el => el.getBoundingClientRect().bottom); + assert(top >= bottom - 1, "contact anchor stays below the header"); + } + const slug = name.toLowerCase().replaceAll(" ", "-"); + const file = `${slug}-${width}.png`; + await page.screenshot({ path: path.join(out, file) }); + await page.screenshot({ path: path.join(out, `${slug}-${width}-full.png`), fullPage: true }); + screenshots.push({ name, width, file }); + console.log(`PASS ${width} ${name}`); + } catch (error) { + findings.push({ name, width, error: error.message }); + await page.screenshot({ path: path.join(out, `failure-${name.replaceAll(" ", "-")}-${width}.png`) }).catch(() => {}); + } finally { await page.close(); } + } + await context.close(); + } + const page = await browser.newPage({ viewport: { width: 767, height: 800 } }); + await page.goto(base + "/learn", { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await page.setViewportSize({ width: 768, height: 800 }); + await page.waitForFunction(() => !document.querySelector("#mobile-menu")); + await noOverflow(page, "768px breakpoint"); + await page.getByRole("button", { name: "Open learning navigation", exact: true }).click(); + await page.setViewportSize({ width: 1024, height: 800 }); + await page.waitForFunction(() => !document.querySelector("#learning-drawer").open); + await page.close(); +} catch (error) { findings.push({ name: "breakpoints", error: error.message }); } +finally { await browser.close(); } + +const gallery = `Shared header review + +

Shared header review

Existing theme and page content. Guest mode, 900px viewport height. Contact is the existing Home section. Local contact service is not configured.

+${pages.map(([name]) => `

${name}

${screenshots.filter(shot => shot.name === name).map(shot => `
${shot.width}px — viewport · full page
${name} at ${shot.width}px
`).join("")}
`).join("")}`; +fs.writeFileSync(path.join(out, "index.html"), gallery); +fs.writeFileSync(path.join(out, "results.json"), JSON.stringify({ findings, screenshots }, null, 2)); +console.log(JSON.stringify({ findings, screenshots: screenshots.length }, null, 2)); +if (findings.length) process.exitCode = 1; diff --git a/next.config.ts b/next.config.ts index be9615d..4d83f47 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { isNonProductionDeployment } from "./src/lib/deployment.ts"; /** * Conservative response headers. No Content-Security-Policy yet: the site loads @@ -16,7 +17,10 @@ const nextConfig: NextConfig = { // Do not announce the framework in an "X-Powered-By: Next.js" response header. poweredByHeader: false, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + const headers = isNonProductionDeployment(process.env.DEPLOYMENT_ENVIRONMENT, process.env.VERCEL_ENV) + ? [...securityHeaders, { key: "X-Robots-Tag", value: "noindex, nofollow" }] + : securityHeaders; + return [{ source: "/:path*", headers }]; }, // The old plain /courses/... reader duplicated the dashboard (same URL structure, no styling). // Send visitors and any old links to the real pages instead of serving duplicate content. diff --git a/scripts/deploy-vercel.mjs b/scripts/deploy-vercel.mjs new file mode 100644 index 0000000..1988e2e --- /dev/null +++ b/scripts/deploy-vercel.mjs @@ -0,0 +1,188 @@ +// Deployment credentials stay in environment secrets, never in source or logs. +// PR jobs never execute this script with deployment credentials. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { activateDeployment, findDeployment, readProviderResult, waitForDeployment } from "./deployment-status.mjs"; + +const root = path.resolve(import.meta.dirname, ".."); +const localStaging = process.argv.includes("--local-staging"); +const branch = localStaging ? "dev" : process.env.GITHUB_REF_NAME; +const temporary = []; +const env = { ...process.env, DEBUG: "" }; +const cli = process.env.VERCEL_CLI; +const command = cli?.endsWith(".js") ? process.execPath : cli || "vercel"; +const prefix = cli?.endsWith(".js") ? [cli] : []; + +function requireValue(key) { + const value = process.env[key]; + if (!value?.trim()) throw new Error(`Missing deployment setting: ${key}`); + return value; +} + +function origin(key) { + const value = requireValue(key); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") { + throw new Error(`Expected an HTTPS origin for ${key}`); + } + return url.origin; +} + +function workspace() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "procoderji-deploy-")); + temporary.push(directory); + return directory; +} + +function vercel(args, cwd, project, token, json = true) { + const result = spawnSync(command, [ + ...prefix, ...args, + ...(token ? ["--token", token] : []), + ], { + cwd, env: { ...env, VERCEL_ORG_ID: requireValue("VERCEL_ORG_ID"), VERCEL_PROJECT_ID: project }, + encoding: "utf8", windowsHide: true, timeout: 12 * 60 * 1000, + maxBuffer: 8 * 1024 * 1024, + }); + if (result.status !== 0) throw new Error(`Vercel ${args[0]} command failed. Inspect the deployment in the project dashboard.`); + if (!json) return; + let data; + try { data = JSON.parse(result.stdout); } catch { /* Never dump raw provider logs. */ } + if (result.status !== 0 || !data) throw new Error("Vercel deployment failed. Inspect its build in the project dashboard."); + return data; +} + +function authentication(tokenKey) { + if (localStaging && !process.env[tokenKey]) return undefined; + // Native CLI 65 requires its explicit token option; no shell or command logging is used. + return requireValue(tokenKey); +} + +async function providerApi(endpoint, cwd, project, token, label, method = "GET") { + const url = new URL(endpoint, "https://api.vercel.com"); + url.searchParams.set("teamId", requireValue("VERCEL_ORG_ID")); + if (!token) return vercel(["api", `${url.pathname}${url.search}`, "--method", "GET"], cwd, project); + const response = await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}`, ...(method === "POST" ? { "Content-Type": "application/json" } : {}) }, + ...(method === "POST" ? { body: "{}" } : {}), + signal: AbortSignal.timeout(30_000), + }); + return readProviderResult(response, label, { empty: method === "POST" }); +} + +async function confirmReady(cwd, project, token, label, commit, run) { + // CLI JSON envelopes differ across platforms. Resolve this exact invocation + // from provider metadata, then verify its project and live readiness state. + const id = await findDeployment({ + label, commit, run, + read: async () => (await providerApi(`/v6/deployments?projectId=${project}&limit=20`, cwd, project, token, label)).deployments, + }); + return waitForDeployment(id, { + label, + read: async id => { + const deployment = await providerApi(`/v13/deployments/${id}`, cwd, project, token, label); + if (deployment.projectId !== project) throw new Error(`${label} deployment belongs to a different project.`); + return { id: deployment.id, readyState: deployment.readyState }; + }, + }); +} + +async function promote(deployment, cwd, project, token, label) { + // A previous rollback can leave domains pinned to an older build. Activate only + // this invocation's verified production build, then confirm the canonical target. + await activateDeployment(deployment.id, { + project, label, + read: () => providerApi(`/v9/projects/${project}`, cwd, project, token, label), + promote: () => token + ? providerApi(`/v10/projects/${project}/promote/${deployment.id}`, cwd, project, token, `${label} promotion`, "POST") + : vercel(["promote", deployment.id, "--yes"], cwd, project, token, false), + }); +} + +function deploySource() { + const result = spawnSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", windowsHide: true }); + if (result.status !== 0) throw new Error("Cannot enumerate tracked deployment source."); + const directory = workspace(); + for (const file of result.stdout.split("\0").filter(Boolean)) { + const source = path.resolve(root, file); + if (!source.startsWith(root + path.sep) || fs.lstatSync(source).isSymbolicLink()) { + throw new Error("Deployment source must contain ordinary repository files."); + } + const target = path.join(directory, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + } + return directory; +} + +async function check(url, inspect) { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(30_000) }); + if (await inspect(response)) return; + } catch { /* Cold starts can need one retry. */ } + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 2000)); + } + throw new Error("A deployed endpoint failed its release check."); +} + +async function main() { + if (localStaging && process.env.GITHUB_ACTIONS === "true") throw new Error("Local staging mode is unavailable in Actions."); + if (!["dev", "main"].includes(branch)) throw new Error("Only dev and main have deployment environments."); + if (!localStaging && (process.env.GITHUB_EVENT_NAME !== "push" || process.env.GITHUB_REPOSITORY !== "procoderji-com/procoderji")) { + throw new Error("Only checked pushes to the canonical repository can deploy."); + } + const publicOrigin = origin("PUBLIC_ORIGIN"); + const backendOrigin = origin("BACKEND_ORIGIN"); + if (branch === "main" && publicOrigin !== "https://procoderji.com") throw new Error("Unexpected production origin."); + if (branch === "dev" && publicOrigin === "https://procoderji.com") throw new Error("Staging must have its own origin."); + const sha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true }).stdout.trim(); + if (!/^[a-f0-9]{40}$/.test(sha) || (!localStaging && sha !== process.env.GITHUB_SHA)) throw new Error("The checkout must match the checked commit."); + const backendProject = requireValue("VERCEL_BACKEND_PROJECT_ID"); + const frontendProject = requireValue("VERCEL_FRONTEND_PROJECT_ID"); + if (backendProject === frontendProject) throw new Error("Backend and frontend projects must be separate."); + + const backendSource = deploySource(); + const frontendSource = deploySource(); + fs.copyFileSync(path.join(backendSource, "vercel.backend.json"), path.join(backendSource, "vercel.json")); + const run = randomUUID(); + const deployArgs = ["deploy", "--prod", "--skip-domain", "--yes", "--json", "--meta", `sourceCommit=${sha}`, "--meta", `sourceBranch=${branch}`, "--meta", `sourceRun=${run}`]; + const backendToken = authentication("VERCEL_BACKEND_TOKEN"); + vercel([...deployArgs, "--project", backendProject], backendSource, backendProject, backendToken); + const backend = await confirmReady(backendSource, backendProject, backendToken, "Backend", sha, run); + await promote(backend, backendSource, backendProject, backendToken, "Backend"); + await check(`${backendOrigin}/api/platform/health`, async r => r.ok && (await r.json()).status === "ok"); + await check(`${backendOrigin}/api/platform/auth/session`, async r => { + if (!r.ok) return false; + const session = await r.json(); + return !!session.csrfToken && (branch !== "main" || session.configured === true); + }); + console.log("Backend is ready; database-backed session check passed."); + + const frontendToken = authentication("VERCEL_FRONTEND_TOKEN"); + vercel([...deployArgs, "--project", frontendProject], frontendSource, frontendProject, frontendToken); + const frontend = await confirmReady(frontendSource, frontendProject, frontendToken, "Frontend", sha, run); + await promote(frontend, frontendSource, frontendProject, frontendToken, "Frontend"); + await check(publicOrigin, async r => r.ok && (branch === "main" || r.headers.get("x-robots-tag")?.includes("noindex"))); + console.log(`Frontend is ready: ${publicOrigin}`); + const summary = `### ${branch === "main" ? "Production" : "Staging"} deployment\n\nCommit: \`${sha}\`\n\n- Frontend: ${publicOrigin}\n- Backend: ${backendOrigin}\n- Health and database-backed session checks passed.\n`; + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : "Deployment failed."); + process.exitCode = 1; +} finally { + const parent = fs.realpathSync(os.tmpdir()); + for (const directory of temporary) { + const resolved = path.resolve(directory); + if (path.dirname(resolved) === parent && path.basename(resolved).startsWith("procoderji-deploy-")) { + fs.rmSync(resolved, { recursive: true, force: true }); + } + } +} diff --git a/scripts/deployment-status.mjs b/scripts/deployment-status.mjs new file mode 100644 index 0000000..45bcacd --- /dev/null +++ b/scripts/deployment-status.mjs @@ -0,0 +1,64 @@ +/** Confirm provider state instead of trusting the CLI's deployment snapshot. */ +export async function readProviderResult(response, label, { empty = false } = {}) { + if (!response.ok) throw new Error(`${label} provider request failed (${response.status}).`); + // Promotion returns a successful empty 200 response, not a JSON document. + if (empty || response.status === 204) return {}; + try { return await response.json(); } + catch { throw new Error(`${label} provider returned invalid JSON.`); } +} + +export async function activateDeployment(id, { + project, label, read, promote, maxAttempts = 30, intervalMs = 2000, + sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), +}) { + const current = async () => { + const record = await read(); + if (record.id !== project) throw new Error(`${label} target belongs to a different project.`); + return record.targets?.production?.id === id; + }; + // Some deployment runtimes assign production domains despite --skip-domain. + // Promoting an already-current deployment returns 409; verify and finish instead. + if (await current()) return; + await promote(); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (await current()) return; + if (attempt + 1 < maxAttempts) await sleep(intervalMs); + } + throw new Error(`${label} canonical target does not serve the checked deployment.`); +} + +export async function findDeployment({ + read, commit, run, label, maxAttempts = 10, intervalMs = 1000, + sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), +}) { + if (!commit || !run) throw new Error(`${label} deployment metadata is missing.`); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const records = await read(); + if (!Array.isArray(records)) throw new Error(`${label} deployment list is invalid.`); + const matches = records.filter(item => item.meta?.sourceCommit === commit && item.meta?.sourceRun === run); + if (matches.length > 1) throw new Error(`${label} deployment metadata is ambiguous.`); + if (matches.length === 1) return matches[0].uid ?? matches[0].id; + if (attempt + 1 < maxAttempts) await sleep(intervalMs); + } + throw new Error(`${label} deployment was not found for this run.`); +} + +export async function waitForDeployment(id, { + read, + label, + maxAttempts = 60, + intervalMs = 5000, + sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), +}) { + if (!/^dpl_[a-zA-Z0-9]+$/.test(id ?? "")) throw new Error(`${label} deployment ID is missing or invalid.`); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const deployment = await read(id); + if (deployment.id !== id) throw new Error(`${label} status returned a different deployment.`); + if (deployment.readyState === "READY") return deployment; + if (["ERROR", "CANCELED", "CANCELLED"].includes(deployment.readyState)) { + throw new Error(`${label} deployment failed (${deployment.readyState}).`); + } + if (attempt + 1 < maxAttempts) await sleep(intervalMs); + } + throw new Error(`${label} deployment did not become ready before the timeout.`); +} diff --git a/scripts/deployment-status.test.mjs b/scripts/deployment-status.test.mjs new file mode 100644 index 0000000..ca1034a --- /dev/null +++ b/scripts/deployment-status.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { activateDeployment, findDeployment, readProviderResult, waitForDeployment } from "./deployment-status.mjs"; + +const id = "dpl_test123"; +const options = { label: "Backend", intervalMs: 0, maxAttempts: 3 }; + +test("successful empty promotion responses are accepted while lookups require JSON", async () => { + assert.deepEqual(await readProviderResult(new Response(null, { status: 200 }), "Promotion", { empty: true }), {}); + assert.deepEqual(await readProviderResult(new Response(null, { status: 204 }), "Promotion"), {}); + assert.deepEqual(await readProviderResult(Response.json({ id }), "Lookup"), { id }); + await assert.rejects(readProviderResult(new Response(null, { status: 200 }), "Lookup"), /invalid JSON/); + await assert.rejects(readProviderResult(new Response(null, { status: 403 }), "Promotion", { empty: true }), /request failed \(403\)/); +}); + +test("already-current production builds do not request a conflicting promotion", async () => { + await activateDeployment(id, { ...options, project: "project", read: async () => ({ id: "project", targets: { production: { id } } }), + promote: async () => assert.fail("already-current deployments must not be promoted again") }); +}); + +test("promotion waits until the checked deployment becomes the project's current target", async () => { + let promoted = false; + let reads = 0; + await activateDeployment(id, { ...options, project: "project", + read: async () => ({ id: "project", targets: { production: { id: ++reads < 3 ? "dpl_old" : id } } }), + promote: async () => { promoted = true; } }); + assert.equal(promoted, true); + assert.equal(reads, 3); +}); + +test("wrong projects, failed promotions and stale canonical targets cannot pass", async () => { + await assert.rejects(activateDeployment(id, { ...options, project: "project", read: async () => ({ id: "other" }), + promote: async () => assert.fail("wrong project must not be promoted") }), /different project/); + const input = { ...options, project: "project", read: async () => ({ id: "project" }) }; + await assert.rejects(activateDeployment(id, { ...input, promote: async () => { throw Error("Promotion denied"); } }), /Promotion denied/); + await assert.rejects(activateDeployment(id, { ...input, promote: async () => {} }), /canonical target/); +}); + +test("deployment discovery ignores old runs and waits for the exact invocation to be indexed", async () => { + let reads = 0; + const older = { uid: "dpl_old", meta: { sourceCommit: "commit", sourceRun: "older" } }; + const current = { uid: id, meta: { sourceCommit: "commit", sourceRun: "current" } }; + const found = await findDeployment({ ...options, commit: "commit", run: "current", read: async () => ++reads === 1 ? [older] : [older, current] }); + assert.equal(found, id); + assert.equal(reads, 2); +}); + +test("discovery rejects wrong commits, ambiguous records and malformed provider lists", async () => { + const record = { id, meta: { sourceCommit: "commit", sourceRun: "current" } }; + const input = { ...options, commit: "commit", run: "current" }; + await assert.rejects(findDeployment({ ...input, read: async () => [{ ...record, meta: { sourceCommit: "different", sourceRun: "current" } }] }), /not found/); + await assert.rejects(findDeployment({ ...input, read: async () => [record, record] }), /ambiguous/); + await assert.rejects(findDeployment({ ...input, read: async () => undefined }), /list is invalid/); + await assert.rejects(findDeployment({ ...input, run: "", read: async () => assert.fail("unexpected lookup") }), /metadata is missing/); +}); + +test("queued and building deployments are checked until the provider reports ready", async () => { + const states = ["QUEUED", "BUILDING", "READY"]; + const seen = []; + const result = await waitForDeployment(id, { ...options, read: async requested => { + seen.push(requested); + return { id, readyState: states.shift() }; + } }); + assert.equal(result.readyState, "READY"); + assert.deepEqual(seen, [id, id, id]); +}); + +test("failed or canceled builds cannot pass the readiness gate", async () => { + for (const readyState of ["ERROR", "CANCELED", "CANCELLED"]) { + await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id, readyState }) }), /deployment failed/); + } +}); + +test("a deployment that stays queued fails after bounded polling", async () => { + let reads = 0; + await assert.rejects(waitForDeployment(id, { ...options, read: async () => { + reads++; + return { id, readyState: "QUEUED" }; + } }), /timeout/); + assert.equal(reads, options.maxAttempts); +}); + +test("missing IDs, mismatched records and unavailable status lookups fail closed", async () => { + await assert.rejects(waitForDeployment(undefined, { ...options, read: async () => assert.fail("unexpected lookup") }), /ID is missing/); + await assert.rejects(waitForDeployment(id, { ...options, read: async () => ({ id: "dpl_other", readyState: "READY" }) }), /different deployment/); + await assert.rejects(waitForDeployment(id, { ...options, read: async () => { throw new Error("Status unavailable"); } }), /Status unavailable/); +}); diff --git a/src/app/(student)/layout.tsx b/src/app/(student)/layout.tsx index 09352b8..e1b8703 100644 --- a/src/app/(student)/layout.tsx +++ b/src/app/(student)/layout.tsx @@ -1,4 +1,3 @@ -import { ProgressProvider } from "@/features/progress/progress-provider"; import { LearningShell } from "@/features/practice/client/learning-shell"; export default function StudentLayout({ @@ -6,9 +5,5 @@ export default function StudentLayout({ }: { children: React.ReactNode; }) { - return ( - - {children} - - ); + return {children}; } diff --git a/src/app/api/platform/[...path]/route.ts b/src/app/api/platform/[...path]/route.ts index 40c6737..f43b9bd 100644 --- a/src/app/api/platform/[...path]/route.ts +++ b/src/app/api/platform/[...path]/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; -import { isBackendPath, parseBackendOrigin } from "@/features/account/config"; +import { canonicalGoogleChallenge, isBackendPath, parseBackendOrigin } from "@/features/account/config"; import { revalidateTag } from "next/cache"; import { portfolioCacheTag } from "@/features/portfolio/public-model"; import { validateUsername } from "@/features/portfolio/validation"; +import { contactClientIp } from "@/lib/contact-proxy"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -20,6 +21,10 @@ async function proxy( context: { params: Promise<{ path: string[] }> }, ) { const path = (await context.params).path.join("/"); + const contact = path === "contact" || path === "contact/config"; + const contactUnavailable = "Message submission is temporarily unavailable. Please try again later."; + if (contact && request.method !== (path === "contact" ? "POST" : "GET")) + return error("This method is not available.", 405); if (!isBackendPath(path)) return error("Unknown learner endpoint.", 404); const backend = parseBackendOrigin(process.env.DOTNET_API_URL); if (!backend) { @@ -29,11 +34,19 @@ async function proxy( { headers: privateHeaders }, ); return error( - "Account sync is not enabled. You can continue practising on this device.", + contact ? contactUnavailable : "Account sync is not enabled. You can continue practising on this device.", 503, ); } const headers = new Headers(); + if (contact) { + const key = process.env.CONTACT_PROXY_KEY; + if (!key || key.length < 32) return error(contactUnavailable, 503); + headers.set("X-Contact-Proxy-Key", key); + const ip = contactClientIp(request.headers, process.env.VERCEL === "1"); + if (!ip) return error("Your connection could not be verified. Please try again.", 400); + headers.set("X-Contact-Client-IP", ip); + } for (const name of [ "cookie", "origin", @@ -45,10 +58,11 @@ async function proxy( if (value) headers.set(name, value); } let body: Uint8Array | undefined; + const bodyLimit = contact ? 32 * 1024 : LIMIT; if (request.method !== "GET") { if (request.headers.get("origin") !== request.nextUrl.origin) - return error("Use the current site to update your account.", 403); - if (Number(request.headers.get("content-length")) > LIMIT) + return error(contact ? "Submit your message from this website." : "Use the current site to update your account.", 403); + if (Number(request.headers.get("content-length")) > bodyLimit) return error("The request is too large.", 413); const reader = request.body?.getReader(); if (reader) { @@ -59,7 +73,7 @@ async function proxy( const chunk = await reader.read(); if (chunk.done) break; size += chunk.value.byteLength; - if (size > LIMIT) { + if (size > bodyLimit) { await reader.cancel(); return error("The request is too large.", 413); } @@ -90,6 +104,13 @@ async function proxy( signal: AbortSignal.timeout(20_000), }, ); + if (path === "auth/google" && request.method === "GET" && upstream.status === 302) { + const canonical = canonicalGoogleChallenge(request.url, upstream.headers.get("location")); + if (canonical) { + // Restart on the callback host before forwarding a host-only correlation cookie. + return new Response(null, { status: 307, headers: { ...privateHeaders, Location: canonical } }); + } + } const responseHeaders = new Headers(privateHeaders); for (const name of ["content-type", "location", "retry-after"]) { const value = upstream.headers.get(name); @@ -110,7 +131,7 @@ async function proxy( }); } catch { return error( - "The account service is temporarily unavailable. Your browser progress is still available.", + contact ? contactUnavailable : "The account service is temporarily unavailable. Your browser progress is still available.", 503, ); } diff --git a/src/app/dashboard/dashboard-layout-client.tsx b/src/app/dashboard/dashboard-layout-client.tsx index d2d382f..c40d6dc 100644 --- a/src/app/dashboard/dashboard-layout-client.tsx +++ b/src/app/dashboard/dashboard-layout-client.tsx @@ -1,45 +1,71 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Menu, X } from "lucide-react"; import { AppSidebar } from "@/components/app-sidebar"; import { usePathname } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; -export function DashboardClientLayout({ children }: { children: React.ReactNode }) { +export function DashboardClientLayout({ + children, +}: { + children: React.ReactNode; +}) { const pathname = usePathname(); // Remember which route the drawer was opened on: navigating elsewhere closes it // without needing an effect. const [openOnPath, setOpenOnPath] = useState(null); const isSidebarOpen = openOnPath === pathname; + const drawer = useRef(null); + const toggle = useRef(null); + const closeButton = useRef(null); const closeSidebar = () => setOpenOnPath(null); - // Escape closes the mobile drawer. + // Keep keyboard focus and scrolling inside the mobile drawer. useEffect(() => { if (!isSidebarOpen) return; + const trigger = toggle.current; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const frame = requestAnimationFrame(() => + closeButton.current?.focus({ preventScroll: true }), + ); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpenOnPath(null); + if (e.key === "Tab") { + const items = [ + ...(drawer.current?.querySelectorAll( + 'a[href],button:not(:disabled),[tabindex="0"]', + ) ?? []), + ].filter((item) => item.getClientRects().length); + const first = items[0]; + const last = items.at(-1); + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } }; + const desktop = window.matchMedia("(min-width: 768px)"); + const onResize = () => { + if (desktop.matches) setOpenOnPath(null); + }; + desktop.addEventListener("change", onResize); document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); + return () => { + cancelAnimationFrame(frame); + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", onKey); + desktop.removeEventListener("change", onResize); + if (!desktop.matches) trigger?.focus({ preventScroll: true }); + }; }, [isSidebarOpen]); return (
- {/* Mobile Header */} -
-
ProCoderJii
- -
- {/* Mobile Sidebar Overlay */} {isSidebarOpen && ( @@ -50,24 +76,30 @@ export function DashboardClientLayout({ children }: { children: React.ReactNode exit={{ opacity: 0 }} onClick={closeSidebar} aria-hidden="true" - className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" + className="md:hidden fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm" />
@@ -84,7 +116,16 @@ export function DashboardClientLayout({ children }: { children: React.ReactNode
{/* Main Content */} -
+
+
+ +
{children}
diff --git a/src/app/games/layout.tsx b/src/app/games/layout.tsx index 910ac4a..6ef849c 100644 --- a/src/app/games/layout.tsx +++ b/src/app/games/layout.tsx @@ -1,19 +1,9 @@ import Link from "next/link"; -import { ArrowLeft, BookOpen } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; export default function GamesLayout({ children }: { children: React.ReactNode }) { return (
-
- -
{children}