Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
18 changes: 0 additions & 18 deletions AGENTS.md

This file was deleted.

6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <files-you-changed>
git commit -m "Describe the change"
Expand All @@ -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;
Expand Down
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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

<details>
<summary>Student learning workspace</summary>
<summary>Learning workspace and mobile previews</summary>

![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)

</details>
<img src="docs/images/responsive-home-mobile.png" alt="ProCoderJii homepage at 360px" width="360" />
<img src="docs/images/responsive-menu-mobile.png" alt="Shared mobile navigation at 360px" width="360" />

Interface contributions follow the [design system](design-system/MASTER.md), with shared
navigation, self-hosted typography, keyboard access and responsive layouts.
</details>

See the [product roadmap](docs/product-roadmap.md) for student validation, content growth, and future monetization work.

Expand Down
8 changes: 0 additions & 8 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,10 +17,16 @@ public sealed class MongoPlatformDatabase(IMongoDatabase database)
public IMongoCollection<MongoUserDocument> Users => Database.GetCollection<MongoUserDocument>("users");
public IMongoCollection<MongoProgressDocument> Progress => Database.GetCollection<MongoProgressDocument>("progress");
public IMongoCollection<MongoKeyDocument> Keys => Database.GetCollection<MongoKeyDocument>("dataProtectionKeys");
public IMongoCollection<ContactMessage> ContactMessages => Database.GetCollection<ContactMessage>("contactMessages");

/// <summary>MongoDB has no EF migrations; provisioning creates the identity and username boundaries.</summary>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await ContactMessages.Indexes.CreateManyAsync([
new CreateIndexModel<ContactMessage>(Builders<ContactMessage>.IndexKeys.Ascending(message => message.CaptchaTokenHash),
new CreateIndexOptions { Unique = true, Name = "IX_contactMessages_CaptchaTokenHash" }),
new CreateIndexModel<ContactMessage>(Builders<ContactMessage>.IndexKeys.Ascending(message => message.CreatedAtUtc))
], cancellationToken: cancellationToken);
await Users.Indexes.CreateManyAsync([
new CreateIndexModel<MongoUserDocument>(Builders<MongoUserDocument>.IndexKeys.Ascending(user => user.GoogleSubject),
new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }),
Expand Down
13 changes: 13 additions & 0 deletions backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -10,10 +11,22 @@ public sealed class PlatformDbContext(DbContextOptions<PlatformDbContext> option
public DbSet<ProgressState> ProgressStates => Set<ProgressState>();
public DbSet<PracticeAttemptEntity> Attempts => Set<PracticeAttemptEntity>();
public DbSet<PortfolioEntity> Portfolios => Set<PortfolioEntity>();
public DbSet<ContactMessage> ContactMessages => Set<ContactMessage>();
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<ContactMessage>(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<LearnerUser>(entity =>
{
entity.HasKey(user => user.Id);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ContactConfiguration>();
services.AddHttpClient<ITurnstileVerifier, TurnstileVerifier>(client => client.Timeout = TimeSpan.FromSeconds(8));
services.AddRateLimiter(options => options.AddPolicy(RatePolicy, context =>
{
var ip = context.RequestServices.GetRequiredService<ContactConfiguration>().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;
}
}
69 changes: 69 additions & 0 deletions backend/src/ProCoderJi.Api/Features/Contact/ContactEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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<IResult> 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<ContactRequest>(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);
}
49 changes: 49 additions & 0 deletions backend/src/ProCoderJi.Api/Features/Contact/ContactStore.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <returns>False if this CAPTCHA token has already been used.</returns>
Task<bool> Save(ContactMessage message, CancellationToken cancellationToken);
}

public sealed class RelationalContactStore(PlatformDbContext database) : IContactStore
{
public async Task<bool> 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<bool> 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; }
}
}
Loading