diff --git a/README.md b/README.md index 81a9784..8e1dee3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A student learning workspace with original practice questions, explanations, and > [!NOTE] -> This is an early student-product foundation. Four practice topics contain 24 original multiple-choice questions. Practice and browser-saved progress work without login. An optional ASP.NET Core backend provides Google sign-in and account progress when configured. Billing and checkout are not enabled. Course notes, guides, and job listings are still awaiting published content. +> This is an early student-product foundation. Four practice topics contain 24 original multiple-choice questions. Practice and browser-saved progress work without login. An optional ASP.NET Core backend provides Google sign-in, account progress, and private portfolio drafts. The portfolio editor, two themes, Markdown import, and public-page support are implemented; public publishing is off by default pending deployment configuration. Billing and checkout are not enabled. Course notes, guides, and job listings are awaiting published content. ![Guest learning workspace before the first session](docs/images/learning-workspace.png) @@ -49,6 +49,8 @@ their answers. Run the app locally to explore the implemented routes: | `/practice/[topic]` | Multiple-choice practice sessions with scores, correct answers, and explanations. | | `/games` | Pattern Sprint and Digit Recall: short reasoning and memory exercises. | | `/account` | Optional Google sign-in and cloud progress controls when the backend is configured. | +| `/learn/portfolio` | In-memory guest preview; signed-in private drafts, Markdown import, theme choice, and explicit publication when enabled. | +| `/u/[username]` | A student's published snapshot; drafts, suspended or withdrawn pages return 404. | | `/dashboard` | Engineering course, branch, and semester navigation for future reviewed notes. | Questions are original learning exercises. They are independent of employer assessments and do @@ -56,9 +58,16 @@ not predict or guarantee hiring outcomes. The first question bank is intentional can be reviewed and improved before expanding. The expanded [placement-game catalogue](https://github.com/procoderji-com/procoderji/issues/24) -and [student portfolios](https://github.com/procoderji-com/procoderji/issues/40) are tracked as -separate work. Check those issues for delivery status; the table above describes the current -student experience. +remains separate work. For portfolios, start with the [student journey and deployment +runbook](docs/portfolio-workspace.md): guests can try the form, Google accounts save drafts, +and students aged 18 or older can explicitly accept the publishing policy. Usernames stay +fixed while the portfolio exists; draft edits remain private until published again. Search +indexing is opt-in. Import the [Markdown template](public/templates/portfolio.md) or fill the +same fields directly; both Simple and Developer themes use that model. + +Repository features do not establish that the live site has been updated. +[Issue #74](https://github.com/procoderji-com/procoderji/issues/74) tracks the outstanding +production hosting work; see the [deployment guide](docs/deployment.md). ## Progress and optional accounts @@ -86,6 +95,7 @@ does not currently enable them. | --- | --- | | Placement practice | 24 questions across four topics, explanations, and saved session history. | | Short games | Pattern Sprint and Digit Recall are playable without login; their scores are not saved. | +| Student portfolios | Form/live preview, Simple and Developer themes, Markdown import, authenticated drafts, and gated public pages with moderation. | | Course catalogue | Course, branch, and semester navigation exists; no subjects or notes are published yet. | | Guides | No reviewed guides published yet. | | Jobs and internships | No reviewed listings published yet. | @@ -131,7 +141,7 @@ troubleshooting, [backend guide](backend/README.md) for accounts, and | `npm run dev` | Start the local development server. | | `npm run lint` | Run ESLint. | | `npm run typecheck` | Check TypeScript types. | -| `npm test` | Run unit tests, including practice-domain, progress-model/store, and content-validation tests. | +| `npm test` | Run practice/progress, portfolio rules/import/client, content-validation and helper tests. | | `npm run build` | Create the production build. | | `npm run e2e` | Build with synthetic content and check frontend browser behavior; opt into the backend with `E2E_DOTNET=1`. | | `npm run launch-check` | Build the real site and crawl links, sitemap, metadata, and SEO rules. | @@ -140,7 +150,7 @@ troubleshooting, [backend guide](backend/README.md) for accounts, and | CI job | Coverage | | --- | --- | | Lint, typecheck, tests and build | TypeScript, code quality, domain/store tests, and the production build. | -| Browser and SEO checks | Desktop/mobile flows, a real .NET process and frontend proxy, the Google sign-in challenge, and a crawl of links/metadata. | +| Browser and SEO checks | Desktop/mobile flows, test-only .NET host using the real API/BFF, portfolio publication/moderation, Google challenge, and links/metadata. | | Backend build and tests | The .NET solution build and tests for backend rules and access behavior. | | Database contracts and container smoke | PostgreSQL, SQL Server and MongoDB contracts, Compose configuration, and the packaged API image. | | Dependency review | Pull-request dependency changes checked for newly introduced high or critical vulnerabilities. | @@ -149,7 +159,9 @@ These jobs need no Google credentials or other external secrets. Browser screens retained for seven days when the site-quality job fails. A deployed Google sign-in flow still needs the configured-provider checks in [Backend setup](backend/README.md). Browser CI verifies the challenge and public callback URL with test client configuration; it does not complete real -Google consent or token exchange. +Google consent or token exchange. Full-stack portfolio checks use disposable accounts and +loopback-only fixtures in `backend/tests/ProCoderJi.BrowserHost`, which is excluded from the +production API image. The production API has no fixture login or moderation HTTP endpoint. The E2E runner refuses to run when `src/data` or `src/content` has local changes because it temporarily installs synthetic fixtures. See [Development](docs/development.md#checks). @@ -161,7 +173,7 @@ src/app/ Next.js routes, metadata, and backend-for-frontend API src/features/practice/ Pure practice rules, question-bank adapter, and practice UI src/features/progress/ Validated attempt records and browser progress storage src/features/account/ Optional account-provider clients and controls -src/features/portfolio/ Shared-rule validation for portfolio fields and usernames +src/features/portfolio/ Portfolio rules, editor, Markdown import, themes, and public delivery src/components/ Shared UI and landing-page sections src/content/ Markdown and MDX learning content src/data/ Course, guide, and job catalogues @@ -193,7 +205,7 @@ The [maintainer guide](docs/maintainer-guide.md) explains triage, review and dep | Saved progress behavior | [Progress feature](src/features/progress/) and its model/store tests. | | Student interface and accessibility | [Practice views](src/features/practice/client/) and shared components. | | Optional accounts, synchronization, or persistence | [Backend setup](backend/README.md), [account feature](src/features/account/), and the Next.js `/api/platform/*` proxy. | -| Portfolio field and username validation | [Shared rules](shared/portfolio/README.md) and the [portfolio roadmap](https://github.com/procoderji-com/procoderji/issues/40). | +| Portfolio editor, themes, import, or publication | [Workspace guide](docs/portfolio-workspace.md), [shared rules](shared/portfolio/README.md), and [API contract](docs/portfolio-api.md). | | Original notes, guides, and listings | [Content guide](docs/content-guide.md). | | Setup and contributor guidance | `docs/` and the [contribution guide](CONTRIBUTING.md). | diff --git a/backend/ProCoderJi.slnx b/backend/ProCoderJi.slnx index 6711b58..db097b3 100644 --- a/backend/ProCoderJi.slnx +++ b/backend/ProCoderJi.slnx @@ -4,5 +4,6 @@ + diff --git a/backend/README.md b/backend/README.md index 775f7a3..be7c78b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,8 @@ -# ProCoderJii account and progress API +# ProCoderJii account, progress and portfolio API -ASP.NET Core 10 provides Google sign-in, server-managed cookie sessions, and durable learner progress. The Next.js app forwards `/api/platform/*` to this service through its server-only BFF. Anonymous browser practice stays available when the backend or Google credentials are not configured. +ASP.NET Core 10 provides Google sign-in, server-managed cookie sessions, durable learner progress, +and portfolio drafts/publication. Next.js forwards `/api/platform/*` through its server-only +BFF. Guest practice and the in-memory portfolio preview work without backend credentials. ## Run locally @@ -39,8 +41,12 @@ 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. +[portfolio API guide](../docs/portfolio-api.md). The editor, two themes, Markdown import, +moderation and 18+ publishing policy are implemented. `Portfolio__PublishingEnabled` remains +false by default; use the [workspace launch runbook](../docs/portfolio-workspace.md) before +enabling it. Frontend production hosting remains separate work under +[#74](https://github.com/procoderji-com/procoderji/issues/74); this guide does not establish a +deployed production service. | Endpoint | Behavior | | --- | --- | @@ -101,8 +107,9 @@ dotnet run --project backend/src/ProCoderJi.Api --no-launch-profile -- --migrate ``` This command uses the current environment's database/provider settings and exits after -provisioning. It applies EF migrations for relational databases and creates MongoDB's unique -Google-subject index for MongoDB. Development and Testing provision automatically; production +provisioning. It applies EF migrations, including portfolio publishing-policy fields, for +relational databases and creates MongoDB's Google-subject and portfolio-username indexes. +Development and Testing provision automatically; production does so only for `--migrate` or explicit `Database__MigrateOnStartup=true`. Back up the database before schema changes. Shared relational migrations contain provider-specific SQL where needed; keep both the default and SQL Server model snapshots current and run the provider checks. @@ -120,6 +127,53 @@ replica set or multi-document transactions. See [ADR 0003](../docs/decisions/000 Razorpay or Paytm integration is planned for a later billing slice. No payment, subscription activation, or live checkout is implemented in this API yet. +## Portfolio moderation + +Each public portfolio's **Report this page** link opens an email to `procoderji@gmail.com` with +its canonical address. Operators review reports and can run these commands using the deployed +API's database environment and authorised database credentials: + +```bash +dotnet ProCoderJi.Api.dll --portfolio suspend --username riya-student +dotnet ProCoderJi.Api.dll --portfolio restore --username riya-student +``` + +For a local development database, use +`dotnet run --project backend/src/ProCoderJi.Api --no-launch-profile -- --portfolio suspend --username riya-student`. +Run `--migrate` separately before these commands when the schema needs updating. Moderation +does not start an HTTP server or migrate a database. It prints only username, status and new +revision, excluding student content and provider connection details. Exit codes are 0 success +(including an already-applied state), 2 invalid arguments, 3 missing portfolio, 1 operation +failure, and 130 cancellation. `--portfolio help` prints syntax. + +Suspend removes the public snapshot, search visibility and publishing acceptance while +retaining the owner's private draft. Owners may edit/unpublish, but cannot publish or delete +to bypass suspension. Restore returns a private **draft**, not a live page: the student must +review and explicitly accept the current policy and publish again. The CLI uses revision/status +checks and supports relational providers and MongoDB. There is no production moderation HTTP +endpoint or student-supplied admin flag. + +Next.js checks fresh publication state before serving cached snapshots, so suspension becomes +404 on the next request without a CLI cache-invalidation callback. Verify the public URL and +sitemap after changes. Keep report decisions and operator access in the deployment's operating +process. Requests to remove a suspended portfolio or an account go to the operator contact; +practice-history deletion is separate. + +## Portfolio publishing configuration + +Phase 1 fixes usernames while the portfolio exists and requires 18+ self-declaration plus +current Terms/Privacy acceptance for every publication. The server privately stores policy +version `2026-11-11` and confirmation time; it collects no birth date. This product policy does +not establish independent age verification or qualified legal review. Existing snapshots +without current acceptance stay unavailable until the student republishes. + +After configuring production Google OAuth, HTTPS `PublicOrigin`, BFF connectivity, durable +storage, migrations, backup/restore, reporting and operator access, set +`Portfolio__PublishingEnabled=true` on the backend to enable publication. Keep it false while +preparing drafts or investigating a public-content incident; disabling it makes public +snapshot/state reads return 404 and empties the public index without deleting drafts. Consult +the [runbook](../docs/portfolio-workspace.md#deployment-runbook) for release checks. + ## Tests ```bash @@ -128,6 +182,14 @@ dotnet test backend/ProCoderJi.slnx --no-restore The default suite creates isolated SQLite databases and uses test-only authentication registered by `WebApplicationFactory` in the test assembly. The API has no test-header login path. Tests cover anonymous behavior, Google challenge URL construction, CSRF/origin checks, account isolation, immutable sync, generation resets, stale requests, question validation, and retention. +Full-stack frontend checks (`E2E_DOTNET=1 npm run e2e`) build the separate +`backend/tests/ProCoderJi.BrowserHost` executable. It hosts the real API on loopback with an +owned temporary SQLite database, seeded disposable-account cookies and fixture-only publishing +controls. Origin, CSRF, ownership and persistence still use the real application pipeline. +Its `/__e2e__` endpoints and authentication scheme exist only in the test executable; the +production API Dockerfile publishes `backend/src/ProCoderJi.Api` only. Never deploy BrowserHost. +See [browser checks](../docs/development.md#browser-checks) for coverage and configuration. + To run the same API contract tests against a disposable server, set exactly one of `PROCODERJI_TEST_POSTGRES`, `PROCODERJI_TEST_SQLSERVER`, or `PROCODERJI_TEST_MONGODB` to its administrative test connection string. Each factory creates and drops only its randomly named diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs index f453bb5..50e41b7 100644 --- a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs +++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs @@ -29,6 +29,16 @@ await Users.Indexes.CreateManyAsync([ Unique = true, Name = "IX_users_Portfolio_Username", PartialFilterExpression = Builders.Filter.Type("Portfolio.Username", BsonType.String) + }), + new CreateIndexModel(Builders.IndexKeys + .Ascending("Portfolio.Status").Ascending("Portfolio.PublishedSearchVisible") + .Ascending("Portfolio.PublishingPolicyVersion").Ascending("Portfolio.Username"), + new CreateIndexOptions + { + Name = "IX_users_Portfolio_PublicIndex", + PartialFilterExpression = Builders.Filter.And( + Builders.Filter.Eq(user => user.Portfolio!.Status, PortfolioStatus.Published), + Builders.Filter.Eq(user => user.Portfolio!.PublishedSearchVisible, true)) }) ], cancellationToken: cancellationToken); } @@ -93,16 +103,20 @@ public sealed class MongoPortfolioDocument public long CreatedAtUtc { get; set; } public long UpdatedAtUtc { get; set; } public long? PublishedAtUtc { get; set; } + [BsonIgnoreIfNull] public string? PublishingPolicyVersion { get; set; } + [BsonIgnoreIfNull] public long? EligibilityConfirmedAtUtc { get; set; } public PortfolioRecord ToRecord(Guid userId) => new(userId, Username, DraftJson, PublishedJson, - SearchVisible, PublishedSearchVisible, Status, Guid.Parse(Revision), CreatedAtUtc, UpdatedAtUtc, PublishedAtUtc); + SearchVisible, PublishedSearchVisible, Status, Guid.Parse(Revision), CreatedAtUtc, UpdatedAtUtc, PublishedAtUtc, + PublishingPolicyVersion, EligibilityConfirmedAtUtc); 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 + UpdatedAtUtc = record.UpdatedAtUtc, PublishedAtUtc = record.PublishedAtUtc, + PublishingPolicyVersion = record.PublishingPolicyVersion, EligibilityConfirmedAtUtc = record.EligibilityConfirmedAtUtc }; } diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs index f29568b..950a995 100644 --- a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs +++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPortfolioStore.cs @@ -38,7 +38,7 @@ public Task ClaimUsername(Guid userId, Guid? revision, string u return current with { Username = username }; }, cancellationToken); - public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken) => + public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken, string? policyVersion = null) => Write(userId, revision, allowCreate: false, (current, now) => { if (current.Status == PortfolioStatus.Suspended) throw new PortfolioConflictException("suspended"); @@ -46,10 +46,24 @@ public Task Publish(Guid userId, Guid revision, CancellationTok return current with { Status = PortfolioStatus.Published, PublishedJson = current.DraftJson, - PublishedSearchVisible = current.SearchVisible, PublishedAtUtc = now + PublishedSearchVisible = current.SearchVisible, PublishedAtUtc = now, + PublishingPolicyVersion = policyVersion, EligibilityConfirmedAtUtc = policyVersion is null ? null : now }; }, cancellationToken); + public async Task> ListSearchVisible(string? after, int limit, CancellationToken cancellationToken) + { + var filter = Builders.Filter.Where(row => row.Portfolio != null + && row.Portfolio.Status == PortfolioStatus.Published && row.Portfolio.PublishedJson != null + && row.Portfolio.Username != null && row.Portfolio.PublishedSearchVisible + && row.Portfolio.PublishingPolicyVersion == PortfolioPublishingPolicy.Version + && row.Portfolio.EligibilityConfirmedAtUtc != null && row.Portfolio.PublishedAtUtc != null); + if (after is not null) filter &= Builders.Filter.Gt("Portfolio.Username", after); + return await database.Users.Find(filter).SortBy(row => row.Portfolio!.Username).Limit(limit) + .Project(row => new PortfolioIndexEntry(row.Portfolio!.Username!, row.Portfolio.PublishedAtUtc!.Value)) + .ToListAsync(cancellationToken); + } + public Task Unpublish(Guid userId, Guid revision, CancellationToken cancellationToken) => Write(userId, revision, allowCreate: false, (current, _) => current with { diff --git a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs index 78dc7bb..eeb0834 100644 --- a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs +++ b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs @@ -54,6 +54,9 @@ protected override void OnModelCreating(ModelBuilder model) .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.PublishingPolicyVersion).HasMaxLength(16); + entity.HasIndex(portfolio => new { portfolio.Status, portfolio.PublishedSearchVisible, portfolio.PublishingPolicyVersion, portfolio.Username }) + .HasDatabaseName("IX_Portfolios_PublicIndex"); 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); @@ -107,4 +110,6 @@ public sealed class PortfolioEntity public long CreatedAtUtc { get; set; } public long UpdatedAtUtc { get; set; } public long? PublishedAtUtc { get; set; } + public string? PublishingPolicyVersion { get; set; } + public long? EligibilityConfirmedAtUtc { get; set; } } diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs index 4aea1bd..6f6d73c 100644 --- a/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/IPortfolioStore.cs @@ -6,7 +6,10 @@ public enum PortfolioStatus { Draft, Published, Suspended } 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); + Guid Revision, long CreatedAtUtc, long UpdatedAtUtc, long? PublishedAtUtc, + string? PublishingPolicyVersion = null, long? EligibilityConfirmedAtUtc = null); + +public sealed record PortfolioIndexEntry(string Username, long PublishedAtUtc); public sealed class PortfolioConflictException(string code) : Exception(code) { @@ -18,10 +21,11 @@ public interface IPortfolioStore { Task GetOwner(Guid userId, CancellationToken cancellationToken); Task GetPublished(string username, CancellationToken cancellationToken); + Task> ListSearchVisible(string? after, int limit, 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 Publish(Guid userId, Guid revision, CancellationToken cancellationToken, string? policyVersion = null); 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/PortfolioEndpoints.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs index 9e82469..0b36c4b 100644 --- a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioEndpoints.cs @@ -23,6 +23,16 @@ public sealed record ClaimPortfolioUsernameRequest( [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] public sealed record PortfolioRevisionRequest([property: JsonRequired] Guid Revision); +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record PortfolioEligibility( + [property: JsonRequired] bool AtLeast18, + [property: JsonRequired] string PolicyVersion); + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record PublishPortfolioRequest( + [property: JsonRequired] Guid Revision, + [property: JsonRequired] PortfolioEligibility? Eligibility); + public static class PortfolioEndpoints { private const int MaxBytes = 256 * 1024; @@ -37,6 +47,8 @@ public static void MapPortfolioEndpoints(this WebApplication app) app.MapGet("/api/platform/portfolio/username", CheckUsername) .RequireRateLimiting(PortfolioApiConfiguration.UsernamePolicy); app.MapGet("/api/platform/portfolio/public/{username}", GetPublic); + app.MapGet("/api/platform/portfolio/public/{username}/state", GetPublicState); + app.MapGet("/api/platform/portfolio/public", GetPublicIndex); app.MapPost("/api/platform/portfolio/draft", SaveDraft) .RequireRateLimiting(PortfolioApiConfiguration.WritePolicy); app.MapPost("/api/platform/portfolio/username", ClaimUsername) @@ -54,7 +66,8 @@ private static async Task GetOwner(HttpContext context, IPortfolioStore 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) }); + return Results.Json(new { portfolio = portfolio is null ? null : Owner(portfolio), publishingEnabled = PublishingEnabled(configuration), + policyVersion = PortfolioPublishingPolicy.Version, minimumPublishingAge = PortfolioPublishingPolicy.MinimumAge }); } private static async Task CheckUsername(HttpContext context, string? username, IPortfolioStore store, PortfolioValidator validator) @@ -72,8 +85,11 @@ private static async Task GetPublic(string username, HttpContext contex 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) + if (portfolio is null || portfolio.Status != PortfolioStatus.Published || portfolio.PublishedJson is null || !PortfolioPublishingPolicy.IsAccepted(portfolio)) return Results.NotFound(); + var version = PortfolioPublishingPolicy.PublicVersion(portfolio); + var expected = context.Request.Query["version"].ToString(); + if (expected.Length != 0 && expected != version) 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 @@ -81,10 +97,36 @@ private static async Task GetPublic(string username, HttpContext contex username = portfolio.Username, data = JsonNode.Parse(portfolio.PublishedJson), searchVisible = portfolio.PublishedSearchVisible, - publishedAt = Timestamp(portfolio.PublishedAtUtc) + publishedAt = Timestamp(portfolio.PublishedAtUtc), version }); } + private static async Task GetPublicState(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 || !PortfolioPublishingPolicy.IsAccepted(portfolio)) return Results.NotFound(); + return Results.Json(new { username, version = PortfolioPublishingPolicy.PublicVersion(portfolio), + searchVisible = portfolio.PublishedSearchVisible, publishedAt = Timestamp(portfolio.PublishedAtUtc) }); + } + + private static async Task GetPublicIndex(HttpContext context, IPortfolioStore store, PortfolioValidator validator, IConfiguration configuration) + { + context.Response.Headers["X-Robots-Tag"] = "noindex"; + if (!PublishingEnabled(configuration)) return Results.Json(new { portfolios = Array.Empty(), nextCursor = (string?)null }); + var after = context.Request.Query["after"].ToString(); + if (after.Length > 0 && validator.ValidateUsername(after).Count != 0) return Results.NotFound(); + var limitText = context.Request.Query["limit"].ToString(); + var limit = 1000; + if (limitText.Length > 0 && (!int.TryParse(limitText, NumberStyles.None, CultureInfo.InvariantCulture, out limit) || limit is < 1 or > 1000)) + return Error("invalid-limit", "Use an index page size between 1 and 1000.", 400); + var records = await store.ListSearchVisible(after.Length == 0 ? null : after, limit + 1, context.RequestAborted); + var page = records.Take(limit).ToArray(); + return Results.Json(new { portfolios = page.Select(row => new { username = row.Username, publishedAt = Timestamp(row.PublishedAtUtc) }), + nextCursor = records.Count > limit ? page[^1].Username : null }); + } + private static async Task SaveDraft(HttpContext context, IPortfolioStore store, PortfolioValidator validator, PlatformConfiguration configuration, IAntiforgery antiforgery) { var error = await CheckWrite(context, configuration, antiforgery); @@ -95,7 +137,7 @@ private static async Task SaveDraft(HttpContext context, IPortfolioStor 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, + return await Mutation(context, () => store.SaveDraft(ApiSecurity.UserId(context)!.Value, body.Revision, body.Data!.ToJsonString(Json), body.SearchVisible, context.RequestAborted)); } @@ -108,7 +150,7 @@ private static async Task ClaimUsername(HttpContext context, IPortfolio 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, + return await Mutation(context, () => store.ClaimUsername(ApiSecurity.UserId(context)!.Value, read.Body.Revision, read.Body.Username!, context.RequestAborted)); } @@ -116,11 +158,15 @@ private static async Task Publish(HttpContext context, IPortfolioStore { var error = await CheckWrite(context, configuration, antiforgery); if (error is not null) return error; - var read = await Read(context); + 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); + if (read.Body.Eligibility is not { AtLeast18: true }) + return Error("publishing-eligibility-required", "Confirm that you are at least 18 and have read the portfolio Terms and Privacy Policy.", 400); + if (read.Body.Eligibility.PolicyVersion != PortfolioPublishingPolicy.Version) + return Error("policy-changed", "The portfolio publishing policy changed. Refresh and review it before publishing.", 409); var userId = ApiSecurity.UserId(context)!.Value; var portfolio = await store.GetOwner(userId, context.RequestAborted); if (portfolio is null) return Conflict("not-found"); @@ -128,7 +174,7 @@ private static async Task Publish(HttpContext context, IPortfolioStore 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)); + return await Mutation(context, () => store.Publish(userId, read.Body.Revision, context.RequestAborted, PortfolioPublishingPolicy.Version)); } private static async Task Unpublish(HttpContext context, IPortfolioStore store, PlatformConfiguration configuration, IAntiforgery antiforgery) @@ -138,7 +184,7 @@ private static async Task Unpublish(HttpContext context, IPortfolioStor 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)); + return await Mutation(context, () => store.Unpublish(ApiSecurity.UserId(context)!.Value, read.Body.Revision, context.RequestAborted)); } private static async Task Delete(HttpContext context, IPortfolioStore store, PlatformConfiguration configuration, IAntiforgery antiforgery) @@ -150,7 +196,10 @@ private static async Task Delete(HttpContext context, IPortfolioStore s if (read.Body!.Revision == Guid.Empty) return InvalidRevision(); try { - await store.Delete(ApiSecurity.UserId(context)!.Value, read.Body.Revision, context.RequestAborted); + var userId = ApiSecurity.UserId(context)!.Value; + var portfolio = await store.GetOwner(userId, context.RequestAborted); + await store.Delete(userId, read.Body.Revision, context.RequestAborted); + if (portfolio?.Username is { } username) context.Response.Headers["X-Portfolio-Username"] = username; return Results.NoContent(); } catch (PortfolioConflictException cause) { return Conflict(cause.Code); } @@ -165,7 +214,9 @@ private static async Task Delete(HttpContext context, IPortfolioStore s revision = record.Revision, createdAt = Timestamp(record.CreatedAtUtc), updatedAt = Timestamp(record.UpdatedAtUtc), - publishedAt = Timestamp(record.PublishedAtUtc) + publishedAt = Timestamp(record.PublishedAtUtc), + publishingPolicyVersion = record.PublishingPolicyVersion, + eligibilityConfirmedAt = Timestamp(record.EligibilityConfirmedAtUtc) }; private static string? Timestamp(long? milliseconds) => milliseconds is null ? null @@ -207,9 +258,14 @@ private static IResult Invalid(IReadOnlyList problems) => Resu _ => Error("stale-revision", "Your portfolio changed. Refresh before trying again.", 409) }; - private static async Task Mutation(Func> action) + private static async Task Mutation(HttpContext context, Func> action) { - try { return Results.Json(new { portfolio = Owner(await action()) }); } + try + { + var portfolio = await action(); + if (portfolio.Username is { } username) context.Response.Headers["X-Portfolio-Username"] = username; + return Results.Json(new { portfolio = Owner(portfolio) }); + } catch (PortfolioConflictException cause) { return Conflict(cause.Code); } } diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioModerationCommand.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioModerationCommand.cs new file mode 100644 index 0000000..bbc0707 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioModerationCommand.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; +using Npgsql; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Data.Mongo; + +namespace ProCoderJi.Api.Features.Portfolio; + +public sealed record PortfolioModerationResult(string Username, string Status, Guid Revision); + +/// +/// An operator-only process command. It exposes no HTTP route and uses the deployment's +/// database credentials. Updates use the same revision/status boundary as student edits. +/// +public static class PortfolioModerationCommand +{ + private const string Usage = "Use --portfolio suspend|restore --username . Use --portfolio help for this syntax."; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public static bool IsRequested(string[] args) => args.Any(argument => + argument.Equals("--portfolio", StringComparison.OrdinalIgnoreCase) + || argument.StartsWith("--portfolio=", StringComparison.OrdinalIgnoreCase)); + + /// Null for normal server startup; otherwise the operator command's exit code. + public static async Task TryRun(string[] args, IServiceProvider services, + CancellationToken cancellationToken = default, TextWriter? output = null, TextWriter? error = null) + { + if (!IsRequested(args)) return null; + output ??= Console.Out; + error ??= Console.Error; + if (args is ["--portfolio", "help"]) + { + await output.WriteLineAsync(Usage); + return 0; + } + if (!TryParse(args, out var action, out var username) || username is null) + { + await error.WriteLineAsync(Usage); + return 2; + } + // Reserved/blocked names must remain operable if an older rule ever allowed one. + // All shape and character checks still apply to the operator's selector. + if (PortfolioValidator.CreateDefault().ValidateUsername(username) + .Any(problem => problem.Code is not ("reserved" or "blocked-word"))) + { + await error.WriteLineAsync("The portfolio username has an invalid format."); + return 2; + } + try + { + await using var scope = services.CreateAsyncScope(); + var provider = scope.ServiceProvider; + var mongo = string.Equals(provider.GetRequiredService()["Database:Provider"], "MongoDb", StringComparison.OrdinalIgnoreCase); + var result = mongo + ? await ApplyMongo(provider.GetRequiredService(), username, action == "suspend", cancellationToken) + : await ApplyRelational(provider.GetRequiredService>(), username, action == "suspend", cancellationToken); + if (result is null) + { + await error.WriteLineAsync("Portfolio not found."); + return 3; + } + await output.WriteLineAsync(JsonSerializer.Serialize(result, Json)); + return 0; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await error.WriteLineAsync("Portfolio moderation was cancelled."); + return 130; + } + catch (Exception) + { + // Provider exceptions can contain deployment details. Do not write them or + // any student portfolio fields to the operator command's public output. + await error.WriteLineAsync("Portfolio moderation failed. Check operator access and database readiness."); + return 1; + } + } + + private static bool TryParse(string[] args, out string? action, out string? username) + { + action = null; + username = null; + if (args.Length != 4) return false; + for (var index = 0; index < args.Length; index += 2) + { + if (args[index] == "--portfolio" && action is null) action = args[index + 1]; + else if (args[index] == "--username" && username is null) username = args[index + 1]; + else return false; + } + return action is "suspend" or "restore" && username is not null; + } + + private static async Task ApplyRelational(IDbContextFactory factory, + string username, bool suspend, CancellationToken cancellationToken) + { + for (var retry = 0; retry < 4; retry++) + { + await using var db = await factory.CreateDbContextAsync(cancellationToken); + try + { + var current = await db.Portfolios.AsNoTracking().SingleOrDefaultAsync(row => row.Username == username, cancellationToken); + if (current is null) return null; + if (suspend && current.Status == PortfolioStatus.Suspended || !suspend && current.Status != PortfolioStatus.Suspended) + return Result(username, current.Status, current.Revision); + var status = suspend ? PortfolioStatus.Suspended : PortfolioStatus.Draft; + var revision = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var changed = await db.Portfolios.Where(row => row.UserId == current.UserId + && row.Revision == current.Revision && row.Status == current.Status) + .ExecuteUpdateAsync(update => update + .SetProperty(row => row.Status, status) + .SetProperty(row => row.Revision, revision) + .SetProperty(row => row.UpdatedAtUtc, now) + .SetProperty(row => row.PublishedJson, (string?)null) + .SetProperty(row => row.PublishedSearchVisible, false) + .SetProperty(row => row.PublishingPolicyVersion, (string?)null) + .SetProperty(row => row.EligibilityConfirmedAtUtc, (long?)null), cancellationToken); + if (changed == 1) return Result(username, status, revision); + } + catch (Exception exception) when (retry < 3 && IsRetryable(exception)) + { + await Task.Delay(25 * (retry + 1), cancellationToken); + } + } + throw new InvalidOperationException("Portfolio changed repeatedly during moderation."); + } + + private static async Task ApplyMongo(MongoPlatformDatabase database, + string username, bool suspend, CancellationToken cancellationToken) + { + for (var retry = 0; retry < 4; retry++) + { + var current = await database.Users.Find(row => row.Portfolio != null && row.Portfolio.Username == username) + .SingleOrDefaultAsync(cancellationToken); + if (current?.Portfolio is null) return null; + var portfolio = current.Portfolio; + if (suspend && portfolio.Status == PortfolioStatus.Suspended || !suspend && portfolio.Status != PortfolioStatus.Suspended) + return Result(username, portfolio.Status, Guid.Parse(portfolio.Revision)); + var status = suspend ? PortfolioStatus.Suspended : PortfolioStatus.Draft; + var revision = Guid.NewGuid(); + var filter = Builders.Filter.Eq(row => row.Id, current.Id) + & Builders.Filter.Eq(row => row.Portfolio!.Username, username) + & Builders.Filter.Eq(row => row.Portfolio!.Revision, portfolio.Revision) + & Builders.Filter.Eq(row => row.Portfolio!.Status, portfolio.Status); + var updated = await database.Users.UpdateOneAsync(filter, Builders.Update + .Set(row => row.Portfolio!.Status, status) + .Set(row => row.Portfolio!.Revision, revision.ToString("D")) + .Set(row => row.Portfolio!.UpdatedAtUtc, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) + .Unset(row => row.Portfolio!.PublishedJson) + .Set(row => row.Portfolio!.PublishedSearchVisible, false) + .Unset(row => row.Portfolio!.PublishingPolicyVersion) + .Unset(row => row.Portfolio!.EligibilityConfirmedAtUtc), cancellationToken: cancellationToken); + if (updated.MatchedCount == 1) return Result(username, status, revision); + } + throw new InvalidOperationException("Portfolio changed repeatedly during moderation."); + } + + private static PortfolioModerationResult Result(string username, PortfolioStatus status, Guid revision) => + new(username, status.ToString().ToLowerInvariant(), revision); + + private static bool IsRetryable(Exception error) => error is SqliteException { SqliteErrorCode: 5 or 6 } + || error is PostgresException { SqlState: "40001" or "40P01" } + || error is SqlException { Number: 1205 or 3960 } + || error.InnerException is not null && IsRetryable(error.InnerException); +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioPublishingPolicy.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioPublishingPolicy.cs new file mode 100644 index 0000000..2312884 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioPublishingPolicy.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ProCoderJi.Api.Features.Portfolio; + +/// Phase 1 publishing decisions recorded in ADR 0002; no birth date is collected. +public static class PortfolioPublishingPolicy +{ + private static readonly Lazy Rules = new(() => PortfolioRules.LoadDefault().Publishing); + public static string Version => Rules.Value.PolicyVersion; + public static int MinimumAge => Rules.Value.MinimumAge; + + public static bool IsAccepted(PortfolioRecord record) => + record.PublishingPolicyVersion == Version && record.EligibilityConfirmedAtUtc is not null && record.PublishedAtUtc is not null; + + // A public snapshot identity never uses the owner's private editing revision. + public static string PublicVersion(PortfolioRecord record) => Convert.ToHexStringLower(SHA256.HashData( + Encoding.UTF8.GetBytes($"{record.PublishedAtUtc}:{record.PublishedSearchVisible}:{record.PublishedJson}"))); +} diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs index 7cee61e..cd84892 100644 --- a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs @@ -15,6 +15,8 @@ public sealed class PortfolioRules }; public required IReadOnlyList RequiredForPublish { get; init; } + public required IReadOnlyList Themes { get; init; } + public required PublishingRules Publishing { get; init; } public required IReadOnlyDictionary MaxItems { get; init; } public required IReadOnlyDictionary MaxLength { get; init; } public required IReadOnlyList MultilineFields { get; init; } @@ -24,6 +26,7 @@ public sealed class PortfolioRules public required UsernameRules Username { get; init; } public sealed record TextRules(string ControlCharacters, string HtmlLike); + public sealed record PublishingRules(string PolicyVersion, int MinimumAge); public sealed record LinkRules( string Scheme, @@ -40,6 +43,8 @@ public sealed record UsernameRules( private sealed record RulesDocument( int Version, + List? Themes, + PublishingRules? Publishing, List? RequiredForPublish, Dictionary? MaxItems, Dictionary? MaxLength, @@ -54,7 +59,9 @@ public static PortfolioRules FromJson(string json) RulesDocument? document; try { document = JsonSerializer.Deserialize(json, RulesJson); } catch (JsonException cause) { throw new InvalidDataException("The shared portfolio rules are not valid JSON.", cause); } - if (document is not { Version: 1 } || document.RequiredForPublish is null || document.MaxItems is null + if (document is not { Version: 2 } || document.Themes is not { Count: > 0 } + || document.Publishing is not { MinimumAge: 18 } || !DateOnly.TryParseExact(document.Publishing.PolicyVersion, "yyyy-MM-dd", out _) + || document.RequiredForPublish is null || document.MaxItems is null || document.MaxLength is null || document.MultilineFields is null || document.Text is null || document.Link is null || document.EmailPattern is null || document.Username is null || document.Username.Reserved is null || document.Username.BlockedWords is null @@ -63,6 +70,8 @@ public static PortfolioRules FromJson(string json) return new PortfolioRules { RequiredForPublish = document.RequiredForPublish, + Themes = document.Themes, + Publishing = document.Publishing, MaxItems = document.MaxItems, MaxLength = document.MaxLength, MultilineFields = document.MultilineFields, diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs index 1f6219b..fc70e09 100644 --- a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs @@ -22,7 +22,7 @@ public readonly record struct PortfolioProblem(string Path, string Code) public sealed class PortfolioValidator { private static readonly string[] TopLevelFields = - ["name", "headline", "about", "skills", "projects", "education", "experience", "achievements", "links"]; + ["theme", "name", "headline", "about", "skills", "projects", "education", "experience", "achievements", "links"]; private readonly PortfolioRules rules; private readonly Regex control; @@ -85,6 +85,14 @@ public IReadOnlyList Validate(JsonNode? node, PortfolioMode mo if (!CheckUnknownFields(problems, "", root, TopLevelFields)) return problems; bool RequiredOnPublish(string key) => mode == PortfolioMode.Publish && rules.RequiredForPublish.Contains(key); + var theme = Get(root, "theme"); + if (theme.Present) + { + var read = TryString(theme.Node, out var name); + if (read == StringRead.NotAString) problems.Add(new("theme", "invalid-type")); + else if (read == StringRead.Malformed) problems.Add(new("theme", "invalid-characters")); + else if (!rules.Themes.Contains(name)) problems.Add(new("theme", "invalid-format")); + } CheckText(problems, "name", Get(root, "name"), "name", RequiredOnPublish("name")); CheckText(problems, "headline", Get(root, "headline"), "headline", RequiredOnPublish("headline")); CheckText(problems, "about", Get(root, "about"), "about", false); diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/RelationalPortfolioStore.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/RelationalPortfolioStore.cs index c26f179..3f162df 100644 --- a/backend/src/ProCoderJi.Api/Features/Portfolio/RelationalPortfolioStore.cs +++ b/backend/src/ProCoderJi.Api/Features/Portfolio/RelationalPortfolioStore.cs @@ -56,7 +56,7 @@ public Task ClaimUsername(Guid userId, Guid? revision, string u return Record(row); }, cancellationToken); - public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken) => + public Task Publish(Guid userId, Guid revision, CancellationToken cancellationToken, string? policyVersion = null) => InTransaction(async db => { var row = await Editable(db, userId, revision, create: false, cancellationToken); @@ -66,6 +66,8 @@ public Task Publish(Guid userId, Guid revision, CancellationTok row.PublishedSearchVisible = row.SearchVisible; row.Status = PortfolioStatus.Published; row.PublishedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + row.PublishingPolicyVersion = policyVersion; + row.EligibilityConfirmedAtUtc = policyVersion is null ? null : row.PublishedAtUtc; Touch(row); await db.SaveChangesAsync(cancellationToken); return Record(row); @@ -120,7 +122,19 @@ private static void Touch(PortfolioEntity row) } 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); + row.SearchVisible, row.PublishedSearchVisible, row.Status, row.Revision, row.CreatedAtUtc, row.UpdatedAtUtc, row.PublishedAtUtc, + row.PublishingPolicyVersion, row.EligibilityConfirmedAtUtc); + + public async Task> ListSearchVisible(string? after, int limit, CancellationToken cancellationToken) + { + await using var db = await factory.CreateDbContextAsync(cancellationToken); + return await db.Portfolios.AsNoTracking().Where(row => row.Status == PortfolioStatus.Published + && row.PublishedJson != null && row.Username != null && row.PublishedSearchVisible + && row.PublishingPolicyVersion == PortfolioPublishingPolicy.Version && row.EligibilityConfirmedAtUtc != null && row.PublishedAtUtc != null + && (after == null || string.Compare(row.Username, after) > 0)) + .OrderBy(row => row.Username).Take(limit) + .Select(row => new PortfolioIndexEntry(row.Username!, row.PublishedAtUtc!.Value)).ToListAsync(cancellationToken); + } private async Task InTransaction(Func> operation, CancellationToken cancellationToken) { diff --git a/backend/src/ProCoderJi.Api/Migrations/20261111090000_PortfolioPublishingPolicy.cs b/backend/src/ProCoderJi.Api/Migrations/20261111090000_PortfolioPublishingPolicy.cs new file mode 100644 index 0000000..1294f86 --- /dev/null +++ b/backend/src/ProCoderJi.Api/Migrations/20261111090000_PortfolioPublishingPolicy.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using ProCoderJi.Api.Data; + +namespace ProCoderJi.Api.Migrations; + +[DbContext(typeof(PlatformDbContext))] +[Migration("20261111090000_PortfolioPublishingPolicy")] +public sealed class PortfolioPublishingPolicyMigration : Migration +{ + protected override void Up(MigrationBuilder migration) + { + migration.AddColumn("PublishingPolicyVersion", "Portfolios", maxLength: 16, nullable: true); + migration.AddColumn("EligibilityConfirmedAtUtc", "Portfolios", nullable: true); + migration.CreateIndex("IX_Portfolios_PublicIndex", "Portfolios", ["Status", "PublishedSearchVisible", "PublishingPolicyVersion", "Username"]); + } + + protected override void Down(MigrationBuilder migration) + { + migration.DropIndex("IX_Portfolios_PublicIndex", "Portfolios"); + migration.DropColumn("PublishingPolicyVersion", "Portfolios"); + migration.DropColumn("EligibilityConfirmedAtUtc", "Portfolios"); + } +} diff --git a/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs index 3a2d66b..205d535 100644 --- a/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs +++ b/backend/src/ProCoderJi.Api/Migrations/PlatformDbContextModelSnapshot.cs @@ -74,8 +74,11 @@ protected override void BuildModel(ModelBuilder model) entity.Property("CreatedAtUtc"); entity.Property("UpdatedAtUtc"); entity.Property("PublishedAtUtc"); + entity.Property("PublishingPolicyVersion").HasMaxLength(16); + entity.Property("EligibilityConfirmedAtUtc"); entity.HasKey("UserId"); entity.HasIndex("Username").IsUnique(); + entity.HasIndex("Status", "PublishedSearchVisible", "PublishingPolicyVersion", "Username").HasDatabaseName("IX_Portfolios_PublicIndex"); entity.ToTable("Portfolios", table => { table.HasCheckConstraint("CK_Portfolio_Status", "\"Status\" IN ('Draft', 'Published', 'Suspended')"); diff --git a/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs index 784c5c3..2d75f0e 100644 --- a/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs +++ b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs @@ -75,8 +75,11 @@ protected override void BuildModel(ModelBuilder model) entity.Property("CreatedAtUtc"); entity.Property("UpdatedAtUtc"); entity.Property("PublishedAtUtc"); + entity.Property("PublishingPolicyVersion").HasMaxLength(16); + entity.Property("EligibilityConfirmedAtUtc"); entity.HasKey("UserId"); entity.HasIndex("Username").IsUnique().HasFilter("[Username] IS NOT NULL"); + entity.HasIndex("Status", "PublishedSearchVisible", "PublishingPolicyVersion", "Username").HasDatabaseName("IX_Portfolios_PublicIndex"); entity.ToTable("Portfolios", table => { table.HasCheckConstraint("CK_Portfolio_Status", "\"Status\" IN ('Draft', 'Published', 'Suspended')"); diff --git a/backend/src/ProCoderJi.Api/Program.cs b/backend/src/ProCoderJi.Api/Program.cs index 4a785b8..ff67354 100644 --- a/backend/src/ProCoderJi.Api/Program.cs +++ b/backend/src/ProCoderJi.Api/Program.cs @@ -13,7 +13,8 @@ using ProCoderJi.Api.Features.Progress; using ProCoderJi.Api.Features.Portfolio; -var builder = WebApplication.CreateBuilder(args); +var operatorCommand = PortfolioModerationCommand.IsRequested(args); +var builder = WebApplication.CreateBuilder(operatorCommand ? [] : args); builder.Services.AddSingleton(services => PlatformConfiguration.From(services.GetRequiredService(), services.GetRequiredService())); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow); builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 256 * 1024); @@ -101,6 +102,11 @@ }); var app = builder.Build(); +if (operatorCommand) +{ + Environment.ExitCode = await PortfolioModerationCommand.TryRun(args, app.Services) ?? 2; + return; +} var platform = app.Services.GetRequiredService(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || builder.Configuration.GetValue("Database:MigrateOnStartup") || args.Contains("--migrate")) { diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioIntegrationTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioIntegrationTests.cs index 6778c30..7c1c9f5 100644 --- a/backend/tests/ProCoderJi.Api.Tests/PortfolioIntegrationTests.cs +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioIntegrationTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -335,6 +336,13 @@ private static HttpRequestMessage Request(Guid user, string? token, string actio 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); + if (action == "publish") + { + var publish = JsonSerializer.SerializeToNode(body, Json)!.AsObject(); + if (!publish.ContainsKey("eligibility")) + publish["eligibility"] = JsonSerializer.SerializeToNode(new { atLeast18 = true, policyVersion = PortfolioPublishingPolicy.Version }, Json); + body = publish; + } request.Content = JsonContent.Create(body, options: Json); return browser.SendAsync(request); } diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioModerationTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioModerationTests.cs new file mode 100644 index 0000000..fe62502 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioModerationTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Features.Portfolio; + +namespace ProCoderJi.Api.Tests; + +public sealed class PortfolioModerationTests +{ + private static readonly IReadOnlyDictionary Publishing = new Dictionary + { + ["Portfolio:PublishingEnabled"] = "true" + }; + + [Fact] + public async Task Command_validation_and_help_never_start_a_database_or_print_arguments() + { + await using var services = new ServiceCollection().BuildServiceProvider(); + Assert.Null(await PortfolioModerationCommand.TryRun([], services)); + Assert.True(PortfolioModerationCommand.IsRequested(["--portfolio=suspend"])); + foreach (var args in new[] + { + new[] { "--portfolio" }, + new[] { "--portfolio", "token=private-value", "--username", "student-one" }, + new[] { "--portfolio", "suspend", "--username", "../secret" }, + new[] { "--portfolio", "suspend", "--username", "Student" }, + new[] { "--portfolio", "suspend", "--username", "student-one", "--other", "private-value" }, + }) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + Assert.Equal(2, await PortfolioModerationCommand.TryRun(args, services, output: output, error: error)); + Assert.Equal("", output.ToString()); + Assert.DoesNotContain("private-value", error.ToString()); + } + using var help = new StringWriter(); + Assert.Equal(0, await PortfolioModerationCommand.TryRun(["--portfolio", "help"], services, output: help)); + Assert.Contains("suspend|restore", help.ToString()); + } + + [Fact] + public async Task Suspension_and_restore_rotate_revisions_hide_the_page_and_require_fresh_publication() + { + using var factory = new PlatformFactory(overrides: Publishing); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + var store = services.GetRequiredService(); + var user = await services.GetRequiredService().SignIn("moderated-subject", "moderated@example.test", "Student", default); + var draft = await store.SaveDraft(user.Id, null, "{\"name\":\"Riya\",\"headline\":\"Student\"}", true, default); + var claim = await store.ClaimUsername(user.Id, draft.Revision, "moderated-student", default); + var published = await store.Publish(user.Id, claim.Revision, default, PortfolioPublishingPolicy.Version); + using var browser = factory.Browser(); + Assert.Equal(HttpStatusCode.OK, (await browser.GetAsync("/api/platform/portfolio/public/moderated-student")).StatusCode); + + var suspended = await Moderate(factory.Services, "suspend", "moderated-student"); + Assert.Equal("suspended", suspended.Status); + Assert.NotEqual(published.Revision, suspended.Revision); + var hidden = await store.GetOwner(user.Id, default); + Assert.Equal(PortfolioStatus.Suspended, hidden!.Status); + Assert.Null(hidden.PublishedJson); + Assert.False(hidden.PublishedSearchVisible); + Assert.Null(hidden.PublishingPolicyVersion); + Assert.Equal(HttpStatusCode.NotFound, (await browser.GetAsync("/api/platform/portfolio/public/moderated-student")).StatusCode); + Assert.Equal(suspended.Revision, (await Moderate(factory.Services, "suspend", "moderated-student")).Revision); + Assert.Equal("suspended", (await Assert.ThrowsAsync(() => store.Delete(user.Id, hidden.Revision, default))).Code); + Assert.Equal("stale-revision", (await Assert.ThrowsAsync(() => + store.SaveDraft(user.Id, published.Revision, "{}", false, default))).Code); + + var restored = await Moderate(factory.Services, "restore", "moderated-student"); + Assert.Equal("draft", restored.Status); + Assert.NotEqual(suspended.Revision, restored.Revision); + var owner = (await store.GetOwner(user.Id, default))!; + Assert.Equal(published.DraftJson, owner.DraftJson); + Assert.True(owner.SearchVisible); + Assert.Null(owner.PublishedJson); + Assert.Null(owner.EligibilityConfirmedAtUtc); + Assert.Equal(HttpStatusCode.NotFound, (await browser.GetAsync("/api/platform/portfolio/public/moderated-student")).StatusCode); + Assert.Equal(restored.Revision, (await Moderate(factory.Services, "restore", "moderated-student")).Revision); + var republished = await store.Publish(user.Id, owner.Revision, default, PortfolioPublishingPolicy.Version); + Assert.Equal(PortfolioStatus.Published, republished.Status); + Assert.Equal(HttpStatusCode.OK, (await browser.GetAsync("/api/platform/portfolio/public/moderated-student")).StatusCode); + } + + [Fact] + public async Task Concurrent_student_publish_cannot_undo_an_operator_suspension() + { + using var factory = new PlatformFactory(overrides: Publishing); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + var store = services.GetRequiredService(); + var user = await services.GetRequiredService().SignIn("racing-subject", "race@example.test", "Student", default); + var draft = await store.SaveDraft(user.Id, null, "{\"name\":\"Riya\",\"headline\":\"Student\"}", false, default); + var claimed = await store.ClaimUsername(user.Id, draft.Revision, "racing-student", default); + async Task Publish() + { + try { await store.Publish(user.Id, claimed.Revision, default, PortfolioPublishingPolicy.Version); } + catch (PortfolioConflictException error) when (error.Code is "stale-revision" or "suspended") { } + } + await Task.WhenAll(Publish(), Moderate(factory.Services, "suspend", "racing-student")); + var final = (await store.GetOwner(user.Id, default))!; + Assert.Equal(PortfolioStatus.Suspended, final.Status); + Assert.Null(final.PublishedJson); + Assert.Null(await store.GetPublished("racing-student", default)); + } + + [Fact] + public async Task Unknown_usernames_return_a_nonzero_exit_code_without_printing_student_data() + { + using var factory = new PlatformFactory(); + using var output = new StringWriter(); + using var error = new StringWriter(); + Assert.Equal(3, await PortfolioModerationCommand.TryRun( + ["--portfolio", "suspend", "--username", "missing-student"], factory.Services, output: output, error: error)); + Assert.Equal("", output.ToString()); + Assert.Contains("not found", error.ToString()); + } + + private static async Task Moderate(IServiceProvider services, string action, string username) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var code = await PortfolioModerationCommand.TryRun(["--portfolio", action, "--username", username], services, + output: output, error: error); + Assert.True(code == 0, $"Moderation exited {code}: {error}"); + var result = JsonSerializer.Deserialize(output.ToString(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); + return Assert.IsType(result); + } +} diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioPublicationTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioPublicationTests.cs new file mode 100644 index 0000000..2179a36 --- /dev/null +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioPublicationTests.cs @@ -0,0 +1,240 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Features.Portfolio; + +namespace ProCoderJi.Api.Tests; + +public sealed class PortfolioPublicationTests +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + private static readonly IReadOnlyDictionary Publishing = new Dictionary + { + ["Portfolio:PublishingEnabled"] = "true" + }; + + [Fact] + public async Task Publish_requires_an_explicit_current_age_and_policy_confirmation_and_stores_no_birth_date() + { + using var factory = new PlatformFactory(overrides: Publishing); + var seeded = await Seed(factory.Services, "policy-student", true); + using var owner = factory.Browser(seeded.UserId); + var csrf = await Token(owner); + (object Body, HttpStatusCode Status)[] invalid = + [ + (new { revision = seeded.Record.Revision }, HttpStatusCode.BadRequest), + (new { revision = seeded.Record.Revision, eligibility = (object?)null }, HttpStatusCode.BadRequest), + (new { revision = seeded.Record.Revision, eligibility = new { atLeast18 = false, policyVersion = PortfolioPublishingPolicy.Version } }, HttpStatusCode.BadRequest), + (new { revision = seeded.Record.Revision, eligibility = new { atLeast18 = true, policyVersion = "outdated" } }, HttpStatusCode.Conflict), + (new { revision = seeded.Record.Revision, eligibility = new { atLeast18 = true } }, HttpStatusCode.BadRequest), + (new { revision = seeded.Record.Revision, eligibility = new { atLeast18 = true, policyVersion = PortfolioPublishingPolicy.Version, birthDate = "2000-01-01" } }, HttpStatusCode.BadRequest), + (new { revision = seeded.Record.Revision, eligibility = new { atLeast18 = "true", policyVersion = PortfolioPublishingPolicy.Version } }, HttpStatusCode.BadRequest) + ]; + foreach (var entry in invalid) + { + using var response = await Write(owner, seeded.UserId, csrf, "publish", entry.Body); + Assert.Equal(entry.Status, response.StatusCode); + var current = await Own(factory.Services, seeded.UserId); + Assert.Equal(PortfolioStatus.Draft, current.Status); + Assert.Null(current.PublishedJson); + Assert.Null(current.PublishingPolicyVersion); + Assert.Null(current.EligibilityConfirmedAtUtc); + } + + var before = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + using var published = await Write(owner, seeded.UserId, csrf, "publish", new + { + revision = seeded.Record.Revision, + eligibility = new { atLeast18 = true, policyVersion = PortfolioPublishingPolicy.Version } + }); + Assert.Equal(HttpStatusCode.OK, published.StatusCode); + var accepted = await Own(factory.Services, seeded.UserId); + Assert.Equal(PortfolioPublishingPolicy.Version, accepted.PublishingPolicyVersion); + Assert.InRange(accepted.EligibilityConfirmedAtUtc!.Value, before, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + using var anonymous = factory.Browser(); + var publicData = await ReadJson(anonymous, "/api/platform/portfolio/public/policy-student"); + Assert.False(publicData.TryGetProperty("publishingPolicyVersion", out _)); + Assert.False(publicData.TryGetProperty("eligibilityConfirmedAt", out _)); + Assert.DoesNotContain("birthDate", publicData.GetRawText()); + } + + [Fact] + public async Task Public_state_and_versioned_snapshots_never_reveal_drafts_and_reject_an_obsolete_public_version() + { + using var factory = new PlatformFactory(overrides: Publishing); + var seeded = await Seed(factory.Services, "snapshot-student", false, PortfolioPublishingPolicy.Version); + using var anonymous = factory.Browser(); + using var stateResponse = await anonymous.GetAsync("/api/platform/portfolio/public/snapshot-student/state"); + Assert.Equal(HttpStatusCode.OK, stateResponse.StatusCode); + Assert.True(stateResponse.Headers.CacheControl!.NoStore); + Assert.Contains("noindex", stateResponse.Headers.GetValues("X-Robots-Tag")); + var state = await stateResponse.Content.ReadFromJsonAsync(Json); + Assert.Equal(new[] { "publishedAt", "searchVisible", "username", "version" }, state.EnumerateObject().Select(property => property.Name).Order().ToArray()); + var version = state.GetProperty("version").GetString()!; + Assert.Matches("^[0-9a-f]{64}$", version); + Assert.False(state.GetProperty("searchVisible").GetBoolean()); + + using var scope = factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var edited = await store.SaveDraft(seeded.UserId, seeded.Record.Revision, + "{\"name\":\"Private replacement\",\"headline\":\"Student\",\"theme\":\"developer\"}", true, default); + var unchangedState = await ReadJson(anonymous, "/api/platform/portfolio/public/snapshot-student/state"); + Assert.Equal(version, unchangedState.GetProperty("version").GetString()); + Assert.False(unchangedState.GetProperty("searchVisible").GetBoolean()); + Assert.DoesNotContain("Private replacement", unchangedState.GetRawText()); + var snapshot = await ReadJson(anonymous, "/api/platform/portfolio/public/snapshot-student?version=" + version); + Assert.Equal("Riya", snapshot.GetProperty("data").GetProperty("name").GetString()); + Assert.False(snapshot.TryGetProperty("revision", out _)); + Assert.False(snapshot.TryGetProperty("userId", out _)); + + var republished = await store.Publish(seeded.UserId, edited.Revision, default, PortfolioPublishingPolicy.Version); + var currentState = await ReadJson(anonymous, "/api/platform/portfolio/public/snapshot-student/state"); + var currentVersion = currentState.GetProperty("version").GetString(); + Assert.NotEqual(version, currentVersion); + Assert.True(currentState.GetProperty("searchVisible").GetBoolean()); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/snapshot-student?version=" + version)).StatusCode); + var current = await ReadJson(anonymous, "/api/platform/portfolio/public/snapshot-student?version=" + currentVersion); + Assert.Equal("Private replacement", current.GetProperty("data").GetProperty("name").GetString()); + Assert.Equal("developer", current.GetProperty("data").GetProperty("theme").GetString()); + + await store.Unpublish(seeded.UserId, republished.Revision, default); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/snapshot-student/state")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public/snapshot-student?version=" + currentVersion)).StatusCode); + } + + [Fact] + public async Task Public_index_pages_only_current_policy_published_and_opted_in_portfolios() + { + using var factory = new PlatformFactory(overrides: Publishing); + await Seed(factory.Services, "alpha-student", true, PortfolioPublishingPolicy.Version); + await Seed(factory.Services, "bravo-student", false, PortfolioPublishingPolicy.Version); + await Seed(factory.Services, "charlie-student", true); + await Seed(factory.Services, "delta-student", true, PortfolioPublishingPolicy.Version); + await Seed(factory.Services, "echo-student", true, legacyPublish: true); + await Seed(factory.Services, "foxtrot-student", true, PortfolioPublishingPolicy.Version); + await Seed(factory.Services, "golf-student", true, PortfolioPublishingPolicy.Version); + await Seed(factory.Services, "hotel-student", true, "previous-policy"); + using var operatorOutput = new StringWriter(); + using var operatorError = new StringWriter(); + Assert.Equal(0, await PortfolioModerationCommand.TryRun( + ["--portfolio", "suspend", "--username", "delta-student"], factory.Services, output: operatorOutput, error: operatorError)); + using var anonymous = factory.Browser(); + + var names = new List(); + string? cursor = null; + foreach (var expected in new[] { "alpha-student", "foxtrot-student", "golf-student" }) + { + var page = await ReadJson(anonymous, "/api/platform/portfolio/public?limit=1" + (cursor is null ? "" : "&after=" + cursor)); + var row = Assert.Single(page.GetProperty("portfolios").EnumerateArray()); + Assert.Equal(new[] { "publishedAt", "username" }, row.EnumerateObject().Select(property => property.Name).Order().ToArray()); + var name = row.GetProperty("username").GetString()!; + Assert.Equal(expected, name); + Assert.NotNull(row.GetProperty("publishedAt").GetString()); + names.Add(name); + cursor = page.GetProperty("nextCursor").ValueKind == JsonValueKind.Null ? null : page.GetProperty("nextCursor").GetString(); + Assert.Equal(expected == "golf-student", cursor is null); + } + Assert.Equal(3, names.Distinct().Count()); + var empty = await ReadJson(anonymous, "/api/platform/portfolio/public?limit=1&after=golf-student"); + Assert.Empty(empty.GetProperty("portfolios").EnumerateArray()); + Assert.Equal(JsonValueKind.Null, empty.GetProperty("nextCursor").ValueKind); + foreach (var username in new[] { "charlie-student", "delta-student", "echo-student", "hotel-student", "unknown-student" }) + { + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"/api/platform/portfolio/public/{username}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"/api/platform/portfolio/public/{username}/state")).StatusCode); + } + foreach (var limit in new[] { "0", "-1", "1001", "many", "1.5" }) + Assert.Equal(HttpStatusCode.BadRequest, (await anonymous.GetAsync("/api/platform/portfolio/public?limit=" + limit)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/platform/portfolio/public?after=admin")).StatusCode); + } + + [Fact] + public async Task Disabling_publication_hides_state_snapshots_and_index_even_for_previously_published_records() + { + using var factory = new PlatformFactory(overrides: new Dictionary { ["Portfolio:PublishingEnabled"] = "false" }); + await Seed(factory.Services, "gated-student", true, PortfolioPublishingPolicy.Version); + using var anonymous = factory.Browser(); + foreach (var path in new[] { "/api/platform/portfolio/public/gated-student", "/api/platform/portfolio/public/gated-student/state" }) + { + using var response = await anonymous.GetAsync(path); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Contains("noindex", response.Headers.GetValues("X-Robots-Tag")); + } + var index = await ReadJson(anonymous, "/api/platform/portfolio/public"); + Assert.Empty(index.GetProperty("portfolios").EnumerateArray()); + Assert.Equal(JsonValueKind.Null, index.GetProperty("nextCursor").ValueKind); + } + + [Fact] + public async Task Theme_values_are_checked_by_the_server_and_only_valid_themes_enter_a_draft() + { + using var factory = new PlatformFactory(overrides: Publishing); + var userId = Guid.NewGuid(); + using var owner = factory.Browser(userId); + var csrf = await Token(owner); + foreach (var theme in new object[] { "custom-css", new { background = "red" }, 42 }) + { + using var invalid = await Write(owner, userId, csrf, "draft", new + { + revision = (Guid?)null, searchVisible = false, data = new { name = "Riya", headline = "Student", theme } + }); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + } + Guid? revision = null; + foreach (var theme in new[] { "simple", "developer" }) + { + using var valid = await Write(owner, userId, csrf, "draft", new + { + revision, searchVisible = false, data = new { name = "Riya", headline = "Student", theme } + }); + Assert.Equal(HttpStatusCode.OK, valid.StatusCode); + var document = await valid.Content.ReadFromJsonAsync(Json); + var portfolio = document.GetProperty("portfolio"); + Assert.Equal(theme, portfolio.GetProperty("data").GetProperty("theme").GetString()); + revision = portfolio.GetProperty("revision").GetGuid(); + } + } + + private static async Task<(Guid UserId, PortfolioRecord Record)> Seed(IServiceProvider services, string username, + bool searchVisible, string? policyVersion = null, bool legacyPublish = false) + { + using var scope = services.CreateScope(); + var user = await scope.ServiceProvider.GetRequiredService().SignIn("publication-" + username, + username + "@example.test", "Private Google profile", default); + var store = scope.ServiceProvider.GetRequiredService(); + var draft = await store.SaveDraft(user.Id, null, "{\"name\":\"Riya\",\"headline\":\"Student\",\"theme\":\"simple\"}", searchVisible, default); + var claimed = await store.ClaimUsername(user.Id, draft.Revision, username, default); + return (user.Id, policyVersion is not null || legacyPublish + ? await store.Publish(user.Id, claimed.Revision, default, policyVersion) : claimed); + } + + private static async Task Own(IServiceProvider services, Guid userId) + { + using var scope = services.CreateScope(); + return (await scope.ServiceProvider.GetRequiredService().GetOwner(userId, default))!; + } + + private static async Task Token(HttpClient client) => + (await client.GetFromJsonAsync("/api/platform/auth/session", Json)).GetProperty("csrfToken").GetString()!; + + private static Task Write(HttpClient client, Guid userId, string csrf, string action, object body) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/platform/portfolio/" + action); + request.Headers.Add("x-account-id", userId.ToString()); + request.Headers.Add("Origin", "http://localhost:3000"); + request.Headers.Add("X-CSRF-Token", csrf); + request.Content = JsonContent.Create(body, options: Json); + return client.SendAsync(request); + } + + private static async Task ReadJson(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.CacheControl!.NoStore); + Assert.Contains("noindex", response.Headers.GetValues("X-Robots-Tag")); + return await response.Content.ReadFromJsonAsync(Json); + } +} diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs index 99c00dd..211f973 100644 --- a/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs +++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioRelationalTests.cs @@ -89,7 +89,10 @@ public async Task Upgrading_an_existing_account_database_keeps_its_records() 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)); + // Later portfolio migrations add metadata/indexes to the same table. Its removal + // restores the original account-only schema; remove their history together. + foreach (var applied in migrations.Migrations.Keys.Where(key => string.CompareOrdinal(key, portfolioMigration.Key) >= 0)) + await db.Database.ExecuteSqlRawAsync(db.GetService().GetDeleteScript(applied)); var userId = Guid.NewGuid(); var generation = Guid.NewGuid(); db.Users.Add(new LearnerUser { Id = userId, GoogleSubject = "existing-subject", Email = "existing@example.test", Name = "Existing", CreatedAtUtc = 1 }); diff --git a/backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs b/backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs new file mode 100644 index 0000000..ce76ef0 --- /dev/null +++ b/backend/tests/ProCoderJi.BrowserHost/BrowserProgram.cs @@ -0,0 +1,247 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Runtime.Loader; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Options; +using ProCoderJi.Api.Data; +using ProCoderJi.Api.Features.Portfolio; +using ProCoderJi.Api.Tests; + +namespace ProCoderJi.BrowserHost; + +/// Loopback-only browser fixtures. This executable is never part of the API publish/image. +internal static class BrowserProgram +{ + public static async Task Main(string[] args) + { + using var stopping = new CancellationTokenSource(); + void Cancel(object? sender, ConsoleCancelEventArgs signal) { signal.Cancel = true; stopping.Cancel(); } + void Unload(AssemblyLoadContext context) => stopping.Cancel(); + Console.CancelKeyPress += Cancel; + AssemblyLoadContext.Default.Unloading += Unload; + try + { + var options = BrowserOptions.Parse(args); + var settings = new Dictionary + { + ["Portfolio:PublishingEnabled"] = "true", + ["Logging:LogLevel:Default"] = "Warning", + }; + if (options.DataDirectory is not null) + settings["ConnectionStrings:Platform"] = new SqliteConnectionStringBuilder + { + DataSource = Path.Combine(options.DataDirectory, "platform.db"), Pooling = false, + }.ConnectionString; + + using var factory = new PlatformFactory(googleConfigured: true, publicOrigin: options.PublicOrigin, + testAuthentication: false, overrides: settings); + var state = new BrowserFixtureState(); + using var host = factory.WithWebHostBuilder(builder => + { + builder.UseContentRoot(options.ApiRoot); + builder.ConfigureTestServices(services => + { + services.AddSingleton(state); + services.AddSingleton(); + services.AddAuthentication(authentication => + { + authentication.DefaultAuthenticateScheme = BrowserCookieAuthentication.SchemeName; + authentication.DefaultChallengeScheme = BrowserCookieAuthentication.SchemeName; + }).AddScheme(BrowserCookieAuthentication.SchemeName, _ => { }); + }); + }); + host.UseKestrel(server => server.Listen(IPAddress.Loopback, options.Port)); + host.StartServer(); + using var registration = host.Services.GetRequiredService() + .ApplicationStopping.Register(stopping.Cancel); + Console.WriteLine($"TEST-ONLY browser fixture ready at http://127.0.0.1:{options.Port}."); + try { await Task.Delay(Timeout.InfiniteTimeSpan, stopping.Token); } + catch (OperationCanceledException) when (stopping.IsCancellationRequested) { } + return 0; + } + catch (Exception error) + { + Console.Error.WriteLine(error); + return 1; + } + finally + { + Console.CancelKeyPress -= Cancel; + AssemblyLoadContext.Default.Unloading -= Unload; + } + } +} + +internal sealed record BrowserOptions(int Port, string PublicOrigin, string ApiRoot, string? DataDirectory) +{ + public static BrowserOptions Parse(string[] args) + { + var port = 5151; + string? dataDirectory = null; + if (args.Length % 2 != 0) throw new ArgumentException("Use --port or --urls , and optional --data-directory ."); + for (var index = 0; index < args.Length; index += 2) + { + var value = args[index + 1]; + switch (args[index]) + { + case "--port": + if (!int.TryParse(value, out port) || port is < 1 or > 65535) + throw new ArgumentException("The browser-fixture port must be between 1 and 65535."); + break; + case "--urls": + var address = LoopbackOrigin(value); + if (address.Host is not ("localhost" or "127.0.0.1")) + throw new ArgumentException("The fixture URL must use localhost or 127.0.0.1."); + port = address.Port; + break; + case "--data-directory": + dataDirectory = FixtureDirectory(value); + break; + default: + throw new ArgumentException("Unknown browser-fixture option."); + } + } + foreach (var name in new[] { "PROCODERJI_TEST_POSTGRES", "PROCODERJI_TEST_SQLSERVER", "PROCODERJI_TEST_MONGODB" }) + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(name))) + throw new InvalidOperationException("Browser fixtures use disposable SQLite only; unset external database test connections."); + var publicOrigin = LoopbackOrigin(Environment.GetEnvironmentVariable("PublicOrigin") ?? "http://localhost:3150"); + if (publicOrigin.Port == port) throw new ArgumentException("Frontend and browser-fixture ports must differ."); + return new BrowserOptions(port, publicOrigin.GetLeftPart(UriPartial.Authority), FindApiRoot(), dataDirectory); + } + + private static Uri LoopbackOrigin(string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var address) || address.Scheme != "http" || !address.IsLoopback + || address.UserInfo.Length != 0 || address.AbsolutePath != "/" || address.Query.Length != 0 || address.Fragment.Length != 0) + throw new ArgumentException("Browser fixtures require an HTTP loopback origin without credentials or a path."); + return address; + } + + private static string FixtureDirectory(string value) + { + var path = Path.GetFullPath(value).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var temporaryRoot = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!string.Equals(Path.GetDirectoryName(path), temporaryRoot, comparison) + || !Path.GetFileName(path).StartsWith("procoderji-e2e-", StringComparison.Ordinal) + || !Directory.Exists(path) || (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new ArgumentException("The data directory must be an existing procoderji-e2e-* directory immediately under the OS temporary directory."); + return path; + } + + private static string FindApiRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, "backend", "src", "ProCoderJi.Api"); + if (File.Exists(Path.Combine(candidate, "ProCoderJi.Api.csproj"))) return candidate; + } + throw new DirectoryNotFoundException("Run the browser host from this repository's build output."); + } +} + +internal sealed class BrowserFixtureState +{ + public const string CookieName = "procoderji.e2e-user"; + public ConcurrentDictionary Accounts { get; } = new(); +} + +internal sealed class BrowserCookieAuthentication(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder, BrowserFixtureState fixtures) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "BrowserFixtureCookie"; + + protected override async Task HandleAuthenticateAsync() + { + if (!Guid.TryParse(Request.Cookies[BrowserFixtureState.CookieName], out var id) + || !fixtures.Accounts.TryGetValue(id, out var account) + || !await Context.RequestServices.GetRequiredService().Exists(id, Context.RequestAborted)) + return AuthenticateResult.NoResult(); + var identity = new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, account.Id.ToString("D")), + new Claim(ClaimTypes.Email, account.Email), + new Claim(ClaimTypes.Name, account.Name), + ], Scheme.Name); + return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)); + } +} + +internal sealed class BrowserFixtureStartupFilter(BrowserFixtureState fixtures) : IStartupFilter +{ + public Action Configure(Action next) => app => + { + app.Use(async (HttpContext context, RequestDelegate nextMiddleware) => + { + if (context.Connection.RemoteIpAddress is not { } remote || !IPAddress.IsLoopback(remote)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + if (context.Request.Path.StartsWithSegments("/__e2e__", out var remaining)) + { + await FixtureRequest(context, remaining); + return; + } + if (context.Request.Path == "/api/platform/auth/logout") + context.Response.OnStarting(() => + { + if (context.Response.StatusCode == StatusCodes.Status204NoContent) + context.Response.Cookies.Delete(BrowserFixtureState.CookieName, new CookieOptions { Path = "/api/platform" }); + return Task.CompletedTask; + }); + await nextMiddleware(context); + }); + next(app); + }; + + private async Task FixtureRequest(HttpContext context, PathString path) + { + context.Response.Headers.CacheControl = "no-store"; + if (!HttpMethods.IsPost(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + if (path == "/account") + { + var token = Guid.NewGuid().ToString("N"); + var account = await context.RequestServices.GetRequiredService() + .SignIn($"browser-{token}", $"{token}@example.test", "Browser student", context.RequestAborted); + fixtures.Accounts[account.Id] = account; + await context.Response.WriteAsJsonAsync(new { id = account.Id.ToString("D"), email = account.Email, name = account.Name }); + return; + } + var segments = path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries) ?? []; + if (segments is ["publishing", var enabled] && bool.TryParse(enabled, out var publishing)) + { + context.RequestServices.GetRequiredService()["Portfolio:PublishingEnabled"] = publishing.ToString(); + await context.Response.WriteAsJsonAsync(new { publishingEnabled = publishing }); + return; + } + if (segments is ["moderate", var username, var action] && action is "suspend" or "restore") + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var code = await PortfolioModerationCommand.TryRun(["--portfolio", action, "--username", username], + context.RequestServices, context.RequestAborted, output, error); + if (code == 0) + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(output.ToString()); + } + else + { + context.Response.StatusCode = code == 3 ? StatusCodes.Status404NotFound : StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { error = error.ToString().Trim() }); + } + return; + } + context.Response.StatusCode = StatusCodes.Status404NotFound; + } +} diff --git a/backend/tests/ProCoderJi.BrowserHost/ProCoderJi.BrowserHost.csproj b/backend/tests/ProCoderJi.BrowserHost/ProCoderJi.BrowserHost.csproj new file mode 100644 index 0000000..914185d --- /dev/null +++ b/backend/tests/ProCoderJi.BrowserHost/ProCoderJi.BrowserHost.csproj @@ -0,0 +1,14 @@ + + + net10.0 + Exe + enable + enable + false + false + true + + + + + diff --git a/backend/tests/ProCoderJi.BrowserHost/packages.lock.json b/backend/tests/ProCoderJi.BrowserHost/packages.lock.json new file mode 100644 index 0000000..6ad4462 --- /dev/null +++ b/backend/tests/ProCoderJi.BrowserHost/packages.lock.json @@ -0,0 +1,470 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.1", + "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==", + "dependencies": { + "Azure.Core": "1.50.0", + "Microsoft.Identity.Client": "4.78.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.78.0" + } + }, + "coverlet.collector": { + "type": "Transitive", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "DnsClient": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==" + }, + "Microsoft.AspNetCore.Authentication.Google": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "3enUvuEqorstWs1o2wc2hd8VgiF+fK0vZtGXl3wjE760887BNXUTh9vl86cLY+qdDujCHHWxA773nkWW4n8FBA==" + }, + "Microsoft.AspNetCore.DataProtection.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "zXov4zfbIsBCAYniekMemgWSAMpfzQ1jhWGs4k2WIr0n1Ac4osMD1xbqf31CllPZ2z4dTsYzWRmGZO+czKGOxw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.14" + } + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "l3eFye4Bv4oGT1Ag8pFtZ6WM0LhR9PkNiDH1AYitZW2LAaFWl1FgF4Dj7wY4Fp/BqCZ4LLkBGLw+NE/CA5k6mQ==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.14", + "Microsoft.Extensions.DependencyModel": "10.0.14" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "u4AX1M5Uzmw7V3NCdJJO4ITwH0DDDufyzmVlksE4QguVd23Co8adaREMp2VahlRB5f/rmtaFYVJIA1iIFnchmQ==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "6.1.7", + "contentHash": "NipUfw4MfNxdiVPekCxuLyTZ0GK68tXW0pmCzp8zK+foDHNMdd9fttxP0SIook03U5oy83vAsHCBNhTz+uqXNA==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Identity": "1.17.1", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.3", + "Microsoft.Identity.Client": "4.84.2", + "Microsoft.Identity.Client.Broker": "4.84.2", + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1", + "Microsoft.SqlServer.Server": "1.0.0", + "System.Configuration.ConfigurationManager": "9.0.11", + "System.IdentityModel.Tokens.Jwt": "7.7.1", + "System.Security.Cryptography.Pkcs": "9.0.11" + } + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "kt2JDsmcFGzeanCo6hJNalMcjEvgtYoxSQjqh00Nm1xXmahHzuR48TxjS1nq/lpnYh+g+wLqD5D9WSz8xsybhw==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "1p9rO3Q2X/L/9YrYog3vaDzi8u+WZfJCOkDYoHqmofqYP0DLPLPyZcS2Rnh/79JXAodgbs1AgsaNwLpIvC3Xew==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.13" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "vY3931PYPIDBbKovGHbACmpOWvURvKNyRXfCjMh5yLW+FH6vLWeqUu2xzSCRo4Oz4tgx9GYpd0JlV5LDyAoQdg==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.14", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.14" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "/SaznoTTFfKzxYEbCPAwwKBR3XT92DZgH4jJ3/cqs2skP3o2D+TL6i9Aaj+8Azwtu6/sRd0ve4P2mN12nfVmCw==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "gaANlGNTxkgODX2eeVT8jjd3AmlFilQV4DFf8qoW30+wUCm2o4N7nEHtZvnHIwfhaJni2DqcjE47Au7tC0Q5jA==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "Ut0kgtfoKX9QElQVEXSEAgdQc79jISWStVWSOhfPDDHNSKMwFzSXG3gtdYFTAbsBoV+8NtXdVaj4xwdqeky8UA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.14" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "lxrwnVwvlIfQD4IF4WUVupE6Qxfj3MfFftDVwo7tsGAWCKLL8Ttb8UjIt+gE4e9nRlH7EgdC6TrsArFGnNYREw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.14", + "Microsoft.Extensions.DependencyModel": "10.0.14", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.13", + "SQLitePCLRaw.core": "2.1.13" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "08VGlMQ9WbK869as9Ho+VZQbM5Qu6zdorgeh6ShVwQgLYgfUBiaIm02T7spfW7Xc1MBHCvclwPgGWpvx5BYGXQ==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.14", + "Microsoft.EntityFrameworkCore.Relational": "10.0.14", + "Microsoft.Extensions.DependencyModel": "10.0.14", + "SQLitePCLRaw.core": "2.1.13" + } + }, + "Microsoft.EntityFrameworkCore.SqlServer": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "S3zuzJE5qcl4jjBwSI1x1WZp/GiwzBk1qlFjI77O1E/FZ0mtXNYRBDCfAY0x7nly2zZjM2U5XIkw7AD3DsSpEQ==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.7", + "Microsoft.EntityFrameworkCore.Relational": "10.0.14" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "N8OcqM0370wSXMJ3ZxO66rSElHrIgxvpfUn0vV7zRj7NvKo0nfwgEoTmC3Yz3860o60IaQ4KIPyeLIGX9EeNWA==" + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.84.2", + "contentHash": "Va9FjmABSgj/lcUfHH0pBNQkmS7SNyepz2ERV7Yynp6QgEYpFdSqR6Vzy1WWipN8GS5pBHuXoZbqaDWuARCrHQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.14.0" + } + }, + "Microsoft.Identity.Client.Broker": { + "type": "Transitive", + "resolved": "4.84.2", + "contentHash": "928ySLeGz1xtvydwq6h9tv7TbxrtA4ZzavLKUqRJh0PW6BGcEJwpI8W8vf2PlQdYemGm/oyGjdzYzdY/I8r/4A==", + "dependencies": { + "Microsoft.Identity.Client": "4.84.2", + "Microsoft.Identity.Client.NativeInterop": "0.20.6" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.78.0", + "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==", + "dependencies": { + "Microsoft.Identity.Client": "4.78.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.Identity.Client.NativeInterop": { + "type": "Transitive", + "resolved": "0.20.6", + "contentHash": "noyfdMfVxyWpM6xUDmlW6gUvZ5ci24z7Qoy15JvynTQfZV5vTD2NEdsnT5Gy4ngViwgdIp5iRjRy10jAKXFAgw==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.14.0", + "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "7.7.1", + "System.IdentityModel.Tokens.Jwt": "7.7.1" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.7.1" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", + "dependencies": { + "Microsoft.CodeCoverage": "17.14.1", + "Microsoft.TestPlatform.TestHost": "17.14.1" + } + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.14.1", + "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.14.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "MongoDB.Bson": { + "type": "Transitive", + "resolved": "3.13.0", + "contentHash": "jb4fGQIS1bTFSK2336FP3rBHDQ+UiVKdTAZcoWHeed1xlxvmclnHA8Y4CAQa+Y2KWw3Fz9OIAkXH/KXJL9yRGg==" + }, + "MongoDB.Driver": { + "type": "Transitive", + "resolved": "3.13.0", + "contentHash": "1Ae+OaZRKVsb4KI39tV9rkV48Bth+3pGaEpXVNCu9ueWUF7zVe00GQzK9jjyNDsScIUZJQleHr5ceNaRnWKJqw==", + "dependencies": { + "DnsClient": "1.6.1", + "MongoDB.Bson": "3.13.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "ZstdSharp.Port": "0.7.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + }, + "SharpCompress": { + "type": "Transitive", + "resolved": "0.48.1", + "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==" + }, + "Snappier": { + "type": "Transitive", + "resolved": "1.3.1", + "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "NlDixlC7uHmo+ojXRgkNvN0tQtVGpLDZ3x2OpgNj+G07VHh5cXSszFDrAiC4BpUiPwkcaeuQkMV9WQ123xVtmQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.13", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.13" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "2joofoinuHBFs0nMX3nKuXgxpXryRkCOdM2Ua6HBqQLLy6IW+5f28ZI+HAvMyF8vG7XfNz/lwfBfE6V4juru8Q==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "D2UAuiGxFYBqeRXjjjGgvJ5UQifzn6wefmSqeKU7XcNGKLbJRP6X7kcPZ6opOBJGdCIlgbSxTvDpBbLvsN594A==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.13", + "contentHash": "ZU60dSqoSNRe49KcQDUmOVgdRtjITOssVXFtiISs+zJXDI4D2Sndwpn2NOtzDQNPu0ZAvpoLPQ+GLKv6iEZn8Q==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.13" + } + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "System.Memory.Data": "8.0.1" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.11", + "contentHash": "vPinosRgEk1CGjohbv0pDuz6gPprkWo5xvBTjOJjks5JOgOEWvPndq5NngGxiTcBcy1+k9GwzZ1c3Bp2fU5ezw==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "9.0.11" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.7.1", + "Microsoft.IdentityModel.Tokens": "7.7.1" + } + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "10.0.14", + "contentHash": "Wz+IgL2xpg8KJ4i2VWj6uz1PmZNu4Q0Ljlwd73P9Vze5NcOBtctLii3itV0PB2MpOqwqDEGfB6RCdv94A4E5yQ==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.11", + "contentHash": "s8yUYuYYu+PAwvBdhLG1KyrGrk9gkYeuPxfAsXsTqqWyepwSyEw8hAaflW4nO98NG52YpYI1am2+9o+79h2RtQ==" + }, + "xunit": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Transitive", + "resolved": "3.1.4", + "contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==" + }, + "procoderji.api": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Authentication.Google": "[10.0.14, )", + "Microsoft.AspNetCore.DataProtection.EntityFrameworkCore": "[10.0.14, )", + "Microsoft.EntityFrameworkCore.SqlServer": "[10.0.14, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.14, )", + "MongoDB.Driver": "[3.13.0, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )", + "System.Security.Cryptography.Pkcs": "[10.0.14, )" + } + }, + "procoderji.api.tests": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Mvc.Testing": "[10.0.14, )", + "Microsoft.NET.Test.Sdk": "[17.14.1, )", + "ProCoderJi.Api": "[1.0.0, )", + "coverlet.collector": "[6.0.4, )", + "xunit": "[2.9.3, )", + "xunit.runner.visualstudio": "[3.1.4, )" + } + } + } + } +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index a32ef86..6d82303 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,7 +18,9 @@ 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) | +| Try portfolios or prepare their deployment | [Portfolio workspace and runbook](portfolio-workspace.md) | +| Import a portfolio from a text template | [Markdown import](portfolio-markdown.md), [template](../public/templates/portfolio.md) | +| Work on portfolio fields, policy, publication, or storage | [Shared rules](../shared/portfolio/README.md), [Portfolio API](portfolio-api.md) | | Configure production hosting and the site domain | [Deployment](deployment.md) | | Run the API and choose a database with Docker | [Containers](containers.md) | | Configure the selected hosted PostgreSQL database | [Neon setup](neon.md) | @@ -30,7 +32,7 @@ synchronization is a separate capability; paid billing is future work. Architecture decisions describe a problem, the chosen boundary, and its consequences. Start with [ADR 0001: student-product boundaries](decisions/0001-student-product-boundaries.md), then -[ADR 0002: student portfolios](decisions/0002-student-portfolios.md) (proposed), and +[ADR 0002: student portfolios](decisions/0002-student-portfolios.md) (accepted for Phase 1), and [ADR 0003: containers and database options](decisions/0003-container-database-options.md). Add a new decision record when a change affects persistence, account requirements, payment handling, or other feature boundaries; update the architecture guide when the implementation changes. diff --git a/docs/architecture.md b/docs/architecture.md index df796c1..4de728d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ flowchart TD Frontend --> Local[Validated browser progress] Frontend --> BFF[Same-origin platform proxy] BFF --> API[ASP.NET Core API] - API --> Stores[Account and progress adapters] + API --> Stores[Account, progress and portfolio adapters] Stores --> EF[EF Core] EF --> Database[SQLite development or PostgreSQL or SQL Server] Stores --> Mongo[MongoDB driver] @@ -34,6 +34,8 @@ site origin. See [ADR 0001](decisions/0001-student-product-boundaries.md) for th | `/practice` | Discover the four practice topics. | | `/practice/[topic]` | Complete questions and review answers and explanations. | | `/account` | Optional Google sign-in and progress/data controls. | +| `/learn/portfolio` | In-memory editor/preview, Markdown import and authenticated private-draft controls. | +| `/u/[username]` | Server-rendered published portfolio and published visibility metadata. | | `/api/platform/*` | Next.js forwarding layer for the configured backend. | | `/api/platform/auth/session` | Current account, account configuration status, and antiforgery token. | | `/api/platform/signin-google` | Public Google callback forwarded to ASP.NET Core. | @@ -101,6 +103,40 @@ The [container guide](containers.md) packages the API and starts it with one dat separate Compose services with persistent volumes. Vercel container hosting uses an external database. [ADR 0003](decisions/0003-container-database-options.md) records these boundaries. +## Student portfolios + +Phase 1 uses one fixed field model for the form, live preview, Markdown import and both owned +themes (Simple and Developer). Rules v2 in `shared/portfolio/` define the fields, themes, limits, +username policy, and publishing age/version. TypeScript supplies editor feedback; .NET validates +trusted writes. The browser parser accepts up to 50 KiB of UTF-8 Markdown and only fills the +form. Student Markdown, HTML, CSS and JavaScript are never rendered as pages. + +Guests can try an in-memory preview. A Google session is required for one saved portfolio per +account. Account changes clear private editor state. The backend controls revisions and the +draft/published/suspended state; a claimed username stays fixed while the portfolio exists. +Explicit publication copies the validated draft and search preference, so later edits cannot +change the public page until the student publishes again. Every publish requires 18+ +self-declaration and current-policy acceptance; version `2026-11-11` and its confirmation time +are stored privately, with no birth date or independent age verification. + +Public `/u/` requests check uncached backend publication state before reading a +version-keyed, published-only snapshot cache. BFF publish/unpublish/delete invalidates the +username tag. Fresh state also hides operator-suspended pages without a cache callback. +Restoring a suspension clears the public copy and acceptance and returns a private draft; +the student must publish again. Moderation uses an operator CLI, not a public admin endpoint. + +Public data excludes account IDs, Google profile data, private edits and acceptance metadata. +Themes use initials and no external image/font requests. Search is opt-in and belongs to the +published snapshot: opted-out pages are `noindex`; the fresh sitemap index includes only +currently eligible opted-in publications. Its 45,000-portfolio cap requires sitemap partitioning +before further growth. An unlisted public address can still be shared and opened. + +SQLite/PostgreSQL/SQL Server store one relational portfolio row per account; MongoDB embeds it +in the account document. Shared revisions and conditional writes enforce ownership and claims. +See the [API contract](portfolio-api.md), [workspace/runbook](portfolio-workspace.md), +[Markdown guide](portfolio-markdown.md) and [ADR 0002](decisions/0002-student-portfolios.md). +Publishing defaults off; repository implementation does not establish production rollout. + ## Reviewed content Course notes, guides, and jobs use JSON catalogues pointing to repository content files. Only @@ -120,8 +156,10 @@ Sample notes are intentionally unregistered. Empty pages stay out of the index a | `src/features/practice/` | TypeScript practice rules, shared-bank adapter, and student views. | | `src/features/progress/` | Attempt model, browser storage, and sync coordination. | | `src/features/account/` | Frontend account API client and controls. | +| `src/features/portfolio/` | Editor/client, Markdown parser, validators, owned themes and public delivery. | | `src/components/` | Shared shell, landing sections, and reusable UI. | | `shared/practice/questions.json` | Canonical original practice bank used by both runtimes. | +| `shared/portfolio/` | Rules v2, publishing policy and cross-runtime fixtures. | | `backend/src/ProCoderJi.Api/` | ASP.NET Core account API, Google auth, and EF persistence. | | `backend/tests/` | Backend integration and validation tests. | | `src/content/` and `src/data/` | Reviewed notes, guides, listings, and their catalogues. | @@ -138,9 +176,11 @@ YouTube feed only when `YOUTUBE_CHANNEL_ID` is configured. Frontend CI checks types, domain/store behavior, content rules, production builds, desktop/mobile flows, and real-site links/metadata. Browser CI also starts a real .NET process with isolated -SQLite persistence and exercises the Next.js proxy and Google sign-in challenge/public callback -URL. It uses test client configuration and does not complete Google consent or token exchange. -Default local browser checks stay frontend-only unless `E2E_DOTNET=1` is set. +SQLite persistence through the test-only `ProCoderJi.BrowserHost`, exercising the real API/BFF, +portfolio editor/publication/moderation, and Google challenge/public callback URL. Disposable +cookie authentication and loopback fixture controls exist only in that test executable; the +production API/image has no fixture login. The suite does not complete Google consent or token +exchange. Local browser checks stay frontend-only unless `E2E_DOTNET=1` is set. CI retains failure screenshots for seven days. Backend CI restores, builds, and tests `backend/ProCoderJi.slnx` with .NET 10 and checks PostgreSQL migrations. Controlled test @@ -151,12 +191,11 @@ 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. -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. +Portfolio launch requires a configured production API, durable storage, migrations, real Google +credentials, verified policies/reporting and operator readiness before enabling the publishing +flag. The [deployment guide](deployment.md) and +[#74](https://github.com/procoderji-com/procoderji/issues/74) describe the separate outstanding +production hosting work. Passing CI does not establish that the live service runs this commit. 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/decisions/0002-student-portfolios.md b/docs/decisions/0002-student-portfolios.md index b84a0d3..60ec17d 100644 --- a/docs/decisions/0002-student-portfolios.md +++ b/docs/decisions/0002-student-portfolios.md @@ -1,7 +1,7 @@ # ADR 0002: student portfolios -- **Status:** Proposed; open questions below need the owner's decision before Phase 1 ships -- **Date:** 2026-11-10 +- **Status:** Accepted for Phase 1 under the owner's delegated implementation decisions; Phase 2 remains future work +- **Date:** 2026-11-11 - **Tracking issue:** [#40](https://github.com/procoderji-com/procoderji/issues/40) ## Context @@ -11,7 +11,7 @@ have built. Most have no hosting, domain, or time to design a page. ProCoderJii optional Google accounts, an ASP.NET Core backend with EF Core persistence, and a same-origin `/api/platform/*` layer ([ADR 0001](0001-student-product-boundaries.md)). Portfolios add the first content that students write and the public reads, so the safety, privacy, and moderation -rules must be settled before the code. +rules form part of the implementation decision. Two filling methods were proposed: a form, and a Markdown file the student generates with their own AI tool. A third option, a fully editable page, was considered and rejected. @@ -22,22 +22,26 @@ own AI tool. A third option, a fully editable page, was considered and rejected. A portfolio is a fixed set of fields: name, headline, about, skills, projects (title, link, technologies, description), education, experience, achievements, and links (GitHub, LinkedIn, -website, email). The editor form and the Markdown import both produce this model. The Markdown -import only fills in the editor; the student reviews the result and saves. The Markdown file is -never rendered as a page. +website, email), with a choice of Simple or Developer theme. The editor form and the Markdown +import both produce this model. Markdown is parsed in the browser, with a 50 KiB UTF-8 input +limit. Import only fills in the editor; the student reviews the result and explicitly saves. +The Markdown file itself is never uploaded, stored, or rendered as a page. No AI service is +called by the importer. Students do not write HTML, CSS, JavaScript, or MDX. Pages are rendered from the model by -themes that the project owns. This keeps pages safe (no injected scripts or phishing forms), -consistent, and readable on a phone. New themes are project contributions, not student content. +themes that the project owns. This excludes student-authored scripts and forms and keeps pages +consistent and readable on a phone. New themes are project contributions, not student content. ### Rules live in one place -Field limits, link rules, username rules, reserved names, and blocked words are defined once +Field limits, link rules, username rules, reserved names, blocked words, and the publishing +policy version and minimum age are defined once under `shared/portfolio/` and consumed by both runtimes, as `shared/practice/questions.json` is. The TypeScript validator in `src/features/portfolio/` gives immediate feedback in the editor; the .NET validator in `backend/` is the trusted check. Both run against the same fixtures. -- Links are `https` only, plus `mailto:` for the email field. Other schemes are rejected. +- Links are `https` only. The email field stores a plain address, from which the renderer builds + a `mailto:` link. Other link schemes are rejected. - Usernames are 3 to 30 lowercase letters, digits, and single hyphens. - Reserved names never resolve as portfolios: `www`, `api`, `app`, `admin`, `mail`, `docs`, `blog`, `login`, `support`, `help`, `procoderji`, `satyam`, and every existing route segment @@ -48,13 +52,19 @@ The TypeScript validator in `src/features/portfolio/` gives immediate feedback i ### Address Phase 1 serves portfolios at `/u/` on the main site. Phase 1 does not require DNS -changes. Public launch still needs the backend, moderation, and owner and legal decisions tracked -in [#40](https://github.com/procoderji-com/procoderji/issues/40). - -Phase 2 adds subdomains (`.`). A wildcard DNS record points at the frontend, -and `src/proxy.ts` rewrites the host to `/u/`. Addresses are mapped inside the -platform: no record is created per student, and students never receive DNS, hosting, or -provider credentials. One address is canonical and the other redirects to it. +changes. Public publishing is controlled by backend deployment configuration and remains off +by default; an operator enables it after configuring and verifying the service. Private drafts +can be prepared while publishing is off. The product decisions in this ADR and the portfolio +Privacy and Terms wording are part of Phase 1, tracked in +[#40](https://github.com/procoderji-com/procoderji/issues/40) and +[#48](https://github.com/procoderji-com/procoderji/issues/48). + +Phase 2 may add subdomains (`.`), tracked separately in +[#49](https://github.com/procoderji-com/procoderji/issues/49). The domain, wildcard DNS, +certificates, host rewriting, and canonical redirects need a separate decision and deployment +work. No domain purchase or DNS change is part of Phase 1. The intended boundary is still that +addresses are mapped inside the platform: no record is created per student, and students never +receive DNS, hosting, or provider credentials. Session and antiforgery cookies stay host-only and scoped to `/api/platform`, as they are today. A test guards against a future `Domain=.procoderji.com`, which would leak sessions to student @@ -63,31 +73,62 @@ subdomains. ### Ownership and state - One portfolio per Google account. Guests can try the editor in the browser but must sign in - to claim a username and publish. + to save a private draft, claim a username, or publish. Guest drafts exist only in the open + page and are cleared on navigation or account changes. +- The username is fixed while the portfolio exists. Ordinary portfolio deletion removes its + draft, public copy, and claim, so that username becomes available again. A suspended portfolio + cannot be deleted and recreated through the editor to evade moderation. - States: **draft** (visible only to the owner), **published** (public), **suspended** (set by - an operator; returns 404 and cannot be republished until restored). + an operator; returns 404 and cannot be republished until restored). Restoring a suspended + portfolio returns it to a private draft and clears its published copy and publishing acceptance. +- Publishing creates a separate public snapshot. Saving later draft edits does not change the + current public page; the student must explicitly publish again. Revision checks prevent a + stale editor from overwriting another update. - Search-engine visibility is opt-in. Pages are `noindex` and absent from the sitemap until the - student turns it on. -- Deleting the account deletes the portfolio. + student chooses it when publishing. A public address can still be read and copied by anyone + who knows it, and third-party copies cannot be recalled. +- Every public request checks the current publishing state before serving cached portfolio + data. Unpublishing, deletion, suspension, or disabling the deployment publishing gate returns + 404 on subsequent requests. Writes invalidate cached public data. +- Deleting practice history does not delete a portfolio. Sign-in account deletion and requests + concerning a suspended portfolio go through the existing operator contact; there is no + self-service account-deletion endpoint. Account-removal requests include the associated + portfolio data. ### Personal data -The model has no phone number and no postal address. Email is optional and shown only if the -student fills it in. Phase 1 has no photo upload: the page shows an initials avatar. Uploads need -storage, scanning, and moderation, and are deferred until a later decision. +The model has no phone number, postal address, or date of birth. Email is optional and shown +only if the student fills it in; the Google account email is never copied into a public page +automatically. Phase 1 has no photo upload: the page shows an initials avatar and themes make +no third-party image or font requests. Uploads need storage, scanning, and moderation, and are +deferred until a later decision. + +Only people aged 18 or older may publish under the Phase 1 product policy. The editor requires +an explicit confirmation of age eligibility and the portfolio Terms and Privacy Policy before +each publish. The backend checks and privately stores the current policy version and confirmation +timestamp. The initial version is `2026-11-11`, defined with the minimum age in +`shared/portfolio/rules.json`. This is self-declaration, not independent age verification or a +parental-consent system. The public Privacy and Terms pages describe these data flows and use +their own `2026-11-11` effective date; unrelated legal pages keep their existing dates. + +Students retain ownership of their portfolio material. The operator receives limited permission +to store it and render the chosen published copy to provide the service. The repository's MIT +License does not implicitly license students' portfolio content or personal information. ### Moderation Every public page has a "Report this page" link that opens an email to the site contact with the page address filled in. Operators can suspend and restore a portfolio through a documented procedure in `backend/README.md`. Reports and suspensions are the operator's responsibility, -not an automated system. +not an automated system. Prohibited content includes impersonation, phishing or scams, +copyright infringement, and other people's private information. A suspended student can contact +the operator for review or a data-removal request; restoring a draft does not publish it. ### Boundaries - `src/features/portfolio/`: types, validator, Markdown parser, editor, and theme components. Pure rules stay independent of React, storage, and identity. -- `src/app/u/[username]/`: public page and metadata. `src/app/learn/portfolio/`: editor. +- `src/app/u/[username]/`: public page and metadata. `src/app/(student)/learn/portfolio/`: editor. - `backend/`: `Portfolios` table, signed-in endpoints under `/api/platform/portfolio`, a public read endpoint for published portfolios, server-side validation, rate limits, and suspension. - `shared/portfolio/`: rules and fixtures used by both runtimes. @@ -98,26 +139,13 @@ Free-form HTML or a visual page builder would let a student publish scripts, hid copied designs, would break on phones, and would need a review step for every change. Students want a good portfolio quickly, not a design tool. Themes give variety without these costs. -## Open questions for the owner - -1. **Age requirement.** Section 9 of India's Digital Personal Data Protection Act, 2023 will - require verifiable parental consent before processing a child's personal data. The DPDP - Rules, 2025 were notified on 13 November 2025 (MeitY, G.S.R. 843(E)), and Section 9 is - scheduled to commence 18 months after that date, so it is not yet in force on this ADR's - date. Separately from the statutory timeline, the proposed product policy is that only - students aged 18 or older may publish a public portfolio, with a confirmation step in the - editor. Confirm this policy, or choose another rule after legal advice. -2. **Username changes.** Should a username be changeable after publishing? Allowing it breaks - shared links; forbidding it needs a clear notice before the first publish. -3. **Phase 2 domain.** Serve subdomains under `procoderji.com`, or buy a separate domain for - student pages (as GitHub uses `github.io`)? A separate domain protects the main site's - reputation if a page is abused. Wildcard certificates on Vercel need - [Vercel nameservers](https://vercel.com/guides/why-use-domain-nameservers-method-wildcard-domains), - or the documented alternative of delegating the `_acme-challenge` record to Vercel DNS with - `NS` records while keeping the current nameservers - ([Vercel guide](https://vercel.com/kb/guide/wildcard-domain-without-vercel-nameservers)). -4. **Legal text.** The Privacy and Terms pages need new sections (issue #48). The owner reviews - and approves the wording; no placeholder legal text is published. +## Deferred decisions + +- **Phase 2 domain.** Choose whether subdomains use `procoderji.com` or a separate domain and + verify the selected hosting provider's current wildcard DNS and certificate requirements + before implementing #49. Phase 1 uses only `/u/`. +- **Later capabilities.** Uploads, username renaming, custom domains, GitHub import, and PDF + export need separate scope and privacy decisions. Any paid feature still follows ADR 0001. ## Consequences @@ -126,7 +154,7 @@ want a good portfolio quickly, not a design tool. Themes give variety without th can be a good first issue once the theme system exists. - The backend gains a second kind of student data with public exposure, so it also gains moderation duties, rate limits, and new privacy and terms wording. -- Phase 2 adds DNS and certificate operations and a proxy layer; the canonical-address rule and +- Phase 2 would add DNS and certificate operations and a proxy layer; the canonical-address rule and cookie test keep SEO and sessions correct. - Custom domains, GitHub import, and PDF export (Phase 3) build on the same model and need no change to these boundaries. Any paid feature still needs the payment decisions required by diff --git a/docs/development.md b/docs/development.md index 525773e..19f9cc9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,11 +30,19 @@ changes when updating a dependency. | `/games` | Existing short reasoning and memory exercises. | | `/dashboard` | Engineering course catalogue and notes. | | `/account` | Optional Google sign-in and progress controls when the backend is configured. | +| `/learn/portfolio` | Guest preview or signed-in portfolio form, Markdown import, and private drafts. | +| `/u/[username]` | Explicitly published portfolio snapshot when publishing is enabled. | Practice works without login. The current browser retains up to 100 completed attempts; clearing its site data can remove that history. Account progress is separate from guest history, with an explicit import action. The account page can export visible history. +The [portfolio workspace](portfolio-workspace.md) supports Simple and Developer themes and +[Markdown import](portfolio-markdown.md). Guest portfolio details stay in the open page only; +saving/claiming needs a Google session. Publishing is disabled by default. Once an operator +enables it, publication requires a claimed fixed username and explicit 18+ policy acceptance. +Private draft saves do not change an existing public snapshot. Search visibility is opt-in. + ## Optional backend and Google accounts Install the **.NET 10 SDK** and run these commands from the repository root: @@ -104,7 +112,10 @@ by Git. synchronization. - `shared/practice/questions.json` is the canonical question bank consumed by the frontend and backend. Keep IDs stable when improving wording; use a new ID when a question's meaning changes. -- `shared/portfolio/` holds the student-portfolio rules and test fixtures used by both runtimes +- `src/features/portfolio/` owns the form, API client, Markdown parser, themes, and public + delivery. The parser fills the form only; public pages render validated fields, not Markdown. +- `shared/portfolio/` holds rules v2, theme choices, the minimum publishing age/policy version, + 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 @@ -124,7 +135,7 @@ working on student portfolios. The [architecture guide](architecture.md) describ | --- | --- | | `npm run lint` | Frontend code quality and accessibility lint rules. | | `npm run typecheck` | TypeScript compatibility. | -| `npm test` | Practice rules, progress model/store, content validation, and helpers. | +| `npm test` | Practice/progress, portfolio rules/parser/client/presentation, content validation, and helpers. | | `npm run build` | Production frontend compilation and generated routes. | | `npm run e2e` | Frontend desktop/mobile browser routes and interactions; optional backend integration is described below. | | `npm run launch-check` | Real production links, metadata, sitemap, and indexing rules. | @@ -157,10 +168,24 @@ $env:E2E_DOTNET = "1" npm run e2e ``` -This mode builds and starts a real .NET process with an isolated SQLite database, connects the -Next.js proxy, and checks the Google sign-in challenge and public callback URL with test client -configuration. It does not complete external Google consent or token exchange. Those live -provider checks remain part of [Backend setup](../backend/README.md). +This mode builds and starts `backend/tests/ProCoderJi.BrowserHost`, a test-only executable +hosting the real API with disposable SQLite data, and connects the Next.js BFF. The runner owns +the temporary directory, removes external database-test connections, and cleans up its process +and data afterward. Defaults are frontend port 3150 and backend port 5151; use `E2E_PORT` and +`E2E_BACKEND_PORT` to choose distinct alternatives. + +BrowserHost enables publishing internally and exposes loopback-only account-seeding, +moderation and publishing-switch fixtures under `/__e2e__`. Its registered disposable-account +cookie is test authentication only; ownership, session, Origin and CSRF checks still run in the +real API. These controls are not Next.js routes and are absent from the production API/image. +Never deploy BrowserHost or substitute it for a development Google account service. + +Coverage includes phone/desktop editors and both themes, save/claim/publish, explicit age +confirmation, Markdown import without autosave, private/public snapshot isolation, account +changes, opt-in sitemap/metadata, and fresh 404 responses after moderation/unpublishing even +with a warmed public cache. It also checks the Google challenge and public callback URL using +test client configuration. External Google consent/token exchange remains a live-provider +check in [Backend setup](../backend/README.md). To use Edge instead of Chrome: diff --git a/docs/portfolio-api.md b/docs/portfolio-api.md index 89bf478..c7066a0 100644 --- a/docs/portfolio-api.md +++ b/docs/portfolio-api.md @@ -1,16 +1,19 @@ # 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. +Phase 1 provides the `/learn/portfolio` editor and public `/u/` pages. The backend +stores one private draft and an explicit published snapshot per account. See the +[workspace guide](portfolio-workspace.md) for the student journey and launch runbook, +[shared rules](../shared/portfolio/README.md) for validation, and +[ADR 0002](decisions/0002-student-portfolios.md) for the accepted product boundaries. ## 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. +`Portfolio__PublishingEnabled` defaults to **false**, including local development. Drafts and +username claims work with a configured account service; publishing returns 503 +`publishing-disabled`, public snapshot/state reads return 404, and the public index is empty. +An operator enables it after the deployment checks in the workspace guide. Implemented features +do not establish production deployment; [#74](https://github.com/procoderji-com/procoderji/issues/74) +tracks the outstanding frontend hosting work. ## Authentication and request format @@ -22,21 +25,23 @@ Mutations accept JSON with a maximum body size of 256 KiB, including streamed bo 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. +API responses use `private, no-store` and `X-Robots-Tag: noindex`. Public HTML uses the +published search-visibility setting for its metadata; private data is never cached publicly. ## Endpoints | Method and path | Behavior | | --- | --- | -| `GET /api/platform/portfolio` | Owner's draft and metadata, or `portfolio: null`; also returns `publishingEnabled`. | +| `GET /api/platform/portfolio` | `{ portfolio, publishingEnabled, policyVersion, minimumPublishingAge }`; `portfolio` is null before creation. | | `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/publish` | Publish the current draft using the revision and explicit eligibility payload below. | | `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. | +| `GET /api/platform/portfolio/public/{username}` | Published snapshot only; otherwise 404. Optional `?version=` must match. | +| `GET /api/platform/portfolio/public/{username}/state` | Fresh public `{ username, version, searchVisible, publishedAt }`; otherwise 404. | +| `GET /api/platform/portfolio/public?limit=1000&after=` | Published, search-opted-in index: `{ portfolios: [{ username, publishedAt }], nextCursor }`. Limit is 1–1000; omit `after` for the first page. | 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 @@ -48,6 +53,7 @@ Example first draft: ```json { "data": { + "theme": "simple", "name": "Riya", "headline": "Computer science student", "skills": ["C#", "PostgreSQL"] @@ -57,30 +63,70 @@ Example first draft: } ``` -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. +The owner record includes draft `data`, `username`, `searchVisible`, `status`, `revision`, +`createdAt`, `updatedAt`, `publishedAt`, `publishingPolicyVersion` and `eligibilityConfirmedAt`. +The public snapshot includes only `username`, published `data`, published `searchVisible`, +`publishedAt` and an opaque `version`. It excludes account identity, Google profile, draft, +editing revision and publishing acceptance metadata. -## Publication and moderation +## Publication policy and state + +Phase 1 publishing requires self-declaration of being **18 or older** and having read the +portfolio Terms and Privacy Policy. The current policy version is **`2026-11-11`**, defined with +the minimum age in `shared/portfolio/rules.json`. Use the workspace's policy version: + +```json +{ + "revision": "CURRENT-REVISION-UUID", + "eligibility": { + "atLeast18": true, + "policyVersion": "2026-11-11" + } +} +``` + +The backend records the accepted version and server timestamp privately. No date of birth is +collected; this is self-declaration, not independent age verification. Public endpoints exclude +snapshots without current acceptance. The policy text is not evidence of qualified legal review. 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. +The username is **fixed while the portfolio exists**; a repeated claim of the same name is +allowed with the current revision. Deleting an ordinary portfolio releases its claim. `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. +restores it, preventing a delete/recreate publication bypass. Restore returns a private draft +and clears publication and acceptance; the student must review and publish again. See the +[operator procedure](../backend/README.md#portfolio-moderation). There is no student-accessible +moderation 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. +and both snapshots. Deleting practice history does not delete a portfolio. Removing the owning +account cascades through a relational foreign key or removes MongoDB's embedded portfolio. +There is no self-service account-deletion endpoint; use the existing operator contact. + +## Public delivery and cache + +Every public page/metadata request reads fresh backend state with `no-store` before using a +cached snapshot. Only validated published data is cached, keyed by backend origin, username +and opaque public version, with one-hour revalidation. The version does not expose the private +editing revision. A publication racing the state read is retried once against fresh state. + +Successful publish, unpublish and delete through the BFF immediately invalidate the username's +cache tag using the trusted upstream username header. Fresh state remains authoritative for +operator CLI changes, which do not call Next.js invalidation: subsequent requests return 404 +after suspension, unpublishing, deletion or disabling publication. + +Search visibility defaults off. Public HTML is `noindex` and absent from the sitemap until an +opted-in version is published; a public address remains accessible to anyone who knows it. +The dynamic sitemap reads the public index afresh and includes only current-policy, published, +opted-in pages. The frontend accepts up to **45,000 portfolio URLs**, reserving space under the +sitemap URL limit. It fails rather than silently omitting later pages when another index page +exists. Partition the sitemap before growing beyond that cap. ## Storage and migration @@ -89,8 +135,9 @@ key, or inherently removes MongoDB's embedded portfolio. Account-management UI i - 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. + relational portfolio/policy migrations or creates MongoDB's portfolio username index. + Existing accounts/progress remain intact. Older snapshots without current acceptance stay + private until the student publishes with it. Follow [Neon setup](neon.md) for direct migration and pooled runtime connections. PostgreSQL runtime startup must not apply migrations through the transaction pooler. @@ -105,8 +152,9 @@ 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. +(409), `publishing-eligibility-required` (400), `policy-changed` (409), `rate-limited` (429), +and `publishing-disabled` (503). Malformed/unknown JSON fields return 400, invalid content types +return 415, and oversized bodies return 413. ## Verify a change @@ -114,7 +162,9 @@ Run `dotnet test backend/ProCoderJi.slnx --configuration Release`. The shared AP 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. +tests also cover incremental migrations and MongoDB document ownership. Use +`E2E_DOTNET=1 npm run e2e` for real BFF/browser journeys with disposable test-only accounts; +see [browser checks](development.md#browser-checks). External Google consent is not automated. Never point the integration-test variables at a production database. See [backend test configuration](../backend/README.md#tests). diff --git a/docs/portfolio-markdown.md b/docs/portfolio-markdown.md new file mode 100644 index 0000000..5e11082 --- /dev/null +++ b/docs/portfolio-markdown.md @@ -0,0 +1,31 @@ +# Import a portfolio from Markdown + +Download [`portfolio.md`](../public/templates/portfolio.md) from the editor, replace the sample +details with your own, then choose the file in the portfolio editor. You can also copy the +editor's AI prompt into your own writing tool with the template and the facts you want to use. +Review the resulting fields and preview before saving. Import never saves or publishes a page. + +Files must contain well-formed UTF-8 text and be at most **50 KiB**. Keep the following structure: + +- A `---` front-matter block with `name`, `headline`, `theme`, `github`, `linkedin`, `website` + and optional `email`. Use `simple` or `developer` for the theme. Links use `https`; email is + a plain address. Omit optional empty values if you prefer. +- The `## About`, `## Skills`, `## Projects`, `## Education`, `## Experience` and + `## Achievements` headings. Keep a heading empty when it does not apply. +- Skills and achievements use one `- item` line per entry. +- Each project, education or experience entry starts with a `###` heading. Its title is the + project title, institution or organization. Follow the labelled fields in the template. +- Technologies are a JSON array of strings, for example `["TypeScript", "C#"]`. + +Plain field values and double-quoted JSON strings are supported. A multiline `Description` or +`Details` can use `|`, followed by lines indented with two spaces. The About paragraph can span +lines directly. Escape literal braces as `\{` and `\}` in unquoted text; escape a leading +Markdown heading marker as `\#` if it is part of that text. + +This is a small import grammar, not a general YAML or MDX interpreter. Unknown sections, +unknown/duplicate fields, malformed input and shared field/link-limit violations produce clear +errors. HTML, JSX, raw JavaScript expressions, code fences and import/export statements are +rejected. The uploaded Markdown is never executed or rendered as a public page. + +The parser and serializer live in `src/features/portfolio/markdown.ts`. Its tests cover the +template round trip, missing and unknown sections, invalid links, unsafe content and byte limits. diff --git a/docs/portfolio-workspace.md b/docs/portfolio-workspace.md new file mode 100644 index 0000000..71752cf --- /dev/null +++ b/docs/portfolio-workspace.md @@ -0,0 +1,83 @@ +# Student portfolio workspace + +Phase 1 lets students prepare a portfolio at `/learn/portfolio` and explicitly publish a copy +at `/u/`. It is free; billing and paid entitlements are future work. These repository +capabilities do not establish production deployment. +[Issue #74](https://github.com/procoderji-com/procoderji/issues/74) tracks outstanding frontend +hosting work. + +## Student journey + +1. Try the editor and live preview as a guest. Details exist only in the open page, with no + portfolio storage in localStorage; leaving or changing accounts clears them. +2. Sign in with Google to save a private draft. Fill name, headline, about, skills, projects, + education, experience, achievements and optional links/public email. Lists can be added, + removed and reordered. Choose Simple or Developer; both themes render the same fields. +3. Optionally [import Markdown](portfolio-markdown.md) using the + [template](../public/templates/portfolio.md). The browser parses up to 50 KiB of UTF-8 text. + Import fills the form for review; it does not upload the source file, call an AI service, + save a draft or publish. +4. Save, check availability and claim a username. It stays fixed while the portfolio exists; + deleting an ordinary portfolio releases it. +5. Review the preview and public fields. Publishing requires a name/headline, an address, and + explicit confirmation that the student is at least 18 and has read the Terms and Privacy + Policy. No birth date is collected. The backend privately records policy version + `2026-11-11` and the server confirmation time. +6. Publish when the deployment enables it. Later draft saves remain private until publishing + again. Search indexing is off by default; it changes only with the published copy. A + `noindex` page can still be opened by anyone with its address. +7. Unpublish to remove the public copy while keeping the draft, or delete the portfolio and + its address. A suspended portfolio can be edited/unpublished, but cannot publish or delete + until operator review. Restore returns a draft; the student must publish again. + +Inline validation explains field errors. Revision conflicts preserve local edits; explicit +reload asks for confirmation before replacing unsaved work. Account changes clear private +editor state. Google email is never automatically published: the optional public email must be +filled by the student. Themes use initials and make no third-party image/font requests; there +are no photos, student scripts or custom forms. + +Every public page links to policies and **Report this page**, a mailto to the fixed project +contact with the canonical page address. Operators handle reports and account-removal requests; +practice-history deletion is separate from portfolio deletion. See +[moderation](../backend/README.md#portfolio-moderation). + +## Deployment runbook + +Backend `Portfolio__PublishingEnabled=false` is the default. Draft editing can be enabled first. +To prepare a real release: + +1. Deploy Next.js and the production .NET API as separate services. Frontend server-only + `DOTNET_API_URL` must reach the API; backend `PublicOrigin` must match the exact HTTPS + frontend origin. Deploy the production API project/image, never BrowserHost. +2. Configure the real Google OAuth web client and frontend callback. Complete the live + session/sign-in/CSRF checks in [backend setup](../backend/README.md); fixtures do not test + external Google consent or token exchange. +3. Provision durable storage and Data Protection keys. Neon PostgreSQL is the selected hosted + option; use its direct migration and pooled runtime connections from [Neon setup](neon.md). + Apply migrations before starting replicas and verify backup/restore. +4. Verify deployed policy text, operator/contact details, reporting and moderator access. + Product decisions and policy wording do not establish qualified legal review. Confirm + monitoring, HTTPS, infrastructure rate limits and database access restrictions. +5. Run repository checks and inspect the deployed commit/routes. Exercise private drafts, + fixed claims, accepted-policy publication, default `noindex`, opt-in metadata/sitemap, + draft/public isolation, unpublish/delete, and suspend/restore followed by republishing. +6. Set backend `Portfolio__PublishingEnabled=true` and roll out the configured release. Check + both themes on phone/desktop and verify withdrawn pages return 404 even after a warmed + cache. Disabling the flag hides pages and empties the portfolio index without deleting + drafts; enable it again after resolving the problem. + +The sitemap supports up to 45,000 opted-in portfolio entries; partition it before exceeding +that cap. Phase 1 uses paths on the main site and needs no wildcard DNS or per-student hosting. +Subdomains, uploads, custom domains and monetization require separate work. + +## Contributor entry points + +| Area | Source or guide | +| --- | --- | +| Editor and account adapter | `src/features/portfolio/editor/`, `src/features/portfolio/client.ts` | +| Themes and public page | `src/features/portfolio/themes/`, `src/app/u/[username]/` | +| Markdown parser and format | [Import guide](portfolio-markdown.md), `src/features/portfolio/markdown.ts` | +| Shared validation and publishing policy | [Rules v2](../shared/portfolio/README.md) | +| Revisions, state/index endpoints and caching | [Portfolio API](portfolio-api.md) | +| Test-only full-stack host | [Browser checks](development.md#browser-checks) | +| Product boundaries | [ADR 0002](decisions/0002-student-portfolios.md) | diff --git a/e2e/checks.mjs b/e2e/checks.mjs index ad5435c..44e9479 100644 --- a/e2e/checks.mjs +++ b/e2e/checks.mjs @@ -1,6 +1,7 @@ import { chromium, devices } from "playwright-core"; import fs from "node:fs"; import path from "node:path"; +import { runPortfolioChecks } from "./portfolio-checks.mjs"; const BASE = process.env.BASE || "http://localhost:3150"; const CHANNEL = process.env.E2E_CHANNEL || "chrome"; @@ -447,6 +448,14 @@ await interaction("privacy page discloses every third-party host the site contac expectTrue(missing.length === 0, "undisclosed third-party host(s): " + missing.join(", ")); }); +if (FULLSTACK) { + try { + findings.push(...await runPortfolioChecks({ browser, base: BASE, backendOrigin: process.env.E2E_BACKEND_ORIGIN || "http://127.0.0.1:5151" })); + } catch (error) { + note("all", "/learn/portfolio", "portfolio", `Fixture setup failed: ${String(error.message ?? error)}`); + } +} + await browser.close(); console.log("\n========== FINDINGS =========="); diff --git a/e2e/launch.mjs b/e2e/launch.mjs index c8d3d10..fab8b62 100644 --- a/e2e/launch.mjs +++ b/e2e/launch.mjs @@ -157,7 +157,7 @@ try { } // ---------- empty content stays hidden ---------- - const mustBeNoindex = ["/dashboard/btech/cse/sem1", "/dashboard/btech/cse/sem8", "/dashboard/btech/aiml/sem3"]; + const mustBeNoindex = ["/dashboard/btech/cse/sem1", "/dashboard/btech/cse/sem8", "/dashboard/btech/aiml/sem3", "/learn/portfolio"]; for (const p of mustBeNoindex) { const res = await get(p); if (res.status === 200 && !/noindex/i.test(analyse(res.text).robots ?? "")) bad(p, "empty page is indexable (should be noindex)"); @@ -180,6 +180,16 @@ try { } const notFound = await get("/this/page/does/not/exist"); if (notFound.status !== 404) bad("/this/page/does/not/exist", `expected 404, got ${notFound.status}`); + for (const p of ["/u/qa-unpublished-student", "/u/admin"]) { + const res = await get(p); + if (res.status !== 404) bad(p, `unpublished portfolio expected 404, got ${res.status}`); + if (!/noindex/i.test(analyse(res.text).robots ?? "")) bad(p, "portfolio 404 must be noindex"); + if (sitemapPaths.has(p)) bad(p, "unpublished portfolio is in the sitemap"); + } + const template = await get("/templates/portfolio.md"); + if (template.status !== 200 || !/^---\r?\n/.test(template.text) || !template.text.includes("## Projects")) + bad("/templates/portfolio.md", "the downloadable Markdown template is missing or malformed"); + if (Buffer.byteLength(template.text, "utf8") > 50 * 1024) bad("/templates/portfolio.md", "template exceeds the import limit"); // ---------- latest videos: visible only when YOUTUBE_CHANNEL_ID is set ---------- { diff --git a/e2e/portfolio-checks.mjs b/e2e/portfolio-checks.mjs new file mode 100644 index 0000000..acc9f56 --- /dev/null +++ b/e2e/portfolio-checks.mjs @@ -0,0 +1,418 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const CONTACT = "procoderji@gmail.com"; +const EDITOR = "/learn/portfolio"; +const ROOT = "/api/platform/portfolio"; + +async function until(condition, message, timeout = 10_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await condition()) return; + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(message); +} + +/** Real frontend/BFF/API flows against the isolated test host, never a fake portfolio response. */ +export async function runPortfolioChecks({ browser, base, backendOrigin }) { + const findings = []; + const output = path.resolve("e2e/.out"); + fs.mkdirSync(output, { recursive: true }); + + async function fixture(route, body) { + const response = await fetch(backendOrigin + "/__e2e__" + route, { + method: "POST", + ...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + }); + assert.ok(response.ok, `Fixture ${route} failed (${response.status}): ${await response.text().then(text => response.ok ? "" : text)}`); + return response; + } + + async function newAccount() { + const response = await fetch(backendOrigin + "/__e2e__/account", { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }); + assert.ok(response.ok, `Account fixture failed (${response.status})`); + const user = await response.json(); + assert.equal(typeof user.id, "string"); + return user; + } + + async function ownerContext(user, viewport = { width: 1280, height: 900 }) { + const context = await browser.newContext({ viewport, reducedMotion: "reduce" }); + await context.addCookies([{ name: "procoderji.e2e-user", value: user.id, + domain: new URL(base).hostname, path: "/api/platform", httpOnly: true, + sameSite: "Lax", secure: new URL(base).protocol === "https:" }]); + return context; + } + + async function session(context, user) { + const response = await context.request.get(base + "/api/platform/auth/session"); + assert.equal(response.status(), 200); + const current = await response.json(); + assert.equal(current.user?.id, user.id, "Fixture cookie must authenticate the real account session"); + assert.equal(typeof current.csrfToken, "string"); + return current; + } + + async function own(context, user) { + const response = await context.request.get(base + ROOT, { headers: { "X-Account-Id": user.id } }); + assert.equal(response.status(), 200, `Owner read failed (${response.status()})`); + return response.json(); + } + + async function write(context, user, action, body, expectedStatus = 200, method = "POST") { + const current = await session(context, user); + const response = await context.request.fetch(base + ROOT + action, { + method, headers: { Origin: new URL(base).origin, "X-Account-Id": user.id, + "X-CSRF-Token": current.csrfToken, "Content-Type": "application/json" }, data: body, + }); + assert.equal(response.status(), expectedStatus, `${method} ${action || "/"} failed: ${await response.text()}`); + return expectedStatus === 204 ? null : response.json(); + } + + async function publish(context, user, revision) { + const workspace = await own(context, user); + assert.equal(typeof workspace.policyVersion, "string"); + return write(context, user, "/publish", { revision, eligibility: { atLeast18: true, policyVersion: workspace.policyVersion } }); + } + + async function indexed(username) { + const response = await fetch(base + "/sitemap.xml", { cache: "no-store" }); + assert.equal(response.status, 200); + return (await response.text()).includes(`/u/${username}`); + } + + async function publicPage(context, username, expectedStatus, expectedName) { + const page = await context.newPage(); + const foreignRequests = new Set(); + page.on("request", request => { + const url = new URL(request.url()); + if (/^https?:$/.test(url.protocol) && url.origin !== new URL(base).origin) foreignRequests.add(url.origin); + }); + const response = await page.goto(base + "/u/" + username, { waitUntil: "networkidle" }); + assert.equal(response?.status(), expectedStatus, `Public ${username} should return ${expectedStatus}`); + if (expectedStatus === 200) { + assert.equal(await page.locator("h1").count(), 1); + assert.equal(await page.getByRole("heading", { level: 1 }).innerText(), expectedName); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false, "Public portfolio overflows horizontally"); + assert.equal(await page.locator('main a[target="_blank"]').evaluateAll(links => links.every(link => link.relList.contains("noopener") && link.relList.contains("noreferrer"))), true); + const report = new URL(await page.getByRole("link", { name: "Report this page" }).getAttribute("href")); + assert.equal(report.protocol, "mailto:"); + assert.equal(report.pathname, CONTACT); + assert.ok(report.searchParams.get("body")?.includes(`/u/${username}`), "Report must include the canonical portfolio path"); + } + assert.deepEqual([...foreignRequests], [], "Public portfolios must not make third-party requests"); + return page; + } + + async function editor(page) { + const response = await page.goto(base + EDITOR, { waitUntil: "networkidle" }); + assert.equal(response?.status(), 200); + await page.getByRole("heading", { name: "Portfolio builder", exact: true }).waitFor(); + await until(() => page.getByRole("button", { name: "Save draft", exact: true }).isEnabled(), "Authenticated editor did not become ready"); + } + + async function openSection(page, title) { + const details = page.locator("details").filter({ has: page.locator("summary", { hasText: title }) }).first(); + if (!(await details.getAttribute("open") !== null)) await details.locator("summary").click(); + } + + async function run(vp, title, action) { + const contexts = []; + try { + await action(contexts); + console.log(`PASS ${vp.padEnd(7)} portfolio: ${title}`); + } catch (error) { + const detail = String(error?.stack ?? error); + findings.push({ kind: "portfolio", vp, url: EDITOR, detail: `${title}: ${detail}` }); + console.error(`FAIL ${vp.padEnd(7)} portfolio: ${title}: ${String(error?.message ?? error)}`); + const lastPage = contexts.flatMap(context => context.pages()).findLast(page => !page.isClosed()); + await lastPage?.screenshot({ path: path.join(output, `portfolio-${vp}-${title.replace(/[^a-z0-9]/gi, "-").slice(0, 60)}.png`), fullPage: true }).catch(() => {}); + } finally { + for (const context of contexts) await context.close(); + } + } + + const controlUser = await newAccount(); + const controlContext = await ownerContext(controlUser); + await session(controlContext, controlUser); + const originalPublishing = (await own(controlContext, controlUser)).publishingEnabled; + await controlContext.close(); + + try { + await fixture("/publishing/true"); + for (const [vp, viewport, theme] of [["desktop", { width: 1280, height: 900 }, "simple"], ["mobile", { width: 390, height: 844 }, "developer"]]) { + await run(vp, "save, claim, publish, review snapshots and unpublish", async contexts => { + const user = await newAccount(); + const context = await ownerContext(user, viewport); contexts.push(context); + const anonymous = await browser.newContext({ viewport, reducedMotion: "reduce" }); contexts.push(anonymous); + const page = await context.newPage(); + await editor(page); + const name = `${vp === "desktop" ? "Simple" : "Developer"} Student`; + const username = `qa-${vp === "desktop" ? "desk" : "mob"}-${user.id.slice(0, 8)}`; + await page.getByLabel("Name", { exact: true }).fill(name); + await page.getByLabel("Headline", { exact: true }).fill("Building useful campus tools"); + await page.getByLabel("About you", { exact: true }).fill("I enjoy creating accessible learning tools with other students."); + await page.getByLabel("Theme", { exact: true }).selectOption(theme); + await openSection(page, "Skills"); + await page.getByRole("button", { name: "Add skill", exact: true }).click(); + await page.getByLabel("Skill 1", { exact: true }).fill("TypeScript"); + await openSection(page, "Projects"); + await page.getByRole("button", { name: "Add project", exact: true }).click(); + await page.getByLabel("Project title", { exact: true }).fill("Campus Planner"); + await page.getByLabel("Project link", { exact: true }).fill("https://github.com/example/campus-planner"); + await page.getByLabel("Project description", { exact: true }).fill("An accessible planner built with reviewed student feedback."); + await page.getByRole("button", { name: "Add technology", exact: true }).click(); + await page.getByLabel("Technology 1", { exact: true }).fill("TypeScript"); + await page.getByRole("button", { name: "Add project", exact: true }).click(); + await page.getByLabel("Project title", { exact: true }).nth(1).fill("Study Tracker"); + await page.getByRole("button", { name: "Move project 2 up", exact: true }).click(); + assert.equal(await page.getByLabel("Project title", { exact: true }).first().inputValue(), "Study Tracker"); + await page.getByRole("button", { name: "Remove project 1", exact: true }).click(); + assert.equal(await page.getByLabel("Project title", { exact: true }).inputValue(), "Campus Planner"); + await openSection(page, "Education"); + await page.getByRole("button", { name: "Add education", exact: true }).click(); + await page.getByLabel("Institution", { exact: true }).fill("Example Institute"); + await page.getByLabel("Degree or qualification", { exact: true }).fill("B.Tech in Computer Science"); + await page.getByLabel("Education period", { exact: true }).fill("2023–2027"); + await page.getByLabel("Education details", { exact: true }).fill("Studying databases and web engineering."); + await openSection(page, "Experience"); + await page.getByRole("button", { name: "Add experience", exact: true }).click(); + await page.getByLabel("Organization", { exact: true }).fill("Student Computing Club"); + await page.getByLabel("Role", { exact: true }).fill("Volunteer developer"); + await page.getByLabel("Experience period", { exact: true }).fill("2025–2026"); + await page.getByLabel("Experience description", { exact: true }).fill("Reviewed accessible campus exercises."); + await openSection(page, "Achievements"); + await page.getByRole("button", { name: "Add achievement", exact: true }).click(); + await page.getByLabel("Achievement 1", { exact: true }).fill("Created a reviewed student project."); + await openSection(page, "Links and optional public email"); + await page.getByLabel("GitHub profile", { exact: true }).fill("https://github.com/example"); + await page.getByLabel("LinkedIn profile", { exact: true }).fill("https://linkedin.com/in/example-student"); + await page.getByLabel("Personal website", { exact: true }).fill("https://example.com"); + await page.getByLabel("Public email", { exact: true }).fill("portfolio.contact@example.com"); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await page.getByText(/Private draft saved\./).waitFor(); + await page.getByLabel("Public username", { exact: true }).fill(username); + await page.getByRole("button", { name: "Check availability", exact: true }).click(); + await page.getByText(/This username is available/).waitFor(); + await page.getByRole("button", { name: "Claim username", exact: true }).click(); + await page.getByText(`Claimed address: /u/${username}`, { exact: true }).waitFor(); + assert.equal(await page.getByLabel("Public username", { exact: true }).isDisabled(), true); + assert.equal(await page.getByRole("button", { name: "Publish portfolio", exact: true }).isDisabled(), true, "Age declaration must be explicit"); + await (await publicPage(anonymous, username, 404)).close(); + await page.getByRole("checkbox", { name: /I am 18 or older/ }).check(); + await page.getByRole("button", { name: "Publish portfolio", exact: true }).click(); + await page.getByText(/Your portfolio is published\./).waitFor(); + let visible = await publicPage(anonymous, username, 200, name); + assert.equal(await visible.locator("[data-portfolio-theme]").getAttribute("data-portfolio-theme"), theme); + assert.match(await visible.locator('meta[name="robots"]').getAttribute("content"), /noindex/); + assert.equal(await indexed(username), false, "Search visibility must default off"); + const html = await visible.content(); + assert.ok(!html.includes(user.id), "Public HTML leaked account identity"); + assert.ok(!user.email || !html.includes(user.email), "Public HTML leaked Google account email"); + await visible.screenshot({ path: path.join(output, `portfolio-${vp}-published.png`), fullPage: true }); + await page.screenshot({ path: path.join(output, `portfolio-${vp}-editor.png`), fullPage: true }); + await visible.close(); + await page.getByLabel("Name", { exact: true }).fill(`Private ${name}`); + await page.getByRole("button", { name: "Save draft", exact: true }).click(); + await page.getByText(/Private draft saved\./).waitFor(); + visible = await publicPage(anonymous, username, 200, name); + assert.ok(!(await visible.content()).includes(`Private ${name}`), "Saving a draft changed published data"); + await visible.close(); + await page.getByRole("checkbox", { name: /I am 18 or older/ }).check(); + await page.getByRole("button", { name: "Publish portfolio", exact: true }).click(); + await page.getByText(/Your portfolio is published\./).waitFor(); + await (await publicPage(anonymous, username, 200, `Private ${name}`)).close(); + await page.getByRole("button", { name: "Unpublish", exact: true }).click(); + await page.getByText(/Your portfolio is no longer public/).waitFor(); + await (await publicPage(anonymous, username, 404)).close(); + assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), `Private ${name}`); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false, "Editor overflows horizontally"); + assert.match(await page.locator('meta[name="robots"]').getAttribute("content"), /noindex/); + }); + } + + await run("desktop", "Markdown import fills the form without saving and reports unsafe input", async contexts => { + const user = await newAccount(); + const context = await ownerContext(user); contexts.push(context); + const page = await context.newPage(); await editor(page); + await openSection(page, "Import a Markdown draft"); + const templateResponse = await fetch(base + "/templates/portfolio.md"); assert.equal(templateResponse.status, 200); + const template = await templateResponse.text(); + const input = page.getByLabel("Choose a Markdown text file (up to 50 KiB)", { exact: true }); + const importErrors = page.getByRole("alert").filter({ has: page.getByText("The Markdown needs a correction", { exact: true }) }); + await input.setInputFiles({ name: "portfolio.md", mimeType: "text/markdown", buffer: Buffer.from(template) }); + await until(async () => await page.getByLabel("Name", { exact: true }).inputValue() === "Riya Student", "Import did not populate Name"); + assert.equal((await own(context, user)).portfolio, null, "Import must not autosave"); + await input.setInputFiles({ name: "extra-section.md", mimeType: "text/markdown", buffer: Buffer.from(template + "\n## Unknown section\nDo not drop me.\n") }); + await page.getByText("The Markdown needs a correction", { exact: true }).waitFor(); + assert.match(await importErrors.innerText(), /unknown/i); + assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), "Riya Student"); + await input.setInputFiles({ name: "unsafe.md", mimeType: "text/markdown", buffer: Buffer.from(template.replace("## About", "## About\n")) }); + await until(async () => /HTML|JSX|plain text/i.test(await importErrors.innerText()), "Unsafe markup should be rejected"); + assert.equal(await page.evaluate(() => typeof window.__portfolioPwned), "undefined"); + await input.setInputFiles({ name: "too-big.md", mimeType: "text/markdown", buffer: Buffer.from("x".repeat(50 * 1024 + 1)) }); + await page.getByText("Choose a Markdown text file no larger than 50 KiB.", { exact: true }).waitFor(); + assert.equal((await own(context, user)).portfolio, null); + assert.equal(await page.getByLabel("Optional AI prompt", { exact: true }).inputValue().then(text => text.includes("Do not invent")), true); + }); + + await run("desktop", "delayed Markdown files preserve newer edits and newer file selections", async contexts => { + const user = await newAccount(); + const context = await ownerContext(user); contexts.push(context); + const page = await context.newPage(); await editor(page); + await openSection(page, "Import a Markdown draft"); + const templateResponse = await fetch(base + "/templates/portfolio.md"); assert.equal(templateResponse.status, 200); + const template = await templateResponse.text(); + await page.evaluate(() => { + const read = File.prototype.arrayBuffer; + window.__portfolioFileReads = {}; + File.prototype.arrayBuffer = function () { + if (!this.name.startsWith("deferred-")) return read.call(this); + return new Promise((resolve, reject) => { + const content = read.call(this); + window.__portfolioFileReads[this.name] = async () => { + try { resolve(await content); } catch (error) { reject(error); } + }; + }); + }; + }); + const input = page.getByLabel("Choose a Markdown text file (up to 50 KiB)", { exact: true }); + const select = (filename, name) => input.setInputFiles({ name: filename, mimeType: "text/markdown", + buffer: Buffer.from(template.replace('name: "Riya Student"', `name: "${name}"`)) }); + const release = filename => page.evaluate(async name => { + await window.__portfolioFileReads[name](); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + }, filename); + let replacementQuestions = 0; + let acceptReplacement = false; + page.on("dialog", async dialog => { + replacementQuestions++; + if (acceptReplacement) await dialog.accept(); else await dialog.dismiss(); + }); + await select("deferred-edit.md", "Delayed file"); + await page.getByLabel("Name", { exact: true }).fill("New unsaved edit"); + await release("deferred-edit.md"); + assert.equal(replacementQuestions, 1, "A file read started from a clean form must still confirm edits made while reading"); + assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), "New unsaved edit"); + await select("deferred-older.md", "Older file"); + await select("deferred-newer.md", "Latest file"); + acceptReplacement = true; + await release("deferred-newer.md"); + await until(async () => await page.getByLabel("Name", { exact: true }).inputValue() === "Latest file", "The latest file selection was not imported"); + assert.equal(replacementQuestions, 2); + await release("deferred-older.md"); + assert.equal(replacementQuestions, 2, "An obsolete file selection must not ask to replace newer imported data"); + assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), "Latest file"); + assert.equal((await own(context, user)).portfolio, null, "Delayed imports must not save private drafts automatically"); + }); + + await run("desktop", "internal navigation confirms before discarding unsaved portfolio edits", async contexts => { + const user = await newAccount(); + const context = await ownerContext(user); contexts.push(context); + const page = await context.newPage(); await editor(page); + await page.getByLabel("Name", { exact: true }).fill("Keep these unsaved edits"); + page.once("dialog", dialog => dialog.dismiss()); + await page.getByRole("link", { name: "Placement practice", exact: true }).click(); + assert.equal(new URL(page.url()).pathname, EDITOR); + assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), "Keep these unsaved edits"); + page.once("dialog", dialog => dialog.accept()); + await Promise.all([ + page.waitForURL(url => url.pathname === "/practice"), + page.getByRole("link", { name: "Placement practice", exact: true }).click(), + ]); + assert.equal(new URL(page.url()).pathname, "/practice"); + }); + + await run("desktop", "moderation and search visibility stay fresh after cached public reads", async contexts => { + const user = await newAccount(); const context = await ownerContext(user); contexts.push(context); + const anonymous = await browser.newContext(); contexts.push(anonymous); + const username = `qa-review-${user.id.slice(0, 8)}`; + let record = (await write(context, user, "/draft", { data: { name: "Moderation Student", headline: "Learning in public", theme: "simple" }, searchVisible: true, revision: null })).portfolio; + record = (await write(context, user, "/username", { username, revision: record.revision })).portfolio; + record = (await publish(context, user, record.revision)).portfolio; + let page = await publicPage(anonymous, username, 200, "Moderation Student"); + assert.ok(!/noindex/.test(await page.locator('meta[name="robots"]').getAttribute("content"))); + assert.ok((await page.locator('link[rel="canonical"]').getAttribute("href")).endsWith(`/u/${username}`)); + assert.equal(await indexed(username), true); + await page.close(); + await (await publicPage(anonymous, username, 200, "Moderation Student")).close(); + await fixture(`/moderate/${username}/suspend`); + await (await publicPage(anonymous, username, 404)).close(); + assert.equal(await indexed(username), false, "Suspended pages remained in the sitemap"); + const owner = await context.newPage(); await editor(owner); + assert.equal(await owner.getByRole("button", { name: "Publish portfolio", exact: true }).isDisabled(), true); + assert.equal(await owner.getByRole("button", { name: "Delete portfolio", exact: true }).isDisabled(), true); + await owner.getByRole("button", { name: "Unpublish", exact: true }).click(); + await owner.getByText(/Your portfolio is no longer public/).waitFor(); + assert.equal((await own(context, user)).portfolio.status, "suspended", "Unpublishing bypassed suspension"); + await fixture(`/moderate/${username}/restore`); + await (await publicPage(anonymous, username, 404)).close(); + record = (await own(context, user)).portfolio; + record = (await publish(context, user, record.revision)).portfolio; + await (await publicPage(anonymous, username, 200, "Moderation Student")).close(); + record = (await write(context, user, "/draft", { data: record.data, searchVisible: false, revision: record.revision })).portfolio; + assert.equal(await indexed(username), true, "A private draft changed published search visibility"); + record = (await publish(context, user, record.revision)).portfolio; + page = await publicPage(anonymous, username, 200, "Moderation Student"); + assert.match(await page.locator('meta[name="robots"]').getAttribute("content"), /noindex/); + assert.equal(await indexed(username), false, "Opt-out did not remove a page from the sitemap"); + await page.close(); + await write(context, user, "", { revision: record.revision }, 204, "DELETE"); + await (await publicPage(anonymous, username, 404)).close(); + }); + + await run("desktop", "publication gate hides existing pages and private owner drafts remain usable", async contexts => { + const user = await newAccount(); const context = await ownerContext(user); contexts.push(context); + const anonymous = await browser.newContext(); contexts.push(anonymous); + const username = `qa-gate-${user.id.slice(0, 8)}`; + let record = (await write(context, user, "/draft", { data: { name: "Gate Student", headline: "A private working draft" }, searchVisible: true, revision: null })).portfolio; + record = (await write(context, user, "/username", { username, revision: record.revision })).portfolio; + const missingDeclaration = await write(context, user, "/publish", { revision: record.revision }, 400); + assert.ok(missingDeclaration.error); + record = (await publish(context, user, record.revision)).portfolio; + await (await publicPage(anonymous, username, 200, "Gate Student")).close(); + try { + await fixture("/publishing/false"); + await (await publicPage(anonymous, username, 404)).close(); + assert.equal(await indexed(username), false); + const page = await context.newPage(); await editor(page); + await page.getByText(/Public publishing is not available on this deployment/).waitFor(); + assert.equal(await page.getByRole("button", { name: "Publish portfolio", exact: true }).isDisabled(), true); + assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), "Gate Student"); + } finally { await fixture("/publishing/true"); } + }); + + await run("desktop", "changing accounts clears private editor details", async contexts => { + const user = await newAccount(); const nextUser = await newAccount(); + const context = await ownerContext(user); contexts.push(context); + await write(context, user, "/draft", { data: { name: "Private Owner One", headline: "Only my draft" }, searchVisible: false, revision: null }); + const page = await context.newPage(); await editor(page); + assert.equal(await page.getByLabel("Name", { exact: true }).inputValue(), "Private Owner One"); + await context.addCookies([{ name: "procoderji.e2e-user", value: nextUser.id, domain: new URL(base).hostname, path: "/api/platform", httpOnly: true, sameSite: "Lax", secure: new URL(base).protocol === "https:" }]); + await page.evaluate(() => window.dispatchEvent(new Event("procoderji-auth-change"))); + await until(async () => await page.getByLabel("Name", { exact: true }).inputValue() === "", "Previous account draft remained in the editor"); + assert.ok(!(await page.locator("main").innerText()).includes("Private Owner One")); + const wrongOwner = await context.request.get(base + ROOT, { headers: { "X-Account-Id": user.id } }); + assert.equal(wrongOwner.status(), 409); + }); + + await run("guest", "guests can preview while private APIs and unpublished pages stay protected", async contexts => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); contexts.push(context); + const page = await context.newPage(); await page.goto(base + EDITOR, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Try the editor, then sign in to save", exact: true }).waitFor(); + await page.getByLabel("Name", { exact: true }).fill("Guest preview"); + assert.equal(await page.getByRole("button", { name: "Save draft", exact: true }).isDisabled(), true); + assert.equal(await context.request.get(base + ROOT).then(response => response.status()), 401); + await (await publicPage(context, "qa-unknown-student", 404)).close(); + await (await publicPage(context, "admin", 404)).close(); + }); + } finally { + await fixture(`/publishing/${originalPublishing ? "true" : "false"}`); + } + return findings; +} diff --git a/e2e/run.mjs b/e2e/run.mjs index 9b2b64e..3376ca1 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -1,6 +1,7 @@ // Browser checks use temporary synthetic content and restore it when they exit. -// E2E_DOTNET=1 also builds and starts an isolated ASP.NET Core API. Google -// credentials are fake: checks inspect the challenge redirect without following it. +// E2E_DOTNET=1 starts an isolated test-only host for the real ASP.NET Core API. +// Fixture authentication exists in the test executable only. The production API +// never exposes fixture account, moderation or publication controls. // Needs Chrome installed (set E2E_CHANNEL=msedge to use Edge instead). import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; @@ -15,8 +16,8 @@ const BASE = `http://localhost:${PORT}`; const FULLSTACK = process.env.E2E_DOTNET === "1"; const BACKEND_PORT = checkedPort(process.env.E2E_BACKEND_PORT || "5151", "E2E_BACKEND_PORT"); const BACKEND_ORIGIN = `http://127.0.0.1:${BACKEND_PORT}`; -const BACKEND_PROJECT = path.join(ROOT, "backend", "src", "ProCoderJi.Api", "ProCoderJi.Api.csproj"); -const BACKEND_DLL = path.join(ROOT, "backend", "src", "ProCoderJi.Api", "bin", "Release", "net10.0", "ProCoderJi.Api.dll"); +const BACKEND_PROJECT = path.join(ROOT, "backend", "tests", "ProCoderJi.BrowserHost", "ProCoderJi.BrowserHost.csproj"); +const BACKEND_DLL = path.join(ROOT, "backend", "tests", "ProCoderJi.BrowserHost", "bin", "Release", "net10.0", "ProCoderJi.BrowserHost.dll"); const NEXT = path.join(ROOT, "node_modules", "next", "dist", "bin", "next"); const TEMP_ROOT = fs.realpathSync(os.tmpdir()); const runEnv = { ...process.env, DOTNET_API_URL: FULLSTACK ? BACKEND_ORIGIN : "" }; @@ -119,27 +120,27 @@ try { await run("dotnet", ["restore", BACKEND_PROJECT, "--locked-mode"]); await run("dotnet", ["build", BACKEND_PROJECT, "--configuration", "Release", "--no-restore"]); tempDirectory = fs.mkdtempSync(path.join(TEMP_ROOT, "procoderji-e2e-")); - const databasePath = path.join(tempDirectory, "platform.db").replaceAll('"', '""'); - backend = start("dotnet", [BACKEND_DLL, "--urls", BACKEND_ORIGIN], { + const backendEnv = { ...runEnv, - ASPNETCORE_ENVIRONMENT: "Development", - DOTNET_ENVIRONMENT: "Development", + ASPNETCORE_ENVIRONMENT: "Testing", + DOTNET_ENVIRONMENT: "Testing", PublicOrigin: BASE, Database__Provider: "Sqlite", Database__MigrateOnStartup: "true", - ConnectionStrings__Platform: `Data Source="${databasePath}"`, - Authentication__Google__ClientId: "e2e-client.apps.googleusercontent.com", - Authentication__Google__ClientSecret: "e2e-not-a-real-secret", Logging__LogLevel__Default: "Warning", - }); + }; + // Never inherit an operator's external test connection into browser fixtures. + for (const name of ["PROCODERJI_TEST_POSTGRES", "PROCODERJI_TEST_SQLSERVER", "PROCODERJI_TEST_MONGODB", "ConnectionStrings__Platform", "Database__Name"]) + delete backendEnv[name]; + backend = start("dotnet", [BACKEND_DLL, "--port", BACKEND_PORT, "--data-directory", tempDirectory], backendEnv); await waitUntilReady(backend, `${BACKEND_ORIGIN}/api/platform/health`, "ASP.NET Core API"); - console.log(`Isolated ASP.NET Core API ready at ${BACKEND_ORIGIN}.`); + console.log(`Isolated test-only ASP.NET Core browser host ready at ${BACKEND_ORIGIN}.`); } await run(process.execPath, [NEXT, "build"]); server = start(process.execPath, [NEXT, "start", "-p", PORT], runEnv); await waitUntilReady(server, BASE, "Next.js", 40); - await run(process.execPath, [path.join(ROOT, "e2e", "checks.mjs")], { ...runEnv, BASE }); + await run(process.execPath, [path.join(ROOT, "e2e", "checks.mjs")], { ...runEnv, BASE, E2E_BACKEND_ORIGIN: BACKEND_ORIGIN }); code = 0; } catch (error) { console.error(String(error.message ?? error)); diff --git a/public/templates/portfolio.md b/public/templates/portfolio.md new file mode 100644 index 0000000..555a0ee --- /dev/null +++ b/public/templates/portfolio.md @@ -0,0 +1,46 @@ +--- +name: "Riya Student" +headline: "Computer science student building useful web applications" +theme: simple +github: https://github.com/example +linkedin: https://linkedin.com/in/example-student +website: https://example.com +email: riya@example.com +--- + +# Portfolio + +## About + +I enjoy building small applications that help other students learn. Replace these sample details with your own before saving. + +## Skills + +- TypeScript +- C# +- PostgreSQL + +## Projects + +### Campus Notes +Link: https://github.com/example/campus-notes +Technologies: ["TypeScript", "Next.js"] +Description: Built a small notes catalogue with keyboard navigation. + +## Education + +### Example Institute +Degree: B.Tech in Computer Science +Period: 2023–2027 +Details: Studying databases and software engineering. + +## Experience + +### Student Computing Club +Role: Volunteer Developer +Period: 2025–2026 +Description: Helped classmates publish reviewed study material. + +## Achievements + +- Built a student project with a small team. diff --git a/shared/portfolio/README.md b/shared/portfolio/README.md index e99b0fc..f7c3350 100644 --- a/shared/portfolio/README.md +++ b/shared/portfolio/README.md @@ -1,11 +1,11 @@ # Portfolio rules -The rules for student portfolios, shared by the frontend and the backend. The decisions behind +Version **2** of the student-portfolio rules is shared by the frontend and backend. The decisions behind them are in [ADR 0002](../../docs/decisions/0002-student-portfolios.md). | File | Purpose | | --- | --- | -| `rules.json` | Limits, reserved names, blocked words, and the patterns for links, hosts and email. The only place these numbers live. | +| `rules.json` | Version, themes, publishing policy, limits, reserved names, blocked words and patterns. Canonical configuration for both runtimes. | | `fixtures.json` | Example usernames and portfolios with the problems each must produce. Both validators run all of them. | The TypeScript validator is `src/features/portfolio/validation.ts`; the .NET validator is @@ -14,10 +14,15 @@ gives quick feedback in the editor; the backend is the trusted check before anyt ## What is checked -A portfolio has fixed fields: `name`, `headline`, `about`, `skills`, `projects`, `education`, +A portfolio has fixed fields: `theme`, `name`, `headline`, `about`, `skills`, `projects`, `education`, `experience`, `achievements`, and `links` (`github`, `linkedin`, `website`, `email`). Anything else is rejected as an `unknown-field`. +`theme` is optional for older drafts and publications; omission renders Simple. The only values +are `simple` and `developer`. Theme selection changes presentation, not allowed content. The +[Markdown importer](../../docs/portfolio-markdown.md) produces the same data and validator +results as the form; its source file is never rendered publicly. + - **Modes.** A *draft* only has to be well-formed, so a student can save half-finished work. *Publish* also requires `name` and `headline`. Entries inside a list (a project's title, an experience's role, a skill) are required whenever the entry exists. @@ -47,6 +52,18 @@ System.Text.Json decodes strings and field names lazily, so the .NET validator r reports the same problem. A lone surrogate escape is not portable JSON (editors and many parsers reject it), so the `malformedText` fixtures list UTF-16 code units and each runtime builds the string itself. +## Publishing policy + +`rules.json` also defines `publishing.minimumAge: 18` and +`publishing.policyVersion: "2026-11-11"`. The editor requires explicit age/Terms/Privacy +confirmation before each publish; the backend validates the current version and stores the +acceptance version and server time privately. This is self-declaration, not age verification; +no birth date is collected. These values are not student-authored portfolio fields. + +Usernames stay fixed while the portfolio exists. Publishing, moderation, revisions and search +visibility are backend state rules, separate from field validation. See the +[API contract](../../docs/portfolio-api.md) and [workspace guide](../../docs/portfolio-workspace.md). + ## Limits Lengths count UTF-16 code units, the unit both JavaScript and .NET use, so one emoji counts as 2. @@ -97,5 +114,10 @@ Lengths count UTF-16 code units, the unit both JavaScript and .NET use, so one e parsers and locale-aware string functions on purpose, because JavaScript and .NET disagree about them. 4. Run `npm test` and `dotnet test backend/ProCoderJi.slnx --configuration Release`. Both must pass. +For a publishing policy change, update policy wording, shared configuration, client/backend +tests and rollout guidance together. The backend rejects old acceptance on public reads until +the student reviews and republishes under the current version. Do not silently accept on +their behalf. + The fixture file is plain ASCII: write any other character as a Unicode escape (a backslash, `u`, and four hex digits) so reviewers can see exactly which character it is. diff --git a/shared/portfolio/fixtures.json b/shared/portfolio/fixtures.json index cb4672a..3abfd90 100644 --- a/shared/portfolio/fixtures.json +++ b/shared/portfolio/fixtures.json @@ -256,6 +256,10 @@ } ], "portfolios": [ + { "name": "simple theme", "mode": "publish", "value": { "name": "Riya", "headline": "Student", "theme": "simple" }, "problems": [] }, + { "name": "developer theme", "mode": "draft", "value": { "theme": "developer" }, "problems": [] }, + { "name": "unknown theme", "mode": "draft", "value": { "theme": "custom" }, "problems": ["theme:invalid-format"] }, + { "name": "theme has wrong type", "mode": "draft", "value": { "theme": null }, "problems": ["theme:invalid-type"] }, { "name": "publish: minimal portfolio", "mode": "publish", diff --git a/shared/portfolio/rules.json b/shared/portfolio/rules.json index 2b79153..1fcb880 100644 --- a/shared/portfolio/rules.json +++ b/shared/portfolio/rules.json @@ -1,5 +1,7 @@ { - "version": 1, + "version": 2, + "themes": ["simple", "developer"], + "publishing": { "policyVersion": "2026-11-11", "minimumAge": 18 }, "requiredForPublish": ["name", "headline"], "maxItems": { "skills": 30, diff --git a/src/app/(student)/learn/portfolio/page.tsx b/src/app/(student)/learn/portfolio/page.tsx new file mode 100644 index 0000000..ff046a6 --- /dev/null +++ b/src/app/(student)/learn/portfolio/page.tsx @@ -0,0 +1,13 @@ +import { PortfolioEditor } from "@/features/portfolio/editor/portfolio-editor"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata = pageMetadata({ + title: "Portfolio Builder", + description: "Prepare your student portfolio, preview two themes, and manage your private draft and public page.", + path: "/learn/portfolio", + noindex: true, +}); + +export default function PortfolioEditorPage() { + return ; +} diff --git a/src/app/api/platform/[...path]/route.ts b/src/app/api/platform/[...path]/route.ts index d7f3cae..40c6737 100644 --- a/src/app/api/platform/[...path]/route.ts +++ b/src/app/api/platform/[...path]/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { isBackendPath, parseBackendOrigin } from "@/features/account/config"; +import { revalidateTag } from "next/cache"; +import { portfolioCacheTag } from "@/features/portfolio/public-model"; +import { validateUsername } from "@/features/portfolio/validation"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -94,6 +97,13 @@ async function proxy( } for (const cookie of upstream.headers.getSetCookie()) responseHeaders.append("set-cookie", cookie); + if (upstream.ok && request.method !== "GET" && ["portfolio/publish", "portfolio/unpublish", "portfolio"].includes(path)) { + const username = upstream.headers.get("x-portfolio-username"); + if (username && validateUsername(username).length === 0) { + try { revalidateTag(portfolioCacheTag(username), { expire: 0 }); } + catch { console.error("Portfolio cache invalidation was unavailable; fresh publication checks remain active."); } + } + } return new Response(upstream.body, { status: upstream.status, headers: responseHeaders, diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 1a89bc4..0a22f53 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -16,10 +16,10 @@ export default function NotFound() { The link may be old or mistyped. Try browsing the notes by branch and semester.

- + Browse notes - + Go to home
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 37bd748..1bae6da 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -1,16 +1,17 @@ import { EmailLink, H2, LegalLayout, Operator, P, UL, legalMetadata } from "@/components/legal/legal-layout"; -import { LEGAL } from "@/lib/legal"; +import { PORTFOLIO_MINIMUM_AGE, PORTFOLIO_POLICY_VERSION } from "@/features/portfolio/policy"; +import { LEGAL, policyEffectiveDates } from "@/lib/legal"; import { getYoutubeChannelId } from "@/lib/youtube"; export const metadata = legalMetadata( "Privacy Policy", - "How ProCoderJii Notes handles visitor data.", + "How ProCoderJii handles learning accounts, practice history, private portfolio drafts, and published portfolios.", "/privacy" ); export default function PrivacyPage() { return ( - +

This policy explains what information is handled when you use ProCoderJii (procoderji.com). The site is operated by . You can reach us at . @@ -35,6 +36,21 @@ export default function PrivacyPage() { create and maintain your learning account. ProCoderJii does not receive your Google password. We do not ask for your college, phone number, or resume to use practice. +

  • + Private portfolio drafts. When you explicitly save a draft while signed in, + we store it in the learning service's database with your account and any username you claim. + Drafts can contain your chosen name, headline, about text, skills, projects, education, + experience, achievements, links, optional public email address, and theme. A guest can try + the form, but that draft stays only in the open page and is lost when the page is left or + the account changes. +
  • +
  • + Publishing confirmation. Before publishing, you must confirm that you are + at least {PORTFOLIO_MINIMUM_AGE} and have read the portfolio Terms and Privacy Policy. We + privately store the accepted policy version (currently {PORTFOLIO_POLICY_VERSION}) and the + confirmation time with your portfolio. We do not collect a date of birth or identity + document for this step; it is your declaration, not independent age verification. +
  • Messages you send us. The contact form on the home page does not send anything by itself. It opens your own email app with a message addressed to {LEGAL.contactEmail}. If you @@ -50,14 +66,44 @@ export default function PrivacyPage() {
  • -

    2. Cookies and similar technologies

    +

    2. What publishing makes public

    +

    + Saving a draft does not publish it. When you choose Publish, we save a separate public copy of + the portfolio details and search preference you chose. Anyone who knows its /u/username address + can read that copy without an account. This includes any email address, education, experience, + project details, and links you put in it. Your Google sign-in email is not added to the public + page automatically. Later edits to your draft stay private until you publish again. +

    +

    + Search visibility is off by default: the page asks search engines not to index it and is left + out of our sitemap. You can opt in when publishing. This setting does not make the address + private or prevent visitors, search engines, or other services from copying its contents. + Search engines may take time to update, and we cannot recall copies held by third parties. + Share only information you want public; do not include another person's private information. +

    +

    + The portfolio has no fields for phone numbers, postal addresses, or dates of birth, and no + photo or other uploaded-file storage. Its avatar uses initials. Free-text fields are still + public if you publish them, so avoid putting sensitive information there. +

    + +

    3. Markdown import

    +

    + You can paste Markdown or select a UTF-8 text file up to 50 KiB to fill the form. Parsing happens + in your browser; the file itself is not uploaded, stored, or rendered as a public page. Review + the imported fields before explicitly saving a private draft or publishing. We do not send + your text to an AI service. If you use your own AI tool to prepare it, that tool has its own + data practices. +

    + +

    4. Cookies and similar technologies

    Browser storage keeps your learning progress on this device. If you sign in, essential authentication cookies maintain your session, and a request-verification cookie protects account updates. These features are used to provide the learning service. The site does not currently use analytics or advertising services.

    -

    3. Third-party content

    +

    5. Third-party content

    The “About” section of the home page displays an animation. The animation file is loaded from lottie.host (LottieFiles) and the player's WebAssembly file is loaded from the @@ -69,23 +115,49 @@ export default function PrivacyPage() { university websites) lead to other sites that have their own privacy practices.

    -

    4. Retention and deletion

    +

    6. Retention, unpublishing, and deletion

    Guest history remains in your browser until you delete it, clear browser data, or older sessions fall outside the 100-session limit. Cloud practice history keeps the most recent 100 sessions per account; older sessions are removed as new sessions are synced. The Account & data page lets you export - the history currently visible in your workspace and delete practice progress. Cloud deletion resets - cached account history on other devices when they next sync. To request deletion of the sign-in - account or other data associated with your email, contact from that email address. + the practice history currently visible in your workspace and delete practice progress. Cloud + deletion resets cached account history on other devices when they next sync. Clearing practice + history does not delete a portfolio or your Google sign-in account.

    -

    5. Future changes

    +

    + Your saved portfolio draft and current public copy remain until you delete the portfolio or + request removal. Unpublish removes public access while keeping your private draft and username. + New requests to the site no longer receive the published page after unpublishing, deletion, + or operator suspension. Published copies can remain in an internal cache for up to one hour, + but each new page request checks that the portfolio is still published before serving a cached + copy. Copies already saved outside the site may remain. +

    +

    + Delete portfolio in the editor removes the saved draft, current public copy, and username claim. + A claimed username cannot be changed while that portfolio exists. Suspended portfolios cannot + be deleted and recreated through the editor to bypass moderation; contact for + a review or a data-removal request. Account deletion is currently handled by contacting us, + rather than an account-deletion button. To request removal of the sign-in account and associated + portfolio or other data, write from the email address used for that account. +

    + +

    7. Reports and moderation

    +

    + The report link on a public portfolio opens your email app with the page address filled in. + Nothing is sent until you send the email. We receive the report and any details you include + through your email provider and use them to investigate and reply. The operator can suspend + a portfolio so it is no longer public. Restoring a suspended portfolio returns it to a private + draft; the student must confirm eligibility and publish again to make it public. +

    + +

    8. Future changes

    If we add analytics, advertising (such as Google AdSense), embedded videos, a newsletter or anything else that collects data or sets cookies, we will update this policy first, describe the third parties involved, and ask for your consent where the law requires it.

    -

    6. Your choices and rights

    +

    9. Your choices and rights

    Depending on where you live, you may have the right to ask what personal data we hold about you, to have it corrected or deleted, and to object to its use. We handle personal data in line with @@ -93,18 +165,21 @@ export default function PrivacyPage() { force. To make a request, email . We will respond within a reasonable time.

    -

    7. Children

    +

    10. Age requirement for public portfolios

    - The site is meant for students. We do not knowingly collect personal data from children through the - site. If you believe a child has sent us personal data, email us and we will delete it. + Public portfolio publishing is available only to people aged {PORTFOLIO_MINIMUM_AGE} or older + under our product policy. Guest practice does not require an account or a portfolio. We rely + on the publishing confirmation described above. If you believe an underage person has published + a portfolio or sent us personal information, contact so we can investigate and + remove the information where appropriate.

    -

    8. Changes to this policy

    +

    11. Changes to this policy

    We may update this policy. The effective date at the top shows when the current version took effect.

    -

    9. Contact

    +

    12. Contact

    Questions about this policy: {LEGAL.postalAddress ? <> · {LEGAL.postalAddress} : null}. diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 3f27161..4285b37 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -6,16 +6,18 @@ import { jobsSitemapRoutes, todayIso } from "@/lib/jobs"; import { legalSitemapRoutes } from "@/lib/legal"; import { readNote } from "@/lib/notes"; import { buildSitemapEntries, getSiteUrl } from "@/lib/site"; +import { getSearchVisiblePortfolios } from "@/features/portfolio/server"; -// Regenerate daily so /jobs drops out as soon as nothing is open. -export const revalidate = 86400; +// Fresh visibility keeps unpublished/suspended pages out of the discovery list. +export const dynamic = "force-dynamic"; -export default function sitemap(): MetadataRoute.Sitemap { +export default async function sitemap(): Promise { return buildSitemapEntries(getSiteUrl(), [ ...getSitemapRoutes((unit) => readNote(unit.path) !== null), ...guideSitemapRoutes(getGuides(), (g) => readNote(g.path) !== null), ...jobsSitemapRoutes(getJobs(), todayIso()), // Legal pages are listed only once the owner's required details are filled in. ...legalSitemapRoutes(), + ...await getSearchVisiblePortfolios(), ]); } diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index 0a020a1..c9fca13 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -1,23 +1,26 @@ import { EmailLink, Fill, H2, LegalLayout, Operator, P, UL, legalMetadata } from "@/components/legal/legal-layout"; +import { PORTFOLIO_MINIMUM_AGE, PORTFOLIO_POLICY_VERSION } from "@/features/portfolio/policy"; +import { policyEffectiveDates } from "@/lib/legal"; export const metadata = legalMetadata( "Terms of Use", - "Review the terms for using ProCoderJii Notes, including acceptable use, ownership of study material, and service limitations.", + "Terms for ProCoderJii learning resources and student portfolios, including publishing, content ownership, and moderation.", "/terms" ); export default function TermsPage() { return ( - +

    - By using ProCoderJii Notes (procoderji.com), operated by , you agree to + By using ProCoderJii (procoderji.com), operated by , you agree to these terms. If you do not agree, please do not use the site.

    1. What the site is

    - ProCoderJii Notes provides study notes and related learning material for engineering students, - alongside the ProCoderJii YouTube channel. It is provided for general educational use. + ProCoderJii provides study notes, placement practice, learning accounts, and optional student + portfolios, alongside the ProCoderJii YouTube channel. It is provided for general educational + use. A portfolio is a way to present your work; it does not guarantee employment or recruitment.

    2. Ownership of content

    @@ -33,39 +36,86 @@ export default function TermsPage() { parties belong to their owners. Where official syllabi or question papers exist, we link to the official source and do not claim them as ours.

    +

    + You retain ownership of the material you put in your portfolio. Publishing a portfolio does not + place your personal information or portfolio content under the repository's MIT License. + You give the operator permission to store and process your draft to provide the service and, + when you choose to publish, display your chosen public copy at its portfolio address. This + permission is limited to operating the portfolio service in accordance with your publishing + choices and the Privacy Policy. You must have the rights or permission needed for what you submit. +

    + +

    3. Portfolio publishing and your choices

    +

    + You must be at least {PORTFOLIO_MINIMUM_AGE} to publish a public portfolio. Publishing requires + Google sign-in, a username claim, and your confirmation that you meet this age requirement and + have read the portfolio Terms and Privacy Policy. We record the accepted policy version + (currently {PORTFOLIO_POLICY_VERSION}) and confirmation time. This is a self-declaration; + we do not collect a date of birth or independently verify your age through this step. +

    +

    + One portfolio is available per account. Your username stays fixed while that portfolio exists, + so choose it carefully before sharing its /u/username address. Saving a draft keeps it private. + Publishing creates a public copy, and later draft edits do not change that copy until you + publish again. Anyone with the address can view a published portfolio. Search indexing is + off by default and can be enabled when publishing; it is not an access restriction. +

    +

    + Unpublish hides the public page and keeps your draft and username. Delete portfolio removes + the saved draft, current public copy, and username claim. Search engines and other third parties + may keep copies after you unpublish or delete. Clearing practice history does not delete your + portfolio. Contact to request deletion of your sign-in account and associated data. +

    -

    3. Acceptable use

    +

    4. Acceptable use

    • Do not attempt to disrupt, overload or gain unauthorised access to the site.
    • -
    • Do not use automated tools to copy the site's content in bulk.
    • +
    • Do not impersonate another person or organisation or misrepresent the work you have done.
    • +
    • Do not publish phishing, scams, malicious links, harassment, or unlawful content.
    • +
    • Do not infringe copyright or publish another person's private information without permission.
    • +
    • Do not collect student contact details in bulk for spam or other abuse.
    • Do not use the site for anything unlawful.
    -

    4. Accuracy and no warranty

    +

    5. Reports and suspension

    +

    + Use Report this page on a portfolio, or email with the page address and the + reason for your report. The report link only prepares a message in your own email app. + The operator reviews reports and may suspend a portfolio that violates these terms or puts + students or the service at risk. Suspension hides the public page and prevents republishing + or deleting and recreating the portfolio through the editor to evade that decision. +

    +

    + Contact the operator to request a review of a suspension or removal of your data. If the + operator restores a suspended portfolio, it returns to a private draft. Restoration does + not publish it automatically; you must confirm eligibility and publish again. +

    + +

    6. Accuracy and no warranty

    We try to keep the content accurate, but it may contain mistakes or become out of date, for example when a university changes its syllabus. The site and its content are provided “as is” without warranties of any kind. See also the Disclaimer.

    -

    5. Links to other sites

    +

    7. Links to other sites

    The site may link to websites we do not control. We are not responsible for their content or practices.

    -

    6. Limitation of liability

    +

    8. Limitation of liability

    To the extent allowed by law, we are not liable for any loss arising from your use of, or inability to use, the site or its content, including loss from relying on the notes in an examination.

    -

    7. Copyright complaints

    +

    9. Copyright complaints

    If you believe content on the site infringes your rights, follow the steps on the Copyright & Takedown page.

    -

    8. Changes and governing law

    +

    10. Changes and governing law

    We may change these terms; the effective date at the top shows the current version. These terms are governed by the laws of . Subject to any rights you have under law that @@ -73,7 +123,7 @@ export default function TermsPage() { dispute arising from them.

    -

    9. Contact

    +

    11. Contact

    Questions about these terms:

    diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx new file mode 100644 index 0000000..4c276cd --- /dev/null +++ b/src/app/u/[username]/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PublicPortfolioPage } from "@/features/portfolio/public-page"; +import { publicPortfolioMetadata } from "@/features/portfolio/public-presentation"; +import { getPublishedPortfolio } from "@/features/portfolio/server"; + +// A fresh publication-state check runs before cached published content is displayed. +// Unpublished or suspended records must never survive as a cached HTML page. +export const dynamic = "force-dynamic"; + +type Props = { params: Promise<{ username: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const { username } = await params; + const portfolio = await getPublishedPortfolio(username); + if (!portfolio) return { title: "Portfolio not found", robots: { index: false, follow: false } }; + return publicPortfolioMetadata(portfolio); +} + +export default async function PortfolioPage({ params }: Props) { + const { username } = await params; + const portfolio = await getPublishedPortfolio(username); + if (!portfolio) notFound(); + return ; +} diff --git a/src/components/legal/legal-layout.tsx b/src/components/legal/legal-layout.tsx index d08774b..f2c15e5 100644 --- a/src/components/legal/legal-layout.tsx +++ b/src/components/legal/legal-layout.tsx @@ -58,7 +58,12 @@ export function EmailLink() { ); } -export function LegalLayout({ title, children }: { title: string; children: React.ReactNode }) { +export function LegalLayout({ title, children, effectiveDate }: { + title: string; + children: React.ReactNode; + /** A trusted publication date supplied by the policy page, never visitor input. */ + effectiveDate?: string; +}) { const missing = missingLegalFields(); return (
    @@ -92,7 +97,7 @@ export function LegalLayout({ title, children }: { title: string; children: Reac

    {title}

    Effective date:{" "} - {LEGAL.effectiveDate ? LEGAL.effectiveDate : } + {effectiveDate ?? (LEGAL.effectiveDate ? LEGAL.effectiveDate : )}

    {children} diff --git a/src/features/portfolio/client.test.ts b/src/features/portfolio/client.test.ts new file mode 100644 index 0000000..e51b2ce --- /dev/null +++ b/src/features/portfolio/client.test.ts @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createPortfolioClient, PortfolioApiError, readOwnPortfolio } from "./client.ts"; + +const identity = { userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", csrfToken: "current-token" }; +const revision = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const record = { username: "riya-student", data: { name: "Riya" }, searchVisible: false, status: "draft", revision, + createdAt: "2026-11-11T00:00:00.000Z", updatedAt: "2026-11-11T00:00:00.000Z", publishedAt: null }; +const response = (body: unknown, status = 200, headers?: HeadersInit) => new Response(JSON.stringify(body), { status, headers }); + +test("owner reads bind identity, avoid caching and whitelist validated portfolio fields", async () => { + const fetcher: typeof fetch = async (url, init) => { + assert.equal(url, "/api/platform/portfolio"); + assert.equal(new Headers(init?.headers).get("X-Account-Id"), identity.userId); + assert.equal(init?.cache, "no-store"); + assert.equal(init?.credentials, "same-origin"); + return response({ portfolio: { ...record, userId: "must-not-be-retained" }, publishingEnabled: true, policyVersion: "2026-11-11", minimumPublishingAge: 18 }); + }; + const workspace = await readOwnPortfolio(identity.userId, undefined, fetcher); + assert.equal(workspace.portfolio?.data.name, "Riya"); + assert.equal(workspace.policyVersion, "2026-11-11"); + assert.ok(!("userId" in workspace.portfolio!)); +}); + +test("drafts and publication send the exact revision, current antiforgery token and eligibility", async () => { + const requests: { path: string; body: unknown }[] = []; + const fetcher: typeof fetch = async (url, init) => { + assert.equal(init?.method, "POST"); + const headers = new Headers(init?.headers); + assert.equal(headers.get("X-Account-Id"), identity.userId); + assert.equal(headers.get("X-CSRF-Token"), identity.csrfToken); + requests.push({ path: String(url), body: JSON.parse(String(init?.body)) }); + return response({ portfolio: record }); + }; + const client = createPortfolioClient(identity, fetcher); + await client.saveDraft({ name: "Riya" }, false, null); + await client.publish(revision, "2026-11-11"); + assert.deepEqual(requests, [ + { path: "/api/platform/portfolio/draft", body: { data: { name: "Riya" }, searchVisible: false, revision: null } }, + { path: "/api/platform/portfolio/publish", body: { revision, eligibility: { atLeast18: true, policyVersion: "2026-11-11" } } }, + ]); +}); + +test("stale writes and rate limits retain server codes for recovery without retrying writes", async () => { + let calls = 0; + const stale: typeof fetch = async () => { calls++; return response({ code: "stale-revision", error: "Refresh before trying again." }, 409); }; + await assert.rejects(createPortfolioClient(identity, stale).saveDraft({}, false, revision), (error: unknown) => error instanceof PortfolioApiError && error.status === 409 && error.code === "stale-revision"); + assert.equal(calls, 1); + const limited: typeof fetch = async () => response({ code: "rate-limited", error: "Wait before trying again." }, 429, { "Retry-After": "37" }); + await assert.rejects(createPortfolioClient(identity, limited).claimUsername("riya-student", revision), (error: unknown) => error instanceof PortfolioApiError && error.retryAfterSeconds === 37); +}); + +test("malformed saved data and private metadata cannot become an editor record", async () => { + const fetcher: typeof fetch = async () => response({ portfolio: { ...record, data: { links: { website: "javascript:alert(1)" } } }, publishingEnabled: true }); + await assert.rejects(readOwnPortfolio(identity.userId, undefined, fetcher), (error: unknown) => error instanceof PortfolioApiError && error.code === "invalid-response"); +}); + +test("missing antiforgery or invalid usernames are rejected before a network call", async () => { + const fetcher: typeof fetch = async () => { throw new Error("The network must not be called."); }; + await assert.rejects(createPortfolioClient({ ...identity, csrfToken: null }, fetcher).saveDraft({}, false, null), (error: unknown) => error instanceof PortfolioApiError && error.code === "sign-in-required"); + await assert.rejects(createPortfolioClient(identity, fetcher).checkUsername("../admin"), (error: unknown) => error instanceof PortfolioApiError && error.problems[0].path === "username"); +}); + +test("portfolio deletion is an authenticated revision-bound DELETE with an empty success body", async () => { + const fetcher: typeof fetch = async (url, init) => { + assert.equal(url, "/api/platform/portfolio"); + assert.equal(init?.method, "DELETE"); + assert.deepEqual(JSON.parse(String(init?.body)), { revision }); + assert.equal(new Headers(init?.headers).get("X-CSRF-Token"), identity.csrfToken); + return new Response(null, { status: 204 }); + }; + await createPortfolioClient(identity, fetcher).delete(revision); +}); diff --git a/src/features/portfolio/client.ts b/src/features/portfolio/client.ts new file mode 100644 index 0000000..64f731e --- /dev/null +++ b/src/features/portfolio/client.ts @@ -0,0 +1,157 @@ +import type { PortfolioData, PortfolioProblem } from "./types.ts"; +import { validatePortfolio, validateUsername } from "./validation.ts"; + +export interface OwnedPortfolio { + username: string | null; + data: PortfolioData; + searchVisible: boolean; + status: "draft" | "published" | "suspended"; + revision: string; + createdAt: string; + updatedAt: string; + publishedAt: string | null; +} + +export interface PortfolioWorkspace { + portfolio: OwnedPortfolio | null; + publishingEnabled: boolean; + policyVersion: string | null; + minimumPublishingAge: number | null; +} + +export interface PortfolioIdentity { + userId: string; + csrfToken: string | null; +} + +export class PortfolioApiError extends Error { + readonly status: number; + readonly code: string; + readonly problems: PortfolioProblem[]; + readonly retryAfterSeconds: number | null; + + constructor(message: string, status: number, code: string, problems: PortfolioProblem[] = [], retryAfterSeconds: number | null = null) { + super(message); + this.name = "PortfolioApiError"; + this.status = status; + this.code = code; + this.problems = problems; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +type Fetcher = typeof fetch; +const ROOT = "/api/platform/portfolio"; +const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function invalidResponse(): never { + throw new PortfolioApiError("The portfolio response could not be read. Refresh and try again.", 502, "invalid-response"); +} + +function parseRecord(value: unknown): OwnedPortfolio { + if (!object(value) || !object(value.data) || validatePortfolio(value.data, "draft").length !== 0 + || typeof value.revision !== "string" || !GUID.test(value.revision) + || typeof value.searchVisible !== "boolean" + || typeof value.status !== "string" || !["draft", "published", "suspended"].includes(value.status) + || typeof value.createdAt !== "string" || typeof value.updatedAt !== "string" + || (value.publishedAt !== null && typeof value.publishedAt !== "string") + || (value.username !== null && validateUsername(value.username).length !== 0)) invalidResponse(); + return { + username: value.username as string | null, + data: value.data as PortfolioData, + searchVisible: value.searchVisible, + status: value.status as OwnedPortfolio["status"], + revision: value.revision, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + publishedAt: value.publishedAt as string | null, + }; +} + +function signalWithTimeout(signal?: AbortSignal) { + const timeout = AbortSignal.timeout(15_000); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +async function request(path: string, userId: string, options: RequestInit, fetcher: Fetcher): Promise { + try { + const headers = new Headers(options.headers); + headers.set("X-Account-Id", userId); + const response = await fetcher(ROOT + path, { + ...options, + cache: "no-store", + credentials: "same-origin", + redirect: "error", + headers, + }); + if (response.status === 204) return null; + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const code = object(body) && typeof body.code === "string" ? body.code : "unavailable"; + const message = object(body) && typeof body.error === "string" ? body.error : "Your portfolio could not be updated. Try again."; + const problems = object(body) && Array.isArray(body.problems) + ? body.problems.filter((problem): problem is PortfolioProblem => object(problem) && typeof problem.path === "string" && typeof problem.code === "string") : []; + const retry = Number(response.headers.get("Retry-After")); + throw new PortfolioApiError(message, response.status, code, problems, Number.isFinite(retry) && retry > 0 ? retry : null); + } + return body; + } catch (cause) { + if (cause instanceof PortfolioApiError) throw cause; + if (cause instanceof DOMException && cause.name === "AbortError") throw cause; + throw new PortfolioApiError("The account service is unavailable. Your edits are still here; try again before leaving this page.", 503, "unavailable"); + } +} + +/** Read only the current authenticated account's private workspace. */ +export async function readOwnPortfolio(userId: string, signal?: AbortSignal, fetcher: Fetcher = fetch): Promise { + const body = await request("", userId, { signal: signalWithTimeout(signal) }, fetcher); + if (!object(body) || typeof body.publishingEnabled !== "boolean" || !("portfolio" in body)) invalidResponse(); + return { + portfolio: body.portfolio === null ? null : parseRecord(body.portfolio), + publishingEnabled: body.publishingEnabled, + policyVersion: typeof body.policyVersion === "string" && body.policyVersion.length > 0 ? body.policyVersion : null, + minimumPublishingAge: typeof body.minimumPublishingAge === "number" ? body.minimumPublishingAge : null, + }; +} + +/** Browser mutations carry the account's current antiforgery token and expected revision. */ +export function createPortfolioClient(identity: PortfolioIdentity, fetcher: Fetcher = fetch) { + async function write(path: string, body: unknown, method = "POST") { + if (!identity.userId || !identity.csrfToken) + throw new PortfolioApiError("Refresh your account session before saving.", 401, "sign-in-required"); + return request(path, identity.userId, { + method, + headers: { "Content-Type": "application/json", "X-CSRF-Token": identity.csrfToken }, + body: JSON.stringify(body), + signal: signalWithTimeout(), + }, fetcher); + } + + async function mutation(path: string, body: unknown) { + const response = await write(path, body); + if (!object(response)) invalidResponse(); + return parseRecord(response.portfolio); + } + + return { + saveDraft: (data: PortfolioData, searchVisible: boolean, revision: string | null) => mutation("/draft", { data, searchVisible, revision }), + claimUsername: (username: string, revision: string | null) => mutation("/username", { username, revision }), + async checkUsername(username: string) { + const problems = validateUsername(username); + if (problems.length) throw new PortfolioApiError("Choose a valid username before checking availability.", 400, "validation-failed", problems); + const body = await request(`/username?username=${encodeURIComponent(username)}`, identity.userId, { signal: signalWithTimeout() }, fetcher); + if (!object(body) || body.username !== username || typeof body.available !== "boolean") invalidResponse(); + return body.available; + }, + publish: (revision: string, policyVersion: string) => mutation("/publish", { revision, eligibility: { atLeast18: true, policyVersion } }), + unpublish: (revision: string) => mutation("/unpublish", { revision }), + async delete(revision: string) { + const result = await write("", { revision }, "DELETE"); + if (result !== null) invalidResponse(); + }, + }; +} diff --git a/src/features/portfolio/editor/portfolio-editor.tsx b/src/features/portfolio/editor/portfolio-editor.tsx new file mode 100644 index 0000000..f768faf --- /dev/null +++ b/src/features/portfolio/editor/portfolio-editor.tsx @@ -0,0 +1,430 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { ArrowDown, ArrowUp, Check, Copy, Eye, Plus, Save, Trash2 } from "lucide-react"; +import { useProgress } from "@/features/progress/progress-provider"; +import { primaryAction, secondaryAction } from "@/features/practice/client/styles"; +import { CONTACT_EMAIL } from "@/lib/contact"; +import { createPortfolioClient, PortfolioApiError, readOwnPortfolio, type OwnedPortfolio, type PortfolioWorkspace } from "../client"; +import { parsePortfolioMarkdown, PORTFOLIO_MARKDOWN_AI_PROMPT, PORTFOLIO_MARKDOWN_MAX_BYTES } from "../markdown"; +import { PORTFOLIO_RULES as RULES } from "../rules"; +import type { PortfolioData, PortfolioProblem } from "../types"; +import { validatePortfolio, validateUsername } from "../validation"; +import { PortfolioPreview } from "../themes/portfolio-preview"; + +const card = "rounded-2xl border border-slate-200 bg-white p-5 sm:p-6"; +const input = "mt-2 block min-h-11 w-full min-w-0 rounded-lg border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 outline-offset-4 placeholder:text-slate-400 focus-visible:outline-2 focus-visible:outline-slate-900 disabled:bg-slate-50 disabled:text-slate-500"; +const smallAction = "inline-flex min-h-11 min-w-11 items-center justify-center rounded-lg border border-slate-300 bg-white p-2 text-slate-700 hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 disabled:cursor-not-allowed disabled:opacity-40"; +const initialData: PortfolioData = {}; +const fieldId = (path: string) => `portfolio-${path.replace(/[^a-zA-Z0-9-]/g, "-")}`; +const fingerprint = (data: PortfolioData, searchVisible: boolean) => JSON.stringify({ data, searchVisible }); + +function problemMessage(problem: PortfolioProblem) { + const messages: Record = { + required: "Complete this field.", "invalid-type": "Enter a value in the expected format.", + "unknown-field": "This field is not supported.", "too-short": "Enter at least 3 characters.", + "too-long": "Shorten this value to the stated limit.", "too-many": "Remove entries above the stated limit.", + "invalid-characters": "Remove hidden characters or unsupported line breaks.", + "html-like": "Use plain text without HTML or JSX.", "invalid-format": "Use lowercase letters, digits and single hyphens.", + "invalid-link": "Enter a complete https:// address without spaces or credentials.", + "invalid-host": "Use the matching GitHub or LinkedIn website.", "invalid-email": "Enter an email address, without mailto:.", + reserved: "This username is reserved.", "blocked-word": "Choose another username.", + }; + return messages[problem.code] ?? "Check this value before continuing."; +} + +interface FieldProps { + path: string; label: string; value: string; onChange: (value: string) => void; + problems: PortfolioProblem[]; maxLength?: number; hint?: string; multiline?: boolean; + type?: "text" | "url" | "email"; disabled?: boolean; +} + +function Field({ path, label, value, onChange, problems, maxLength, hint, multiline, type = "text", disabled }: FieldProps) { + const error = problems.find(problem => problem.path === path); + const id = fieldId(path); + const describedBy = [hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(" ") || undefined; + const attributes = { + id, value, maxLength, disabled, "aria-invalid": error ? true as const : undefined, "aria-describedby": describedBy, + className: `${input} ${error ? "border-rose-600" : ""}`, + onChange: (event: React.ChangeEvent) => onChange(event.target.value), + }; + return
    + + {hint &&

    {hint}

    } + {multiline ?