From 831c1dc0a8202edfe52cfc7c808410cfe10a9823 Mon Sep 17 00:00:00 2001 From: eeminionn <109454414+eeminionn@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:49:56 -0400 Subject: [PATCH] feat: centralize student submissions --- .env.example | 1 + .github/workflows/supabase.yml | 29 +- README.md | 42 ++- docs/BETA_ROLLOUT.md | 7 +- supabase/functions/_shared/github-paths.ts | 38 +++ .../functions/_shared/github-submissions.ts | 266 +++++++++++------- ...07300007_central_submission_repository.sql | 49 ++++ supabase/tests/rls.sql | 96 ++++++- v2/src/pages/AccessPage.tsx | 2 +- v2/src/pages/DashboardPage.tsx | 43 ++- v2/src/pages/MentorPage.tsx | 21 +- v2/src/state/classroom-context.tsx | 13 +- v2/src/styles.css | 14 + v2/src/types.ts | 9 +- v2/tests/github-submission-path.test.ts | 37 +++ v2/tests/security-contract.test.ts | 20 +- 16 files changed, 540 insertions(+), 147 deletions(-) create mode 100644 supabase/functions/_shared/github-paths.ts create mode 100644 supabase/migrations/202607300007_central_submission_repository.sql create mode 100644 v2/tests/github-submission-path.test.ts diff --git a/.env.example b/.env.example index fe52542..3cf1828 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,4 @@ JUDGE0_API_KEY= # SUBMISSION_REPOSITORY_TOKEN because GitHub reserves the GITHUB_ prefix. GITHUB_REPOSITORY_TOKEN=github_pat_REPLACE_ME GITHUB_REPOSITORY_OWNER=eeminionn +GITHUB_REPOSITORY_NAME=tomatin-code-lab-resoluciones diff --git a/.github/workflows/supabase.yml b/.github/workflows/supabase.yml index 1fbfa86..d367deb 100644 --- a/.github/workflows/supabase.yml +++ b/.github/workflows/supabase.yml @@ -25,6 +25,7 @@ jobs: PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} SUBMISSION_REPOSITORY_TOKEN: ${{ secrets.SUBMISSION_REPOSITORY_TOKEN }} SUBMISSION_REPOSITORY_OWNER: ${{ vars.SUBMISSION_REPOSITORY_OWNER }} + SUBMISSION_REPOSITORY_NAME: ${{ vars.SUBMISSION_REPOSITORY_NAME }} steps: - name: Checkout uses: actions/checkout@v6 @@ -60,13 +61,39 @@ jobs: - name: Apply database migrations run: supabase db push + - name: Verify central submission repository + run: | + owner="${SUBMISSION_REPOSITORY_OWNER:-eeminionn}" + repository="${SUBMISSION_REPOSITORY_NAME:-tomatin-code-lab-resoluciones}" + status="$( + curl --silent --show-error \ + --output repository.json \ + --write-out "%{http_code}" \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $SUBMISSION_REPOSITORY_TOKEN" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "https://api.github.com/repos/$owner/$repository" + )" + test "$status" = "200" || { + echo "::error::The submission token cannot access $owner/$repository (HTTP $status)." + exit 1 + } + jq --exit-status \ + '.private == true and .permissions.push == true' \ + repository.json >/dev/null || { + echo "::error::$owner/$repository must be private and writable by the submission token." + exit 1 + } + - name: Configure repository sync run: | owner="${SUBMISSION_REPOSITORY_OWNER:-eeminionn}" + repository="${SUBMISSION_REPOSITORY_NAME:-tomatin-code-lab-resoluciones}" supabase secrets set \ --project-ref "$PROJECT_ID" \ GITHUB_REPOSITORY_TOKEN="$SUBMISSION_REPOSITORY_TOKEN" \ - GITHUB_REPOSITORY_OWNER="$owner" + GITHUB_REPOSITORY_OWNER="$owner" \ + GITHUB_REPOSITORY_NAME="$repository" - name: Deploy Edge Functions run: supabase functions deploy --project-ref "$PROJECT_ID" diff --git a/README.md b/README.md index 467c141..bc47085 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ II con veinte misiones originales. backend cuando está disponible. - JavaScript en Web Worker, Python con Pyodide y ejecución remota con Judge0. - Supabase Auth exclusivamente con GitHub, Postgres, Realtime, RLS, - invitaciones de un uso y roles `owner`, `mentor` y `student`. + invitaciones configurables y roles `owner`, `mentor` y `student`. - Asignaciones por estudiante, revisión, reentrega, XP idempotente y ranking basado únicamente en tareas aprobadas. -- Un repositorio privado por estudiante bajo `eeminionn`, con una carpeta por - misión y actualización automática al entregar. +- Un único repositorio privado de resoluciones bajo `eeminionn`, con carpetas + aisladas por estudiante y actualización automática al entregar. - Panel del mentor con métricas reales, matriz filtrable, actividad reciente, detalle por estudiante, historial de código guardado y vista estudiante de solo lectura. @@ -79,24 +79,36 @@ VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... a las Edge Functions. `JUDGE0_URL` y `JUDGE0_API_KEY` son opcionales; por defecto se usa la instancia pública de Judge0 CE. -## Repositorios de estudiantes +## Entregas en GitHub -La aplicación crea un repositorio privado -`tomatin-code-lab-USUARIO-ID` bajo `eeminionn`, invita al estudiante como -colaborador y guarda cada entrega en: +La aplicación usa un único repositorio privado +`eeminionn/tomatin-code-lab-resoluciones` y guarda cada entrega en: ```text -misiones/slug-de-la-mision/solucion.js -misiones/slug-de-la-mision/solucion.py -misiones/slug-de-la-mision/solucion.cpp +resoluciones/usuario-id-corto/misiones/slug-de-la-mision/solucion.js +resoluciones/usuario-id-corto/misiones/slug-de-la-mision/solucion.py +resoluciones/usuario-id-corto/misiones/slug-de-la-mision/solucion.cpp ``` +Los estudiantes no son colaboradores del repositorio central. GitHub concede +permisos por repositorio, no por carpeta; invitarlos permitiría que vieran +respuestas ajenas. El estudiante consulta su código, historial y estado de +sincronización desde el aula, mientras que el mentor puede abrir la carpeta +privada desde `/admin`. Los commits usan el correo `noreply` verificado del +estudiante para conservar la atribución cuando GitHub puede asociarla. + Para activarlo, crea un fine-grained personal access token de GitHub cuyo -resource owner sea `eeminionn`, con acceso a todos los repositorios y permisos -`Administration: Read and write` y `Contents: Read and write`. Guárdalo como el -secret de Actions `SUBMISSION_REPOSITORY_TOKEN` y vuelve a ejecutar `Deploy -Supabase`. La variable opcional `SUBMISSION_REPOSITORY_OWNER` permite cambiar -el propietario; si no existe, se usa `eeminionn`. +resource owner sea `eeminionn`, con acceso al repositorio central y permisos +`Administration: Read and write` y `Contents: Read and write`. Guárdalo como +el secret de Actions `SUBMISSION_REPOSITORY_TOKEN` y vuelve a ejecutar `Deploy +Supabase`. Las variables opcionales `SUBMISSION_REPOSITORY_OWNER` y +`SUBMISSION_REPOSITORY_NAME` permiten cambiar el propietario y el nombre; sus +valores por defecto son `eeminionn` y `tomatin-code-lab-resoluciones`. + +Los repositorios individuales creados por versiones anteriores no se eliminan +automáticamente. La siguiente sesión o entrega migra el registro del estudiante +al almacenamiento central; los repositorios anteriores pueden archivarse +después de comprobar la copia y definir una política de conservación. ## Verificación diff --git a/docs/BETA_ROLLOUT.md b/docs/BETA_ROLLOUT.md index 7b119ab..be97904 100644 --- a/docs/BETA_ROLLOUT.md +++ b/docs/BETA_ROLLOUT.md @@ -7,12 +7,12 @@ versión estable de la raíz no se reemplaza todavía. ## Barreras automáticas - CI ejecuta las pruebas heredadas, TypeScript, Edge Functions, las 60 - soluciones de referencia, el build y nueve recorridos Playwright. + soluciones de referencia, el build y los recorridos Playwright. - pgTAP aplica todas las migraciones desde cero y verifica RLS, privacidad y contratos de versión. - GitHub Pages falla si faltan las variables públicas de Supabase. - Supabase falla si faltan los secretos de despliegue o de sincronización con - los repositorios estudiantiles. + el repositorio privado de resoluciones. - Después de publicar, Pages comprueba que `/beta/` responda y sirva la versión 3.0 desde la ruta correcta. @@ -23,7 +23,8 @@ versión estable de la raíz no se reemplaza todavía. - [ ] Abrir la Misión 01 en dos acciones y comprenderla sin revelar pistas. - [ ] Guardar código distinto en JavaScript, Python y C++ sin perder borradores. - [ ] Ejecutar al menos un caso correcto y uno incorrecto en cada lenguaje. -- [ ] Entregar una misión y comprobar su actualización en el repositorio privado. +- [ ] Entregar una misión y comprobar que cada estudiante actualice únicamente + su carpeta dentro del repositorio privado central. - [ ] Revisar desde `/admin`, comentar una línea y solicitar cambios. - [ ] Confirmar que la reentrega usa la versión actual sin borrar el historial. - [ ] Aprobar una entrega y comprobar que el XP se emita una sola vez. diff --git a/supabase/functions/_shared/github-paths.ts b/supabase/functions/_shared/github-paths.ts new file mode 100644 index 0000000..7fff1ef --- /dev/null +++ b/supabase/functions/_shared/github-paths.ts @@ -0,0 +1,38 @@ +import type { Language } from "./types.ts"; + +const FILE_NAMES: Record = { + javascript: "solucion.js", + python: "solucion.py", + cpp: "solucion.cpp", +}; + +function safeSegment(value: string, fallback: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[._-]+|[._-]+$/g, "") + .slice(0, 80) || fallback + ); +} + +export function studentSubmissionFolder( + githubLogin: string, + userId: string, +): string { + const login = safeSegment(githubLogin, "estudiante"); + const shortId = + userId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "sin-id"; + return `${login}-${shortId}`; +} + +export function submissionFilePath(input: { + githubLogin: string; + userId: string; + missionSlug: string; + language: Language; +}): string { + const student = studentSubmissionFolder(input.githubLogin, input.userId); + const mission = safeSegment(input.missionSlug, "mision"); + return `resoluciones/${student}/misiones/${mission}/${FILE_NAMES[input.language]}`; +} diff --git a/supabase/functions/_shared/github-submissions.ts b/supabase/functions/_shared/github-submissions.ts index c00fe89..a51db1e 100644 --- a/supabase/functions/_shared/github-submissions.ts +++ b/supabase/functions/_shared/github-submissions.ts @@ -1,10 +1,16 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { Language } from "./types.ts"; +import { + studentSubmissionFolder, + submissionFilePath, +} from "./github-paths.ts"; const GITHUB_API = "https://api.github.com"; const GITHUB_API_VERSION = "2026-03-10"; const DEFAULT_OWNER = "eeminionn"; -const DEFAULT_PREFIX = "tomatin-code-lab-"; +const DEFAULT_REPOSITORY_NAME = "tomatin-code-lab-resoluciones"; +const REPOSITORY_DESCRIPTION = + "Entregas privadas de estudiantes de Tomatin Code Lab, administradas por el backend del aula."; interface ProfileRow { id: string; @@ -23,7 +29,14 @@ interface RepositoryRow { html_url: string; visibility: "private"; status: "ready" | "error"; - collaborator_status: "pending" | "invited" | "active" | "error"; + collaborator_status: + | "pending" + | "invited" + | "active" + | "not_required" + | "error"; + storage_mode: "legacy_per_student" | "central"; + student_path: string | null; last_synced_at: string | null; last_error: string | null; } @@ -42,17 +55,20 @@ interface GitHubRepository { interface GitHubContent { sha: string; - html_url: string | null; } interface GitHubContentCommit { - content: GitHubContent | null; commit: { sha: string; html_url: string | null; }; } +interface GitHubAuthor { + name: string; + email: string; +} + export interface RepositorySyncResult { status: "synced" | "pending_setup" | "failed" | "not_applicable"; message: string; @@ -93,8 +109,9 @@ function githubConfiguration() { token: Deno.env.get("GITHUB_REPOSITORY_TOKEN")?.trim() ?? "", owner: Deno.env.get("GITHUB_REPOSITORY_OWNER")?.trim() || DEFAULT_OWNER, - prefix: - Deno.env.get("GITHUB_REPOSITORY_PREFIX")?.trim() || DEFAULT_PREFIX, + repositoryName: + Deno.env.get("GITHUB_REPOSITORY_NAME")?.trim() || + DEFAULT_REPOSITORY_NAME, }; } @@ -130,14 +147,6 @@ function safeError(error: unknown): string { return message.replace(/\s+/g, " ").slice(0, 1000); } -function repositoryName(prefix: string, login: string, userId: string): string { - return `${prefix}${login}-${userId.slice(0, 8)}` - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^[._-]+|[._-]+$/g, "") - .slice(0, 100); -} - function encodeGitHubPath(path: string): string { return path.split("/").map(encodeURIComponent).join("/"); } @@ -162,6 +171,8 @@ function toFrontendRepository(repository: RepositoryRow) { visibility: repository.visibility, status: repository.status, collaboratorStatus: repository.collaborator_status, + storageMode: repository.storage_mode, + studentPath: repository.student_path ?? undefined, lastSyncedAt: repository.last_synced_at ?? undefined, lastError: repository.last_error ?? undefined, }; @@ -234,6 +245,66 @@ async function findGitHubRepository( } } +async function upsertGitHubContent(input: { + token: string; + owner: string; + repository: string; + path: string; + branch: string; + message: string; + content: string; + author?: GitHubAuthor; +}): Promise { + const encodedOwner = encodeURIComponent(input.owner); + const encodedRepository = encodeURIComponent(input.repository); + const encodedPath = encodeGitHubPath(input.path); + + for (let attempt = 0; attempt < 3; attempt += 1) { + let currentFile: GitHubContent | null = null; + try { + const contentResponse = await githubFetch( + input.token, + `/repos/${encodedOwner}/${encodedRepository}/contents/${encodedPath}?ref=${encodeURIComponent(input.branch)}`, + ); + currentFile = (await contentResponse.json()) as GitHubContent; + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 404) { + throw error; + } + } + + try { + const response = await githubFetch( + input.token, + `/repos/${encodedOwner}/${encodedRepository}/contents/${encodedPath}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: input.message, + content: encodeBase64(input.content), + branch: input.branch, + ...(currentFile?.sha ? { sha: currentFile.sha } : {}), + ...(input.author ? { author: input.author } : {}), + }), + }, + ); + return (await response.json()) as GitHubContentCommit; + } catch (error) { + if ( + !(error instanceof GitHubApiError) || + error.status !== 409 || + attempt === 2 + ) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 150 * 2 ** attempt)); + } + } + + throw new Error("No se pudo actualizar la entrega después de reintentar."); +} + async function upsertRepository( admin: SupabaseClient, row: Omit, @@ -264,19 +335,25 @@ export async function provisionStudentRepository( const { profile, classId } = context; const current = await storedRepository(admin, classId, userId); + const config = githubConfiguration(); + const studentPath = studentSubmissionFolder( + profile.github_login ?? "estudiante", + userId, + ); if ( current?.status === "ready" && - (current.collaborator_status === "active" || - current.collaborator_status === "invited") + current.storage_mode === "central" && + current.owner_login.toLowerCase() === config.owner.toLowerCase() && + current.repository_name === config.repositoryName && + current.student_path === studentPath ) { return { status: "ready", - message: "Repositorio listo.", + message: "Carpeta privada de entregas lista.", repository: toFrontendRepository(current), }; } - const config = githubConfiguration(); if (!config.token) { return { status: "pending_setup", @@ -295,10 +372,7 @@ export async function provisionStudentRepository( ); } - const name = - current?.repository_name ?? - repositoryName(config.prefix, profile.github_login, userId); - const description = `Private Tomatin Code Lab submissions for @${profile.github_login}.`; + const name = config.repositoryName; let githubRepository = await findGitHubRepository( config.token, config.owner, @@ -306,43 +380,58 @@ export async function provisionStudentRepository( ); if (!githubRepository) { - const createResponse = await githubFetch(config.token, "/user/repos", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + try { + const createResponse = await githubFetch(config.token, "/user/repos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description: REPOSITORY_DESCRIPTION, + private: true, + auto_init: true, + has_issues: false, + has_projects: false, + has_wiki: false, + has_discussions: false, + }), + }); + githubRepository = (await createResponse.json()) as GitHubRepository; + } catch (error) { + if (!(error instanceof GitHubApiError) || error.status !== 422) { + throw error; + } + githubRepository = await findGitHubRepository( + config.token, + config.owner, name, - description, - private: true, - auto_init: true, - has_issues: false, - has_projects: false, - has_wiki: false, - has_discussions: false, - }), - }); - githubRepository = (await createResponse.json()) as GitHubRepository; - } else if ( - !githubRepository.private || - githubRepository.description !== description - ) { + ); + if (!githubRepository) throw error; + } + } + + if (!githubRepository.private) { throw new Error( - `El repositorio ${config.owner}/${name} ya existe y no pertenece a este curso.`, + `El repositorio ${config.owner}/${name} debe ser privado.`, ); } - let collaboratorStatus: RepositoryRow["collaborator_status"] = "pending"; - let collaboratorError: string | null = null; - try { - const collaboratorResponse = await githubFetch( + if (githubRepository.description !== REPOSITORY_DESCRIPTION) { + const updateResponse = await githubFetch( config.token, - `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(profile.github_login)}`, - { method: "PUT" }, + `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(name)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + description: REPOSITORY_DESCRIPTION, + has_issues: false, + has_projects: false, + has_wiki: false, + has_discussions: false, + }), + }, ); - collaboratorStatus = - collaboratorResponse.status === 201 ? "invited" : "active"; - } catch (error) { - collaboratorStatus = "error"; - collaboratorError = safeError(error); + githubRepository = (await updateResponse.json()) as GitHubRepository; } const saved = await upsertRepository(admin, { @@ -352,20 +441,17 @@ export async function provisionStudentRepository( repository_name: githubRepository.name, html_url: githubRepository.html_url, visibility: "private", - status: collaboratorStatus === "error" ? "error" : "ready", - collaborator_status: collaboratorStatus, + status: "ready", + collaborator_status: "not_required", + storage_mode: "central", + student_path: studentPath, last_synced_at: current?.last_synced_at ?? null, - last_error: collaboratorError, + last_error: null, }); return { status: "ready", - message: - collaboratorStatus === "invited" - ? "Repositorio creado; la invitación de GitHub está pendiente." - : collaboratorStatus === "active" - ? "Repositorio listo." - : "Repositorio creado, pero la invitación debe reintentarse.", + message: "Carpeta privada de entregas lista.", repository: toFrontendRepository(saved), }; } @@ -430,12 +516,12 @@ export async function syncSubmissionToGitHub( }; } - const fileNames: Record = { - javascript: "solucion.js", - python: "solucion.py", - cpp: "solucion.cpp", - }; - const filePath = `misiones/${mission.slug}/${fileNames[input.language]}`; + const filePath = submissionFilePath({ + githubLogin: context.profile.github_login ?? "estudiante", + userId: input.userId, + missionSlug: mission.slug, + language: input.language, + }); let repository: RepositoryRow | null = null; try { @@ -452,7 +538,7 @@ export async function syncSubmissionToGitHub( return { status: "pending_setup", message: - "La entrega quedó guardada en el aula; el repositorio se activará al conectar GitHub.", + "La entrega quedó guardada en el aula; la carpeta se sincronizará cuando la conexión privada esté disponible.", path: filePath, }; } @@ -473,7 +559,6 @@ export async function syncSubmissionToGitHub( const config = githubConfiguration(); const encodedOwner = encodeURIComponent(repository.owner_login); const encodedName = encodeURIComponent(repository.repository_name); - const encodedPath = encodeGitHubPath(filePath); const repositoryResponse = await githubFetch( config.token, `/repos/${encodedOwner}/${encodedName}`, @@ -481,17 +566,6 @@ export async function syncSubmissionToGitHub( const githubRepository = (await repositoryResponse.json()) as GitHubRepository; - let currentFile: GitHubContent | null = null; - try { - const contentResponse = await githubFetch( - config.token, - `/repos/${encodedOwner}/${encodedName}/contents/${encodedPath}?ref=${encodeURIComponent(githubRepository.default_branch)}`, - ); - currentFile = (await contentResponse.json()) as GitHubContent; - } catch (error) { - if (!(error instanceof GitHubApiError) || error.status !== 404) throw error; - } - const author = context.profile.github_id && context.profile.github_login ? { @@ -499,26 +573,17 @@ export async function syncSubmissionToGitHub( email: `${context.profile.github_id}+${context.profile.github_login}@users.noreply.github.com`, } : undefined; - const updateResponse = await githubFetch( - config.token, - `/repos/${encodedOwner}/${encodedName}/contents/${encodedPath}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: `Entrega: ${mission.title} (${input.language})`, - content: encodeBase64(input.code), - branch: githubRepository.default_branch, - ...(currentFile?.sha ? { sha: currentFile.sha } : {}), - ...(author ? { author } : {}), - }), - }, - ); - const committed = (await updateResponse.json()) as GitHubContentCommit; + const committed = await upsertGitHubContent({ + token: config.token, + owner: repository.owner_login, + repository: repository.repository_name, + path: filePath, + branch: githubRepository.default_branch, + message: `Entrega: ${mission.title} (${input.language})`, + content: input.code, + author, + }); const syncedAt = new Date().toISOString(); - const fileUrl = - committed.content?.html_url ?? - `${repository.html_url}/blob/${githubRepository.default_branch}/${filePath}`; const commitUrl = committed.commit.html_url ?? undefined; await input.admin @@ -540,8 +605,6 @@ export async function syncSubmissionToGitHub( return { status: "synced", message: `${filePath} quedó actualizado.`, - repositoryUrl: repository.html_url, - fileUrl, path: filePath, commitSha: committed.commit.sha, }; @@ -563,7 +626,6 @@ export async function syncSubmissionToGitHub( status: "failed", message: "La entrega quedó guardada en el aula, pero GitHub rechazó la sincronización.", - repositoryUrl: repository?.html_url, path: filePath, }; } diff --git a/supabase/migrations/202607300007_central_submission_repository.sql b/supabase/migrations/202607300007_central_submission_repository.sql new file mode 100644 index 0000000..cc98250 --- /dev/null +++ b/supabase/migrations/202607300007_central_submission_repository.sql @@ -0,0 +1,49 @@ +alter table public.student_repositories + add column storage_mode text not null default 'legacy_per_student', + add column student_path text; + +alter table public.student_repositories + add constraint student_repositories_storage_mode_check + check (storage_mode in ('legacy_per_student', 'central')), + add constraint student_repositories_student_path_check + check ( + student_path is null + or ( + char_length(student_path) between 1 and 100 + and student_path ~ '^[a-z0-9][a-z0-9._-]*$' + ) + ), + add constraint student_repositories_mode_path_check + check ( + (storage_mode = 'legacy_per_student' and student_path is null) + or (storage_mode = 'central' and student_path is not null) + ); + +alter table public.student_repositories + drop constraint if exists student_repositories_owner_login_repository_name_key, + drop constraint if exists student_repositories_collaborator_status_check; + +alter table public.student_repositories + add constraint student_repositories_collaborator_status_check + check ( + collaborator_status in ( + 'pending', + 'invited', + 'active', + 'not_required', + 'error' + ) + ); + +create unique index student_repositories_class_student_path_key + on public.student_repositories(class_id, student_path) + where student_path is not null; + +create index student_repositories_physical_repository_idx + on public.student_repositories(owner_login, repository_name); + +comment on column public.student_repositories.storage_mode is + 'legacy_per_student for repositories created before 3.1; central for the shared private repository.'; + +comment on column public.student_repositories.student_path is + 'Server-derived folder for this student inside the central private repository.'; diff --git a/supabase/tests/rls.sql b/supabase/tests/rls.sql index a0aa144..a8804cc 100644 --- a/supabase/tests/rls.sql +++ b/supabase/tests/rls.sql @@ -1,7 +1,7 @@ begin; create extension if not exists pgtap with schema extensions; -select plan(35); +select plan(39); insert into auth.users ( id, instance_id, aud, role, email, encrypted_password, @@ -643,5 +643,99 @@ select throws_ok( 'a third student cannot exceed the invitation capacity' ); +set local role service_role; + +select lives_ok( + $$ + insert into public.student_repositories ( + class_id, + user_id, + owner_login, + repository_name, + html_url, + status, + collaborator_status, + storage_mode, + student_path + ) + values + ( + '00000000-0000-0000-0000-000000000201', + '00000000-0000-0000-0000-000000000102', + 'eeminionn', + 'tomatin-code-lab-resoluciones', + 'https://github.com/eeminionn/tomatin-code-lab-resoluciones', + 'ready', + 'not_required', + 'central', + 'student-one-00000102' + ), + ( + '00000000-0000-0000-0000-000000000201', + '00000000-0000-0000-0000-000000000103', + 'eeminionn', + 'tomatin-code-lab-resoluciones', + 'https://github.com/eeminionn/tomatin-code-lab-resoluciones', + 'ready', + 'not_required', + 'central', + 'student-two-00000103' + ) + $$, + 'multiple students can map to the same private repository' +); + +set local role authenticated; +select set_config( + 'request.jwt.claim.sub', + '00000000-0000-0000-0000-000000000102', + true +); +select set_config( + 'request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-000000000102","role":"authenticated"}', + true +); + +select is( + (select count(*) from public.student_repositories), + 1::bigint, + 'a student reads only their central repository mapping' +); + +select set_config( + 'request.jwt.claim.sub', + '00000000-0000-0000-0000-000000000103', + true +); +select set_config( + 'request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-000000000103","role":"authenticated"}', + true +); + +select is( + (select count(*) from public.student_repositories), + 1::bigint, + 'another student cannot read the first student mapping' +); + +select set_config( + 'request.jwt.claim.sub', + '00000000-0000-0000-0000-000000000101', + true +); +select set_config( + 'request.jwt.claims', + '{"sub":"00000000-0000-0000-0000-000000000101","role":"authenticated"}', + true +); + +select is( + (select count(*) from public.student_repositories), + 2::bigint, + 'class staff reads every central repository mapping' +); + select * from finish(); rollback; diff --git a/v2/src/pages/AccessPage.tsx b/v2/src/pages/AccessPage.tsx index 9e1f2d3..e265804 100644 --- a/v2/src/pages/AccessPage.tsx +++ b/v2/src/pages/AccessPage.tsx @@ -106,7 +106,7 @@ export function AccessPage() {

