Skip to main content

Hosting structures securely

How to share a D2H web package with a customer over the internet — without exposing it to everyone else.

A structure is not ordinary web content

When D2H exports a web package, it produces a static microsite: a browsable tree of the recovered files, with no backend. The customer opens it and sees exactly what was recovered — before they pay.

But that file listing is private. It contains the names of one customer’s documents, photos and business data. So it must not be publicly reachable, must not sit on a guessable address, must not be indexed by search engines, and access must be time-limited and revocable.

The whole guide in one sentence

Never serve the static files directly — always put a thin gateway in front that verifies a signed link and only then releases the file.

See a live example of the output

The setup at a glance

Three independent parts. You can reason about each one on its own.

A

Generate

D2H produces a static microsite and packs it into a .tar.gz named after the case number.

B

Transfer & unpack

The technician uploads over chroot SFTP; a cron job validates the archive and unpacks it into the data directory.

C

Serve securely

A thin gateway verifies a signed, time-limited link and hands the file to the web server via an internal redirect.

Step A — The generated microsite

D2H output is purely static — HTML, JS and data, no backend, no database, no outbound calls. That is deliberate: static content has almost no attack surface, and one archive runs anywhere.

Rules for the archive

  • Name the file after the case number: 12345.tar.gz (an optional suffix like 12345-Data.tar.gz is fine).
  • Put the files at the root of the archive (index.html, data/ …) — no nested folder inside.
  • A new archive with the same number replaces the previous version.
12345.tar.gz
├── index.html      # the whole viewer, self-contained
├── data/           # compressed file list, loaded on demand
├── pako.min.js     # the only JS dependency
└── logo.svg

Keep the page self-contained

  • No external dependencies — CDNs, fonts, analytics. Every outbound request tells a third party who is looking at what. Keep everything inline or local.
  • No internal metadata in the published files — disk paths from the technician’s machine, names, internal IDs (see the done.json note below).
  • Reference assets relatively (data/…, pako.min.js) so the page works behind the gateway under a token.

Step B — Transfer and unpack

Upload over chroot SFTP (no shell)

Create a dedicated system user just for uploads, jailed (chroot) inside the data directory with the shell disabled. Even if the password leaks, the attacker can only drop a file into one folder — the rest of the server is out of reach. Uploads go to a staging folder, not the served directory, so a half-finished file is never shown to a customer.

sftp d2h-upload@structures.example.com
cd upload
put 12345.tar.gz
quit

Unpack by cron (validate, then quarantine)

A cron job (say every 5 minutes) watches the staging folder and validates each archive against a whitelist before unpacking:

  • The name must be a clean case number (optionally with an allowed suffix).
  • The number must fall inside your allowed order-number range — this rejects typos and forged names.
  • Invalid files are not deleted — they are moved to a quarantine folder (with a timestamp prefix) for later review. Deleting them would hide the trace of a mistake or an attack.
  • Every action is logged: what, when, and why it was rejected.

Step C — Secure serving through the gateway

The data files are never directly reachable. In nginx the data directory is marked internal (unreachable from outside). The only thing facing the internet is a thin gateway that verifies a signed token (or a signed cookie) and then tells nginx to send the file via X-Accel-Redirect.

Signed links (HMAC, time-limited)

Your information system generates a link cryptographically signed with a shared secret. The token is not a random string stored in a database — it is an HMAC, so the gateway verifies it by recomputing it, with no storage at all.

  • Signed, not guessable — without the secret you cannot forge a valid link.
  • Time-limited — the expiry is part of the signature, so an old link cannot be extended.
  • Stateless — the gateway looks nothing up; it just recomputes the HMAC and compares.
gateway.php
// Generate the link (in your information system)
// shape: /v/{number}-{expiry}-{hmac12}
$expiry = time() + 86400;            // 24 h for a direct link
$hmac   = substr(hash_hmac('sha256',
          $orderNumber . '.' . $expiry, $secret), 0, 12);
