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/AGENTS.md b/AGENTS.md deleted file mode 100644 index db00921..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ -# Repository guidance - -Read `CONTRIBUTING.md` before making changes. Contributions target `dev`; -production promotions follow `docs/branching-and-releases.md`. - -For UI work, follow `design-system/MASTER.md` and reuse the shared brand and -action styles. Keep real student activity distinct from interactive samples. -Verify keyboard navigation and narrow layouts alongside the relevant checks. - - - -## This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f6c5b8..a895501 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,6 +114,10 @@ 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" @@ -124,8 +128,6 @@ Open a pull request from your fork branch to `procoderji-com/procoderji:dev`. Fi 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. -For interface changes, follow the [design system](design-system/MASTER.md): use the shared -brand, typography, buttons and colour tokens, and check both keyboard and mobile navigation. `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; diff --git a/README.md b/README.md index 1ea5608..a96667b 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,9 @@ # ProCoderJii Learning Hub -### For the semester. For what’s next. +### Practise for campus placements. Understand your answers. Build a study habit. -A student learning brand bringing together course resources, placement practice, games, -portfolios, concept videos and community. This repository builds the open-source web platform. +A student learning workspace with original practice questions, explanations, and saved progress. [Website](https://procoderji.com) · [Documentation](docs/README.md) · [Roadmap](https://github.com/procoderji-com/procoderji/issues/22) · [Good first issues](https://github.com/procoderji-com/procoderji/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) · [Report a bug](https://github.com/procoderji-com/procoderji/issues/new?template=bug_report.yml) · [Propose a question](https://github.com/procoderji-com/procoderji/issues/new?template=practice_question.yml) @@ -22,17 +21,17 @@ portfolios, concept videos and community. This repository builds the open-source > [!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. -![ProCoderJii homepage with a placement practice sample](docs/images/homepage.png) +![ProCoderJii homepage with the current yellow brand and shared navigation](docs/images/homepage-desktop.png)
-Student learning workspace +Learning workspace and mobile previews -![Guest learning workspace before the first session](docs/images/learning-workspace.png) +![Guest learning workspace with the shared navigation and dashboard sidebar](docs/images/learning-workspace.png) -
+ProCoderJii homepage at 360px +Shared mobile navigation at 360px -Interface contributions follow the [design system](design-system/MASTER.md), with shared -navigation, self-hosted typography, keyboard access and responsive layouts. + See the [product roadmap](docs/product-roadmap.md) for student validation, content growth, and future monetization work. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2e63418..ed624bc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -20,14 +20,6 @@ is distributed. Preserve applicable package licenses and component notices when frontend archive or image, and inspect that actual artifact against the upstream terms. The backend-only container does not bundle the Next.js image-processing dependencies. -## DM Sans - -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts). - -The self-hosted font in `public/fonts/dm-sans-variable.ttf` is distributed under the -[SIL Open Font License 1.1](public/fonts/OFL.txt). Its full notice is included alongside -the font. See [the source and usage notes](public/fonts/README.md). - ## UI/UX Pro Max Source: [nextlevelbuilder/ui-ux-pro-max-skill](https://github.com/nextlevelbuilder/ui-ux-pro-max-skill) 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/design-system/MASTER.md b/design-system/MASTER.md deleted file mode 100644 index eb08ec4..0000000 --- a/design-system/MASTER.md +++ /dev/null @@ -1,44 +0,0 @@ -# ProCoderJii interface - -ProCoderJii is a student learning brand, spanning notes, placement practice, games, videos, -community and portfolios. The interface should feel like a useful study desk, with clear -choices and readable content. It must not imply hiring guarantees or invent social proof. - -## Direction - -UI/UX Pro Max's Swiss Modernism guidance informs the grid, hierarchy, restrained decoration -and keyboard accessibility. Use an editorial opening on the public site and compact, -functional layouts in the student workspace. The existing gold brand remains an accent. - -| Token | Value | Use | -| ------------- | --------- | ------------------------------------------- | -| Paper | `#f8f7f2` | Page background | -| Surface | `#ffffff` | Forms, reading and practice | -| Ink | `#242b25` | Headings and primary text | -| Secondary ink | `#60685d` | Supporting text | -| Forest | `#345443` | Links, primary actions, selected navigation | -| Gold | `#edc76e` | Small highlights and warm-up activities | -| Rule | `#dedfd5` | Quiet dividers and input boundaries | - -DM Sans is self-hosted under the SIL Open Font License. Georgia provides occasional editorial -display headings; the workspace keeps sans-serif headings. Body text is at least 16px for -reading and form inputs. Small labels supplement, never replace, visible field labels. - -## Patterns - -- One clear primary action per section. Prefer content rows and dividers over repeated cards. -- Desktop public navigation is shared across public pages. Mobile navigation has visible - controls, an expanded state, Escape dismissal and focus restoration. -- Learning navigation stays consistent between practice, account and portfolio editing. -- Progress displays real saved values, with useful empty states; homepage samples are labelled - as samples and do not impersonate saved activity. -- Notes keep branch/semester URLs and readable document layouts. Games keep their original - controls and feedback. Portfolio drafts remain private until explicit publication. -- Avoid animated backgrounds, gradient text, glass effects, floating decorations and invented - testimonials, rankings, company affiliations or activity counts. - -## Verification - -Check 375px, 768px, 1024px and 1440px layouts; keyboard focus and mobile menu; practice feedback; -notes navigation; portfolio preview; reading contrast; reduced motion; and existing browser/SEO -checks. Stage changes through `dev`; production uses the reviewed promotion workflow. diff --git a/docs/README.md b/docs/README.md index 2f372d9..c82e3f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ synchronization is a separate capability; paid billing is future work. | 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/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/homepage.png b/docs/images/homepage.png deleted file mode 100644 index 3b67191..0000000 Binary files a/docs/images/homepage.png and /dev/null differ diff --git a/docs/images/learning-workspace.png b/docs/images/learning-workspace.png index 7ec5774..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/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 4be8357..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,39 +170,34 @@ 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}`); } expectTrue((await page.locator("a[href*='procoderji.xyz']").count()) === 0, "no .xyz links"); }); -for (const vp of ["desktop", "mobile"]) { - await interaction("home: sample explains answers without saving a session", vp, async (page) => { - await page.goto(BASE + "/", { waitUntil: "networkidle" }); - const sample = page.locator(".sample-question"); - const check = sample.getByRole("button", { name: "Check answer" }); - expectTrue(await check.isDisabled(), "an answer is required"); - await sample.getByRole("radio").first().check(); - await check.click(); - expectTrue((await sample.locator(".sample-answer").textContent()).length > 30, "show a useful explanation"); - await sample.getByRole("radio").nth(1).check(); - expectTrue(await sample.locator(".sample-answer").count() === 0, "changing an answer clears outdated feedback"); - await page.goto(BASE + "/learn", { waitUntil: "networkidle" }); - const sessions = page.locator("section").filter({ has: page.getByRole("heading", { name: "Completed sessions", exact: true }) }); - expectTrue(await sessions.getByText("0", { exact: true }).isVisible(), "a sample is not recorded as a practice session"); - }); -} - await interaction("games: pattern round reaches a result", "desktop", async (page) => { await page.goto(BASE + "/games/pattern-sprint", { waitUntil: "networkidle" }); await page.getByRole("button", { name: "Start game" }).click(); @@ -289,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"); @@ -299,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) => { @@ -307,9 +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), "Escape restores focus to the drawer toggle"); + 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) => { @@ -378,22 +380,40 @@ await interaction("legal pages: completed details are indexable", "desktop", asy expectTrue((await page.locator('link[rel="canonical"]').getAttribute("href")) === "https://procoderji.com/terms", "canonical URL"); }); -await interaction("home: headline and sample stay separate at every breakpoint", "desktop", async (page) => { - for (const [width, height] of [[375, 900], [768, 900], [1024, 768], [1280, 800], [1366, 768], [1440, 900], [1920, 1080]]) { +await interaction("hero headline is never covered by the floating logo (several desktop widths)", "desktop", async (page) => { + // The floating logo tile has pointer-events:none, so elementFromPoint cannot see it: compare geometry instead. + for (const [width, height] of [[768, 900], [1024, 768], [1280, 800], [1366, 768], [1440, 900], [1920, 1080]]) { await page.setViewportSize({ width, height }); - // Visible layout and fonts are the readiness signal. Responsive link prefetches - // need not become network-idle before we can measure the rendered interface. - await page.goto(BASE + "/", { waitUntil: "domcontentloaded" }); - await page.getByRole("heading", { level: 1 }).waitFor({ state: "visible" }); - await page.locator(".sample-question").waitFor({ state: "visible" }); - await page.evaluate(() => document.fonts.ready); - const heading = await page.getByRole("heading", { level: 1 }).boundingBox(); - const sample = await page.locator(".sample-question").boundingBox(); - const overlaps = heading.x < sample.x + sample.width && heading.x + heading.width > sample.x && heading.y < sample.y + sample.height && heading.y + heading.height > sample.y; - expectTrue(!overlaps, "headline and question overlap at " + width + "px"); + await page.goto(BASE + "/", { waitUntil: "networkidle" }); + await page.waitForTimeout(1200); // let the entrance animations finish + const overlaps = await page.evaluate(() => { + const h1 = document.querySelector("h1"); + const range = document.createRange(); + range.selectNodeContents(h1); + const textLines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0); + // Outermost pointer-events:none wrapper around every visible image = decorative overlays such as the logo tile. + const overlays = new Set(); + for (const img of document.querySelectorAll("img")) { + if (!img.getClientRects().length) continue; + let el = img; + while (el.parentElement && getComputedStyle(el.parentElement).pointerEvents === "none") el = el.parentElement; + if (el !== img || getComputedStyle(img).pointerEvents === "none") overlays.add(el); + } + const hits = []; + for (const el of overlays) { + const o = el.getBoundingClientRect(); + if (o.width === 0 || o.height === 0 || getComputedStyle(el).display === "none") continue; + for (const t of textLines) { + if (o.left < t.right && o.right > t.left && o.top < t.bottom && o.bottom > t.top) hits.push(`${el.tagName}.${String(el.className).slice(0, 50)}`); + } + } + return [...new Set(hits)]; + }); + expectTrue(overlaps.length === 0, `at ${width}px the headline text is overlapped by: ${overlaps.join(", ")}`); } }); + // Needs the live channel feed, so it only runs when YOUTUBE_CHANNEL_ID is set (otherwise the section is hidden by design). const videosEnabled = /^UC[A-Za-z0-9_-]{22}$/.test(process.env.YOUTUBE_CHANNEL_ID ?? ""); if (!videosEnabled) console.log("SKIP latest videos responsive checks (set YOUTUBE_CHANNEL_ID to run them)"); @@ -427,7 +447,7 @@ if (videosEnabled) { await interaction("privacy page discloses every third-party host the site contacts", "desktop", async (page) => { // Every external host that these pages request must be named on the Privacy page. - const DISCLOSED_AS = { "i.ytimg.com": "i.ytimg.com" }; + const DISCLOSED_AS = { "lottie.host": "lottie.host", "cdn.jsdelivr.net": "jsdelivr", "i.ytimg.com": "i.ytimg.com" }; const base = new URL(BASE); const hosts = new Set(); page.on("request", (r) => { @@ -441,8 +461,7 @@ await interaction("privacy page discloses every third-party host the site contac await page.waitForTimeout(120); } } - const retiredServices = [...hosts].filter(h => /(^|\.)(lottie\.host|jsdelivr\.net|googleapis\.com|gstatic\.com)$/.test(h)); - expectTrue(retiredServices.length === 0, "interface fonts and artwork must stay self-hosted: " + retiredServices.join(", ")); + expectTrue(hosts.size > 0, "expected the home page to contact at least the animation hosts"); await page.goto(BASE + "/privacy", { waitUntil: "networkidle" }); const text = (await page.locator("main").innerText()).toLowerCase(); const missing = [...hosts].filter((h) => !text.includes((DISCLOSED_AS[h] ?? h).toLowerCase())); @@ -450,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) { @@ -460,7 +480,6 @@ if (FULLSTACK) { await browser.close(); console.log("\n========== FINDINGS =========="); -fs.writeFileSync(path.join(OUT, "findings.json"), JSON.stringify(findings, null, 2) + "\n"); if (!findings.length) console.log("none"); const grouped = {}; for (const f of findings) (grouped[f.kind] ??= []).push(f); 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 f963e24..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"; @@ -320,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"); }); @@ -419,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/package-lock.json b/package-lock.json index 964d33d..4ab4f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "procoderji-notes", "version": "0.1.0", "dependencies": { + "@lottiefiles/dotlottie-react": "^0.21.1", "@radix-ui/react-slot": "^1.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^14.10.1", "lucide-react": "^0.577.0", "next": "16.4.5", "next-mdx-remote": "6.0.0", @@ -1138,6 +1140,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@lottiefiles/dotlottie-react": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.21.1.tgz", + "integrity": "sha512-y5ec2Ipko1TvUfoUmS9zn+g0Ud+oYtUEJS5Xj27edv1kO8ZAowoA2v6DUWNniZ/+A9m5wjEswqPzShOK6+pdAw==", + "license": "MIT", + "dependencies": { + "@lottiefiles/dotlottie-web": "0.82.1" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19" + } + }, + "node_modules/@lottiefiles/dotlottie-web": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.82.1.tgz", + "integrity": "sha512-hLPdAL0dUeHKvQvXqw1p3FtvzZUHRtBsTMt3FLLgphFThsUPNUj2+U+GyttJ5H5qSt5JreLzoaD+IaKO8H/VnA==", + "license": "MIT", + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -4297,6 +4325,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "14.10.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.10.1.tgz", + "integrity": "sha512-hT8WLczAcreOHG4EHymMtATztwUc3al1/88/stal3tpxYkMefGNzIPHHgFFHW3iLnrNY36BrKKJDuT2V7o8Tfw==", + "license": "MIT", + "dependencies": { + "motion-dom": "14.10.1", + "motion-utils": "14.7.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "remix": "^3.1.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "remix": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6506,6 +6561,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "14.10.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.10.1.tgz", + "integrity": "sha512-NzVzM7y3JJl3rYlVN4yM6kbziab/tHNBEV6gqOoAQIg8npHlIMhW2oPnzr0HkP+rR8xMoiX9+zSZ4BLaVuJadw==", + "license": "MIT", + "dependencies": { + "motion-utils": "14.7.0" + } + }, + "node_modules/motion-utils": { + "version": "14.7.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.7.0.tgz", + "integrity": "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index eaa0468..27a1826 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "launch-check": "node e2e/launch.mjs" }, "dependencies": { + "@lottiefiles/dotlottie-react": "^0.21.1", "@radix-ui/react-slot": "^1.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^14.10.1", "lucide-react": "^0.577.0", "next": "16.4.5", "next-mdx-remote": "6.0.0", diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt deleted file mode 100644 index a0eedfb..0000000 --- a/public/fonts/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/README.md b/public/fonts/README.md deleted file mode 100644 index 4add13f..0000000 --- a/public/fonts/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# DM Sans - -`dm-sans-variable.ttf` is the variable DM Sans font distributed by Google Fonts. -It is self-hosted with Next.js so reading the application does not request fonts -from a third-party server. - -- Source: https://github.com/google/fonts/tree/main/ofl/dmsans -- License: [SIL Open Font License 1.1](OFL.txt) - -Georgia, where used for editorial headings, is a system-font fallback. diff --git a/public/fonts/dm-sans-variable.ttf b/public/fonts/dm-sans-variable.ttf deleted file mode 100644 index c672f98..0000000 Binary files a/public/fonts/dm-sans-variable.ttf and /dev/null differ 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/(student)/practice/page.tsx b/src/app/(student)/practice/page.tsx index 6116225..e9db4db 100644 --- a/src/app/(student)/practice/page.tsx +++ b/src/app/(student)/practice/page.tsx @@ -14,39 +14,44 @@ export const metadata = pageMetadata({ export default function PracticePage() { return ( <> -