{hasInvitation - ? "Usa la cuenta de GitHub donde quieres recibir tus entregas." + ? "Usa la cuenta de GitHub que identificará tus entregas." : "El acceso de estudiantes requiere una invitación activa del curso."}

diff --git a/v2/src/pages/DashboardPage.tsx b/v2/src/pages/DashboardPage.tsx index 0183c93..bd00876 100644 --- a/v2/src/pages/DashboardPage.tsx +++ b/v2/src/pages/DashboardPage.tsx @@ -249,25 +249,40 @@ export function Component() { {repository ? ( <> - - - {repository.name} - Repositorio privado - - + {repository.storageMode === "central" ? ( +
+ + Carpeta privada sincronizada + + {repository.studentPath + ? `resoluciones/${repository.studentPath}` + : "Repositorio central del curso"} + + +
+ ) : ( + + + {repository.name} + Repositorio individual anterior + + + )} @@ -276,7 +291,7 @@ export function Component() {
diff --git a/v2/src/pages/MentorPage.tsx b/v2/src/pages/MentorPage.tsx index b23eccf..30d3a68 100644 --- a/v2/src/pages/MentorPage.tsx +++ b/v2/src/pages/MentorPage.tsx @@ -444,6 +444,10 @@ function StudentDirectory({ const repository = snapshot.repositories.find( (entry) => entry.userId === selected.id, ); + const repositoryHref = + repository?.storageMode === "central" && repository.studentPath + ? `${repository.htmlUrl}/tree/main/resoluciones/${repository.studentPath}` + : repository?.htmlUrl; const latestActivity = progress .filter((entry) => entry.lastActivityAt) .sort( @@ -582,12 +586,23 @@ function StudentDirectory({ ) : null} {repository ? ( - + ) : ( - Repositorio pendiente + Carpeta de entregas pendiente )} diff --git a/v2/src/state/classroom-context.tsx b/v2/src/state/classroom-context.tsx index 1dde4f0..e4c545f 100644 --- a/v2/src/state/classroom-context.tsx +++ b/v2/src/state/classroom-context.tsx @@ -320,6 +320,8 @@ async function fetchSupabaseSnapshot(session: Session) { visibility: row.visibility, status: row.status, collaboratorStatus: row.collaborator_status, + storageMode: row.storage_mode ?? "legacy_per_student", + studentPath: row.student_path ?? undefined, lastSyncedAt: row.last_synced_at ?? undefined, lastError: row.last_error ?? undefined, })), @@ -365,11 +367,14 @@ export function ClassroomProvider({ children }: { children: ReactNode }) { const loaded = await fetchSupabaseSnapshot(data.session); setProfile(loaded.profile); setSnapshot(loaded.snapshot); + const ownRepository = loaded.snapshot.repositories.find( + (repository) => repository.userId === loaded.profile.id, + ); if ( loaded.profile.role === "student" && - !loaded.snapshot.repositories.some( - (repository) => repository.userId === loaded.profile.id, - ) && + (!ownRepository || + ownRepository.storageMode !== "central" || + ownRepository.status === "error") && !provisionRequested.current.has(loaded.profile.id) ) { provisionRequested.current.add(loaded.profile.id); @@ -383,7 +388,7 @@ export function ClassroomProvider({ children }: { children: ReactNode }) { ...current, repositories: [ ...current.repositories.filter( - (entry) => entry.id !== repository.id, + (entry) => entry.userId !== repository.userId, ), repository, ], diff --git a/v2/src/styles.css b/v2/src/styles.css index 2a6b238..9cafb9f 100644 --- a/v2/src/styles.css +++ b/v2/src/styles.css @@ -1452,6 +1452,15 @@ fieldset legend { background: rgba(103, 232, 165, 0.05); } +.repository-location { + cursor: default; +} + +.repository-location:hover { + border-color: var(--line); + background: rgba(255, 255, 255, 0.025); +} + .repository-link > span, .repository-pending > span:last-child { display: grid; @@ -4139,10 +4148,15 @@ fieldset legend { .student-current-state > a { display: inline-flex; + max-width: 62%; + min-width: 0; align-items: center; gap: 6px; + overflow: hidden; color: var(--cyan); font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; } .student-current-state svg { diff --git a/v2/src/types.ts b/v2/src/types.ts index 761733a..e20acac 100644 --- a/v2/src/types.ts +++ b/v2/src/types.ts @@ -236,7 +236,14 @@ export interface StudentRepository { htmlUrl: string; visibility: "private"; status: "ready" | "error"; - collaboratorStatus: "pending" | "invited" | "active" | "error"; + collaboratorStatus: + | "pending" + | "invited" + | "active" + | "not_required" + | "error"; + storageMode: "legacy_per_student" | "central"; + studentPath?: string; lastSyncedAt?: string; lastError?: string; } diff --git a/v2/tests/github-submission-path.test.ts b/v2/tests/github-submission-path.test.ts new file mode 100644 index 0000000..ae040bf --- /dev/null +++ b/v2/tests/github-submission-path.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { + studentSubmissionFolder, + submissionFilePath, +} from "../../supabase/functions/_shared/github-paths"; + +describe("central GitHub submission paths", () => { + it("uses a stable student folder with a collision-resistant suffix", () => { + expect( + studentSubmissionFolder( + "Louu27", + "ff7b18ec-1286-4c47-8fc5-9dbaf35664cc", + ), + ).toBe("louu27-ff7b18ec"); + }); + + it("removes path traversal and unsupported characters", () => { + expect(studentSubmissionFolder("../L O U", "../../ABC-123")).toBe( + "l-o-u-abc123", + ); + }); + + it("derives the complete path without trusting a client path", () => { + expect( + submissionFilePath({ + githubLogin: "louu27", + userId: "ff7b18ec-1286-4c47-8fc5-9dbaf35664cc", + missionSlug: "../La Once de Tomatin", + language: "python", + }), + ).toBe( + "resoluciones/louu27-ff7b18ec/misiones/la-once-de-tomatin/solucion.py", + ); + }); +}); diff --git a/v2/tests/security-contract.test.ts b/v2/tests/security-contract.test.ts index 3445051..cf78b2c 100644 --- a/v2/tests/security-contract.test.ts +++ b/v2/tests/security-contract.test.ts @@ -108,6 +108,12 @@ describe("security contracts", () => { ), "utf8", ); + const centralRepositoryMigration = readFileSync( + resolve( + "supabase/migrations/202607300007_central_submission_repository.sql", + ), + "utf8", + ); const repositoryService = readFileSync( resolve("supabase/functions/_shared/github-submissions.ts"), "utf8", @@ -125,12 +131,22 @@ describe("security contracts", () => { expect(repositoryMigration).toContain( "user_id = auth.uid() or is_class_staff(class_id)", ); + expect(centralRepositoryMigration).toContain( + "storage_mode in ('legacy_per_student', 'central')", + ); + expect(centralRepositoryMigration).toContain( + "student_repositories_class_student_path_key", + ); expect(repositoryService).toContain( 'Deno.env.get("GITHUB_REPOSITORY_TOKEN")', ); - expect(repositoryService).toContain("private: true"); expect(repositoryService).toContain( - "misiones/${mission.slug}/${fileNames[input.language]}", + 'Deno.env.get("GITHUB_REPOSITORY_NAME")', ); + expect(repositoryService).toContain("private: true"); + expect(repositoryService).toContain('collaborator_status: "not_required"'); + expect(repositoryService).not.toContain("/collaborators/"); + expect(repositoryService).toContain("attempt < 3"); + expect(repositoryService).toContain("error.status !== 409"); }); });