$url    = 'https://structures.example.com/v/'
        . $orderNumber . '-' . $expiry . '-' . $hmac;

// Verify at the gateway — same computation, constant-time compare
$expected = substr(hash_hmac('sha256',
          $orderNumber . '.' . $expiry, $secret), 0, 12);
if ($expiry < time() || !hash_equals($expected, $hmac)) {
    http_response_code(403);
    exit;
}

Before building the X-Accel-Redirect path, validate the case number from the URL as strictly numeric. Parsing /v/{number}-… already enforces this, but stating it explicitly guards against path traversal.

Suggested link lifetimes

Internal preview (technician / office, from the IS)24 hours
Link e-mailed to the customer14 days

Signed cookies for assets (not a PHP session)

The microsite loads further files (data/…, JS). So the gateway doesn’t need a token in every URL, it sets a signed cookie after verifying the entry link; assets are then authorized by that cookie. Not a PHP session — no server-side state to leak or clean up, and it scales without a shared session store.

Read-only database user

If the gateway checks a case against your database, use a separate account with SELECT only on the necessary tables — never the production IS user. If the gateway is ever compromised, nothing can be written or deleted.

nginx — the key idea

nginx.conf
server {
    server_name structures.example.com;

    # add_header does NOT inherit — repeat it in every nested location
    add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
    add_header Strict-Transport-Security "max-age=31536000" always;

    # data directory — unreachable from outside, only via X-Accel-Redirect
    location /internal-data/ {
        internal;
        alias /var/www/structures-data/;
        add_header Cache-Control "no-cache" always;
        add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
        add_header Strict-Transport-Security "max-age=31536000" always;
    }

    # everything else goes through the gateway
    location / {
        try_files $uri /index.php?$query_string;
    }

    # block done.json — D2H metadata with internal paths, the viewer never uses it
    location ~ /done\.json$ { return 404; }
}

The data directory is internal — reachable only through the gateway’s X-Accel-Redirect. Note that add_header does not inherit in nginx: once a nested location sets its own add_header, every header must be repeated there.

Threat model

What can go wrong, and what stops it.

ThreatMitigation
Guessing the URL (/12345/)No direct path exists — only /v/<token> with an HMAC signature.
A stolen link lives foreverThe expiry is part of the signature; the gateway rejects an expired link.
Search-engine / scanner indexingX-Robots-Tag: noindex and robots.txt: Disallow: /.
Direct access to the filesnginx internal + X-Accel-Redirect — everything goes through the gateway.
Leaking internal metadataReturn 404 for done.json (see below).
Compromised upload accountSFTP chroot + no shell, upload into a staging folder only.
Gateway compromise → DB writesThe gateway’s DB account is SELECT-only.
MITM / downgradeHTTPS (e.g. Let’s Encrypt) + HSTS.
A CDN caching private dataRun the CDN in DNS-only mode (no proxy) for this domain.
Browser holding an old versionCache-Control: no-cache on the data files.

One file to always block: done.json

D2H writes a done.json manifest next to the structure. It contains source_dir — the absolute path on the technician’s machine. The viewer does not use this file, but if it is served, it leaks internal information (machine and user names, internal data layout). Return 404 for done.json at the gateway, or delete it when the archive is unpacked.

The recommended recipe

A reusable pattern any time you host private, generated content.

  1. 1Generate static output with no outbound dependencies — everything inline or local.
  2. 2Separate storage from serving — mark the data directory internal, expose only a thin gateway.
  3. 3Grant access only through a signed, time-limited link — HMAC-SHA256 over {id}.{expiry} with a secret, truncated (12 chars is enough). Keep the secret out of the repository.
  4. 4After verifying the token, use X-Accel-Redirect (nginx) / X-Sendfile (Apache) — the gateway never reads the file itself, it just tells the server to send it.
  5. 5Authorize assets with a signed cookie, not a PHP session.
  6. 6Lock down uploads — a dedicated SFTP account, chroot + no shell, into a staging folder.
  7. 7Validate input against a whitelist (name format + number range); quarantine invalid files, don’t delete them.
  8. 8Disable indexing (X-Robots-Tag, robots.txt) and enforce HTTPS + HSTS.
  9. 9Use a read-only DB account from the gateway.
  10. 10Run any CDN in DNS-only mode for the private content.
  11. 11Review exactly what the published files contain — no internal paths or metadata (always block done.json).