+

Prepare with purpose

-

Campus placement practice

-

+

+ Campus placement practice +

+

Work through short topic sessions. Understand why an answer works, review mistakes, and use your own progress to decide what comes next.

-
+
- - -
-
+
{PRACTICE_TOPICS.map((topic, index) => ( -
- +
+ 0{index + 1}

{topic.title}

-

+

{topic.description}

-

+

{QUESTION_BANK.filter((q) => q.topicId === topic.id).length}{" "} questions · Easy & medium · Explanations included

@@ -60,9 +65,9 @@ export default function PracticePage() {
))}
-
+

Learn from every attempt

-

+

These starter sets build familiarity with common reasoning and aptitude skills. They are independent learning exercises, and the small question bank is not a calibrated employer assessment. Use diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 67f61c9..2027a3c 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,111 +1,73 @@ import Link from "next/link"; -import { - EmailLink, - H2, - LegalLayout, - Operator, - P, - UL, - legalMetadata, -} from "@/components/legal/legal-layout"; +import { EmailLink, H2, LegalLayout, Operator, P, UL, legalMetadata } from "@/components/legal/legal-layout"; export const metadata = legalMetadata( - "About", - "Learn about ProCoderJii, an open-source student platform for placement practice, explanations, free games and saved progress, and how to contribute.", - "/about", + "About", + "Learn about ProCoderJii, an open-source student platform for placement practice, explanations, free games and saved progress, and how to contribute.", + "/about" ); export default function AboutPage() { - return ( - -

- ProCoderJii is a student learning brand built around concept videos, - course resources, placement practice and a community of people learning - together. Our YouTube channel, Telegram and WhatsApp communities connect - students beyond this website. The open-source platform is operated by{" "} - and grows through reviewed community contributions. -

+ return ( + +

+ ProCoderJii is an open-source learning and campus-placement practice project for students. + It is operated by and grows through reviewed community contributions. +

-

Practise and understand your answers

-
    -
  • - - Placement practice - {" "} - covers quantitative aptitude, logical reasoning, verbal ability and - programming foundations. Each session includes correct answers and - explanations. -
  • -
  • - - Free practice games - {" "} - include Pattern Sprint for number patterns and Digit Recall for - short-term memory. -
  • -
  • - The{" "} - - learner workspace - {" "} - shows question-practice history, topic progress and a suggested next - step. -
  • -
+

Practise and understand your answers

+
    +
  • + Placement practice covers + quantitative aptitude, logical reasoning, verbal ability and programming foundations. + Each session includes correct answers and explanations. +
  • +
  • + Free practice games include + Pattern Sprint for number patterns and Digit Recall for short-term memory. +
  • +
  • + The learner workspace shows + question-practice history, topic progress and a suggested next step. +
  • +
-

Free practice and your progress

-

- Practice is free and works without an account. Question-practice history - is saved in this browser; clearing site data removes it. Optional Google - sign-in can synchronize that history when account services are - available. Game scores are shown at the end of a round and are not yet - saved. -

+

Free practice and your progress

+

+ Practice is free and works without an account. Question-practice history is saved in this + browser; clearing site data removes it. Optional Google sign-in can synchronize that + history when account services are available. Game scores are shown at the end of a round + and are not yet saved. +

-

Learning content in development

-

- Course, branch and semester navigation is in place. Reviewed course - notes, guides and job listings are awaiting publication. The current - learning exercises are independent practice for building skills; they do - not predict or guarantee a placement result. -

+

Learning content in development

+

+ Course, branch and semester navigation is in place. Reviewed course notes, guides and job + listings are awaiting publication. The current learning exercises are independent practice + for building skills; they do not predict or guarantee a placement result. +

-

How to contribute

-

- Students and developers can help with original questions, explanations, - accessibility, documentation and code. Visit the{" "} - - GitHub repository and contribution guide - {" "} - to find a focused task and the checks it needs. -

-
    -
  • - Learning material is reviewed for correctness and clarity before - publication. -
  • -
  • - Where an official syllabus or question paper exists, we link to the - university's own page instead of copying it. -
  • -
  • - We do not republish other people's notes, papers or PDFs without - permission. -
  • -
+

How to contribute

+

+ Students and developers can help with original questions, explanations, accessibility, + documentation and code. Visit the{" "} + + GitHub repository and contribution guide + {" "} + to find a focused task and the checks it needs. +

+
    +
  • Learning material is reviewed for correctness and clarity before publication.
  • +
  • Where an official syllabus or question paper exists, we link to the university's own page instead of copying it.
  • +
  • We do not republish other people's notes, papers or PDFs without permission.
  • +
-

Corrections and requests

-

- Found a mistake, or want a subject covered? Email or open - a reproducible issue in the repository. See the{" "} - - Disclaimer - {" "} - for the limits of the learning material. -

-
- ); +

Corrections and requests

+

+ Found a mistake, or want a subject covered? Email or open a reproducible issue + in the repository. See the Disclaimer{" "} + for the limits of the learning material. +

+ + ); } diff --git a/src/app/api/platform/[...path]/route.ts b/src/app/api/platform/[...path]/route.ts index b6de8c8..f43b9bd 100644 --- a/src/app/api/platform/[...path]/route.ts +++ b/src/app/api/platform/[...path]/route.ts @@ -3,6 +3,7 @@ import { canonicalGoogleChallenge, isBackendPath, parseBackendOrigin } from "@/f 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); } @@ -117,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 a99a604..c40d6dc 100644 --- a/src/app/dashboard/dashboard-layout-client.tsx +++ b/src/app/dashboard/dashboard-layout-client.tsx @@ -1,76 +1,135 @@ "use client"; -import { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Menu, X } from "lucide-react"; -import { usePathname } from "next/navigation"; import { AppSidebar } from "@/components/app-sidebar"; -import { Brand } from "@/components/brand"; +import { usePathname } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; export function DashboardClientLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const pathname = usePathname(); - const drawer = useRef(null); - const toggle = useRef(null); + 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); + + // 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 () => { + 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 Sidebar Overlay */} + + {isSidebarOpen && ( + <> + - useEffect(() => { - drawer.current?.close(); - }, [pathname]); - useEffect(() => { - const desktop = window.matchMedia("(min-width: 768px)"); - const closeOnDesktop = () => { - if (desktop.matches) drawer.current?.close(); - }; - desktop.addEventListener("change", closeOnDesktop); - return () => desktop.removeEventListener("change", closeOnDesktop); - }, []); + {/* Desktop Sidebar */} +
+ +
- return ( -
-
- - -
- toggle.current?.focus()} - onClick={(event) => { - if (event.target === event.currentTarget) drawer.current?.close(); - }} - className="notes-drawer fixed inset-y-0 left-0 m-0 h-dvh max-h-none w-80 max-w-[90vw] border-r border-border bg-background text-foreground backdrop:bg-black/40" - > - - - -
- -
-
-
- {children} + {/* Main Content */} +
+
+ +
+
+
{children}
+
+
-
-
- ); + ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 1a12c67..ab33597 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,15 +1,20 @@ import { DashboardClientLayout } from "./dashboard-layout-client"; export default function DashboardLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( - <> -