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:
bun run --filter '@rctf/web' buildbun run export \ --api-url https://ctf.example.com \ --backend cloudflare-pages \ --output ./export-outputThe 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/configwithisArchivedpatched totrue./api/v2/challsand/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/:idresponse per unique user seen on the leaderboard. - Every challenge’s paginated solves, merged into one
api-data/v2/challs/:id/solves.jsonper challenge. - A static
401 badTokenstub for/api/v2/users/meso 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:
_redirectswith/* /index.html 200so SvelteKit’s client-side routes resolve._headerssettingCache-Control: public, max-age=31536000, immutableon/api-data/*,/uploads/*, and/_app/*, plusAccess-Control-Allow-Origin: *on/api-data/*.wrangler.tomlwith a placeholdername = "rctf-archive"andpages_build_output_dir = ".". Edit the name before deploying.
Deploy from the output directory:
npx wrangler pages deploy ./export-outputThe 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:
npx gh-pages -d ./export-output --dotfilesPages 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-
GETrequest returns405 Method Not Allowedwith"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/graphare answered fromdump.jsonwith in-memorydivisionfiltering,searchsubstring matching, andoffset/limitslicing. The scoreboard stays searchable without a backend./api/v2/challs/:id/solvesis sliced from each challenge’ssolves.json./api/v2/integrations/analytics/scriptreturns an empty404so the archived bundle does not ping a third-party analytics provider.- Every other
/api/*GETis rewritten to/api-data/<path>.jsonand served from the static bundle. Missing files return a404 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/mealways returns401 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.