Pre-launch checklist

  • The microsite makes no external requests (check the browser Network tab).
  • done.json returns 404 (or is deleted on unpack).
  • The archive is <number>.tar.gz with files at the archive root.
  • A direct GET /internal-data/… returns 404.
  • GET /<number>/ without a token is not reachable.
  • A valid /v/<token> works; after expiry it returns 403/404.
  • A forged HMAC returns 403/404.
  • robots.txt is Disallow: /; responses carry X-Robots-Tag: noindex and HSTS.
  • The SFTP account is chroot + no shell (an ssh attempt must fail).
  • The gateway’s DB account can only SELECT (an INSERT must fail).
  • Any CDN is DNS-only for this domain.
  • Secrets and passwords live only in server-side config, out of the repository.

What not to do

  • Serving a structure on a public, guessable URL (/orders/12345/).
  • Relying on “nobody knows the URL” instead of a cryptographic signature.
  • A random token in the database as the only protection (extra state; leaks with the DB).
  • Letting the web server serve the data directory directly, without a gateway.
  • Internal metadata in the published files (done.json!).
  • External dependencies in the page (CDN, analytics).
  • A production read-write DB account in the gateway instead of read-only.
  • A CDN proxy caching private content.
  • Deleting invalid uploads instead of quarantining them.

Automate it

Every GUI feature is scriptable.

Batch-process cases, hook D2H into your intake pipeline, or run it on a headless server. Each web package includes a machine-readable manifest (done.json) with totals and version — ideal for watch-folder automation on the hosting side.

terminal
$ d2h --dir /recovered/case-1042 --output /srv/catalogs --lang en --html

D2H v1.6.1
Scanning: /recovered/case-1042
Found 239,556 files in 30,818 folders (18.7 GB)
Generating web output...
  Archive: /srv/catalogs/case-1042.tar.gz (48.2 MB)
  HTML:    /srv/catalogs/case-1042.html
Done!

Common options

--dir--output--html--title--lang--theme--logo--hidden--include-empty-dirs--follow-symlinks--config

Deploy once, configure per site

Drop a d2h.config.json next to the executable (perfect for a network share or a USB stick) and every technician gets the same defaults: branding, output paths, and regex rules that recognize your job numbering and auto-select language and destination per case. No installation, no per-machine setup.

d2h.config.json
{
  "branding": { "logoPath": "our-logo.png", "headerBackground": "#0a3d62" },
  "defaults": { "language": "de", "webOutputDir": "\\\\nas\\catalogs" },
  "jobId": {
    "pattern": "JOB-\\d{4}",
    "rules": [
      { "match": "^JOB-9", "language": "de", "webOutputDir": "\\\\nas\\catalogs-de" }
    ]
  }
}

Technical notes

  • Native desktop application (Rust + Tauri) — not a web service.
  • Output is static HTML/JS/CSS — no backend, no database, runs anywhere.
  • Web package: index.html plus compressed (gzip) data loaded on demand.
  • Single-file export: all data embedded in one HTML file (gzip + base64), works offline.
  • Search index and tree are built at export time; browsing is fully client-side.
  • The catalog contains metadata only — file names, sizes and modification dates. Never file contents.

What’s inside a web package

case-1042.tar.gz
├── index.html          # the whole viewer, self-contained
├── data/
│   ├── chunk_0.json.gz # directory data, on demand
│   ├── …
│   └── chunk_index.json.gz
├── full_index.json.gz  # search index (lazy)
├── pako.min.js         # the only JS dependency
├── logo.svg
└── done.json           # machine-readable manifest

Back to D2H

This setup shares the web packages D2H generates. See the tool itself for how they are produced.

D2H overview