rCTF Docs
Overview

Static export

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

The apps/export/ tool produces a self-contained static snapshot of a running rCTF instance. It crawls the live API, writes every response to JSON under api-data/, mirrors uploaded files, copies the SvelteKit web build, and injects a window.fetch interceptor that serves those JSON files in place of the API. The exported client config has isArchived set to true, and non-GET requests return 405 Method Not Allowed, so the archive remains functional without a database or API server.

CLI#

The exporter runs against a live rCTF API. Build the SvelteKit frontend first so a static apps/web/build/ exists, then invoke the root script:

Terminal window
bun run --filter '@rctf/web' build
bun run export \
--api-url https://ctf.example.com \
--backend cloudflare-pages \
--output ./export-output
FlagDefaultDescription
--api-urlrequiredBase URL of the live rCTF instance to crawl.
--backendrequiredTarget host. One of cloudflare-pages or github-pages.
--web-buildapps/web/build/Pre-built SvelteKit frontend that is copied into the output.
--output./export-output/Destination directory. Wiped and recreated on every run.
--concurrency5Concurrent in-flight API requests during the discovery phase.
--skip-uploadsoffSkips 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 passed, every challenge file URL, avatar URL, sponsor icon, favicon, logo, and OG image is downloaded. Anything under /uploads/ is mirrored to the same path. Absolute URLs (S3, Discord CDN, and similar) are hashed and stored under uploads/external/<hash>/<filename>, and the JSON files in api-data/ are rewritten to point at the local copies so the archive is self-contained.

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 injector also strips the Content-Security-Policy meta tag from index.html so the inline interceptor script can execute on the static host.

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

The github-pages backend writes an empty .nojekyll so files under _app/ are served instead of being filtered by Jekyll.

Publish with gh-pages from the repository root. The --dotfiles flag is required so .nojekyll is uploaded:

Terminal window
npx gh-pages -d ./export-output --dotfiles

Pages served from a project subpath (username.github.io/repo) need a custom domain or root deployment, since the injected interceptor and SvelteKit build both assume the site is served from /.

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.

The frontend reads isArchived from the client config and hides interactive controls (flag submission, profile edits, account creation) in the UI.

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.

Re-run the exporter against the live instance to refresh the snapshot. The output directory is rebuilt from scratch on every invocation.

Esc

Start typing to search the docs.