Static export

Generate a read-only snapshot of a finished rCTF instance for Cloudflare Pages or GitHub Pages.

Edit this page View Markdown

The rctf export command of the rctf CLI turns a running rCTF instance into a read-only static site. It preserves challenges, profiles, solves, the leaderboard, and uploaded files without requiring a database or API server.

CLI

The exporter reads from a live rCTF API and copies a built SvelteKit frontend. Build the frontend first, then run the export command:

Terminal window
bun run --filter '@rctf/web' build
bun rctf export \
--api-url https://ctf.example.com \
--backend cloudflare-pages \
--output ./export-output
Flag Default Description
--api-url required Base URL of the live rCTF instance to crawl.
--backend required Target host. One of cloudflare-pages or github-pages.
--web-build apps/web/build/ Pre-built SvelteKit frontend that is copied into the output.
--output ./export-output/ Destination directory. Wiped and recreated on every run.
--concurrency 5 Concurrent in-flight API requests during the discovery phase.
--skip-uploads off Skips downloading challenge files and avatars. URL rewriting is skipped with it.

The output directory is deleted before each run. Point --output somewhere that does not contain other files.

What the exporter collects

The discovery phase fetches:

  • /api/v2/integrations/client/config with isArchived patched to true.
  • /api/v2/challs and /api/v2/leaderboard/challs.
  • The full leaderboard and graph, paginated and merged into a single api-data/v2/leaderboard/dump.json.
  • One /api/v2/users/:id response per unique user seen on the leaderboard.
  • Every challenge’s paginated solves, merged into one api-data/v2/challs/:id/solves.json per challenge.
  • A static 401 badToken stub for /api/v2/users/me so the frontend stays in a logged-out state.

Unless --skip-uploads is set, the exporter downloads challenge files, avatars, sponsor icons, the favicon, logos, and the OG image. Files already under /uploads/ keep that path. Files from S3, Discord, and other external hosts are stored under uploads/external/<hash>/<filename>. The saved JSON responses are updated to use the local copies.

Output layout

The resulting directory is ready to upload to a static host:

  • export-output/
    • index.html SvelteKit shell with the fetch interceptor injected
    • 404.html Copy of index.html for SPA fallback
    • index.html.gz
    • index.html.br
    • _app/ SvelteKit build assets
    • api-data/ Static API responses
      • v2/
        • challs.json
        • leaderboard/
          • dump.json Full leaderboard + graph for client-side filtering
        • users/
          • [id].json
        • challs/
          • [id]/
            • solves.json
    • uploads/ Mirrored /uploads/ and uploads/external/[hash]/
    • _redirects cloudflare-pages only
    • _headers cloudflare-pages only
    • wrangler.toml cloudflare-pages only
    • .nojekyll github-pages only

The exporter removes the Content-Security-Policy meta tag from index.html because the static archive injects an inline request interceptor.

Deployment

The exporter prints the matching deploy command on success.

The cloudflare-pages backend writes:

  • _redirects with /* /index.html 200 so SvelteKit’s client-side routes resolve.
  • _headers setting Cache-Control: public, max-age=31536000, immutable on /api-data/*, /uploads/*, and /_app/*, plus Access-Control-Allow-Origin: * on /api-data/*.
  • wrangler.toml with a placeholder name = "rctf-archive" and pages_build_output_dir = ".". Edit the name before deploying.

Deploy from the output directory:

Terminal window
npx wrangler pages deploy ./export-output

Runtime behavior

The injected script wraps window.fetch and routes /api/* calls through static files:

  • Any non-GET request returns 405 Method Not Allowed with "kind": "badEndpoint" and the message "This is a read-only archive.". Login, registration, flag submission, profile edits, and admin actions all fail with this response.
  • /api/v2/leaderboard/with-graph, /api/v2/leaderboard/now, and /api/v2/leaderboard/graph are answered from dump.json with in-memory division filtering, search substring matching, and offset / limit slicing. The scoreboard stays searchable without a backend.
  • /api/v2/challs/:id/solves is sliced from each challenge’s solves.json.
  • /api/v2/integrations/analytics/script returns an empty 404 so the archived bundle does not ping a third-party analytics provider.
  • Every other /api/* GET is rewritten to /api-data/<path>.json and served from the static bundle. Missing files return a 404 badEndpoint.

When isArchived is set, the frontend hides flag submission, profile editing, account creation, and other interactive controls.

Limitations

Warning (Read-only by design)

The archive contains every JSON response the exporter saw at crawl time. Anything that requires authentication, server state, or live computation is absent.

  • No login, registration, password recovery, or token issuance. /api/v2/users/me always returns 401 badToken.
  • No admin pages. Admin endpoints require an authenticated session that the archive cannot produce.
  • No live updates. The leaderboard reflects the moment the exporter ran. Re-run the export to refresh.
  • No instancer, admin bot, or CTFtime export. Those depend on the live API and external services.
  • Captcha, analytics, and any other integration that calls back to the API or a third party is disabled.

To refresh the snapshot, run the exporter again while the live instance is still available. Each run rebuilds the output directory from scratch.

Type to search.