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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
49 changes: 45 additions & 4 deletions backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,10 +17,21 @@ public sealed class MongoPlatformDatabase(IMongoDatabase database)
public IMongoCollection<MongoProgressDocument> Progress => Database.GetCollection<MongoProgressDocument>("progress");
public IMongoCollection<MongoKeyDocument> Keys => Database.GetCollection<MongoKeyDocument>("dataProtectionKeys");

/// <summary>MongoDB has no EF migrations; provisioning creates the identity uniqueness boundary.</summary>
public Task InitializeAsync(CancellationToken cancellationToken = default) => Users.Indexes.CreateOneAsync(
new CreateIndexModel<MongoUserDocument>(Builders<MongoUserDocument>.IndexKeys.Ascending(user => user.GoogleSubject),
new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }), cancellationToken: cancellationToken);
/// <summary>MongoDB has no EF migrations; provisioning creates the identity and username boundaries.</summary>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await Users.Indexes.CreateManyAsync([
new CreateIndexModel<MongoUserDocument>(Builders<MongoUserDocument>.IndexKeys.Ascending(user => user.GoogleSubject),
new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }),
new CreateIndexModel<MongoUserDocument>(Builders<MongoUserDocument>.IndexKeys.Ascending("Portfolio.Username"),
new CreateIndexOptions<MongoUserDocument>
{
Unique = true,
Name = "IX_users_Portfolio_Username",
PartialFilterExpression = Builders<MongoUserDocument>.Filter.Type("Portfolio.Username", BsonType.String)
})
], cancellationToken: cancellationToken);
}
}

public static class MongoDatabaseRegistration
Expand Down Expand Up @@ -46,6 +59,7 @@ public static IServiceCollection AddMongoDatabase(this IServiceCollection servic
services.AddSingleton<MongoPlatformDatabase>();
services.AddSingleton<MongoAccountStore>();
services.AddSingleton<MongoProgressStore>();
services.AddSingleton<MongoPortfolioStore>();
services.AddSingleton<MongoXmlRepository>();
return services;
}
Expand All @@ -58,13 +72,40 @@ 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()
{
Id = Guid.Parse(Id), GoogleSubject = GoogleSubject, Email = Email, Name = Name, CreatedAtUtc = CreatedAtUtc
};
}

/// <summary>Embedded in the owning account so deleting it also removes its private and public portfolio.</summary>
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; }
Expand Down
124 changes: 124 additions & 0 deletions backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using MongoDB.Driver;
using ProCoderJi.Api.Features.Portfolio;

namespace ProCoderJi.Api.Data.Mongo;

/// <summary>Each write replaces only the owner's embedded portfolio under its expected revision.</summary>
public sealed class MongoPortfolioStore(MongoPlatformDatabase database) : IPortfolioStore
{
public async Task<PortfolioRecord?> 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<PortfolioRecord?> 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<bool> 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<PortfolioRecord> 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<PortfolioRecord> 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<PortfolioRecord> 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<PortfolioRecord> 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<bool> 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<MongoUserDocument>.Filter.Eq(row => row.Portfolio!.Status, user.Portfolio.Status);
var result = await database.Users.UpdateOneAsync(filter,
Builders<MongoUserDocument>.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<PortfolioRecord> Write(Guid userId, Guid? revision, bool allowCreate,
Func<PortfolioRecord, long, PortfolioRecord> 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<MongoUserDocument>.Filter.Eq(row => row.Portfolio!.Status, user.Portfolio.Status);
result = await database.Users.UpdateOneAsync(filter,
Builders<MongoUserDocument>.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<MongoUserDocument> 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<MongoUserDocument> RevisionFilter(Guid userId, Guid? revision)
{
var filter = Builders<MongoUserDocument>.Filter.Eq(row => row.Id, userId.ToString("D"));
return filter & (revision is null
? Builders<MongoUserDocument>.Filter.Eq(row => row.Portfolio, null)
: Builders<MongoUserDocument>.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 };
}
35 changes: 35 additions & 0 deletions backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ProCoderJi.Api.Features.Portfolio;

namespace ProCoderJi.Api.Data;

Expand All @@ -8,6 +9,7 @@ public sealed class PlatformDbContext(DbContextOptions<PlatformDbContext> option
public DbSet<LearnerUser> Users => Set<LearnerUser>();
public DbSet<ProgressState> ProgressStates => Set<ProgressState>();
public DbSet<PracticeAttemptEntity> Attempts => Set<PracticeAttemptEntity>();
public DbSet<PortfolioEntity> Portfolios => Set<PortfolioEntity>();
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder model)
Expand Down Expand Up @@ -43,6 +45,24 @@ protected override void OnModelCreating(ModelBuilder model)
: "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
});
});
model.Entity<PortfolioEntity>(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<string>().HasMaxLength(16).IsConcurrencyToken();
entity.Property(portfolio => portfolio.Revision).IsConcurrencyToken();
entity.HasOne<LearnerUser>().WithOne().HasForeignKey<PortfolioEntity>(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)");
});
});
}
}

Expand Down Expand Up @@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace ProCoderJi.Api.Features.Portfolio;

public enum PortfolioStatus { Draft, Published, Suspended }

/// <summary>Private storage snapshot. Public responses must expose only the published fields.</summary>
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;
}

/// <summary>Owner-scoped writes use a revision; public reads return published snapshots only.</summary>
public interface IPortfolioStore
{
Task<PortfolioRecord?> GetOwner(Guid userId, CancellationToken cancellationToken);
Task<PortfolioRecord?> GetPublished(string username, CancellationToken cancellationToken);
Task<bool> IsUsernameAvailable(string username, Guid userId, CancellationToken cancellationToken);
Task<PortfolioRecord> SaveDraft(Guid userId, Guid? revision, string dataJson, bool searchVisible, CancellationToken cancellationToken);
Task<PortfolioRecord> ClaimUsername(Guid userId, Guid? revision, string username, CancellationToken cancellationToken);
Task<PortfolioRecord> Publish(Guid userId, Guid revision, CancellationToken cancellationToken);
Task<PortfolioRecord> Unpublish(Guid userId, Guid revision, CancellationToken cancellationToken);
Task<bool> Delete(Guid userId, Guid revision, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -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<string> 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
});
}
}
Loading