Skip to content

feat(export): full data export — ZIP download + S3 presigned upload#1475

Merged
rumblefrog merged 8 commits into
mainfrom
feat/data-export-bundle
May 27, 2026
Merged

feat(export): full data export — ZIP download + S3 presigned upload#1475
rumblefrog merged 8 commits into
mainfrom
feat/data-export-bundle

Conversation

@rumblefrog

@rumblefrog rumblefrog commented May 27, 2026

Copy link
Copy Markdown
Member

Summary

Add a one-shot full data export feature to the panel. Owner-only
(no new permission flag — strictly ADMIN_OWNER). Two delivery
modes:

  1. ZIP download — streams a Content-Type: application/zip body
    straight from php://output through maennchen/zipstream-php.
    No staging file; the browser's progress bar moves in real time
    via per-entry flush() calls.
  2. S3 presigned PUT upload — builds the bundle to a staging
    tempfile under SB_CACHE/exports/, then PUTs it via cURL to the
    operator-supplied presigned URL. The tempfile is registered for
    register_shutdown_function cleanup so a mid-build fatal still
    wipes the staging file.

Both modes honour the bundle spec (manifest-first, per-
entity JSONL, STORE-mode demo entries, SteamID64-as-decimal-string,
unix-seconds integer timestamps, ZIP 2.0 with 4 GiB hard cap minus
64 MiB safety margin, pii_policy.password_hashes="never"
attestation).

Runtime model is synchronous in-request with PHP hardening
(@set_time_limit(0), @ini_set('memory_limit', '256M'),
ignore_user_abort(true), ob_buffer drain, X-Accel-Buffering: no,
Apache no-gzip). Works on non-FPM hosts, shared hosting, and
behind reverse proxies without requiring a background-worker
subsystem the panel doesn't have today.

Security highlights

  • ADMIN_OWNER-only. Navbar + command palette + page-builder
    route + page-handler defence-in-depth + top-level entry-point
    gate all check the same permission.
  • CSRF required on every state-changing POST.
  • Method gate: POST-only; GET/HEAD return 405.
  • FORBIDDEN_ADMIN_COLUMNS (password, validate, attempts,
    lockout_until, srv_password) NEVER projected from the SELECT.
    The list is the load-bearing contract that keeps the manifest's
    pii_policy.password_hashes="never" attestation truthful.
    srv_password specifically was added in the adversarial-review
    fix sweep (B1): the panel stores SourceMod admin server-login
    passwords cleartext, exporting them would let a bundle recipient
    authenticate against every game server the admin manages.
  • FORBIDDEN_SERVER_COLUMNS (rcon) NEVER projected — RCON is
    the keys-to-the-game-server credential.
  • FORBIDDEN_SETTING_KEYS filtered at the SQL WHERE level so a
    future post-fetch helper can't re-add them.
  • Audit-log entries on every reachable terminal branch (success +
    every error code), tagged with bundle_id so an operator can
    correlate a painted toast with the audit row.

Adversarial review findings (all addressed)

A read-only adversarial subagent reviewed the worker's first pass
and surfaced six issues; the final commit on this branch (ab167a41)
addresses all of them:

  • B1 (BLOCKER, security): drop plaintext admins.srv_password.
  • H1 (HIGH, OOM): BundleWriter::writeEntity now spills to
    php://temp/maxmemory:8M instead of concatenating the full
    entity into a PHP string.
  • M1 (MEDIUM, UX): cap counter snaps to fstat($outputHandle)['size']
    in s3 mode for exact compressed-byte tracking; zip-mode keeps
    the conservative uncompressed-byte estimate (over-counting is the
    safe direction).
  • L1: Log::add on mode_invalid + presign-URL-malformed
    rejection branches.
  • L2: bundle_id in disk-write-failed + fopen-failed log entries.
  • L3: empty-state CTA retargeted from the non-existent
    ?p=admin&c=bans&section=add-ban Demo column to the public banlist.

Tests

  • tests/unit/EntityExporterTest.php — per-entity column rules,
    forbidden-column non-leak, SteamID-as-string contract, mute-kind
    derivation, bans state matrix, unix-seconds timestamps. The
    forbidden-admin-column test now seeds a distinctive srv_password
    marker AND asserts the constant list itself includes every
    credential field.
  • tests/integration/ExportBundleWriterTest.php — full pipeline
    (ManifestBuilder → BundleWriter → on-disk ZIP), structural
    contract assertions (manifest-first, row-count agreement,
    STORE-mode demos, no bare-number SteamIDs, no leaked credentials).
    Two new regression tests:
    • testWriterBytesWrittenMatchesOnDiskSizeInS3Mode pins M1's
      exact-byte tracking.
    • testWriterDoesNotBufferLargeEntityIntoMemory pins H1's spill
      behaviour by seeding >15 MiB of comments and asserting peak
      memory delta stays under 64 MiB.
  • tests/unit/ManifestBuilderTest.php — manifest shape, cap math.
  • tests/integration/AdminExportPermissionTest.php +
    AdminExportRuntimePermissionTest.php — HTTP entry gates
    (CSRF, owner-only, method gate).
  • tests/integration/S3PresignedUploaderTest.php — cURL stub paths
    (success, network failure, non-2xx response, oversized body).
  • tests/e2e/specs/flows/data-export.spec.ts — Playwright end-to-end
    zip-mode download + jszip-based bundle inspection.

