diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3ff4ab7..343eeb9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "alembic>=1.15,<2", "aio-pika>=9.5,<10", "argon2-cffi>=25.1,<26", + "httpx>=0.28,<1", ] [project.scripts] @@ -31,7 +32,6 @@ kf-outbox-publisher = "kf_oj.workers.outbox:main" dev = [ "ruff>=0.11,<1", "pytest>=8.3,<10", - "httpx>=0.28,<1", "jsonschema>=4.23,<5", ] diff --git a/backend/src/kf_oj/api/app.py b/backend/src/kf_oj/api/app.py index 4889ef3..1fe70f7 100644 --- a/backend/src/kf_oj/api/app.py +++ b/backend/src/kf_oj/api/app.py @@ -16,9 +16,11 @@ from kf_oj.core.logging import configure_logging from kf_oj.core.settings import Settings from kf_oj.infra.artifacts.repository import ArtifactRepository from kf_oj.infra.artifacts.store import ArtifactStore +from kf_oj.infra.coordinator import CoordinatorControl from kf_oj.infra.db.session import build_engine, build_session_factory from kf_oj.modules.identity.service import IdentityService from kf_oj.modules.problems.service import ProblemService +from kf_oj.modules.submissions.queries import SubmissionQueries from kf_oj.modules.submissions.service import SubmissionService from kf_oj.modules.submissions.worker_access import WorkerAccess @@ -58,12 +60,23 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.state.worker_access = WorkerAccess( app.state.sessions, app.state.problems, artifacts ) + cancellation = ( + CoordinatorControl( + settings.coordinator_url, settings.coordinator_token.get_secret_value() + ) + if settings.coordinator_url and settings.coordinator_token + else None + ) + app.state.submission_queries = SubmissionQueries( + app.state.sessions, artifacts, cancellation + ) yield finally: app.state.identity = None app.state.problems = None app.state.worker_access = None app.state.submissions = None + app.state.submission_queries = None if engine is not None: await engine.dispose() if store is not None: @@ -82,6 +95,13 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.state.settings = settings app.add_middleware(BodyLimitMiddleware) + @app.middleware("http") + async def private_responses(request, call_next): + response = await call_next(request) + if request.url.path.startswith(("/api/v1/auth", "/api/v1/submissions", "/api/v1/internal")): + response.headers["Cache-Control"] = "no-store" + return response + @app.exception_handler(RequestValidationError) async def invalid_request(_, __): return JSONResponse({"detail": "invalid request"}, status_code=422) diff --git a/backend/src/kf_oj/api/v1/submissions.py b/backend/src/kf_oj/api/v1/submissions.py index 316c2ac..fdd70df 100644 --- a/backend/src/kf_oj/api/v1/submissions.py +++ b/backend/src/kf_oj/api/v1/submissions.py @@ -1,12 +1,21 @@ from typing import Annotated +from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response -from kf_oj.api.dependencies import unsafe_principal -from kf_oj.contracts.submissions import CreateSubmission, SubmissionCreated +from kf_oj.api.dependencies import current_principal, unsafe_principal +from kf_oj.contracts.submissions import ( + CancellationAccepted, + CreateSubmission, + SubmissionCreated, + SubmissionDetail, + SubmissionPage, + SubmissionSource, +) from kf_oj.infra.artifacts.store import ArtifactError from kf_oj.modules.identity.public import Principal from kf_oj.modules.submissions.public import ( + CancelUnavailable, IdempotencyConflict, SubmissionBusy, SubmissionUnavailable, @@ -41,3 +50,70 @@ async def create( raise HTTPException(422, "submission outside admission policy") from None response.headers["Cache-Control"] = "no-store" return result + + +def queries(request: Request): + service = getattr(request.app.state, "submission_queries", None) + if service is None: + raise HTTPException(503, "submission service unavailable") + return service + + +@router.get("", response_model=SubmissionPage) +async def history( + request: Request, + principal: Annotated[Principal, Depends(current_principal)], + after: Annotated[str | None, Query(max_length=128)] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, +): + try: + return await queries(request).page(principal, after, limit) + except ValueError: + raise HTTPException(422, "invalid history request") from None + + +@router.get("/{submission_id}", response_model=SubmissionDetail) +async def detail( + submission_id: UUID, + request: Request, + principal: Annotated[Principal, Depends(current_principal)], +): + try: + return await queries(request).detail(principal, submission_id) + except SubmissionUnavailable: + raise HTTPException(404, "submission unavailable") from None + except ValueError: + raise HTTPException(503, "stored result unavailable") from None + + +@router.get("/{submission_id}/source", response_model=SubmissionSource) +async def source( + submission_id: UUID, + request: Request, + principal: Annotated[Principal, Depends(current_principal)], +): + service = queries(request) + try: + result = await service.detail(principal, submission_id) + data = await service.owned_source(principal, submission_id) + return SubmissionSource( + submission_id=submission_id, language=result.language, source=data.decode("utf-8") + ) + except SubmissionUnavailable: + raise HTTPException(404, "submission unavailable") from None + except ValueError: + raise HTTPException(503, "stored source unavailable") from None + + +@router.post("/{submission_id}/cancel", status_code=202, response_model=CancellationAccepted) +async def cancel( + submission_id: UUID, + request: Request, + principal: Annotated[Principal, Depends(unsafe_principal)], +): + try: + return await queries(request).cancel(principal, submission_id) + except SubmissionUnavailable: + raise HTTPException(404, "submission unavailable") from None + except CancelUnavailable: + raise HTTPException(503, "cancellation service unavailable") from None diff --git a/backend/src/kf_oj/contracts/submissions.py b/backend/src/kf_oj/contracts/submissions.py index a92ecd0..f5f3abf 100644 --- a/backend/src/kf_oj/contracts/submissions.py +++ b/backend/src/kf_oj/contracts/submissions.py @@ -3,7 +3,16 @@ from typing import Annotated, Literal from pydantic import Field, model_validator -from kf_oj.contracts.judging import MAX_SOURCE_BYTES, Identifier, Language, WireModel +from kf_oj.contracts.judging import ( + MAX_SOURCE_BYTES, + CompilationStatus, + FailureCode, + Identifier, + Language, + Measurement, + Verdict, + WireModel, +) EvaluationState = Literal[ "queued", "claimed", "compiling", "running", "cancelling", "finished", "cancelled", "failed" @@ -35,3 +44,49 @@ class SubmissionCreated(WireModel): language: Language state: EvaluationState created_at: datetime + + +class SubmissionSummary(SubmissionCreated): + updated_at: datetime + attempts: int + verdict: Verdict | None + failure_code: FailureCode | None + + +class PublicCaseResult(WireModel): + position: int + verdict: Verdict + cpu_time_us: Measurement | None + memory_peak_bytes: Measurement | None + + +class PublicJudgement(WireModel): + compilation_status: CompilationStatus + diagnostics: Annotated[str, Field(max_length=65536)] + diagnostics_truncated: bool + cpu_time_us: Measurement | None + memory_peak_bytes: Measurement | None + cleanup_complete: bool + cases: tuple[PublicCaseResult, ...] + + +class SubmissionDetail(SubmissionSummary): + result: PublicJudgement | None + + +class SubmissionPage(WireModel): + items: tuple[SubmissionSummary, ...] + next_after: str | None + + +class SubmissionSource(WireModel): + submission_id: Identifier + language: Language + source: Annotated[str, Field(max_length=MAX_SOURCE_BYTES, repr=False)] + + +class CancellationAccepted(WireModel): + submission_id: Identifier + evaluation_id: Identifier + # Acceptance means Coordinator durably accepted intent, not that processes have stopped. + accepted: Literal[True] = True diff --git a/backend/src/kf_oj/core/settings.py b/backend/src/kf_oj/core/settings.py index 166c88d..a7c0adb 100644 --- a/backend/src/kf_oj/core/settings.py +++ b/backend/src/kf_oj/core/settings.py @@ -14,17 +14,50 @@ class Settings(BaseSettings): log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" database_url: SecretStr | None = None amqp_url: SecretStr | None = None - browser_origins: tuple[str, ...] = ("http://localhost:5173", "http://localhost:8000") + browser_origins: tuple[str, ...] = ( + "http://localhost:5173", + "http://localhost:8000", + "http://127.0.0.1:5173", + "http://127.0.0.1:8000", + ) session_lifetime_seconds: int = Field(default=43200, ge=300, le=86400) artifact_root: Path | None = None artifact_max_bytes: int = Field(default=536870912, ge=1, le=16 * 1024**3) artifact_max_files: int = Field(default=4096, ge=1, le=65536) judge_admission: AdmissionPolicy | None = None + coordinator_url: str | None = None + coordinator_token: SecretStr | None = None @model_validator(mode="after") def secure_origins(self): + from ipaddress import ip_address from urllib.parse import urlsplit + if (self.coordinator_url is None) != (self.coordinator_token is None): + raise ValueError("both coordinator URL and token required") + if self.coordinator_url is not None: + parts = urlsplit(self.coordinator_url) + try: + valid = parts.hostname is not None and ip_address(parts.hostname).is_loopback + port = parts.port + except ValueError: + valid, port = False, None + if ( + not valid + or parts.scheme != "http" + or port is None + or parts.path not in {"", "/"} + or parts.username + or parts.password + or parts.query + or parts.fragment + ): + raise ValueError("coordinator must be an explicit numeric loopback HTTP origin") + import re + + if not re.fullmatch(r"[0-9a-f]{64}", self.coordinator_token.get_secret_value()): + raise ValueError("invalid coordinator service credential") + if not self.browser_origins or len(self.browser_origins) > 8: raise ValueError("one to eight browser origins required") for origin in self.browser_origins: diff --git a/backend/src/kf_oj/infra/coordinator.py b/backend/src/kf_oj/infra/coordinator.py new file mode 100644 index 0000000..c09fd5a --- /dev/null +++ b/backend/src/kf_oj/infra/coordinator.py @@ -0,0 +1,30 @@ +"""Bounded loopback control adapter; never owns evaluation database writes.""" + +from uuid import UUID + +import httpx + + +class CoordinatorControl: + def __init__(self, url: str, token: str): + self.url, self.token = url.rstrip("/"), token + + async def cancel(self, evaluation_id: UUID) -> bool: + try: + async with httpx.AsyncClient( + timeout=3, trust_env=False, follow_redirects=False + ) as client: + # Response has no business payload. Do not buffer or log arbitrary remote bodies. + async with client.stream( + "POST", + self.url + "/internal/cancel", + headers={"Authorization": "Bearer " + self.token}, + json={ + "schema_version": 1, + "kind": "judge.evaluation", + "evaluation_id": str(evaluation_id), + }, + ) as response: + return response.status_code == 204 + except httpx.HTTPError: + return False diff --git a/backend/src/kf_oj/modules/submissions/public.py b/backend/src/kf_oj/modules/submissions/public.py index 612abaa..d8151fd 100644 --- a/backend/src/kf_oj/modules/submissions/public.py +++ b/backend/src/kf_oj/modules/submissions/public.py @@ -1,3 +1,15 @@ +from typing import Protocol +from uuid import UUID + + +class CancelUnavailable(ValueError): + pass + + +class CancellationPort(Protocol): + async def cancel(self, evaluation_id: UUID) -> bool: ... + + class SubmissionUnavailable(ValueError): pass diff --git a/backend/src/kf_oj/modules/submissions/queries.py b/backend/src/kf_oj/modules/submissions/queries.py index 1a3cce4..a8eb9bc 100644 --- a/backend/src/kf_oj/modules/submissions/queries.py +++ b/backend/src/kf_oj/modules/submissions/queries.py @@ -1,20 +1,148 @@ +import base64 +from datetime import datetime from uuid import UUID -from sqlalchemy import select +from sqlalchemy import select, tuple_ from sqlalchemy.ext.asyncio import async_sessionmaker -from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference +from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference, JudgementReport +from kf_oj.contracts.submissions import ( + CancellationAccepted, + PublicCaseResult, + PublicJudgement, + SubmissionDetail, + SubmissionPage, + SubmissionSummary, +) from kf_oj.infra.artifacts.models import Artifact from kf_oj.infra.artifacts.repository import ArtifactRepository from kf_oj.infra.artifacts.store import ArtifactError from kf_oj.modules.identity.public import Principal -from kf_oj.modules.submissions.models import Submission -from kf_oj.modules.submissions.public import SubmissionUnavailable +from kf_oj.modules.submissions.models import Evaluation, Submission +from kf_oj.modules.submissions.public import ( + CancellationPort, + CancelUnavailable, + SubmissionUnavailable, +) + + +def cursor_encode(submission: Submission) -> str: + return ( + base64.urlsafe_b64encode( + f"{submission.created_at.isoformat()}|{submission.id}".encode("ascii") + ) + .decode("ascii") + .rstrip("=") + ) + + +def cursor_decode(value: str) -> tuple[datetime, UUID]: + try: + if not 1 <= len(value) <= 128: + raise ValueError + date, identifier = ( + base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True) + .decode("ascii") + .split("|") + ) + timestamp = datetime.fromisoformat(date) + if timestamp.utcoffset() is None: + raise ValueError + return timestamp, UUID(identifier) + except (ValueError, UnicodeError): + raise ValueError("invalid history cursor") from None + + +def summary(submission: Submission, evaluation: Evaluation) -> SubmissionSummary: + terminal = evaluation.state in {"finished", "failed", "cancelled"} + return SubmissionSummary( + submission_id=submission.id, + evaluation_id=evaluation.id, + problem_version_id=submission.problem_version_id, + language=submission.language, + state=evaluation.state, + created_at=submission.created_at, + updated_at=evaluation.updated_at, + attempts=evaluation.attempts_count, + verdict=evaluation.verdict if terminal else None, + failure_code=evaluation.failure_code, + ) class SubmissionQueries: - def __init__(self, sessions: async_sessionmaker, artifacts: ArtifactRepository): + def __init__( + self, + sessions: async_sessionmaker, + artifacts: ArtifactRepository, + cancellation: CancellationPort | None = None, + ): self.sessions, self.artifacts = sessions, artifacts + self.cancellation = cancellation + + def owned(self, principal: Principal, submission_id: UUID): + return ( + select(Submission, Evaluation) + .join(Evaluation, Evaluation.submission_id == Submission.id) + .where(Submission.id == submission_id, Submission.account_id == principal.account_id) + ) + + async def page(self, principal: Principal, after: str | None, limit: int) -> SubmissionPage: + if not 1 <= limit <= 100: + raise ValueError("invalid history limit") + query = ( + select(Submission, Evaluation) + .join(Evaluation, Evaluation.submission_id == Submission.id) + .where(Submission.account_id == principal.account_id) + .order_by(Submission.created_at.desc(), Submission.id.desc()) + .limit(limit + 1) + ) + if after is not None: + query = query.where(tuple_(Submission.created_at, Submission.id) < cursor_decode(after)) + async with self.sessions() as session: + rows = (await session.execute(query)).all() + return SubmissionPage( + items=tuple(summary(s, e) for s, e in rows[:limit]), + next_after=cursor_encode(rows[limit - 1][0]) if len(rows) > limit else None, + ) + + async def detail(self, principal: Principal, submission_id: UUID) -> SubmissionDetail: + async with self.sessions() as session: + row = (await session.execute(self.owned(principal, submission_id))).one_or_none() + if row is None: + raise SubmissionUnavailable("submission unavailable") + submission, evaluation = row + result = None + if evaluation.state in {"finished", "failed", "cancelled"} and evaluation.final_report: + # A single joined snapshot, strict validation and explicit projection prevent leaking + # internal bindings, artifacts, hidden case identifiers or partial attempt reports. + report = JudgementReport.model_validate(evaluation.final_report) + if str(report.binding.evaluation_id) != str(evaluation.id): + raise ValueError("stored result binding invalid") + if report.verdict.value != evaluation.verdict: + raise ValueError("stored result verdict invalid") + result = PublicJudgement( + compilation_status=report.compilation.status, + diagnostics=report.compilation.diagnostics, + diagnostics_truncated=report.compilation.diagnostics_truncated, + cpu_time_us=report.run_cpu_time_us, + memory_peak_bytes=report.run_memory_peak_bytes, + cleanup_complete=report.cleanup_complete, + cases=tuple( + PublicCaseResult(position=i, **c.model_dump(exclude={"case_id"})) + for i, c in enumerate(report.cases) + ), + ) + return SubmissionDetail(**summary(submission, evaluation).model_dump(), result=result) + + async def cancel(self, principal: Principal, submission_id: UUID) -> CancellationAccepted: + async with self.sessions() as session: + row = (await session.execute(self.owned(principal, submission_id))).one_or_none() + if row is None: + raise SubmissionUnavailable("submission unavailable") + submission, evaluation = row + if self.cancellation is None or not await self.cancellation.cancel(evaluation.id): + raise CancelUnavailable("cancellation service unavailable") + return CancellationAccepted(submission_id=submission.id, evaluation_id=evaluation.id) async def owned_source(self, principal: Principal, submission_id: UUID) -> bytes: async with self.sessions() as session: diff --git a/backend/tests/test_coordinator_control.py b/backend/tests/test_coordinator_control.py new file mode 100644 index 0000000..0fda4eb --- /dev/null +++ b/backend/tests/test_coordinator_control.py @@ -0,0 +1,47 @@ +import asyncio +import json +import secrets +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread +from uuid import uuid4 + +from kf_oj.infra.coordinator import CoordinatorControl + + +def test_real_loopback_cancellation_notification_and_failure(): + token, evaluation = secrets.token_hex(32), uuid4() + + class Handler(BaseHTTPRequestHandler): + code = 204 + seen = [] + + def do_POST(self): + assert self.path == "/internal/cancel" + assert self.headers["Authorization"] == "Bearer " + token + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + assert body == { + "schema_version": 1, + "kind": "judge.evaluation", + "evaluation_id": str(evaluation), + } + self.seen.append(body) + self.send_response(self.code) + self.end_headers() + + def log_message(self, *_): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + control = CoordinatorControl(f"http://127.0.0.1:{server.server_port}/", token) + try: + assert asyncio.run(control.cancel(evaluation)) is True + Handler.code = 503 + assert asyncio.run(control.cancel(evaluation)) is False + assert len(Handler.seen) == 2 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + assert asyncio.run(control.cancel(evaluation)) is False diff --git a/backend/tests/test_submission_queries.py b/backend/tests/test_submission_queries.py new file mode 100644 index 0000000..106306c --- /dev/null +++ b/backend/tests/test_submission_queries.py @@ -0,0 +1,152 @@ +"""Real PostgreSQL owner projection/pagination acceptance; no program execution.""" + +import asyncio +import json +import secrets +from pathlib import Path +from uuid import uuid4 + +import httpx +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine +from test_submission_create import setup_app + +from kf_oj.contracts.submissions import CreateSubmission +from kf_oj.core.settings import Settings +from kf_oj.modules.submissions.queries import cursor_decode + + +def test_cursor_and_control_configuration_fail_closed(): + for value in ["garbage", "_" * 129, "", "eHx4"]: + with pytest.raises(ValueError): + cursor_decode(value) + for url in ["http://localhost:8100", "https://127.0.0.1:8100", "http://127.0.0.1:8100/path"]: + with pytest.raises(ValueError): + Settings(_env_file=None, coordinator_url=url, coordinator_token="a" * 64) + Settings(_env_file=None, coordinator_url="http://127.0.0.1:8100", coordinator_token="a" * 64) + + +def test_actual_owned_history_source_result_and_cancel(postgres_url, tmp_path, monkeypatch): + async def run(): + app, engine, mod = await setup_app(postgres_url, tmp_path) + monkeypatch.setattr(mod, "build_engine", lambda _: engine) + async with app.router.lifespan_context(app): + passwords = [secrets.token_urlsafe(24), secrets.token_urlsafe(24)] + owners = [ + await app.state.identity.create_account(name, password) + for name, password in zip(["reader_one", "reader_two"], passwords, strict=True) + ] + version = await app.state.problems.import_directory(tmp_path / "import") + records = [] + for i in range(4): + records.append( + await app.state.submissions.create( + owners[0], + CreateSubmission( + problem_version_id=version, + language="c17", + idempotency_key=uuid4(), + source="synthetic private source " + str(i), + ), + ) + ) + # Synthetic contract projection only, never claimed as a real judgement. + fixture = Path(__file__).parents[2] / "tests/fixtures/contracts/judge-result.valid.json" + report = json.loads(fixture.read_text()) + report["binding"]["evaluation_id"] = str(records[0].evaluation_id) + admin = create_async_engine(postgres_url) + async with admin.begin() as conn: + await conn.execute(text("SET LOCAL ROLE kf_oj_coordinator")) + await conn.execute( + text( + "UPDATE evaluations SET state='finished',verdict='accepted'," + "final_report=CAST(:report AS jsonb) WHERE id=:id" + ), + {"id": records[0].evaluation_id, "report": json.dumps(report)}, + ) + await admin.dispose() + + class CancelProbe: + accepted = True + seen = [] + + async def cancel(self, evaluation_id): + self.seen.append(evaluation_id) + return self.accepted + + probe = CancelProbe() + app.state.submission_queries.cancellation = probe + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000" + ) as client: + first = await client.post( + "/api/v1/auth/login", + json={"username": "reader_one", "password": passwords[0]}, + headers={"Origin": "http://localhost:8000"}, + ) + assert first.status_code == 200 + csrf = { + "Origin": "http://localhost:8000", + "X-CSRF-Token": first.json()["csrf_token"], + } + page = await client.get("/api/v1/submissions?limit=2") + assert page.status_code == 200 and page.headers["Cache-Control"] == "no-store" + after = page.json()["next_after"] + # Concurrent insertion must not shift the cursor into repeats or omit old rows. + await app.state.submissions.create( + owners[0], + CreateSubmission( + problem_version_id=version, + language="cpp20", + idempotency_key=uuid4(), + source="new synthetic private source", + ), + ) + tail = await client.get("/api/v1/submissions", params={"limit": 2, "after": after}) + seen = [i["submission_id"] for i in page.json()["items"] + tail.json()["items"]] + assert len(set(seen)) == 4 and set(seen) == {str(r.submission_id) for r in records} + assert tail.json()["next_after"] is None + base = f"/api/v1/submissions/{records[0].submission_id}" + public = await client.get(base) + assert public.status_code == 200 and public.json()["verdict"] == "accepted" + projection = public.json()["result"] + assert projection["cpu_time_us"] == report["run_cpu_time_us"] + assert projection["memory_peak_bytes"] == report["run_memory_peak_bytes"] + assert all( + key not in public.text + for key in ["case_id", "digest", "lease", "expected", "manifest", "program"] + ) + source = await client.get(base + "/source") + assert source.json()["source"] == "synthetic private source 0" + waiting = await client.get(f"/api/v1/submissions/{records[1].submission_id}") + assert waiting.json()["result"] is None and waiting.json()["verdict"] is None + cancel = f"/api/v1/submissions/{records[1].submission_id}/cancel" + assert (await client.post(cancel)).status_code == 403 + assert (await client.post(cancel, headers=csrf)).status_code == 202 + assert probe.seen == [records[1].evaluation_id] + assert (await client.get(cancel.removesuffix("/cancel"))).json()[ + "state" + ] == "queued" + probe.accepted = False + assert (await client.post(cancel, headers=csrf)).status_code == 503 + assert (await client.get("/api/v1/submissions?after=invalid")).status_code == 422 + second = await client.post( + "/api/v1/auth/login", + json={"username": "reader_two", "password": passwords[1]}, + headers={"Origin": "http://localhost:8000"}, + ) + csrf["X-CSRF-Token"] = second.json()["csrf_token"] + assert (await client.get("/api/v1/submissions")).json()["items"] == [] + for suffix in ["", "/source"]: + response = await client.get(base + suffix) + assert ( + response.status_code == 404 + and response.headers["Cache-Control"] == "no-store" + ) + assert (await client.post(cancel, headers=csrf)).status_code == 404 + assert len(probe.seen) == 2 + assert (await client.post("/api/v1/auth/logout", headers=csrf)).status_code == 204 + assert (await client.get(base)).status_code == 401 + + asyncio.run(run()) diff --git a/backend/uv.lock b/backend/uv.lock index 7c004ba..0a97dd2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -311,6 +311,7 @@ dependencies = [ { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, + { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -319,7 +320,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "jsonschema" }, { name = "pytest" }, { name = "ruff" }, @@ -332,6 +332,7 @@ requires-dist = [ { name = "argon2-cffi", specifier = ">=25.1,<26" }, { name = "asyncpg", specifier = ">=0.30,<1" }, { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "httpx", specifier = ">=0.28,<1" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "pydantic-settings", specifier = ">=2.7,<3" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3" }, @@ -340,7 +341,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28,<1" }, { name = "jsonschema", specifier = ">=4.23,<5" }, { name = "pytest", specifier = ">=8.3,<10" }, { name = "ruff", specifier = ">=0.11,<1" }, diff --git a/contracts/openapi/platform.v1.json b/contracts/openapi/platform.v1.json index 6c68835..827833c 100644 --- a/contracts/openapi/platform.v1.json +++ b/contracts/openapi/platform.v1.json @@ -25,6 +25,44 @@ "title": "ArtifactReference", "type": "object" }, + "CancellationAccepted": { + "additionalProperties": false, + "properties": { + "accepted": { + "const": true, + "default": true, + "title": "Accepted", + "type": "boolean" + }, + "evaluation_id": { + "format": "uuid", + "title": "Evaluation Id", + "type": "string" + }, + "submission_id": { + "format": "uuid", + "title": "Submission Id", + "type": "string" + } + }, + "required": [ + "submission_id", + "evaluation_id" + ], + "title": "CancellationAccepted", + "type": "object" + }, + "CompilationStatus": { + "enum": [ + "succeeded", + "compilation_error", + "compilation_limit", + "cancelled", + "system_error" + ], + "title": "CompilationStatus", + "type": "string" + }, "CreateSubmission": { "additionalProperties": false, "properties": { @@ -77,6 +115,17 @@ "title": "CurrentUser", "type": "object" }, + "FailureCode": { + "enum": [ + "artifact_unavailable", + "sandbox_unavailable", + "cleanup_failed", + "lease_lost", + "job_deadline" + ], + "title": "FailureCode", + "type": "string" + }, "HTTPValidationError": { "properties": { "detail": { @@ -394,6 +443,117 @@ "title": "ProblemSummary", "type": "object" }, + "PublicCaseResult": { + "additionalProperties": false, + "properties": { + "cpu_time_us": { + "anyOf": [ + { + "maximum": 9.223372036854776e+18, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cpu Time Us" + }, + "memory_peak_bytes": { + "anyOf": [ + { + "maximum": 9.223372036854776e+18, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Memory Peak Bytes" + }, + "position": { + "title": "Position", + "type": "integer" + }, + "verdict": { + "$ref": "#/components/schemas/Verdict" + } + }, + "required": [ + "position", + "verdict", + "cpu_time_us", + "memory_peak_bytes" + ], + "title": "PublicCaseResult", + "type": "object" + }, + "PublicJudgement": { + "additionalProperties": false, + "properties": { + "cases": { + "items": { + "$ref": "#/components/schemas/PublicCaseResult" + }, + "title": "Cases", + "type": "array" + }, + "cleanup_complete": { + "title": "Cleanup Complete", + "type": "boolean" + }, + "compilation_status": { + "$ref": "#/components/schemas/CompilationStatus" + }, + "cpu_time_us": { + "anyOf": [ + { + "maximum": 9.223372036854776e+18, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Cpu Time Us" + }, + "diagnostics": { + "maxLength": 65536, + "title": "Diagnostics", + "type": "string" + }, + "diagnostics_truncated": { + "title": "Diagnostics Truncated", + "type": "boolean" + }, + "memory_peak_bytes": { + "anyOf": [ + { + "maximum": 9.223372036854776e+18, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Memory Peak Bytes" + } + }, + "required": [ + "compilation_status", + "diagnostics", + "diagnostics_truncated", + "cpu_time_us", + "memory_peak_bytes", + "cleanup_complete", + "cases" + ], + "title": "PublicJudgement", + "type": "object" + }, "PublicSample": { "additionalProperties": false, "properties": { @@ -552,6 +712,241 @@ "title": "SubmissionCreated", "type": "object" }, + "SubmissionDetail": { + "additionalProperties": false, + "properties": { + "attempts": { + "title": "Attempts", + "type": "integer" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "evaluation_id": { + "format": "uuid", + "title": "Evaluation Id", + "type": "string" + }, + "failure_code": { + "anyOf": [ + { + "$ref": "#/components/schemas/FailureCode" + }, + { + "type": "null" + } + ] + }, + "language": { + "$ref": "#/components/schemas/Language" + }, + "problem_version_id": { + "format": "uuid", + "title": "Problem Version Id", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/components/schemas/PublicJudgement" + }, + { + "type": "null" + } + ] + }, + "state": { + "enum": [ + "queued", + "claimed", + "compiling", + "running", + "cancelling", + "finished", + "cancelled", + "failed" + ], + "title": "State", + "type": "string" + }, + "submission_id": { + "format": "uuid", + "title": "Submission Id", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "verdict": { + "anyOf": [ + { + "$ref": "#/components/schemas/Verdict" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "submission_id", + "evaluation_id", + "problem_version_id", + "language", + "state", + "created_at", + "updated_at", + "attempts", + "verdict", + "failure_code", + "result" + ], + "title": "SubmissionDetail", + "type": "object" + }, + "SubmissionPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SubmissionSummary" + }, + "title": "Items", + "type": "array" + }, + "next_after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next After" + } + }, + "required": [ + "items", + "next_after" + ], + "title": "SubmissionPage", + "type": "object" + }, + "SubmissionSource": { + "additionalProperties": false, + "properties": { + "language": { + "$ref": "#/components/schemas/Language" + }, + "source": { + "maxLength": 262144, + "title": "Source", + "type": "string" + }, + "submission_id": { + "format": "uuid", + "title": "Submission Id", + "type": "string" + } + }, + "required": [ + "submission_id", + "language", + "source" + ], + "title": "SubmissionSource", + "type": "object" + }, + "SubmissionSummary": { + "additionalProperties": false, + "properties": { + "attempts": { + "title": "Attempts", + "type": "integer" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "evaluation_id": { + "format": "uuid", + "title": "Evaluation Id", + "type": "string" + }, + "failure_code": { + "anyOf": [ + { + "$ref": "#/components/schemas/FailureCode" + }, + { + "type": "null" + } + ] + }, + "language": { + "$ref": "#/components/schemas/Language" + }, + "problem_version_id": { + "format": "uuid", + "title": "Problem Version Id", + "type": "string" + }, + "state": { + "enum": [ + "queued", + "claimed", + "compiling", + "running", + "cancelling", + "finished", + "cancelled", + "failed" + ], + "title": "State", + "type": "string" + }, + "submission_id": { + "format": "uuid", + "title": "Submission Id", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "verdict": { + "anyOf": [ + { + "$ref": "#/components/schemas/Verdict" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "submission_id", + "evaluation_id", + "problem_version_id", + "language", + "state", + "created_at", + "updated_at", + "attempts", + "verdict", + "failure_code" + ], + "title": "SubmissionSummary", + "type": "object" + }, "TestCase": { "additionalProperties": false, "properties": { @@ -642,6 +1037,21 @@ ], "title": "ValidationError", "type": "object" + }, + "Verdict": { + "enum": [ + "accepted", + "wrong_answer", + "compilation_error", + "runtime_error", + "time_limit_exceeded", + "memory_limit_exceeded", + "output_limit_exceeded", + "cancelled", + "system_error" + ], + "title": "Verdict", + "type": "string" } }, "securitySchemes": { @@ -1028,6 +1438,66 @@ } }, "/api/v1/submissions": { + "get": { + "operationId": "history_api_v1_submissions_get", + "parameters": [ + { + "in": "query", + "name": "after", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 128, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionPage" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "History", + "tags": [ + "submissions" + ] + }, "post": { "operationId": "create_api_v1_submissions_post", "requestBody": { @@ -1067,6 +1537,135 @@ "submissions" ] } + }, + "/api/v1/submissions/{submission_id}": { + "get": { + "operationId": "detail_api_v1_submissions__submission_id__get", + "parameters": [ + { + "in": "path", + "name": "submission_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Submission Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionDetail" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Detail", + "tags": [ + "submissions" + ] + } + }, + "/api/v1/submissions/{submission_id}/cancel": { + "post": { + "operationId": "cancel_api_v1_submissions__submission_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "submission_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Submission Id", + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancellationAccepted" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Cancel", + "tags": [ + "submissions" + ] + } + }, + "/api/v1/submissions/{submission_id}/source": { + "get": { + "operationId": "source_api_v1_submissions__submission_id__source_get", + "parameters": [ + { + "in": "path", + "name": "submission_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Submission Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmissionSource" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Source", + "tags": [ + "submissions" + ] + } } } } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ac8260f..1b3d723 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,13 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' +import { boundedFetch } from './transport' + +let expired: () => void = () => {} +export function onUnauthorized(handler: () => void) { + expired = handler +} export const api = createClient({ baseUrl: import.meta.env.VITE_API_BASE_URL || '', + fetch: boundedFetch(globalThis.fetch.bind(globalThis), () => expired()), }) diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index 46a1fe1..db5fc3d 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -150,7 +150,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** History */ + get: operations["history_api_v1_submissions_get"]; put?: never; /** Create */ post: operations["create_api_v1_submissions_post"]; @@ -160,6 +161,57 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/submissions/{submission_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Detail */ + get: operations["detail_api_v1_submissions__submission_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/submissions/{submission_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel */ + post: operations["cancel_api_v1_submissions__submission_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/submissions/{submission_id}/source": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Source */ + get: operations["source_api_v1_submissions__submission_id__source_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -171,6 +223,30 @@ export interface components { /** Digest */ digest: string; }; + /** CancellationAccepted */ + CancellationAccepted: { + /** + * Accepted + * @default true + * @constant + */ + accepted: true; + /** + * Evaluation Id + * Format: uuid + */ + evaluation_id: string; + /** + * Submission Id + * Format: uuid + */ + submission_id: string; + }; + /** + * CompilationStatus + * @enum {string} + */ + CompilationStatus: "succeeded" | "compilation_error" | "compilation_limit" | "cancelled" | "system_error"; /** CreateSubmission */ CreateSubmission: { /** @@ -197,6 +273,11 @@ export interface components { /** Username */ username: string; }; + /** + * FailureCode + * @enum {string} + */ + FailureCode: "artifact_unavailable" | "sandbox_unavailable" | "cleanup_failed" | "lease_lost" | "job_deadline"; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -339,6 +420,32 @@ export interface components { */ version_id: string; }; + /** PublicCaseResult */ + PublicCaseResult: { + /** Cpu Time Us */ + cpu_time_us: number | null; + /** Memory Peak Bytes */ + memory_peak_bytes: number | null; + /** Position */ + position: number; + verdict: components["schemas"]["Verdict"]; + }; + /** PublicJudgement */ + PublicJudgement: { + /** Cases */ + cases: components["schemas"]["PublicCaseResult"][]; + /** Cleanup Complete */ + cleanup_complete: boolean; + compilation_status: components["schemas"]["CompilationStatus"]; + /** Cpu Time Us */ + cpu_time_us: number | null; + /** Diagnostics */ + diagnostics: string; + /** Diagnostics Truncated */ + diagnostics_truncated: boolean; + /** Memory Peak Bytes */ + memory_peak_bytes: number | null; + }; /** PublicSample */ PublicSample: { /** @@ -404,6 +511,101 @@ export interface components { */ submission_id: string; }; + /** SubmissionDetail */ + SubmissionDetail: { + /** Attempts */ + attempts: number; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Evaluation Id + * Format: uuid + */ + evaluation_id: string; + failure_code: components["schemas"]["FailureCode"] | null; + language: components["schemas"]["Language"]; + /** + * Problem Version Id + * Format: uuid + */ + problem_version_id: string; + result: components["schemas"]["PublicJudgement"] | null; + /** + * State + * @enum {string} + */ + state: "queued" | "claimed" | "compiling" | "running" | "cancelling" | "finished" | "cancelled" | "failed"; + /** + * Submission Id + * Format: uuid + */ + submission_id: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + verdict: components["schemas"]["Verdict"] | null; + }; + /** SubmissionPage */ + SubmissionPage: { + /** Items */ + items: components["schemas"]["SubmissionSummary"][]; + /** Next After */ + next_after: string | null; + }; + /** SubmissionSource */ + SubmissionSource: { + language: components["schemas"]["Language"]; + /** Source */ + source: string; + /** + * Submission Id + * Format: uuid + */ + submission_id: string; + }; + /** SubmissionSummary */ + SubmissionSummary: { + /** Attempts */ + attempts: number; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Evaluation Id + * Format: uuid + */ + evaluation_id: string; + failure_code: components["schemas"]["FailureCode"] | null; + language: components["schemas"]["Language"]; + /** + * Problem Version Id + * Format: uuid + */ + problem_version_id: string; + /** + * State + * @enum {string} + */ + state: "queued" | "claimed" | "compiling" | "running" | "cancelling" | "finished" | "cancelled" | "failed"; + /** + * Submission Id + * Format: uuid + */ + submission_id: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + verdict: components["schemas"]["Verdict"] | null; + }; /** TestCase */ TestCase: { /** @@ -439,6 +641,11 @@ export interface components { /** Error Type */ type: string; }; + /** + * Verdict + * @enum {string} + */ + Verdict: "accepted" | "wrong_answer" | "compilation_error" | "runtime_error" | "time_limit_exceeded" | "memory_limit_exceeded" | "output_limit_exceeded" | "cancelled" | "system_error"; }; responses: never; parameters: never; @@ -673,6 +880,38 @@ export interface operations { }; }; }; + history_api_v1_submissions_get: { + parameters: { + query?: { + after?: string | null; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SubmissionPage"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; create_api_v1_submissions_post: { parameters: { query?: never; @@ -706,4 +945,97 @@ export interface operations { }; }; }; + detail_api_v1_submissions__submission_id__get: { + parameters: { + query?: never; + header?: never; + path: { + submission_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SubmissionDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + cancel_api_v1_submissions__submission_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + submission_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CancellationAccepted"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + source_api_v1_submissions__submission_id__source_get: { + parameters: { + query?: never; + header?: never; + path: { + submission_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SubmissionSource"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/frontend/src/api/polling.ts b/frontend/src/api/polling.ts new file mode 100644 index 0000000..062efec --- /dev/null +++ b/frontend/src/api/polling.ts @@ -0,0 +1,71 @@ +export function boundedPoll(options: { + load: (signal: AbortSignal) => Promise + terminal: (value: T) => boolean + result: (value: T) => void + error: (error: unknown) => void + exhausted: () => void + interval?: number + maxRequests?: number + maxTime?: number +}) { + let timer: ReturnType | undefined + let request: AbortController | undefined + let generation = 0 + let disposed = false + let visible = true + let active = false + let count = 0 + let deadline = 0 + function stop() { + generation++ + clearTimeout(timer) + request?.abort() + request = undefined + } + async function tick() { + if (!active || !visible || disposed) return + if (count >= (options.maxRequests ?? 240) || Date.now() >= deadline) { + active = false + options.exhausted() + return + } + const current = generation + const controller = new AbortController() + request = controller + count++ + try { + const value = await options.load(controller.signal) + if (current !== generation || disposed) return + options.result(value) + if (options.terminal(value)) active = false + if (active) timer = setTimeout(tick, options.interval ?? 2000) + } catch (error) { + if (current !== generation || disposed) return + active = false + options.error(error) + } finally { + if (request === controller) request = undefined + } + } + return { + start() { + if (disposed) return + stop() + active = true + count = 0 + deadline = Date.now() + (options.maxTime ?? 600000) + void tick() + }, + visible(value: boolean) { + if (disposed || visible === value) return + visible = value + stop() + if (visible) void tick() + }, + dispose() { + disposed = true + active = false + stop() + }, + } +} diff --git a/frontend/src/api/submissionIntent.ts b/frontend/src/api/submissionIntent.ts new file mode 100644 index 0000000..7298ed2 --- /dev/null +++ b/frontend/src/api/submissionIntent.ts @@ -0,0 +1,59 @@ +// No source is stored here. Fingerprints bind an immutable request and owner to a UUID. +export type SubmissionInput = { + problem_version_id: string + language: 'c17' | 'cpp20' + source: string +} +type Intent = { fingerprint: string; key: string; submissionId?: string } + +export function submissionIntent(storage: Pick, owner: string) { + const prefix = 'kf-oj:submission-intent:' + owner + ':' + let pending: Promise | undefined + return { + submit(input: SubmissionInput, send: (key: string) => Promise): Promise { + if (pending) return pending + const snapshot = { ...input } + pending = (async () => { + const bytes = new TextEncoder().encode(snapshot.source) + if (!bytes.length || bytes.length > 262144) throw new Error('源码必须为 1 至 262144 字节。') + const hash = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(JSON.stringify(snapshot)), + ) + const fingerprint = Array.from(new Uint8Array(hash), (v) => + v.toString(16).padStart(2, '0'), + ).join('') + const location = prefix + snapshot.problem_version_id + let intent: Intent + try { + const raw: unknown = JSON.parse(storage.getItem(location) ?? 'null') + intent = + raw && + typeof raw === 'object' && + 'fingerprint' in raw && + raw.fingerprint === fingerprint && + 'key' in raw && + typeof raw.key === 'string' && + /^[0-9a-f-]{36}$/.test(raw.key) + ? (raw as Intent) + : { fingerprint, key: crypto.randomUUID() } + storage.setItem(location, JSON.stringify(intent)) + } catch { + throw new Error('浏览器无法保存提交凭据。请恢复本地存储后重试,草稿仍在编辑器中。') + } + if (intent.submissionId) return intent.submissionId + const id = await send(intent.key) + // The key was saved BEFORE the network call. If this write fails a retry still uses it. + try { + storage.setItem(location, JSON.stringify({ ...intent, submissionId: id })) + } catch { + /* stable key already persisted */ + } + return id + })().finally(() => { + pending = undefined + }) + return pending + }, + } +} diff --git a/frontend/src/api/transport.ts b/frontend/src/api/transport.ts new file mode 100644 index 0000000..3efffed --- /dev/null +++ b/frontend/src/api/transport.ts @@ -0,0 +1,55 @@ +export class ApiError extends Error { + status: number + constructor(status: number) { + const messages: Record = { + 401: '登录已过期,请重新登录。草稿已保留。', + 403: '请求未通过会话验证,请刷新后重试。', + 404: '记录不存在或无权访问。', + 409: '提交凭据与内容不一致,请检查草稿后重试。', + 422: '请求不符合题目或提交要求。', + 429: '当前容量已满,请稍后重试。', + 503: '服务暂时不可用,请稍后重试。', + } + super(messages[status] ?? `请求失败(HTTP ${status})`) + this.status = status + } +} + +export function boundedFetch(fetcher: typeof fetch, unauthorized: () => void): typeof fetch { + return async (input, init) => { + const request = new Request(input, init) + try { + const response = await fetcher( + new Request(request, { + credentials: 'same-origin', + cache: 'no-store', + signal: AbortSignal.any([request.signal, AbortSignal.timeout(10000)]), + }), + ) + if (response.status === 401) unauthorized() + return response + } catch (error) { + if (request.signal.aborted) throw error + throw new Error('网络中断或请求超时。草稿与提交凭据已保留,可手动重试。', { cause: error }) + } + } +} + +export function unwrap(result: { data?: T; response: Response }): T { + if (!result.response.ok) throw new ApiError(result.response.status) + if (result.data === undefined) throw new Error('服务返回了不完整的数据,请重试。') + return result.data +} + +export function csrfHeaders(): Record { + const token = document.cookie + .split('; ') + .find((v) => v.startsWith('kf_csrf=')) + ?.slice(8) + if (!token || !/^[0-9a-f]{64}$/.test(token)) throw new ApiError(401) + return { 'X-CSRF-Token': token } +} + +export function errorText(error: unknown): string { + return error instanceof Error ? error.message : '请求失败,请重试。' +} diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue index ab88a09..228f03e 100644 --- a/frontend/src/components/CodeEditor.vue +++ b/frontend/src/components/CodeEditor.vue @@ -28,7 +28,10 @@ let subscription: monaco.IDisposable | undefined ).MonacoEnvironment = { getWorker: () => new EditorWorker() } onMounted(() => { if (!container.value) return - model = monaco.editor.createModel(props.modelValue, props.language) + model = monaco.editor.createModel( + props.modelValue, + props.language === 'c' ? 'cpp' : props.language, + ) editor = monaco.editor.create(container.value, { model, theme: props.theme, @@ -57,7 +60,7 @@ watch( watch( () => props.language, (value) => { - if (model) monaco.editor.setModelLanguage(model, value) + if (model) monaco.editor.setModelLanguage(model, value === 'c' ? 'cpp' : value) }, ) watch( diff --git a/frontend/src/components/JudgementStatus.vue b/frontend/src/components/JudgementStatus.vue new file mode 100644 index 0000000..2146ab8 --- /dev/null +++ b/frontend/src/components/JudgementStatus.vue @@ -0,0 +1,35 @@ + + diff --git a/frontend/src/composables/useCodeDraft.ts b/frontend/src/composables/useCodeDraft.ts index f1c17c7..8d55371 100644 --- a/frontend/src/composables/useCodeDraft.ts +++ b/frontend/src/composables/useCodeDraft.ts @@ -1,11 +1,13 @@ import { computed, onScopeDispose, ref, watch } from 'vue' -export type Language = 'cpp' | 'python' | 'rust' +export type Language = 'c' | 'cpp' | 'python' | 'rust' export const languageLabels: Record = { - cpp: 'C++', + c: 'C17', + cpp: 'C++20', python: 'Python', rust: 'Rust', } export const codeTemplates: Record = { + c: '#include \n\nint main(void) {\n return 0;\n}\n', cpp: '#include \nusing namespace std;\n\nint main() {\n // 在这里编写你的解法\n return 0;\n}\n', python: '# 在这里编写你的解法\n\n', rust: 'use std::io::{self, Read};\n\nfn main() {\n let mut input = String::new();\n io::stdin().read_to_string(&mut input).unwrap();\n // 在这里编写你的解法\n}\n', @@ -30,11 +32,11 @@ export function useCodeDraft(problemId: string) { typeof saved.drafts === 'object' ) { const storedDrafts = saved.drafts as Record - for (const lang of ['cpp', 'python', 'rust'] as const) { + for (const lang of ['c', 'cpp', 'python', 'rust'] as const) { const value = storedDrafts[lang] if (typeof value === 'string') drafts.value[lang] = value } - if ('language' in saved && ['cpp', 'python', 'rust'].includes(String(saved.language))) + if ('language' in saved && ['c', 'cpp', 'python', 'rust'].includes(String(saved.language))) language.value = saved.language as Language state.value = 'saved' } diff --git a/frontend/src/composables/useSession.ts b/frontend/src/composables/useSession.ts new file mode 100644 index 0000000..581be13 --- /dev/null +++ b/frontend/src/composables/useSession.ts @@ -0,0 +1,49 @@ +import { readonly, ref } from 'vue' +import { api, onUnauthorized } from '../api/client' +import { ApiError, csrfHeaders, unwrap } from '../api/transport' +import type { components } from '../api/generated/schema' + +const user = ref(null) +let generation = 0 +let pending: Promise | undefined +onUnauthorized(() => { + generation++ + user.value = null +}) + +async function refresh() { + if (pending) return pending + const current = generation + pending = (async () => { + try { + const data = unwrap(await api.GET('/api/v1/auth/me')) + if (current === generation) user.value = data + return current === generation && user.value !== null + } catch (error) { + if (error instanceof ApiError && error.status === 401) return false + throw error + } finally { + pending = undefined + } + })() + return pending +} + +async function login(username: string, password: string) { + const current = ++generation + user.value = null + const data = unwrap(await api.POST('/api/v1/auth/login', { body: { username, password } })) + if (current === generation) user.value = data.user +} + +async function logout() { + const response = await api.POST('/api/v1/auth/logout', { headers: csrfHeaders() }) + if (!response.response.ok && response.response.status !== 401) + throw new ApiError(response.response.status) + generation++ + user.value = null +} + +export function useSession() { + return { user: readonly(user), refresh, login, logout } +} diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 851d43b..4cfe238 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -1,7 +1,12 @@