diff --git a/backend/README.md b/backend/README.md index 4fc6c26..775f7a3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -38,6 +38,10 @@ Users are linked by Google's stable provider subject, never by matching email ad ## API contract +Portfolio storage and authenticated/public endpoints are documented in the +[portfolio API guide](../docs/portfolio-api.md). Public publishing is disabled by default until +the owner decisions and launch work in #47/#48 are complete. + | Endpoint | Behavior | | --- | --- | | `GET /api/platform/auth/session` | Returns `{ configured, user, csrfToken }` and issues the antiforgery cookie. | diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs index 9694237..f453bb5 100644 --- a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs +++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs @@ -1,7 +1,9 @@ using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson; using MongoDB.Driver; using ProCoderJi.Api.Features.Practice; using ProCoderJi.Api.Features.Progress; +using ProCoderJi.Api.Features.Portfolio; namespace ProCoderJi.Api.Data.Mongo; @@ -15,10 +17,21 @@ public sealed class MongoPlatformDatabase(IMongoDatabase database) public IMongoCollection Progress => Database.GetCollection("progress"); public IMongoCollection Keys => Database.GetCollection("dataProtectionKeys"); - /// MongoDB has no EF migrations; provisioning creates the identity uniqueness boundary. - public Task InitializeAsync(CancellationToken cancellationToken = default) => Users.Indexes.CreateOneAsync( - new CreateIndexModel(Builders.IndexKeys.Ascending(user => user.GoogleSubject), - new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }), cancellationToken: cancellationToken); + /// MongoDB has no EF migrations; provisioning creates the identity and username boundaries. + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + await Users.Indexes.CreateManyAsync([ + new CreateIndexModel(Builders.IndexKeys.Ascending(user => user.GoogleSubject), + new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }), + new CreateIndexModel(Builders.IndexKeys.Ascending("Portfolio.Username"), + new CreateIndexOptions + { + Unique = true, + Name = "IX_users_Portfolio_Username", + PartialFilterExpression = Builders.Filter.Type("Portfolio.Username", BsonType.String) + }) + ], cancellationToken: cancellationToken); + } } public static class MongoDatabaseRegistration @@ -46,6 +59,7 @@ public static IServiceCollection AddMongoDatabase(this IServiceCollection servic services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; } @@ -58,6 +72,7 @@ public sealed class MongoUserDocument public required string Email { get; set; } public required string Name { get; set; } public long CreatedAtUtc { get; set; } + [BsonIgnoreIfNull] public MongoPortfolioDocument? Portfolio { get; set; } public LearnerUser ToUser() => new() { @@ -65,6 +80,32 @@ public sealed class MongoUserDocument }; } +/// Embedded in the owning account so deleting it also removes its private and public portfolio. +public sealed class MongoPortfolioDocument +{ + [BsonIgnoreIfNull] public string? Username { get; set; } + public required string DraftJson { get; set; } + [BsonIgnoreIfNull] public string? PublishedJson { get; set; } + public bool SearchVisible { get; set; } + public bool PublishedSearchVisible { get; set; } + [BsonRepresentation(BsonType.String)] public PortfolioStatus Status { get; set; } + public required string Revision { get; set; } + public long CreatedAtUtc { get; set; } + public long UpdatedAtUtc { get; set; } + public long? PublishedAtUtc { get; set; } + + public PortfolioRecord ToRecord(Guid userId) => new(userId, Username, DraftJson, PublishedJson, + SearchVisible, PublishedSearchVisible, Status, Guid.Parse(Revision), CreatedAtUtc, UpdatedAtUtc, PublishedAtUtc); + + public static MongoPortfolioDocument From(PortfolioRecord record) => new() + { + Username = record.Username, DraftJson = record.DraftJson, PublishedJson = record.PublishedJson, + SearchVisible = record.SearchVisible, PublishedSearchVisible = record.PublishedSearchVisible, + Status = record.Status, Revision = record.Revision.ToString("D"), CreatedAtUtc = record.CreatedAtUtc, + UpdatedAtUtc = record.UpdatedAtUtc, PublishedAtUtc = record.PublishedAtUtc + }; +} + public sealed class MongoProgressDocument { [BsonId] public required string Id { get; set; } diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs new file mode 100644 index 0000000..f29568b --- /dev/null +++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs @@ -0,0 +1,124 @@ +using MongoDB.Driver; +using ProCoderJi.Api.Features.Portfolio; + +namespace ProCoderJi.Api.Data.Mongo; + +/// Each write replaces only the owner's embedded portfolio under its expected revision. +public sealed class MongoPortfolioStore(MongoPlatformDatabase database) : IPortfolioStore +{ + public async Task GetOwner(Guid userId, CancellationToken cancellationToken) + { + var user = await database.Users.Find(row => row.Id == userId.ToString("D")).SingleOrDefaultAsync(cancellationToken); + return user?.Portfolio?.ToRecord(userId); + } + + public async Task GetPublished(string username, CancellationToken cancellationToken) + { + var user = await database.Users.Find(row => row.Portfolio != null && row.Portfolio.Username == username + && row.Portfolio.Status == PortfolioStatus.Published && row.Portfolio.PublishedJson != null) + .SingleOrDefaultAsync(cancellationToken); + return user?.Portfolio?.ToRecord(Guid.Parse(user.Id)); + } + + public async Task IsUsernameAvailable(string username, Guid userId, CancellationToken cancellationToken) => + !await database.Users.Find(row => row.Id != userId.ToString("D") && row.Portfolio != null + && row.Portfolio.Username == username).AnyAsync(cancellationToken); + + public Task SaveDraft(Guid userId, Guid? revision, string dataJson, bool searchVisible, CancellationToken cancellationToken) => + Write(userId, revision, allowCreate: true, (current, _) => current with + { + DraftJson = dataJson, SearchVisible = searchVisible + }, cancellationToken); + + public Task ClaimUsername(Guid userId, Guid? revision, string username, CancellationToken cancellationToken) => + Write(userId, revision, allowCreate: true, (current, _) => + { + if (current.Username is not null && current.Username != username) + throw new PortfolioConflictException("username-locked"); + return current with { Username = username }; + }, cancellationToken); + + public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken) => + Write(userId, revision, allowCreate: false, (current, now) => + { + if (current.Status == PortfolioStatus.Suspended) throw new PortfolioConflictException("suspended"); + if (current.Username is null) throw new PortfolioConflictException("username-required"); + return current with + { + Status = PortfolioStatus.Published, PublishedJson = current.DraftJson, + PublishedSearchVisible = current.SearchVisible, PublishedAtUtc = now + }; + }, cancellationToken); + + public Task Unpublish(Guid userId, Guid revision, CancellationToken cancellationToken) => + Write(userId, revision, allowCreate: false, (current, _) => current with + { + Status = current.Status == PortfolioStatus.Suspended ? PortfolioStatus.Suspended : PortfolioStatus.Draft, + PublishedJson = null, PublishedSearchVisible = false + }, cancellationToken); + + public async Task Delete(Guid userId, Guid revision, CancellationToken cancellationToken) + { + var user = await User(userId, cancellationToken); + if (user.Portfolio is null) return false; + if (user.Portfolio.Revision != revision.ToString("D")) throw new PortfolioConflictException("stale-revision"); + if (user.Portfolio.Status == PortfolioStatus.Suspended) throw new PortfolioConflictException("suspended"); + var filter = RevisionFilter(userId, revision) + & Builders.Filter.Eq(row => row.Portfolio!.Status, user.Portfolio.Status); + var result = await database.Users.UpdateOneAsync(filter, + Builders.Update.Unset(row => row.Portfolio), cancellationToken: cancellationToken); + if (result.MatchedCount == 1) return true; + var latest = await User(userId, cancellationToken); + if (latest.Portfolio is null) return false; + if (latest.Portfolio.Status == PortfolioStatus.Suspended) throw new PortfolioConflictException("suspended"); + throw new PortfolioConflictException("stale-revision"); + } + + private async Task Write(Guid userId, Guid? revision, bool allowCreate, + Func change, CancellationToken cancellationToken) + { + var user = await User(userId, cancellationToken); + var current = user.Portfolio?.ToRecord(userId); + if (current is null && !allowCreate) throw new PortfolioConflictException("not-found"); + if (current?.Revision != revision) throw new PortfolioConflictException("stale-revision"); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + current ??= new PortfolioRecord(userId, null, "{}", null, false, false, + PortfolioStatus.Draft, Guid.Empty, now, now, null); + var next = change(current, now) with { Revision = Guid.NewGuid(), UpdatedAtUtc = now }; + UpdateResult result; + try + { + var filter = RevisionFilter(userId, revision); + // Also preserve an operator's concurrent status change, including a suspension + // written by an administrative tool that has not advanced the revision. + if (user.Portfolio is not null) + filter &= Builders.Filter.Eq(row => row.Portfolio!.Status, user.Portfolio.Status); + result = await database.Users.UpdateOneAsync(filter, + Builders.Update.Set(row => row.Portfolio, MongoPortfolioDocument.From(next)), + cancellationToken: cancellationToken); + } + catch (Exception error) when (IsDuplicateKey(error)) + { + throw new PortfolioConflictException("username-taken"); + } + if (result.MatchedCount == 1) return next; + if (!await database.Users.Find(row => row.Id == userId.ToString("D")).AnyAsync(cancellationToken)) + throw new PortfolioConflictException("account-not-found"); + throw new PortfolioConflictException("stale-revision"); + } + + private async Task User(Guid userId, CancellationToken cancellationToken) => + await database.Users.Find(row => row.Id == userId.ToString("D")).SingleOrDefaultAsync(cancellationToken) + ?? throw new PortfolioConflictException("account-not-found"); + + private static FilterDefinition RevisionFilter(Guid userId, Guid? revision) + { + var filter = Builders.Filter.Eq(row => row.Id, userId.ToString("D")); + return filter & (revision is null + ? Builders.Filter.Eq(row => row.Portfolio, null) + : Builders.Filter.Eq("Portfolio.Revision", revision.Value.ToString("D"))); + } + + private static bool IsDuplicateKey(Exception error) => error is MongoCommandException { Code: 11000 or 11001 } + or MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey }; +} diff --git a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs index 511c32c..78dc7bb 100644 --- a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs +++ b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using ProCoderJi.Api.Features.Portfolio; namespace ProCoderJi.Api.Data; @@ -8,6 +9,7 @@ public sealed class PlatformDbContext(DbContextOptions option public DbSet Users => Set(); public DbSet ProgressStates => Set(); public DbSet Attempts => Set(); + public DbSet Portfolios => Set(); public DbSet DataProtectionKeys { get; set; } = null!; protected override void OnModelCreating(ModelBuilder model) @@ -43,6 +45,24 @@ protected override void OnModelCreating(ModelBuilder model) : "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192"); }); }); + model.Entity(entity => + { + entity.HasKey(portfolio => portfolio.UserId); + entity.Property(portfolio => portfolio.UserId).ValueGeneratedNever(); + entity.Property(portfolio => portfolio.Username).HasMaxLength(30); + entity.HasIndex(portfolio => portfolio.Username).IsUnique() + .HasFilter(Database.IsSqlServer() ? "[Username] IS NOT NULL" : null); + entity.Property(portfolio => portfolio.DraftJson).HasMaxLength(262144); + entity.Property(portfolio => portfolio.PublishedJson).HasMaxLength(262144); + entity.Property(portfolio => portfolio.Status).HasConversion().HasMaxLength(16).IsConcurrencyToken(); + entity.Property(portfolio => portfolio.Revision).IsConcurrencyToken(); + entity.HasOne().WithOne().HasForeignKey(portfolio => portfolio.UserId).OnDelete(DeleteBehavior.Cascade); + entity.ToTable(table => + { + table.HasCheckConstraint("CK_Portfolio_Status", "\"Status\" IN ('Draft', 'Published', 'Suspended')"); + table.HasCheckConstraint("CK_Portfolio_Publication", "\"Status\" <> 'Published' OR (\"Username\" IS NOT NULL AND \"PublishedJson\" IS NOT NULL)"); + }); + }); } } @@ -73,3 +93,18 @@ public sealed class PracticeAttemptEntity public int DurationSeconds { get; set; } public long CompletedAtUtc { get; set; } } + +public sealed class PortfolioEntity +{ + public Guid UserId { get; set; } + public string? Username { get; set; } + public required string DraftJson { get; set; } + public string? PublishedJson { get; set; } + public bool SearchVisible { get; set; } + public bool PublishedSearchVisible { get; set; } + public PortfolioStatus Status { get; set; } + public Guid Revision { get; set; } + public long CreatedAtUtc { get; set; } + public long UpdatedAtUtc { get; set; } + public long? PublishedAtUtc { get; set; } +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs new file mode 100644 index 0000000..4aea1bd --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs @@ -0,0 +1,27 @@ +namespace ProCoderJi.Api.Features.Portfolio; + +public enum PortfolioStatus { Draft, Published, Suspended } + +/// Private storage snapshot. Public responses must expose only the published fields. +public sealed record PortfolioRecord( + Guid UserId, string? Username, string DraftJson, string? PublishedJson, + bool SearchVisible, bool PublishedSearchVisible, PortfolioStatus Status, + Guid Revision, long CreatedAtUtc, long UpdatedAtUtc, long? PublishedAtUtc); + +public sealed class PortfolioConflictException(string code) : Exception(code) +{ + public string Code { get; } = code; +} + +/// Owner-scoped writes use a revision; public reads return published snapshots only. +public interface IPortfolioStore +{ + Task GetOwner(Guid userId, CancellationToken cancellationToken); + Task GetPublished(string username, CancellationToken cancellationToken); + Task IsUsernameAvailable(string username, Guid userId, CancellationToken cancellationToken); + Task SaveDraft(Guid userId, Guid? revision, string dataJson, bool searchVisible, CancellationToken cancellationToken); + Task ClaimUsername(Guid userId, Guid? revision, string username, CancellationToken cancellationToken); + Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken); + Task Unpublish(Guid userId, Guid revision, CancellationToken cancellationToken); + Task Delete(Guid userId, Guid revision, CancellationToken cancellationToken); +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioApiConfiguration.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioApiConfiguration.cs new file mode 100644 index 0000000..96e3bf4 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioApiConfiguration.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Threading.RateLimiting; +using ProCoderJi.Api.Infrastructure; + +namespace ProCoderJi.Api.Features.Portfolio; + +public static class PortfolioApiConfiguration +{ + public const string WritePolicy = "portfolio-write"; + public const string UsernamePolicy = "portfolio-username"; + + public static IServiceCollection AddPortfolioApi(this IServiceCollection services) + { + services.AddSingleton(_ => PortfolioValidator.CreateDefault()); + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy(WritePolicy, context => Partition(context, 10)); + options.AddPolicy(UsernamePolicy, context => Partition(context, 30)); + options.OnRejected = async (context, cancellationToken) => + { + var seconds = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter) + ? Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)) : 60; + context.HttpContext.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture); + await context.HttpContext.Response.WriteAsJsonAsync(new + { + code = "rate-limited", + error = "Too many portfolio requests. Wait before trying again." + }, cancellationToken); + }; + }); + return services; + } + + private static RateLimitPartition Partition(HttpContext context, int permits) + { + var id = ApiSecurity.UserId(context); + // Unauthenticated requests reach the endpoint's 401 response. Never partition on + // browser-supplied IDs: one authenticated learner cannot spend another's allowance. + if (id is null) return RateLimitPartition.GetNoLimiter("anonymous"); + return RateLimitPartition.GetFixedWindowLimiter(id.Value.ToString("D"), _ => new FixedWindowRateLimiterOptions + { + PermitLimit = permits, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + }); + } +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs new file mode 100644 index 0000000..9e82469 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs @@ -0,0 +1,239 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.RateLimiting; +using ProCoderJi.Api.Configuration; +using ProCoderJi.Api.Infrastructure; + +namespace ProCoderJi.Api.Features.Portfolio; + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record SavePortfolioDraftRequest( + [property: JsonRequired] JsonNode? Data, + [property: JsonRequired] bool SearchVisible, + [property: JsonRequired] Guid? Revision); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record ClaimPortfolioUsernameRequest( + [property: JsonRequired] string? Username, + [property: JsonRequired] Guid? Revision); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record PortfolioRevisionRequest([property: JsonRequired] Guid Revision); + +public static class PortfolioEndpoints +{ + private const int MaxBytes = 256 * 1024; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + + public static void MapPortfolioEndpoints(this WebApplication app) + { + app.MapGet("/api/platform/portfolio", GetOwner); + app.MapGet("/api/platform/portfolio/username", CheckUsername) + .RequireRateLimiting(PortfolioApiConfiguration.UsernamePolicy); + app.MapGet("/api/platform/portfolio/public/{username}", GetPublic); + app.MapPost("/api/platform/portfolio/draft", SaveDraft) + .RequireRateLimiting(PortfolioApiConfiguration.WritePolicy); + app.MapPost("/api/platform/portfolio/username", ClaimUsername) + .RequireRateLimiting(PortfolioApiConfiguration.WritePolicy); + app.MapPost("/api/platform/portfolio/publish", Publish) + .RequireRateLimiting(PortfolioApiConfiguration.WritePolicy); + app.MapPost("/api/platform/portfolio/unpublish", Unpublish) + .RequireRateLimiting(PortfolioApiConfiguration.WritePolicy); + app.MapDelete("/api/platform/portfolio", Delete) + .RequireRateLimiting(PortfolioApiConfiguration.WritePolicy); + } + + private static async Task GetOwner(HttpContext context, IPortfolioStore store, IConfiguration configuration) + { + var error = CheckOwner(context, out var userId); + if (error is not null) return error; + var portfolio = await store.GetOwner(userId, context.RequestAborted); + return Results.Json(new { portfolio = portfolio is null ? null : Owner(portfolio), publishingEnabled = PublishingEnabled(configuration) }); + } + + private static async Task CheckUsername(HttpContext context, string? username, IPortfolioStore store, PortfolioValidator validator) + { + var error = CheckOwner(context, out var userId); + if (error is not null) return error; + var problems = validator.ValidateUsername(JsonValue.Create(username)); + if (problems.Count != 0) return Invalid(problems); + return Results.Json(new { username, available = await store.IsUsernameAvailable(username!, userId, context.RequestAborted) }); + } + + private static async Task GetPublic(string username, HttpContext context, IPortfolioStore store, PortfolioValidator validator, IConfiguration configuration) + { + context.Response.Headers["X-Robots-Tag"] = "noindex"; + if (!PublishingEnabled(configuration) || validator.ValidateUsername(username).Count != 0) + return Results.NotFound(); + var portfolio = await store.GetPublished(username, context.RequestAborted); + if (portfolio is null || portfolio.Status != PortfolioStatus.Published || portfolio.PublishedJson is null) + return Results.NotFound(); + // This whitelist intentionally excludes drafts, internal identity, revision, and + // the account's Google profile. Search visibility comes from the published version. + return Results.Json(new + { + username = portfolio.Username, + data = JsonNode.Parse(portfolio.PublishedJson), + searchVisible = portfolio.PublishedSearchVisible, + publishedAt = Timestamp(portfolio.PublishedAtUtc) + }); + } + + private static async Task SaveDraft(HttpContext context, IPortfolioStore store, PortfolioValidator validator, PlatformConfiguration configuration, IAntiforgery antiforgery) + { + var error = await CheckWrite(context, configuration, antiforgery); + if (error is not null) return error; + var read = await Read(context); + if (read.Error is not null) return read.Error; + var body = read.Body!; + if (body.Revision == Guid.Empty) return InvalidRevision(); + var problems = validator.Validate(body.Data, PortfolioMode.Draft); + if (problems.Count != 0) return Invalid(problems); + return await Mutation(() => store.SaveDraft(ApiSecurity.UserId(context)!.Value, body.Revision, + body.Data!.ToJsonString(Json), body.SearchVisible, context.RequestAborted)); + } + + private static async Task ClaimUsername(HttpContext context, IPortfolioStore store, PortfolioValidator validator, PlatformConfiguration configuration, IAntiforgery antiforgery) + { + var error = await CheckWrite(context, configuration, antiforgery); + if (error is not null) return error; + var read = await Read(context); + if (read.Error is not null) return read.Error; + if (read.Body!.Revision == Guid.Empty) return InvalidRevision(); + var problems = validator.ValidateUsername(JsonValue.Create(read.Body.Username)); + if (problems.Count != 0) return Invalid(problems); + return await Mutation(() => store.ClaimUsername(ApiSecurity.UserId(context)!.Value, + read.Body.Revision, read.Body.Username!, context.RequestAborted)); + } + + private static async Task Publish(HttpContext context, IPortfolioStore store, PortfolioValidator validator, IConfiguration settings, PlatformConfiguration configuration, IAntiforgery antiforgery) + { + var error = await CheckWrite(context, configuration, antiforgery); + if (error is not null) return error; + var read = await Read(context); + if (read.Error is not null) return read.Error; + if (read.Body!.Revision == Guid.Empty) return InvalidRevision(); + if (!PublishingEnabled(settings)) + return Error("publishing-disabled", "Public portfolio publishing is not enabled yet.", 503); + var userId = ApiSecurity.UserId(context)!.Value; + var portfolio = await store.GetOwner(userId, context.RequestAborted); + if (portfolio is null) return Conflict("not-found"); + if (portfolio.Revision != read.Body.Revision) return Conflict("stale-revision"); + if (portfolio.Status == PortfolioStatus.Suspended) return Conflict("suspended"); + var problems = validator.Validate(JsonNode.Parse(portfolio.DraftJson), PortfolioMode.Publish); + if (problems.Count != 0) return Invalid(problems); + return await Mutation(() => store.Publish(userId, read.Body.Revision, context.RequestAborted)); + } + + private static async Task Unpublish(HttpContext context, IPortfolioStore store, PlatformConfiguration configuration, IAntiforgery antiforgery) + { + var error = await CheckWrite(context, configuration, antiforgery); + if (error is not null) return error; + var read = await Read(context); + if (read.Error is not null) return read.Error; + if (read.Body!.Revision == Guid.Empty) return InvalidRevision(); + return await Mutation(() => store.Unpublish(ApiSecurity.UserId(context)!.Value, read.Body.Revision, context.RequestAborted)); + } + + private static async Task Delete(HttpContext context, IPortfolioStore store, PlatformConfiguration configuration, IAntiforgery antiforgery) + { + var error = await CheckWrite(context, configuration, antiforgery); + if (error is not null) return error; + var read = await Read(context); + if (read.Error is not null) return read.Error; + if (read.Body!.Revision == Guid.Empty) return InvalidRevision(); + try + { + await store.Delete(ApiSecurity.UserId(context)!.Value, read.Body.Revision, context.RequestAborted); + return Results.NoContent(); + } + catch (PortfolioConflictException cause) { return Conflict(cause.Code); } + } + + private static object Owner(PortfolioRecord record) => new + { + username = record.Username, + data = JsonNode.Parse(record.DraftJson), + searchVisible = record.SearchVisible, + status = record.Status.ToString().ToLowerInvariant(), + revision = record.Revision, + createdAt = Timestamp(record.CreatedAtUtc), + updatedAt = Timestamp(record.UpdatedAtUtc), + publishedAt = Timestamp(record.PublishedAtUtc) + }; + + private static string? Timestamp(long? milliseconds) => milliseconds is null ? null + : DateTimeOffset.FromUnixTimeMilliseconds(milliseconds.Value).ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); + + private static bool PublishingEnabled(IConfiguration configuration) => configuration.GetValue("Portfolio:PublishingEnabled"); + + private static IResult? CheckOwner(HttpContext context, out Guid userId) + { + var id = ApiSecurity.UserId(context); + userId = id ?? Guid.Empty; + return id is null ? Error("sign-in-required", "Sign in to manage your portfolio.", 401) + : ApiSecurity.CheckAccount(context, userId); + } + + private static async Task CheckWrite(HttpContext context, PlatformConfiguration configuration, IAntiforgery antiforgery) + { + var error = CheckOwner(context, out _); + return error ?? await ApiSecurity.CheckWrite(context, configuration, antiforgery); + } + + private static IResult Invalid(IReadOnlyList problems) => Results.Json(new + { + code = "validation-failed", error = "Correct the portfolio fields before saving.", problems + }, statusCode: 400); + + private static IResult InvalidRevision() => Error("invalid-revision", "Send the current portfolio revision, or null when creating a draft.", 400); + + private static IResult Error(string code, string message, int status) => Results.Json(new { code, error = message }, statusCode: status); + + private static IResult Conflict(string code) => code switch + { + "not-found" => Error(code, "Portfolio not found.", 404), + "account-not-found" => Error(code, "Sign in to manage your portfolio.", 401), + "username-required" => Error(code, "Claim a username before publishing.", 400), + "suspended" => Error(code, "This portfolio is suspended. Contact support before publishing.", 403), + "username-taken" => Error(code, "That username is already claimed.", 409), + "username-locked" => Error(code, "A claimed username cannot be changed.", 409), + _ => Error("stale-revision", "Your portfolio changed. Refresh before trying again.", 409) + }; + + private static async Task Mutation(Func> action) + { + try { return Results.Json(new { portfolio = Owner(await action()) }); } + catch (PortfolioConflictException cause) { return Conflict(cause.Code); } + } + + private static async Task<(T? Body, IResult? Error)> Read(HttpContext context) where T : class + { + if (!context.Request.HasJsonContentType()) return (null, Error("json-required", "Send a JSON portfolio request.", 415)); + if (context.Request.ContentLength > MaxBytes) return (null, Error("payload-too-large", "The portfolio request is too large.", 413)); + try + { + using var buffer = new MemoryStream(); + var chunk = new byte[8192]; + int count; + while ((count = await context.Request.Body.ReadAsync(chunk, context.RequestAborted)) > 0) + { + if (buffer.Length + count > MaxBytes) return (null, Error("payload-too-large", "The portfolio request is too large.", 413)); + await buffer.WriteAsync(chunk.AsMemory(0, count), context.RequestAborted); + } + var body = JsonSerializer.Deserialize(buffer.ToArray(), Json); + return body is null ? (null, Error("invalid-payload", "Send a valid portfolio request.", 400)) : (body, null); + } + catch (JsonException) { return (null, Error("invalid-payload", "Send a valid portfolio request and revision.", 400)); } + catch (BadHttpRequestException cause) when (cause.StatusCode == 413) + { + return (null, Error("payload-too-large", "The portfolio request is too large.", 413)); + } + } +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/RelationalPortfolioStore.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/RelationalPortfolioStore.cs new file mode 100644 index 0000000..c26f179 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/RelationalPortfolioStore.cs @@ -0,0 +1,155 @@ +using System.Data; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using ProCoderJi.Api.Data; + +namespace ProCoderJi.Api.Features.Portfolio; + +/// Relational portfolio writes are owner-scoped, revision-checked transactions. +public sealed class RelationalPortfolioStore(IDbContextFactory factory) : IPortfolioStore +{ + public async Task GetOwner(Guid userId, CancellationToken cancellationToken) + { + await using var db = await factory.CreateDbContextAsync(cancellationToken); + var row = await db.Portfolios.AsNoTracking().SingleOrDefaultAsync(row => row.UserId == userId, cancellationToken); + return row is null ? null : Record(row); + } + + public async Task GetPublished(string username, CancellationToken cancellationToken) + { + await using var db = await factory.CreateDbContextAsync(cancellationToken); + var row = await db.Portfolios.AsNoTracking().SingleOrDefaultAsync(row => row.Username == username + && row.Status == PortfolioStatus.Published && row.PublishedJson != null, cancellationToken); + return row is null ? null : Record(row); + } + + public async Task IsUsernameAvailable(string username, Guid userId, CancellationToken cancellationToken) + { + await using var db = await factory.CreateDbContextAsync(cancellationToken); + return !await db.Portfolios.AnyAsync(row => row.Username == username && row.UserId != userId, cancellationToken); + } + + public Task SaveDraft(Guid userId, Guid? revision, string dataJson, bool searchVisible, CancellationToken cancellationToken) => + InTransaction(async db => + { + var row = await Editable(db, userId, revision, create: true, cancellationToken); + row.DraftJson = dataJson; + row.SearchVisible = searchVisible; + Touch(row); + await db.SaveChangesAsync(cancellationToken); + return Record(row); + }, cancellationToken); + + public Task ClaimUsername(Guid userId, Guid? revision, string username, CancellationToken cancellationToken) => + InTransaction(async db => + { + var row = await Editable(db, userId, revision, create: true, cancellationToken); + if (row.Username is not null && row.Username != username) throw new PortfolioConflictException("username-locked"); + if (await db.Portfolios.AnyAsync(other => other.Username == username && other.UserId != userId, cancellationToken)) + throw new PortfolioConflictException("username-taken"); + if (row.Username == username) return Record(row); + row.Username = username; + Touch(row); + await db.SaveChangesAsync(cancellationToken); + return Record(row); + }, cancellationToken); + + public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken) => + InTransaction(async db => + { + var row = await Editable(db, userId, revision, create: false, cancellationToken); + if (row.Status == PortfolioStatus.Suspended) throw new PortfolioConflictException("suspended"); + if (row.Username is null) throw new PortfolioConflictException("username-required"); + row.PublishedJson = row.DraftJson; + row.PublishedSearchVisible = row.SearchVisible; + row.Status = PortfolioStatus.Published; + row.PublishedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + Touch(row); + await db.SaveChangesAsync(cancellationToken); + return Record(row); + }, cancellationToken); + + public Task Unpublish(Guid userId, Guid revision, CancellationToken cancellationToken) => + InTransaction(async db => + { + var row = await Editable(db, userId, revision, create: false, cancellationToken); + row.PublishedJson = null; + row.PublishedSearchVisible = false; + if (row.Status != PortfolioStatus.Suspended) row.Status = PortfolioStatus.Draft; + Touch(row); + await db.SaveChangesAsync(cancellationToken); + return Record(row); + }, cancellationToken); + + public Task Delete(Guid userId, Guid revision, CancellationToken cancellationToken) => + InTransaction(async db => + { + var row = await db.Portfolios.SingleOrDefaultAsync(row => row.UserId == userId, cancellationToken); + if (row is null) return false; + if (row.Revision != revision) throw new PortfolioConflictException("stale-revision"); + if (row.Status == PortfolioStatus.Suspended) throw new PortfolioConflictException("suspended"); + db.Portfolios.Remove(row); + await db.SaveChangesAsync(cancellationToken); + return true; + }, cancellationToken); + + private static async Task Editable(PlatformDbContext db, Guid userId, Guid? revision, bool create, CancellationToken cancellationToken) + { + var row = await db.Portfolios.SingleOrDefaultAsync(row => row.UserId == userId, cancellationToken); + if (row is not null) + { + if (revision is null || row.Revision != revision) throw new PortfolioConflictException("stale-revision"); + return row; + } + if (!create) throw new PortfolioConflictException("not-found"); + if (revision is not null) throw new PortfolioConflictException("stale-revision"); + if (!await db.Users.AnyAsync(user => user.Id == userId, cancellationToken)) throw new PortfolioConflictException("account-not-found"); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + row = new PortfolioEntity { UserId = userId, DraftJson = "{}", Status = PortfolioStatus.Draft, + Revision = Guid.NewGuid(), CreatedAtUtc = now, UpdatedAtUtc = now }; + db.Portfolios.Add(row); + return row; + } + + private static void Touch(PortfolioEntity row) + { + row.Revision = Guid.NewGuid(); + row.UpdatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + private static PortfolioRecord Record(PortfolioEntity row) => new(row.UserId, row.Username, row.DraftJson, row.PublishedJson, + row.SearchVisible, row.PublishedSearchVisible, row.Status, row.Revision, row.CreatedAtUtc, row.UpdatedAtUtc, row.PublishedAtUtc); + + private async Task InTransaction(Func> operation, CancellationToken cancellationToken) + { + for (var retry = 0; ; retry++) + { + await using var db = await factory.CreateDbContextAsync(cancellationToken); + try + { + await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + var result = await operation(db); + await transaction.CommitAsync(cancellationToken); + return result; + } + catch (Exception error) when (retry < 3 && IsRetryable(error)) + { + await Task.Delay(25 * (retry + 1), cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + throw new PortfolioConflictException("stale-revision"); + } + } + } + + private static bool IsRetryable(Exception error) => error is DbUpdateConcurrencyException + || error is SqliteException { SqliteErrorCode: 5 or 6 or 19 } + || error is PostgresException { SqlState: "40001" or "40P01" or "23505" } + || error is SqlException { Number: 1205 or 2601 or 2627 or 3960 } + // Provider execution strategies may wrap a serialization/deadlock failure in + // InvalidOperationException around DbUpdateException. Retry the whole transaction. + || error.InnerException is not null && IsRetryable(error.InnerException); +} diff --git a/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs b/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs index 01e389e..46a3536 100644 --- a/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs +++ b/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs @@ -96,5 +96,7 @@ private static bool IsRetryable(Exception error) => error is DbUpdateConcurrency || error is SqliteException { SqliteErrorCode: 5 or 6 } || error is PostgresException { SqlState: "40001" or "40P01" or "23505" } || error is SqlException { Number: 1205 or 2601 or 2627 or 3960 } - || error is DbUpdateException { InnerException: not null } update && IsRetryable(update.InnerException!); + // EF provider strategies can add an InvalidOperationException wrapper around a + // transient save failure. Preserve whole-transaction retries through that wrapper. + || error.InnerException is not null && IsRetryable(error.InnerException); } diff --git a/backend/src/ProCoderJi.Api/Infrastructure/ApiSecurity.cs b/backend/src/ProCoderJi.Api/Infrastructure/ApiSecurity.cs index 91c1b00..9f00ddf 100644 --- a/backend/src/ProCoderJi.Api/Infrastructure/ApiSecurity.cs +++ b/backend/src/ProCoderJi.Api/Infrastructure/ApiSecurity.cs @@ -10,7 +10,7 @@ public static class ApiSecurity public static IResult? CheckAccount(HttpContext context, Guid id) => !Guid.TryParse(context.Request.Headers["x-account-id"], out var expected) || expected != id - ? Results.Json(new { error = "The signed-in account changed. Refresh before syncing progress." }, statusCode: 409) : null; + ? Results.Json(new { error = "The signed-in account changed. Refresh before trying again." }, statusCode: 409) : null; public static async Task CheckWrite(HttpContext context, PlatformConfiguration configuration, IAntiforgery antiforgery) { diff --git a/backend/src/ProCoderJi.Api/Migrations/20261110133000_StudentPortfolios.cs b/backend/src/ProCoderJi.Api/Migrations/20261110133000_StudentPortfolios.cs new file mode 100644 index 0000000..c06afdd --- /dev/null +++ b/backend/src/ProCoderJi.Api/Migrations/20261110133000_StudentPortfolios.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using ProCoderJi.Api.Data; + +namespace ProCoderJi.Api.Migrations; + +[DbContext(typeof(PlatformDbContext))] +[Migration("20261110133000_StudentPortfolios")] +public sealed class StudentPortfolios : Migration +{ + protected override void Up(MigrationBuilder migration) + { + migration.CreateTable("Portfolios", columns: table => new + { + UserId = table.Column(nullable: false), Username = table.Column(maxLength: 30, nullable: true), + DraftJson = table.Column(maxLength: 262144, nullable: false), PublishedJson = table.Column(maxLength: 262144, nullable: true), + SearchVisible = table.Column(nullable: false), PublishedSearchVisible = table.Column(nullable: false), + Status = table.Column(maxLength: 16, nullable: false), Revision = table.Column(nullable: false), + CreatedAtUtc = table.Column(nullable: false), UpdatedAtUtc = table.Column(nullable: false), PublishedAtUtc = table.Column(nullable: true), + }, constraints: table => + { + table.PrimaryKey("PK_Portfolios", portfolio => portfolio.UserId); + table.ForeignKey("FK_Portfolios_Users_UserId", portfolio => portfolio.UserId, "Users", "Id", onDelete: ReferentialAction.Cascade); + table.CheckConstraint("CK_Portfolio_Status", "\"Status\" IN ('Draft', 'Published', 'Suspended')"); + table.CheckConstraint("CK_Portfolio_Publication", "\"Status\" <> 'Published' OR (\"Username\" IS NOT NULL AND \"PublishedJson\" IS NOT NULL)"); + }); + migration.CreateIndex("IX_Portfolios_Username", "Portfolios", "Username", unique: true, + filter: ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer" ? "[Username] IS NOT NULL" : null); + } + + protected override void Down(MigrationBuilder migration) => migration.DropTable("Portfolios"); +} diff --git a/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs index 372c869..3a2d66b 100644 --- a/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs +++ b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using ProCoderJi.Api.Data; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ProCoderJi.Api.Features.Portfolio; namespace ProCoderJi.Api.Migrations; @@ -60,5 +61,27 @@ protected override void BuildModel(ModelBuilder model) }); entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithMany().HasForeignKey("UserId").OnDelete(DeleteBehavior.Cascade).IsRequired(); }); + model.Entity("ProCoderJi.Api.Data.PortfolioEntity", entity => + { + entity.Property("UserId").ValueGeneratedNever(); + entity.Property("Username").HasMaxLength(30); + entity.Property("DraftJson").IsRequired().HasMaxLength(262144); + entity.Property("PublishedJson").HasMaxLength(262144); + entity.Property("SearchVisible"); + entity.Property("PublishedSearchVisible"); + entity.Property("Status").HasConversion().HasMaxLength(16).IsConcurrencyToken(); + entity.Property("Revision").IsConcurrencyToken(); + entity.Property("CreatedAtUtc"); + entity.Property("UpdatedAtUtc"); + entity.Property("PublishedAtUtc"); + entity.HasKey("UserId"); + entity.HasIndex("Username").IsUnique(); + entity.ToTable("Portfolios", table => + { + table.HasCheckConstraint("CK_Portfolio_Status", "\"Status\" IN ('Draft', 'Published', 'Suspended')"); + table.HasCheckConstraint("CK_Portfolio_Publication", "\"Status\" <> 'Published' OR (\"Username\" IS NOT NULL AND \"PublishedJson\" IS NOT NULL)"); + }); + entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithOne().HasForeignKey("ProCoderJi.Api.Data.PortfolioEntity", "UserId").OnDelete(DeleteBehavior.Cascade).IsRequired(); + }); } } diff --git a/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs index 4bf17e0..784c5c3 100644 --- a/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs +++ b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using ProCoderJi.Api.Data; using Microsoft.EntityFrameworkCore.Metadata; +using ProCoderJi.Api.Features.Portfolio; namespace ProCoderJi.Api.Migrations; @@ -61,5 +62,27 @@ protected override void BuildModel(ModelBuilder model) }); entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithMany().HasForeignKey("UserId").OnDelete(DeleteBehavior.Cascade).IsRequired(); }); + model.Entity("ProCoderJi.Api.Data.PortfolioEntity", entity => + { + entity.Property("UserId").ValueGeneratedNever(); + entity.Property("Username").HasMaxLength(30); + entity.Property("DraftJson").IsRequired().HasMaxLength(262144); + entity.Property("PublishedJson").HasMaxLength(262144); + entity.Property("SearchVisible"); + entity.Property("PublishedSearchVisible"); + entity.Property("Status").HasConversion().HasMaxLength(16).IsConcurrencyToken(); + entity.Property("Revision").IsConcurrencyToken(); + entity.Property("CreatedAtUtc"); + entity.Property("UpdatedAtUtc"); + entity.Property("PublishedAtUtc"); + entity.HasKey("UserId"); + entity.HasIndex("Username").IsUnique().HasFilter("[Username] IS NOT NULL"); + entity.ToTable("Portfolios", table => + { + table.HasCheckConstraint("CK_Portfolio_Status", "\"Status\" IN ('Draft', 'Published', 'Suspended')"); + table.HasCheckConstraint("CK_Portfolio_Publication", "\"Status\" <> 'Published' OR (\"Username\" IS NOT NULL AND \"PublishedJson\" IS NOT NULL)"); + }); + entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithOne().HasForeignKey("ProCoderJi.Api.Data.PortfolioEntity", "UserId").OnDelete(DeleteBehavior.Cascade).IsRequired(); + }); } } diff --git a/backend/src/ProCoderJi.Api/Program.cs b/backend/src/ProCoderJi.Api/Program.cs index 12e899b..4a785b8 100644 --- a/backend/src/ProCoderJi.Api/Program.cs +++ b/backend/src/ProCoderJi.Api/Program.cs @@ -11,6 +11,7 @@ using ProCoderJi.Api.Features.Accounts; using ProCoderJi.Api.Features.Practice; using ProCoderJi.Api.Features.Progress; +using ProCoderJi.Api.Features.Portfolio; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(services => PlatformConfiguration.From(services.GetRequiredService(), services.GetRequiredService())); @@ -23,10 +24,14 @@ builder.Services.AddMongoDatabase(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(services => IsMongo(services.GetRequiredService()) ? services.GetRequiredService() : services.GetRequiredService()); builder.Services.AddScoped(services => IsMongo(services.GetRequiredService()) ? services.GetRequiredService() : services.GetRequiredService()); +builder.Services.AddScoped(services => IsMongo(services.GetRequiredService()) + ? services.GetRequiredService() : services.GetRequiredService()); +builder.Services.AddPortfolioApi(); builder.Services.AddDataProtection().SetApplicationName("ProCoderJi.Platform"); builder.Services.AddOptions().Configure((options, services) => options.XmlRepository = IsMongo(services.GetRequiredService()) @@ -123,10 +128,12 @@ await next(); }); app.UseAuthentication(); +app.UseRateLimiter(); app.UseAuthorization(); app.UseAntiforgery(); app.MapAccountEndpoints(); app.MapProgressEndpoints(); +app.MapPortfolioEndpoints(); app.MapGet("/api/platform/health", () => Results.Json(new { status = "ok" })); app.Run(); diff --git a/backend/src/ProCoderJi.Api/appsettings.json b/backend/src/ProCoderJi.Api/appsettings.json index 127eb7e..bdeefb6 100644 --- a/backend/src/ProCoderJi.Api/appsettings.json +++ b/backend/src/ProCoderJi.Api/appsettings.json @@ -4,6 +4,9 @@ "Provider": "Sqlite", "MigrateOnStartup": false }, + "Portfolio": { + "PublishingEnabled": false + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/backend/tests/ProCoderJi.Api.Tests/MongoPortfolioTests.cs b/backend/tests/ProCoderJi.Api.Tests/MongoPortfolioTests.cs new file mode 100644 index 0000000..1b6fe09 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/MongoPortfolioTests.cs @@ -0,0 +1,160 @@ +using MongoDB.Driver; +using ProCoderJi.Api.Data.Mongo; +using ProCoderJi.Api.Features.Portfolio; + +namespace ProCoderJi.Api.Tests; + +public sealed class MongoPortfolioTests +{ + private const string Original = """{"name":"Riya","headline":"Student"}"""; + private const string Edited = """{"name":"Riya","headline":"Unpublished edit"}"""; + + [MongoFact] + public async Task UniqueUsernamesAreClaimedAtomicallyAndNullNamesDoNotConflict() + { + await using var sandbox = await Sandbox.Open(); + var first = await sandbox.Accounts.SignIn("first", "a@example.test", "A", default); + var second = await sandbox.Accounts.SignIn("second", "b@example.test", "B", default); + var a = await sandbox.Portfolios.SaveDraft(first.Id, null, Original, false, default); + var b = await sandbox.Portfolios.SaveDraft(second.Id, null, Original, false, default); + + var claims = await Task.WhenAll( + Attempt(() => sandbox.Portfolios.ClaimUsername(first.Id, a.Revision, "shared-name", default)), + Attempt(() => sandbox.Portfolios.ClaimUsername(second.Id, b.Revision, "shared-name", default))); + var winner = Assert.Single(claims, result => result.Record is not null).Record!; + Assert.Equal("username-taken", Assert.Single(claims, result => result.Error is not null).Error); + Assert.True(await sandbox.Portfolios.IsUsernameAvailable("shared-name", winner.UserId, default)); + var loserId = winner.UserId == first.Id ? second.Id : first.Id; + Assert.False(await sandbox.Portfolios.IsUsernameAvailable("shared-name", loserId, default)); + Assert.Equal("username-locked", (await Attempt(() => sandbox.Portfolios.ClaimUsername( + winner.UserId, winner.Revision, "other-name", default))).Error); + Assert.Equal("stale-revision", (await Attempt(() => sandbox.Portfolios.SaveDraft( + winner.UserId, null, Edited, false, default))).Error); + + Assert.True(await sandbox.Portfolios.Delete(winner.UserId, winner.Revision, default)); + var loser = (await sandbox.Portfolios.GetOwner(loserId, default))!; + var claimed = await sandbox.Portfolios.ClaimUsername(loserId, loser.Revision, "shared-name", default); + Assert.Equal("shared-name", claimed.Username); + Assert.Equal("account-not-found", (await Attempt(() => sandbox.Portfolios.SaveDraft( + Guid.NewGuid(), null, Original, false, default))).Error); + } + + [MongoFact] + public async Task SavingAPublishedDraftDoesNotChangeItsPublicSnapshotOrSearchSetting() + { + await using var sandbox = await Sandbox.Open(); + var user = await sandbox.Accounts.SignIn("published", "a@example.test", "A", default); + var draft = await sandbox.Portfolios.SaveDraft(user.Id, null, Original, false, default); + Assert.Null(await sandbox.Portfolios.GetPublished("riya", default)); + var named = await sandbox.Portfolios.ClaimUsername(user.Id, draft.Revision, "riya", default); + var published = await sandbox.Portfolios.Publish(user.Id, named.Revision, default); + var saved = await sandbox.Portfolios.SaveDraft(user.Id, published.Revision, Edited, true, default); + + var visible = (await sandbox.Portfolios.GetPublished("riya", default))!; + Assert.Equal(Original, visible.PublishedJson); + Assert.False(visible.PublishedSearchVisible); + Assert.Equal(Edited, saved.DraftJson); + Assert.Equal(PortfolioStatus.Published, saved.Status); + Assert.Equal(published.PublishedAtUtc, visible.PublishedAtUtc); + + var republished = await sandbox.Portfolios.Publish(user.Id, saved.Revision, default); + Assert.Equal(Edited, republished.PublishedJson); + Assert.True(republished.PublishedSearchVisible); + var privateAgain = await sandbox.Portfolios.Unpublish(user.Id, republished.Revision, default); + Assert.Equal(PortfolioStatus.Draft, privateAgain.Status); + Assert.Null(privateAgain.PublishedJson); + Assert.Null(await sandbox.Portfolios.GetPublished("riya", default)); + + await sandbox.Accounts.SignIn("published", "updated@example.test", "Updated", default); + Assert.Equal(Edited, (await sandbox.Portfolios.GetOwner(user.Id, default))!.DraftJson); + } + + [MongoFact] + public async Task ConcurrentSaveAndPublishCannotPublishAnUnvalidatedRevision() + { + await using var sandbox = await Sandbox.Open(); + var user = await sandbox.Accounts.SignIn("race", "a@example.test", "A", default); + var draft = await sandbox.Portfolios.SaveDraft(user.Id, null, Original, false, default); + var named = await sandbox.Portfolios.ClaimUsername(user.Id, draft.Revision, "riya", default); + var results = await Task.WhenAll( + Attempt(() => sandbox.Portfolios.SaveDraft(user.Id, named.Revision, Edited, true, default)), + Attempt(() => sandbox.Portfolios.Publish(user.Id, named.Revision, default))); + Assert.Single(results, result => result.Record is not null); + Assert.Equal("stale-revision", Assert.Single(results, result => result.Error is not null).Error); + var owner = (await sandbox.Portfolios.GetOwner(user.Id, default))!; + var visible = await sandbox.Portfolios.GetPublished("riya", default); + if (visible is not null) + { + Assert.Equal(Original, visible.PublishedJson); + Assert.False(visible.PublishedSearchVisible); + } + else + { + Assert.Equal(Edited, owner.DraftJson); + Assert.Equal(PortfolioStatus.Draft, owner.Status); + } + } + + [MongoFact] + public async Task SuspensionCannotBeBypassedAndAccountDeletionRemovesTheEmbeddedPortfolio() + { + await using var sandbox = await Sandbox.Open(); + var user = await sandbox.Accounts.SignIn("suspended", "a@example.test", "A", default); + var named = await sandbox.Portfolios.ClaimUsername(user.Id, null, "riya", default); + var saved = await sandbox.Portfolios.SaveDraft(user.Id, named.Revision, Original, false, default); + var published = await sandbox.Portfolios.Publish(user.Id, saved.Revision, default); + await sandbox.Database.Users.UpdateOneAsync(row => row.Id == user.Id.ToString("D"), + Builders.Update.Set(row => row.Portfolio!.Status, PortfolioStatus.Suspended)); + Assert.Null(await sandbox.Portfolios.GetPublished("riya", default)); + Assert.Equal("suspended", (await Attempt(() => sandbox.Portfolios.Publish(user.Id, published.Revision, default))).Error); + var edited = await sandbox.Portfolios.SaveDraft(user.Id, published.Revision, Edited, true, default); + var unpublished = await sandbox.Portfolios.Unpublish(user.Id, edited.Revision, default); + Assert.Equal(PortfolioStatus.Suspended, unpublished.Status); + Assert.Equal("suspended", (await Attempt(() => sandbox.Portfolios.Publish(user.Id, unpublished.Revision, default))).Error); + var deletion = await Assert.ThrowsAsync(() => sandbox.Portfolios.Delete(user.Id, unpublished.Revision, default)); + Assert.Equal("suspended", deletion.Code); + Assert.Equal(PortfolioStatus.Suspended, (await sandbox.Portfolios.GetOwner(user.Id, default))!.Status); + + await Task.WhenAll( + sandbox.Database.Users.DeleteOneAsync(row => row.Id == user.Id.ToString("D")), + Attempt(() => sandbox.Portfolios.SaveDraft(user.Id, unpublished.Revision, Original, false, default))); + Assert.Null(await sandbox.Portfolios.GetOwner(user.Id, default)); + Assert.Null(await sandbox.Portfolios.GetPublished("riya", default)); + Assert.True(await sandbox.Portfolios.IsUsernameAvailable("riya", Guid.NewGuid(), default)); + } + + private static async Task<(PortfolioRecord? Record, string? Error)> Attempt(Func> action) + { + try { return (await action(), null); } + catch (PortfolioConflictException error) { return (null, error.Code); } + } + + private sealed class Sandbox : IAsyncDisposable + { + private readonly MongoClient client = new(Environment.GetEnvironmentVariable("PROCODERJI_TEST_MONGODB")!); + private readonly string name = "procoderji_portfolio_" + Guid.NewGuid().ToString("N"); + public MongoPlatformDatabase Database { get; } + public MongoAccountStore Accounts { get; } + public MongoPortfolioStore Portfolios { get; } + + private Sandbox() + { + Database = new MongoPlatformDatabase(client.GetDatabase(name)); + Accounts = new MongoAccountStore(Database); + Portfolios = new MongoPortfolioStore(Database); + } + + public static async Task Open() + { + var sandbox = new Sandbox(); + await sandbox.Database.InitializeAsync(); + return sandbox; + } + + public async ValueTask DisposeAsync() + { + try { await client.DropDatabaseAsync(name); } + finally { client.Dispose(); } + } + } +} diff --git a/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs index 287dade..ad16719 100644 --- a/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs +++ b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs @@ -17,7 +17,7 @@ namespace ProCoderJi.Api.Tests; -public sealed class PlatformFactory(bool googleConfigured = false, string publicOrigin = "http://localhost:3000", bool testAuthentication = true) : WebApplicationFactory +public sealed class PlatformFactory(bool googleConfigured = false, string publicOrigin = "http://localhost:3000", bool testAuthentication = true, IReadOnlyDictionary? overrides = null) : WebApplicationFactory { private readonly string sqlitePath = Path.Combine(Path.GetTempPath(), $"procoderji-{Guid.NewGuid():N}.db"); private readonly string? postgresConnection = Environment.GetEnvironmentVariable("PROCODERJI_TEST_POSTGRES"); @@ -61,7 +61,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) mongoDatabase = "procoderji_test_" + Guid.NewGuid().ToString("N"); connection = mongoConnection; } - builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary + var settings = new Dictionary { ["PublicOrigin"] = publicOrigin, ["Database:Provider"] = provider, @@ -69,7 +69,10 @@ 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" : "", - })); + }; + if (overrides is not null) + foreach (var (key, value) in overrides) settings[key] = value; + builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(settings)); if (testAuthentication) builder.ConfigureTestServices(services => services.AddAuthentication(options => { diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioIntegrationTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioIntegrationTests.cs new file mode 100644 index 0000000..6778c30 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioIntegrationTests.cs @@ -0,0 +1,365 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Data.Mongo; +using ProCoderJi.Api.Features.Portfolio; + +namespace ProCoderJi.Api.Tests; + +public sealed class PortfolioIntegrationTests +{ + private static readonly Guid UserA = Guid.Parse("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + private static readonly Guid UserB = Guid.Parse("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + private static readonly IReadOnlyDictionary Publishing = new Dictionary { ["Portfolio:PublishingEnabled"] = "true" }; + + [Fact] + public async Task Anonymous_requests_cannot_read_drafts_claim_names_or_mutate_portfolios() + { + using var factory = new PlatformFactory(testAuthentication: false, overrides: Publishing); + using var browser = factory.Browser(UserA); + Assert.Equal(HttpStatusCode.Unauthorized, (await Read(browser, UserA)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await Read(browser, UserA, "/api/platform/portfolio/username?username=riya-student")).StatusCode); + foreach (var path in new[] { "draft", "username", "publish", "unpublish" }) + Assert.Equal(HttpStatusCode.Unauthorized, await Status(browser, UserA, null, new { revision = (Guid?)null }, path)); + Assert.Equal(HttpStatusCode.Unauthorized, await Status(browser, UserA, null, new { revision = Guid.NewGuid() }, "", HttpMethod.Delete)); + Assert.Equal(HttpStatusCode.NotFound, (await browser.GetAsync("/api/platform/portfolio/public/riya-student")).StatusCode); + } + + [Fact] + public async Task Mutations_validate_identity_antiforgery_payload_and_shared_field_rules() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + var token = await Token(browser); + var valid = new { data = Data("Riya"), searchVisible = false, revision = (Guid?)null }; + Assert.Equal(HttpStatusCode.Forbidden, await Status(browser, UserA, null, valid, "draft")); + Assert.Equal(HttpStatusCode.Forbidden, await Status(browser, UserA, token, valid, "draft", origin: "https://other.example")); + Assert.Equal(HttpStatusCode.Conflict, await Status(browser, UserB, token, valid, "draft")); + Assert.Equal(HttpStatusCode.BadRequest, await Status(browser, UserA, token, new { data = Data("Riya"), searchVisible = false }, "draft")); + Assert.Equal(HttpStatusCode.BadRequest, await Status(browser, UserA, token, new { data = Data("Riya"), searchVisible = false, revision = (Guid?)null, userId = UserB }, "draft")); + Assert.Equal(HttpStatusCode.BadRequest, await Status(browser, UserA, token, new { data = Data("Riya"), searchVisible = false, revision = Guid.Empty }, "draft")); + using var invalid = await Write(browser, UserA, token, new { data = new { name = "Riya", links = new { website = "javascript:alert(1)" } }, searchVisible = false, revision = (Guid?)null }, "draft"); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + var errors = await invalid.Content.ReadFromJsonAsync(Json); + Assert.Contains(errors.GetProperty("problems").EnumerateArray(), problem => problem.GetProperty("path").GetString() == "links.website" && problem.GetProperty("code").GetString() == "invalid-link"); + using var name = await Read(browser, UserA, "/api/platform/portfolio/username?username=admin"); + Assert.Equal(HttpStatusCode.BadRequest, name.StatusCode); + Assert.Equal(JsonValueKind.Null, (await Owner(browser, UserA)).GetProperty("portfolio").ValueKind); + } + + [Fact] + public async Task Declared_and_chunked_oversized_bodies_are_rejected_before_deserialization() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + var token = await Token(browser); + foreach (var unknownLength in new[] { false, true }) + { + using var request = Request(UserA, token, "draft", HttpMethod.Post); + var text = new string(' ', 256 * 1024 + 1); + request.Content = unknownLength ? new UnknownLengthJsonContent(text) : new StringContent(text, Encoding.UTF8, "application/json"); + using var response = await browser.SendAsync(request); + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + using var plain = Request(UserA, token, "draft", HttpMethod.Post); + plain.Content = new StringContent("{}", Encoding.UTF8, "text/plain"); + using var plainResponse = await browser.SendAsync(plain); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, plainResponse.StatusCode); + } + + [Fact] + public async Task Malformed_UTF16_in_metadata_and_property_names_returns_a_client_error() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + var token = await Token(browser); + foreach (var hex in new[] { "d800", "dc00" }) + { + var escaped = (char)92 + "u" + hex; + foreach (var (action, body) in new[] + { + ("username", "{\"username\":\"" + escaped + "\",\"revision\":null}"), + ("draft", "{\"data\":{},\"searchVisible\":false,\"revision\":null,\"" + escaped + "\":1}"), + ("draft", "{\"data\":{\"name\":\"" + escaped + "\"},\"searchVisible\":false,\"revision\":null}") + }) + { + using var request = Request(UserA, token, action, HttpMethod.Post); + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await browser.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } + Assert.Equal(JsonValueKind.Null, (await Owner(browser, UserA)).GetProperty("portfolio").ValueKind); + } + + [Fact] + public async Task Drafts_remain_private_and_publication_uses_an_explicit_snapshot() + { + using var factory = new PlatformFactory(overrides: Publishing); + using var owner = factory.Browser(UserA); + using var other = factory.Browser(UserB); + using var anonymous = factory.Browser(); + var token = await Token(owner); + await Token(other); + var draft = await Change(owner, UserA, token, new { data = Data("Published name"), searchVisible = false, revision = (Guid?)null }, "draft"); + Assert.Equal(JsonValueKind.Null, (await Owner(other, UserB)).GetProperty("portfolio").ValueKind); + Assert.Equal(HttpStatusCode.Conflict, await Status(other, UserA, token, new { revision = Revision(draft) }, "publish")); + var claimed = await Change(owner, UserA, token, new { username = "riya-student", revision = Revision(draft) }, "username"); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/riya-student")).StatusCode); + var published = await Change(owner, UserA, token, new { revision = Revision(claimed) }, "publish"); + var edited = await Change(owner, UserA, token, new { data = Data("Private edit"), searchVisible = true, revision = Revision(published) }, "draft"); + Assert.Equal("published", edited.GetProperty("status").GetString()); + using (var response = await anonymous.GetAsync("/api/platform/portfolio/public/riya-student")) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("noindex", response.Headers.GetValues("X-Robots-Tag")); + Assert.True(response.Headers.CacheControl!.NoStore); + var visible = await response.Content.ReadFromJsonAsync(Json); + Assert.Equal("Published name", visible.GetProperty("data").GetProperty("name").GetString()); + Assert.False(visible.GetProperty("searchVisible").GetBoolean()); + Assert.False(visible.TryGetProperty("userId", out _)); + Assert.False(visible.TryGetProperty("revision", out _)); + Assert.False(visible.TryGetProperty("updatedAt", out _)); + Assert.DoesNotContain("Private edit", visible.ToString()); + } + var republished = await Change(owner, UserA, token, new { revision = Revision(edited) }, "publish"); + var fresh = await anonymous.GetFromJsonAsync("/api/platform/portfolio/public/riya-student", Json); + Assert.Equal("Private edit", fresh.GetProperty("data").GetProperty("name").GetString()); + Assert.True(fresh.GetProperty("searchVisible").GetBoolean()); + var hidden = await Change(owner, UserA, token, new { revision = Revision(republished) }, "unpublish"); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/riya-student")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, await Status(owner, UserA, token, new { revision = Revision(hidden) }, "", HttpMethod.Delete)); + Assert.Equal(JsonValueKind.Null, (await Owner(owner, UserA)).GetProperty("portfolio").ValueKind); + } + + [Fact] + public async Task Publishing_is_disabled_by_default_while_drafts_remain_usable() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + using var anonymous = factory.Browser(); + var token = await Token(browser); + var saved = await Change(browser, UserA, token, new { data = Data("Riya"), searchVisible = false, revision = (Guid?)null }, "draft"); + var claimed = await Change(browser, UserA, token, new { username = "riya-student", revision = Revision(saved) }, "username"); + using var response = await Write(browser, UserA, token, new { revision = Revision(claimed) }, "publish"); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("publishing-disabled", (await response.Content.ReadFromJsonAsync(Json)).GetProperty("code").GetString()); + Assert.False((await Owner(browser, UserA)).GetProperty("publishingEnabled").GetBoolean()); + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().Publish(UserA, Revision(claimed), default); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/riya-student")).StatusCode); + } + + [Fact] + public async Task Publish_requires_complete_data_and_a_claimed_username() + { + using var factory = new PlatformFactory(overrides: Publishing); + using var browser = factory.Browser(UserA); + var token = await Token(browser); + var incomplete = await Change(browser, UserA, token, new { data = new { about = "Work in progress" }, searchVisible = false, revision = (Guid?)null }, "draft"); + Assert.Equal(HttpStatusCode.BadRequest, await Status(browser, UserA, token, new { revision = Revision(incomplete) }, "publish")); + var complete = await Change(browser, UserA, token, new { data = Data("Riya"), searchVisible = false, revision = Revision(incomplete) }, "draft"); + using var response = await Write(browser, UserA, token, new { revision = Revision(complete) }, "publish"); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("username-required", (await response.Content.ReadFromJsonAsync(Json)).GetProperty("code").GetString()); + } + + [Fact] + public async Task Concurrent_username_claims_have_one_owner_and_claimed_names_cannot_change() + { + using var factory = new PlatformFactory(); + using var first = factory.Browser(UserA); + using var second = factory.Browser(UserB); + var firstToken = await Token(first); + var secondToken = await Token(second); + var results = await Task.WhenAll( + Write(first, UserA, firstToken, new { username = "riya-student", revision = (Guid?)null }, "username"), + Write(second, UserB, secondToken, new { username = "riya-student", revision = (Guid?)null }, "username")); + try + { + Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, results.Select(response => response.StatusCode).Order().ToArray()); + var winner = results[0].StatusCode == HttpStatusCode.OK ? first : second; + var winnerId = results[0].StatusCode == HttpStatusCode.OK ? UserA : UserB; + var winnerToken = results[0].StatusCode == HttpStatusCode.OK ? firstToken : secondToken; + var record = (await Owner(winner, winnerId)).GetProperty("portfolio"); + using var changed = await Write(winner, winnerId, winnerToken, new { username = "another-student", revision = Revision(record) }, "username"); + Assert.Equal(HttpStatusCode.Conflict, changed.StatusCode); + Assert.Equal("username-locked", (await changed.Content.ReadFromJsonAsync(Json)).GetProperty("code").GetString()); + } + finally { foreach (var response in results) response.Dispose(); } + } + + [Fact] + public async Task Concurrent_draft_updates_reject_the_stale_revision() + { + using var factory = new PlatformFactory(); + using var browser = factory.Browser(UserA); + var token = await Token(browser); + var initial = await Change(browser, UserA, token, new { data = Data("Initial"), searchVisible = false, revision = (Guid?)null }, "draft"); + var revision = Revision(initial); + var results = await Task.WhenAll( + Write(browser, UserA, token, new { data = Data("First"), searchVisible = false, revision }, "draft"), + Write(browser, UserA, token, new { data = Data("Second"), searchVisible = false, revision }, "draft")); + try { Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, results.Select(response => response.StatusCode).Order().ToArray()); } + finally { foreach (var response in results) response.Dispose(); } + var final = (await Owner(browser, UserA)).GetProperty("portfolio"); + Assert.NotEqual(revision, Revision(final)); + Assert.Contains(final.GetProperty("data").GetProperty("name").GetString()!, new[] { "First", "Second" }); + } + + [Fact] + public async Task Suspension_hides_public_data_and_cannot_be_bypassed_by_unpublishing() + { + using var factory = new PlatformFactory(overrides: Publishing); + using var browser = factory.Browser(UserA); + using var anonymous = factory.Browser(); + var token = await Token(browser); + var draft = await Change(browser, UserA, token, new { data = Data("Riya"), searchVisible = false, revision = (Guid?)null }, "draft"); + var claimed = await Change(browser, UserA, token, new { username = "riya-student", revision = Revision(draft) }, "username"); + await Change(browser, UserA, token, new { revision = Revision(claimed) }, "publish"); + await Suspend(factory, UserA); + var suspended = (await Owner(browser, UserA)).GetProperty("portfolio"); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/riya-student")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, await Status(browser, UserA, token, new { revision = Revision(suspended) }, "publish")); + Assert.Equal(HttpStatusCode.Forbidden, await Status(browser, UserA, token, new { revision = Revision(suspended) }, "", HttpMethod.Delete)); + var edited = await Change(browser, UserA, token, new { data = Data("Corrected"), searchVisible = false, revision = Revision(suspended) }, "draft"); + Assert.Equal("suspended", edited.GetProperty("status").GetString()); + var hidden = await Change(browser, UserA, token, new { revision = Revision(edited) }, "unpublish"); + Assert.Equal("suspended", hidden.GetProperty("status").GetString()); + Assert.Equal(HttpStatusCode.Forbidden, await Status(browser, UserA, token, new { revision = Revision(hidden) }, "publish")); + } + + [Fact] + public async Task Deleting_the_account_removes_its_private_and_published_portfolio() + { + using var factory = new PlatformFactory(overrides: Publishing); + using var browser = factory.Browser(UserA); + using var anonymous = factory.Browser(); + var token = await Token(browser); + var saved = await Change(browser, UserA, token, new { data = Data("Riya"), searchVisible = true, revision = (Guid?)null }, "draft"); + var claimed = await Change(browser, UserA, token, new { username = "riya-student", revision = Revision(saved) }, "username"); + await Change(browser, UserA, token, new { revision = Revision(claimed) }, "publish"); + using var scope = factory.Services.CreateScope(); + if (Mongo(scope.ServiceProvider)) + await scope.ServiceProvider.GetRequiredService().Users.DeleteOneAsync(user => user.Id == UserA.ToString("D")); + else + { + await using var db = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + db.Users.Remove(await db.Users.SingleAsync(user => user.Id == UserA)); + await db.SaveChangesAsync(); + } + Assert.Null(await scope.ServiceProvider.GetRequiredService().GetOwner(UserA, default)); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/riya-student")).StatusCode); + } + + [Fact] + public async Task Write_and_username_limits_are_separate_and_partitioned_by_authenticated_identity() + { + using var factory = new PlatformFactory(); + using var first = factory.Browser(UserA); + using var second = factory.Browser(UserB); + var firstToken = await Token(first); + var secondToken = await Token(second); + for (var i = 0; i < 10; i++) + Assert.Equal(HttpStatusCode.BadRequest, await Status(first, UserA, firstToken, new { data = new { html = "unexpected" }, searchVisible = false, revision = (Guid?)null }, "draft")); + using (var limited = await Write(first, UserA, firstToken, new { data = Data("Riya"), searchVisible = false, revision = (Guid?)null }, "draft")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode); + Assert.True(limited.Headers.RetryAfter!.Delta > TimeSpan.Zero); + } + Assert.Equal(HttpStatusCode.OK, await Status(second, UserB, secondToken, new { data = Data("Other"), searchVisible = false, revision = (Guid?)null }, "draft")); + for (var i = 0; i < 30; i++) + { + using var available = await Read(first, UserA, "/api/platform/portfolio/username?username=riya-student"); + Assert.Equal(HttpStatusCode.OK, available.StatusCode); + } + using var check = await Read(first, UserA, "/api/platform/portfolio/username?username=riya-student"); + Assert.Equal(HttpStatusCode.TooManyRequests, check.StatusCode); + Assert.True(check.Headers.RetryAfter!.Delta > TimeSpan.Zero); + using var other = await Read(second, UserB, "/api/platform/portfolio/username?username=riya-student"); + Assert.Equal(HttpStatusCode.OK, other.StatusCode); + } + + private static object Data(string name) => new { name, headline = "Student developer", about = "My original projects" }; + private static Guid Revision(JsonElement portfolio) => portfolio.GetProperty("revision").GetGuid(); + private static bool Mongo(IServiceProvider services) => services.GetRequiredService()["Database:Provider"] == "MongoDb"; + + private static async Task Suspend(PlatformFactory factory, Guid id) + { + using var scope = factory.Services.CreateScope(); + if (Mongo(scope.ServiceProvider)) + { + var database = scope.ServiceProvider.GetRequiredService(); + await database.Users.UpdateOneAsync(user => user.Id == id.ToString("D"), Builders.Update + .Set(user => user.Portfolio!.Status, PortfolioStatus.Suspended).Set(user => user.Portfolio!.Revision, Guid.NewGuid().ToString("D"))); + } + else + { + await using var db = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + var portfolio = await db.Portfolios.SingleAsync(portfolio => portfolio.UserId == id); + portfolio.Status = PortfolioStatus.Suspended; + portfolio.Revision = Guid.NewGuid(); + await db.SaveChangesAsync(); + } + } + + private static async Task Token(HttpClient browser) => (await browser.GetFromJsonAsync("/api/platform/auth/session", Json)).GetProperty("csrfToken").GetString()!; + private static async Task Owner(HttpClient browser, Guid user) + { + using var response = await Read(browser, user); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(Json); + } + private static Task Read(HttpClient browser, Guid user, string path = "/api/platform/portfolio") + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.Add("x-account-id", user.ToString()); + return browser.SendAsync(request); + } + private static HttpRequestMessage Request(Guid user, string? token, string action, HttpMethod method, string origin = "http://localhost:3000") + { + var request = new HttpRequestMessage(method, "/api/platform/portfolio" + (action.Length == 0 ? "" : "/" + action)); + request.Headers.Add("x-account-id", user.ToString()); + request.Headers.Add("Origin", origin); + if (token is not null) request.Headers.Add("X-CSRF-Token", token); + return request; + } + private static Task Write(HttpClient browser, Guid user, string? token, object body, string action, HttpMethod? method = null, string origin = "http://localhost:3000") + { + var request = Request(user, token, action, method ?? HttpMethod.Post, origin); + request.Content = JsonContent.Create(body, options: Json); + return browser.SendAsync(request); + } + private static async Task Status(HttpClient browser, Guid user, string? token, object body, string action, HttpMethod? method = null, string origin = "http://localhost:3000") + { + using var response = await Write(browser, user, token, body, action, method, origin); + return response.StatusCode; + } + private static async Task Change(HttpClient browser, Guid user, string token, object body, string action) + { + using var response = await Write(browser, user, token, body, action); + var value = await response.Content.ReadFromJsonAsync(Json); + Assert.True(response.IsSuccessStatusCode, $"{action}: {(int)response.StatusCode} {value}"); + return value.GetProperty("portfolio"); + } + + private sealed class UnknownLengthJsonContent : HttpContent + { + private readonly byte[] bytes; + public UnknownLengthJsonContent(string text) + { + bytes = Encoding.UTF8.GetBytes(text); + Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(bytes).AsTask(); + protected override bool TryComputeLength(out long length) { length = 0; return false; } + } +} diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs new file mode 100644 index 0000000..99c00dd --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Features.Portfolio; + +namespace ProCoderJi.Api.Tests; + +public sealed class PortfolioRelationalTests +{ + [RelationalFact] + public async Task Database_allows_multiple_unclaimed_names_but_enforces_claimed_name_uniqueness() + { + using var app = new PlatformFactory(); + using var scope = app.Services.CreateScope(); + var accounts = scope.ServiceProvider.GetRequiredService(); + var portfolios = scope.ServiceProvider.GetRequiredService(); + var first = await accounts.SignIn("first-subject", "first@example.test", "First", default); + var second = await accounts.SignIn("second-subject", "second@example.test", "Second", default); + var draft = await portfolios.SaveDraft(first.Id, null, "{}", false, default); + await portfolios.SaveDraft(second.Id, null, "{}", false, default); + await portfolios.ClaimUsername(first.Id, draft.Revision, "student-one", default); + + await using var db = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + var other = await db.Portfolios.SingleAsync(row => row.UserId == second.Id); + other.Username = "student-one"; + await Assert.ThrowsAsync(() => db.SaveChangesAsync()); + } + + [RelationalFact] + public async Task Concurrent_claims_give_the_name_to_exactly_one_account() + { + using var app = new PlatformFactory(); + using var scope = app.Services.CreateScope(); + var accounts = scope.ServiceProvider.GetRequiredService(); + var portfolios = scope.ServiceProvider.GetRequiredService(); + var first = await accounts.SignIn("first-subject", "first@example.test", "First", default); + var second = await accounts.SignIn("second-subject", "second@example.test", "Second", default); + async Task Claim(Guid id) + { + try { await portfolios.ClaimUsername(id, null, "same-student", default); return true; } + catch (PortfolioConflictException error) when (error.Code == "username-taken") { return false; } + } + var results = await Task.WhenAll(Claim(first.Id), Claim(second.Id)); + Assert.Single(results, result => result); + Assert.Single(results, result => !result); + } + + [RelationalFact] + public async Task Suspension_blocks_stale_writes_and_delete_recreate_bypass() + { + using var app = new PlatformFactory(); + using var scope = app.Services.CreateScope(); + var accounts = scope.ServiceProvider.GetRequiredService(); + var portfolios = scope.ServiceProvider.GetRequiredService(); + var user = await accounts.SignIn("suspended-subject", "suspended@example.test", "Student", default); + var portfolio = await portfolios.ClaimUsername(user.Id, null, "suspended-student", default); + var contexts = scope.ServiceProvider.GetRequiredService>(); + await using var staleWriter = await contexts.CreateDbContextAsync(); + var stale = await staleWriter.Portfolios.SingleAsync(row => row.UserId == user.Id); + await using (var moderator = await contexts.CreateDbContextAsync()) + { + var row = await moderator.Portfolios.SingleAsync(row => row.UserId == user.Id); + row.Status = PortfolioStatus.Suspended; + // Even an operator that forgets to rotate Revision cannot have this state overwritten. + await moderator.SaveChangesAsync(); + } + stale.Status = PortfolioStatus.Published; + stale.PublishedJson = "{}"; + await Assert.ThrowsAsync(() => staleWriter.SaveChangesAsync()); + var error = await Assert.ThrowsAsync(() => portfolios.Delete(user.Id, portfolio.Revision, default)); + Assert.Equal("suspended", error.Code); + Assert.Equal(PortfolioStatus.Suspended, (await portfolios.GetOwner(user.Id, default))!.Status); + } + + [RelationalFact] + public async Task Upgrading_an_existing_account_database_keeps_its_records() + { + using var app = new PlatformFactory(); + using var scope = app.Services.CreateScope(); + await using var db = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + var migrator = db.GetService(); + // Restore the exact pre-feature schema/history without asking EF to resolve the + // original hand-written 12-digit ID as a generated 14-digit migration timestamp. + // 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); + await db.Database.ExecuteSqlRawAsync(db.GetService().GetDeleteScript(portfolioMigration.Key)); + 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 }); + db.ProgressStates.Add(new ProgressState { UserId = userId, Generation = generation, Revision = Guid.NewGuid() }); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + await migrator.MigrateAsync(); + Assert.True(await db.Users.AnyAsync(user => user.Id == userId)); + Assert.Equal(generation, (await db.ProgressStates.SingleAsync(row => row.UserId == userId)).Generation); + Assert.Empty(await db.Portfolios.ToListAsync()); + } +} + +internal sealed class RelationalFactAttribute : FactAttribute +{ + public RelationalFactAttribute() + { + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("PROCODERJI_TEST_MONGODB"))) + Skip = "Relational constraint and migration checks run on SQLite, PostgreSQL and SQL Server."; + } +} diff --git a/docs/README.md b/docs/README.md index 592368c..a32ef86 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ synchronization is a separate capability; paid billing is future work. | Understand routes, content, and feature boundaries | [Architecture](architecture.md) | | Add original notes, guides, or career listings | [Content guide](content-guide.md) | | Run the .NET API, Google sign-in, and account persistence | [Backend setup](../backend/README.md) | +| Work on private portfolio drafts, publication, and storage | [Portfolio API](portfolio-api.md) | | Configure production hosting and the site domain | [Deployment](deployment.md) | | Run the API and choose a database with Docker | [Containers](containers.md) | | Configure the selected hosted PostgreSQL database | [Neon setup](neon.md) | diff --git a/docs/architecture.md b/docs/architecture.md index 95b8623..df796c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,9 +151,12 @@ The backend-container workflow runs the API contract against PostgreSQL, SQL Ser checks provider migrations and shared MongoDB keys, validates Compose options, and builds and smoke-tests the non-root API image. It runs when backend, shared data, or container files change. -Student portfolios are planned but not implemented. [ADR 0002](decisions/0002-student-portfolios.md) -records the proposed model, address scheme, and safety rules; [#40](https://github.com/procoderji-com/procoderji/issues/40) -tracks the work. +Portfolio storage and APIs support private drafts, username claims, publication snapshots and +unpublishing through all configured database providers. The [portfolio API guide](portfolio-api.md) +records the contract, revisions, rate limits and deletion behavior. Production publishing is +disabled until the owner decisions and moderation/privacy launch work are complete. Public +pages and the editor remain follow-up work under [#40](https://github.com/procoderji-com/procoderji/issues/40). +[ADR 0002](decisions/0002-student-portfolios.md) records the proposal and outstanding owner decisions. Billing, checkout, and paid entitlements are not implemented. Razorpay or Paytm is a future provider decision that also needs server-verified payment state, cancellation/support behavior, diff --git a/docs/development.md b/docs/development.md index b589932..525773e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -107,6 +107,8 @@ by Git. - `shared/portfolio/` holds the student-portfolio rules and test fixtures used by both runtimes (see its [README](../shared/portfolio/README.md)); the validators live in `src/features/portfolio/` and `backend/src/ProCoderJi.Api/Features/Portfolio/`. + Read the [portfolio API guide](portfolio-api.md) for storage, revisions, request protection and + the publishing launch setting before changing these endpoints. - `backend/src/ProCoderJi.Api/` owns Google sign-in, cookie/antiforgery handling, account progress, and EF persistence. Backend tests live under `backend/tests/`. - `src/content/` and `src/data/` publish reviewed notes, guides, and listings; see the diff --git a/docs/portfolio-api.md b/docs/portfolio-api.md new file mode 100644 index 0000000..89bf478 --- /dev/null +++ b/docs/portfolio-api.md @@ -0,0 +1,120 @@ +# Portfolio storage and API + +The portfolio backend stores private drafts and explicit published snapshots using the shared +rules in `shared/portfolio/`. It is the backend portion of [#43](https://github.com/procoderji-com/procoderji/issues/43). +Public pages and the editor are tracked by #44 and #45. + +## Launch setting + +`Portfolio__PublishingEnabled` defaults to **false**. Students can prepare a draft and claim a +username, but publication returns `503` with `publishing-disabled`, and public reads return 404. +Enable publishing only after the owner decisions in [ADR 0002](decisions/0002-student-portfolios.md) +and the moderation/privacy launch work in #47 and #48 are complete. This API does not decide the +minimum age or publish legal wording on the owner's behalf. + +## Authentication and request format + +Browser requests go through the Next.js `/api/platform/*` proxy. Owner requests require a valid +Google account session and `X-Account-Id` matching that session. Mutations also require the exact +frontend `Origin` and `X-CSRF-Token` returned by `/api/platform/auth/session`. + +Mutations accept JSON with a maximum body size of 256 KiB, including streamed bodies. Unknown +request fields are rejected. The server validates portfolio data and usernames with the shared +.NET validator. The client cannot supply the owner ID or status. + +Every response is uncached. API JSON carries `X-Robots-Tag: noindex`; the future public page +uses the published search-visibility setting when creating its own metadata. + +## Endpoints + +| Method and path | Behavior | +| --- | --- | +| `GET /api/platform/portfolio` | Owner's draft and metadata, or `portfolio: null`; also returns `publishingEnabled`. | +| `POST /api/platform/portfolio/draft` | Save `{ data, searchVisible, revision }`; creates a private draft when `revision` is null. | +| `GET /api/platform/portfolio/username?username=riya-student` | Validate a username and report availability for the signed-in owner. | +| `POST /api/platform/portfolio/username` | Claim `{ username, revision }`; can create an empty draft with `revision: null`. | +| `POST /api/platform/portfolio/publish` | Validate the current draft for publication and atomically publish it using `{ revision }`. | +| `POST /api/platform/portfolio/unpublish` | Remove the public snapshot using `{ revision }`. | +| `DELETE /api/platform/portfolio` | Delete the owner's portfolio using `{ revision }`; returns 204 when already absent. | +| `GET /api/platform/portfolio/public/{username}` | Anonymous read of published fields only; otherwise 404. | + +A mutation response contains `{ portfolio }`. Always use its current `revision` for the next +mutation. The `revision` field must be present in every mutation request: null is allowed only +for creating a draft or making its first username claim. A stale revision returns 409, so two +devices cannot silently overwrite each other's edits. + +Example first draft: + +```json +{ + "data": { + "name": "Riya", + "headline": "Computer science student", + "skills": ["C#", "PostgreSQL"] + }, + "searchVisible": false, + "revision": null +} +``` + +The owner response includes draft `data`, `username`, `searchVisible`, `status`, `revision`, +`createdAt`, `updatedAt` and `publishedAt`. Public responses include only `username`, published +`data`, published `searchVisible` and `publishedAt`. Internal account identity, Google profile +data and unpublished edits never enter that public response. + +## Publication and moderation + +Saving a draft does not replace an existing published snapshot or its search visibility. +Publishing copies the validated draft and its visibility setting together. A username is +required, and the publish validator additionally requires a name and headline. + +This API version keeps an existing username claim immutable; a repeated claim of the same name +is allowed with the current revision. Deleting an ordinary portfolio releases its claim. A +rename policy still needs the owner's decision before public launch. + +`draft`, `published` and `suspended` are server-owned states. Public reads never return drafts +or suspended records. A suspended owner can edit their private draft or unpublish, but those +actions preserve the suspension. Publishing and portfolio deletion return 403 until an operator +restores it, preventing a delete/recreate publication bypass. The operator procedure is tracked +by #47; this API exposes no student-accessible suspension endpoint. + +The historical last `publishedAt` is retained after unpublishing. Deletion removes the portfolio +and both snapshots. Removing the owning account record cascades through a relational foreign +key, or inherently removes MongoDB's embedded portfolio. Account-management UI is separate work. + +## Storage and migration + +- SQLite, PostgreSQL/Neon and SQL Server use the `Portfolios` table, with one row per account, + a unique nullable username and revision/status concurrency guards. +- MongoDB embeds the portfolio in the existing account document. A partial unique index claims + usernames, and conditional updates change only the expected portfolio revision/status. +- Run the normal `--migrate` release command before starting the new version. It applies the + incremental relational migration or creates MongoDB's portfolio username index. Existing + account/progress records remain intact. + +Follow [Neon setup](neon.md) for direct migration and pooled runtime connections. PostgreSQL +runtime startup must not apply migrations through the transaction pooler. + +## Rate limits and errors + +Each API process allows **10 portfolio mutations per minute per authenticated account**, and +**30 username availability checks per minute per account**. The policies are separate, return +429 with `Retry-After`, and do not trust a browser-supplied account ID as the rate-limit key. +These are process-local limits; a deployment with multiple replicas also needs an infrastructure +limit appropriate to its traffic. + +Common error codes are `validation-failed` (400, with field `problems`), `sign-in-required` (401), +`suspended` (403), `not-found` (404), `stale-revision`, `username-taken` and `username-locked` +(409), `rate-limited` (429), and `publishing-disabled` (503). Invalid content types return 415; +oversized bodies return 413. + +## Verify a change + +Run `dotnet test backend/ProCoderJi.slnx --configuration Release`. The shared API tests run with +SQLite by default and with each configured disposable PostgreSQL, SQL Server or MongoDB test +server. They cover authentication, CSRF, validation/body limits, concurrent claims and edits, +publication snapshots, suspension, account cascading, and per-account rate limits. Provider +tests also cover incremental migrations and MongoDB document ownership. + +Never point the integration-test variables at a production database. See +[backend test configuration](../backend/README.md#tests). diff --git a/e2e/checks.mjs b/e2e/checks.mjs index c10cc38..ad5435c 100644 --- a/e2e/checks.mjs +++ b/e2e/checks.mjs @@ -146,6 +146,13 @@ if (FULLSTACK) { const progress = await page.request.get(BASE + "/api/platform/progress"); expectTrue(progress.status() === 401, `anonymous progress remains protected (got ${progress.status()})`); + const portfolio = await page.request.get(BASE + "/api/platform/portfolio"); + expectTrue(portfolio.status() === 401, `anonymous portfolio drafts remain protected (got ${portfolio.status()})`); + const publicPortfolio = await page.request.get(BASE + "/api/platform/portfolio/public/riya-student"); + expectTrue(publicPortfolio.status() === 404, "public portfolio reads stay disabled before launch"); + expectTrue(publicPortfolio.headers()["x-robots-tag"] === "noindex", "public API responses cannot be indexed as portfolio pages"); + const invalidPortfolioPath = await page.request.get(BASE + "/api/platform/portfolio/operator"); + expectTrue(invalidPortfolioPath.status() === 404, "the frontend proxy rejects unknown portfolio actions"); const logout = await page.request.post(BASE + "/api/platform/auth/logout", { headers: { Origin: new URL(BASE).origin }, maxRedirects: 0 }); expectTrue(logout.status() === 403, `same-origin logout still requires an antiforgery token (got ${logout.status()})`); }); diff --git a/src/app/api/platform/[...path]/route.ts b/src/app/api/platform/[...path]/route.ts index be1961a..d7f3cae 100644 --- a/src/app/api/platform/[...path]/route.ts +++ b/src/app/api/platform/[...path]/route.ts @@ -1,11 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; -import { BACKEND_PATHS, parseBackendOrigin } from "@/features/account/config"; +import { isBackendPath, parseBackendOrigin } from "@/features/account/config"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const privateHeaders = { "Cache-Control": "private, no-store", Vary: "Cookie, Origin", + "X-Robots-Tag": "noindex", }; const error = (message: string, status: number) => NextResponse.json({ error: message }, { status, headers: privateHeaders }); @@ -16,7 +17,7 @@ async function proxy( context: { params: Promise<{ path: string[] }> }, ) { const path = (await context.params).path.join("/"); - if (!BACKEND_PATHS.has(path)) return error("Unknown learner endpoint.", 404); + if (!isBackendPath(path)) return error("Unknown learner endpoint.", 404); const backend = parseBackendOrigin(process.env.DOTNET_API_URL); if (!backend) { if (path === "auth/session" && request.method === "GET") @@ -87,7 +88,7 @@ async function proxy( }, ); const responseHeaders = new Headers(privateHeaders); - for (const name of ["content-type", "location"]) { + for (const name of ["content-type", "location", "retry-after"]) { const value = upstream.headers.get(name); if (value) responseHeaders.set(name, value); } diff --git a/src/features/account/config.test.ts b/src/features/account/config.test.ts index e8e6ace..5dd6bbd 100644 --- a/src/features/account/config.test.ts +++ b/src/features/account/config.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { BACKEND_PATHS, parseBackendOrigin } from "./config.ts"; +import { BACKEND_PATHS, isBackendPath, parseBackendOrigin } from "./config.ts"; test("backend proxy accepts explicit root origins for local and hosted .NET services", () => { assert.equal( @@ -42,3 +42,16 @@ test("backend proxy exposes only the learner API paths", () => { ]) assert.ok(!BACKEND_PATHS.has(path)); }); + +test("portfolio proxy paths stay bounded to owner actions and one public username", () => { + for (const path of [ + "portfolio", "portfolio/draft", "portfolio/username", "portfolio/publish", + "portfolio/unpublish", "portfolio/public/riya-sharma", "portfolio/public/abc", + ]) assert.ok(isBackendPath(path), path); + for (const path of [ + "portfolio/admin", "portfolio/public/", "portfolio/public/ab", "portfolio/public/Abc", + "portfolio/public/abc/extra", "portfolio/public/../account", "portfolio/public/abc%2fadmin", + "portfolio/public/abc?other=path", "portfolio/public/-abc", "portfolio/public/abc--def", + `portfolio/public/${"a".repeat(31)}`, "portfolio/public/abc\\admin", + ]) assert.ok(!isBackendPath(path), path); +}); diff --git a/src/features/account/config.ts b/src/features/account/config.ts index 5e5f01d..9c92a55 100644 --- a/src/features/account/config.ts +++ b/src/features/account/config.ts @@ -24,4 +24,18 @@ export const BACKEND_PATHS = new Set([ "auth/logout", "signin-google", "progress", + "portfolio", + "portfolio/draft", + "portfolio/username", + "portfolio/publish", + "portfolio/unpublish", ]); + +/** Only a validated username may extend the public portfolio path. */ +export function isBackendPath(path: string): boolean { + if (BACKEND_PATHS.has(path)) return true; + const prefix = "portfolio/public/"; + if (!path.startsWith(prefix)) return false; + const username = path.slice(prefix.length); + return username.length >= 3 && username.length <= 30 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(username); +}