Documentation

  • AGENTS.md — "Full data export" conventions block + "Where to
    find what" rows for the entry point, writer, exporter, manifest.
  • ARCHITECTURE.md — "Data export" subsystem section.
  • docs/src/content/docs/configuring/data-export.mdx — operator-
    facing guide covering both modes, the bundle wire format, the
    pii_policy attestation, and S3 presign-URL setup.

Test plan

  • PHPStan clean
  • ts-check clean
  • api-contract regenerated + no diff
  • PHPUnit: full suite — only failures are pre-existing
    PluginVersionResolveTest cases (missing sbpp_version.inc
    in the dev container; unrelated to this branch, confirmed
    reproducing on stashed-clean main)
  • CI Playwright E2E (will run on PR push)
  • CI plugin-build (untouched — branch doesn't modify SourcePawn)
  • CLA workflow signature (web/** PR, will gate on signing)

Adds the ZIP-streaming library the upcoming "Full Data Export" feature
uses to produce the bundle. Locked at 3.2.2.
Lifts the data-export subsystem out of nothing into the five classes
the plan calls for: BundleWriter (orchestrator), EntityExporter
(per-entity streaming SELECT + JSONL emission), ManifestBuilder
(pre-flight pass + bundle cap math), S3PresignedUploader (HTTPS
PUT with single-flight test override), ExportError (typed exception
with code constants), plus a readonly Manifest DTO.

Producer contracts pinned per the plan:
- null-for-absent (never empty string, never omitted)
- decimal-string Steam64 + preserved Steam2 via validate-then-convert
  through SteamID::isValidID + toSteam64/toSteam2
- unix-seconds integers for every timestamp
- forbidden columns (admins.{password,validate,attempts,lockout_until},
  servers.rcon, settings.{smtp.pass,telemetry.instance_id}) dropped
  at the SQL projection level so a future code-path that bypasses
  the row builder still cannot leak them
- JSON_INVALID_UTF8_SUBSTITUTE on every encode site to defend against
  pre-#1108 Latin-1-on-utf8 truncation in player names
- 4 GiB cap minus 64 MiB safety margin enforced both pre-flight (via
  ManifestBuilder) and at-write (via BundleWriter's running-byte gate)

Entry point (web/export.php) lands in the next commit. PHPStan: clean.
Top-level entry point that lifts the data-export bundle out of the
admin page-handler flow. Lives at panel-root for the same reason
`exportbans.php` / `getdemo.php` do: the wire format is binary
(`Content-Type: application/zip`), so the JSON dispatcher's
`Content-Type: application/json` envelope doesn't fit and there's
no clean extension point for a streaming binary handler.

Hardening (each justified in the file's docblock):
- POST-only (GET / HEAD → 405; the form posts, direct GET is a probe)
- CSRF gate via `CSRF::rejectIfInvalid()` (state-changing PII surface;
  a stale tab MUST NOT trigger an export)
- Owner-only via `HasAccess(WebPermission::Owner)` (navbar / palette
  is UX gating; this is the load-bearing security check)
- `@set_time_limit(0)` + `@ini_set('memory_limit', '256M')` +
  `ignore_user_abort(true)` (shared-host safe — the `@` swallows
  `disable_functions = set_time_limit,ini_set` warnings)
- Output-buffer drain + `X-Accel-Buffering: no` + Apache's `no-gzip`
  so streamed bytes flow directly without re-buffering

Two delivery modes:
- ZIP: streams `php://output` straight to the wire; `flush()` after
  each entry keeps the browser's download progress bar moving;
  `application/zip` + `Content-Disposition: attachment` + aggressive
  `Cache-Control: no-store` so the dynamic response can't leak
  across operators
- S3: stages under `SB_CACHE/exports/<bundle-id>.zip` (S3 presigned
  PUT requires `Content-Length` upfront — can't be set on a
  chunked-streaming response), then `S3PresignedUploader::upload`
  PUTs to the operator URL; a `register_shutdown_function` wipes
  the staging file before the build even runs so a mid-build fatal
  still cleans up

Error handling: only `ExportError` is caught — anything else
(real DB outage, memory exhaustion, regression in the writer)
propagates to the dispatcher's generic 500 so the stack trace
lands in the audit log via the project's error handler. Catching
`Throwable` blanket would mask real bugs behind a generic toast.

Every reachable terminal branch (success + every error class) emits
an audit-log entry that includes the operator's `aid`, the
`bundle_id`, and (on failure) the `ExportError::code()` value so
operators can correlate the painted toast with the audit row.

The `Variable \$userbank might not be defined` PHPStan note lands
in `phpstan-baseline.neon` matching the existing `exportbans.php`
shape — same root cause (PHPStan can't follow $userbank from
init.php's top-level scope across a require_once).

Admin page handler + form template land in the next commit.
…ort.tpl form

Adds the owner-only admin landing page for the Full data export feature.
Mirrors the back-link-only AdminTabs shape used by admin.email.php /
admin.rcon.php (single section, no sub-routes).

The page handler re-runs the ManifestBuilder pre-flight (counts only —
no JSONL emission) so the form can surface the row counts + the
demo-on-disk byte budget + the 4 GiB cap status before the operator
commits to a stream. Toast feedback consumed from ?result=success&bid=…
or ?result=error&code=… set by web/export.php's S3 mode redirect.

Both submit buttons ride the window.SBPP.setBusy triple (data-loading +
aria-busy + disabled) via the local fallback wrapper per AGENTS.md
"Loading state on action buttons" — neither branch flips busy back to
false because both tear the page down (ZIP streams the response body,
S3 mode 302-redirects after the upload completes).
…alette

Adds the discovery surfaces for the Full data export admin page. All
three are gated on WebPermission::Owner only — the feature is
intentionally NOT delegated via a per-flag permission today because
every PII category in scope (admin emails, IP addresses, Steam IDs,
unban reasons) is owner-territory by default; a future split into
per-category exports can revisit this without breaking the v1
manifest contract.

No web.json edit; no WebPermission::ExportData case; no api-contract
regen (the entry point is web/export.php — a top-level streaming
script, not a JSON action — so the Actions/Perms surfaces are
unchanged).
Five PHPUnit files + one Playwright spec covering the data export
wire-format contract end-to-end:

- ExportBundleWriterTest (integration) — opens the streamed ZIP via
  ZipArchive, asserts manifest.json is the first entry, every entity's
  manifest row_count matches its JSONL line count, demos are STORE
  compression (already ZIP-style compressed binary), every Steam ID
  is a quoted decimal string never a JSON number, every timestamp is
  an integer, no forbidden column value leaks. 393 assertions.
- EntityExporterTest (unit) — forbidden columns absent per entity,
  SteamID always quoted decimal, mute_kind enum, BanType×BanRemoval
  state derivation matrix, null-for-absent contract, log level
  derivation, timestamps as int.
- ManifestBuilderTest (unit) — 4 GiB cap math, pii_policy block,
  format_version pin, UUIDv4 shape, created_at as unix-seconds int,
  exceeds_cap flag exposure.
- AdminExportPermissionTest + AdminExportRuntimePermissionTest —
  three permission gates per AGENTS.md "Public auth surfaces" sister
  pattern: anonymous → 403, non-owner with every other web flag →
  403, owner → allowed. Form GET access mirrors the gate.
- S3PresignedUploaderTest — scheme guard (http:// rejected), URL
  parser guard (garbage rejected), happy path with mocked transport
  via _setHttpTransportForTests, 200/201/204 all accepted, non-2xx
  surfaces as s3_put_failed with response body truncated to 2 KiB,
  byte-stable error code constants.
- data-export.spec.ts (Playwright, 6 tests across chromium + mobile
  chromium) — log in as seeded admin, navigate to ?p=admin&c=export,
  click Export as ZIP, page.waitForEvent('download'), parse with
  jszip, assert manifest-first contract + row_counts shape + 405 on
  direct GET.

The paired ManifestBuilder.php change filters the settings row count
through the same EntityExporter::FORBIDDEN_SETTING_KEYS allowlist the
exporter applies — without it the manifest's row_counts.settings was
N + |forbidden_keys| while the JSONL line count was N, which
ExportBundleWriterTest's "row_counts agrees with JSONL line counts"
assertion caught immediately.
…rt.mdx

Three documentation surfaces for the Full data export feature:

- AGENTS.md — new "Full data export" Conventions block (synchronous
  in-request execution model, ZIP-vs-S3 delivery asymmetry, the
  ignore_user_abort + @set_time_limit(0) + @ini_set memory_limit
  shared-host triplet, manifest-first contract, null-for-absent +
  Steam64-as-decimal-string + unix-seconds wire contracts, the
  hard-coded forbidden columns list with the "never relax" rationale,
  owner-only access with the per-flag delegation deferral, no schema
  change + no JSON handler rationale, persistent error toasts,
  HTTPS-only S3 scheme guard) + new "Where to find what" rows
  pointing at the entry point / page handler / View / template /
  presigned-URL workflow + a new sync-docs table row covering the
  Export/** + admin.export.* + page_admin_export.tpl surfaces.
- ARCHITECTURE.md — new "Data export" subsystem section under the
  panel's request-lifecycle area describing the Sbpp\Export\*
  classes, the entry-point dispatch, the manifest-first contract,
  and the 4 GiB cap. The web/ Directory layout listing gets
  web/export.php (in the panel-root scripts list) + the Export/
  namespace entry.
- docs/src/content/docs/configuring/data-export.mdx — operator-
  facing. What's in the bundle (entity list + spec contract: ZIP
  2.0 / 4 GiB cap / manifest-first / JSONL / Steam64-as-string /
  unix-seconds), two delivery modes (ZIP download vs S3 upload),
  worked presigned-URL generation examples for AWS S3 / Cloudflare
  R2 / MinIO, the 4 GiB cap, audit logging, and a security-model
  summary (owner-only gate, single-use presigned URLs, ≤1h expiry,
  never paste the URL into chat / logs) + a troubleshooting card
  per ExportError code (cap_exceeded / s3_put_failed /
  presign_invalid_scheme / presign_invalid_url / disk_write_failed
  / disk_full). New sidebar entry in docs/astro.config.mjs under
  the "Configuring" group, next to the announcements page.
Reviewer flagged six issues against the worker-produced export feature.
All six addressed in-place; runtime contract / streaming semantics
preserved.

- **B1 (BLOCKER, security)**: drop `admins.srv_password` from the
  export. The panel stores this field cleartext (varchar(128) with
  stringwise compare in `account.php`), so exporting it lets a bundle
  recipient authenticate as the admin against every game server they
  manage. Add the column to `FORBIDDEN_ADMIN_COLUMNS` so the
  manifest's `pii_policy.password_hashes="never"` attestation stays
  truthful. Regression test seeds a distinctive marker AND asserts
  the constant carries every credential-class field.

- **H1 (HIGH, OOM)**: switch `BundleWriter::writeEntity` from
  in-memory concatenation inside `addFileFromCallback` to
  `php://temp/maxmemory:8M` spill via `addFileFromStream`. Pre-fix
  the writer buffered the entire entity into a single PHP string,
  defeating the documented streaming contract and risking OOM on
  installs with multi-hundred-MB audit logs. Spill threshold matches
  the 256 MiB runtime hardening ceiling minus PDO/Smarty/Composer
  headroom. Regression test seeds >15 MiB of comments and asserts
  peak memory delta stays under 64 MiB.

- **M1 (MEDIUM, UX)**: BundleWriter now accepts an optional
  `$outputHandle` parameter; in s3 mode (build-to-disk path) the
  cap counter snaps to `fstat($handle)['size']` after each entry
  for exact compressed-byte tracking. Pre-fix the counter advanced
  by uncompressed bytes against a compressed limit, prematurely
  tripping `CAP_EXCEEDED` on JSONL bundles (DEFLATE typically
  compresses 3-8x). zip mode (php://output, unseekable) keeps the
  conservative uncompressed estimate as the documented fallback —
  over-counting is the safe direction (a premature error message
  beats silently exceeding ZIP 2.0's 4 GiB ceiling). Regression
  test asserts `bytesWritten()` matches on-disk size byte-for-byte.

- **L1**: emit `LogType::Warning` audit-log entries on the
  `mode_invalid` and presign-URL-malformed rejection branches so
  the painted toast correlates with an audit trail. URL length is
  logged (not the raw URL — operator-typed presign params can
  carry sensitive signature data even on the failure branch).

- **L2**: include `bundle_id` in the disk-write-failed and
  fopen-failed log entries so the audit row can be correlated with
  the manifest-build event upstream.

- **L3**: empty-state CTA in `page_admin_export.tpl` pointed at
  the non-existent `?p=admin&c=bans&section=add-ban` Demo column;
  retarget to the public banlist where per-row demo affordances
  actually live.
@rumblefrog
rumblefrog added this pull request to the merge queue May 27, 2026
Merged via the queue into main with commit 9dc06cc May 27, 2026
7 checks passed
@rumblefrog
rumblefrog deleted the feat/data-export-bundle branch May 27, 2026 15:20
@github-actions github-actions Bot locked and limited conversation to collaborators May 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant