Client-Server Synchronisation

Sitehoster synchronises your local sites to the server over a private, token-protected API on the server's IP. Caddy terminates TLS and reverse-proxies to a localhost-only Node service, and the sync client pushes only the files that changed. The read-only endpoints behind the logs and stats browsers share this shape and live on the Tools page.

GET    /api/version
GET    /api/v2/tree
PUT    /api/v2/file
DELETE /api/v2/file
The sync API: discover the version, walk the Merkle tree, and upload or delete a file.

Private API

Every call is authenticated with a bearer token — the sitehoster-config-apiToken from your client configuration, sent as Authorization: Bearer <token>. There is nothing else to the surface: no dashboard, login, session or cookie. Without a valid token — a missing or wrong token, or an unknown route — every reply is an identical plain 404, so a probe cannot tell the API is there at all (see Server). With a valid token the HTTP status line carries the outcome: 200 ok, 400 a bad path, 410 the path is absent (normal descent — the client will create it), 500 a failed write, and 426 a request on a different API version's path. The file routes are versioned under /api/v2/; the client pins the prefix to its own shared/version.js version, so a server on a different version answers 426 (or the wall) rather than the client silently following it onto a contract it does not speak.

EndpointPurpose
GET /api/versionReport the server's API version, so the client can detect skew and build its path prefix.
GET /api/v2/treeFetch the Merkle node at a path and depth, so the client can compare and descend only where it differs.
PUT /api/v2/fileUpload one file and stamp it with its source mtime, so both sides hash it identically.
DELETE /api/v2/fileDelete a file, or a whole site, that is on the server but gone locally.

GET /api/version

Reports the server's wire-contract version. It is deliberately unversioned (no /api/vN prefix), so a client whose version has drifted can still ask what the server speaks. When a versioned request comes back 426 (or the wall), the client reads this endpoint to say which side to upgrade — turning an otherwise opaque failure into a clear message. The client itself pins its path prefix to its own shared/version.js version; see common/net.js.

Request

MethodGET
Path/api/version
AuthAuthorization: Bearer <token>
ParametersNone.
BodyNone.

Response

A 200 JSON object:

{ "api": 2 }
FieldTypeMeaning
apiintegerThe server's API/protocol version. The client compares it to its own; on a mismatch it reports which side to upgrade.

GET /api/v2/tree

Returns the Merkle node for a directory, so the client can compare hashes and descend only into the subtrees that differ. A file node is its metadata hash; a directory node is the hash of its children, optionally with those children one level down. Every hash is derived from stat() alone — size and whole-second mtime — so this reads no file contents. The server answers it from an in-memory tree, so it never walks the disk per request.

Request

GET /api/v2/tree?path=example.com&depth=1 HTTP/1.1
Authorization: Bearer <token>
Fetch the example.com directory one level deep.
ParameterInMeaning
pathqueryThe relative directory path. Empty (path=) is the content root. An illegal or unsafe path is answered with 410, the same as a path that does not exist.
depthquery0 = this node's hash only, no children; 1 = include the immediate children; omitted / -1 = the whole subtree.

Plus Authorization: Bearer <token>; no body.

Response

A 200 with the tree node, or 410 Gone when the path is not present (an empty body's worth of meaning — the client reads a 410 as “the server has nothing here” and uploads the subtree). A directory fetched at depth=1 looks like:

{
  "type": "dir",
  "hash": "b1946ac9…",
  "children": {
    "example.com": { "type": "dir",  "hash": "9f86d081…" },
    "robots.txt":  { "type": "file", "hash": "3a7bd3e2…" }
  }
}
A directory node at depth=1. At depth=0 the children map is omitted.
FieldTypeMeaning
typestring"dir" or "file".
hashstringThe node's Merkle hash — for a file, sha256(size:mtime); for a directory, sha256 of its sorted name:childhash lines. Equal hashes ⇒ identical subtrees.
childrenobjectPresent on a directory only when depth ≠ 0: a map of child name → node, each trimmed to the remaining depth.

A path that does not exist, or is not a legal path, is 410 Gone rather than a 200 body — see Errors.

PUT /api/v2/file

Uploads one file — creating or overwriting it — and stamps it with the client's mtime, so the metadata hash matches on both sides next run. The body streams straight to a hidden .shtmp-* temp file in the same directory and is renamed into place atomically, so a half-written file is never served and memory stays bounded regardless of file size (see the content root). HTML is uploaded with its sitehoster: directive comments stripped.

Request

PUT /api/v2/file?path=example.com/index.html&mtime=1784514159000 HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/octet-stream

<file bytes>
Upload one file, stamped with its source mtime.
ParameterInMeaning
pathqueryThe relative file path. It must sit inside a domain folder; a bare top-level file, or an unsafe path, is rejected with 400.
mtimequeryThe source modification time in milliseconds; the server applies it to the written file so the hashes agree.
(body)bodyThe raw file bytes, streamed with backpressure.

Method PUT, plus Authorization: Bearer <token>.

Response

A 200 JSON object describing the write:

{ "size": 1234, "mtime": 1784514159000 }
FieldTypeMeaning
sizeintegerThe number of bytes written on disk.
mtimeintegerThe applied modification time in milliseconds (floored to the whole millisecond).

An invalid path is 400, and a failed upload or write is 500, each with an { "error": … } body — see Errors.

DELETE /api/v2/file

Removes a file, or a whole directory subtree (a site), that exists on the server but is gone locally. A directory delete removes its subtree and then prunes any now-empty parent directories, since the server never stores empty directories. Deleting a path that is already absent is still a 200 — the desired state is reached either way, so delete is idempotent.

Request

DELETE /api/v2/file?path=example.com/old-page.html HTTP/1.1
Authorization: Bearer <token>
Remove a file that is gone locally.
ParameterInMeaning
pathqueryThe relative file or directory path to remove. An unsafe path is rejected with 400.

Method DELETE, plus Authorization: Bearer <token>; no body.

Response

A 200 JSON object:

{ "deleted": true }
FieldTypeMeaning
deletedbooleantrue if something was removed; false if the path was already absent (still a 200).

An invalid path is 400 with an { "error": … } body — see Errors.

Synchronisation Process

Synchronisation is a recursive comparison of two Merkle trees built from file metadata; the client only ever sends the differences. Each file's hash is sha256(size : mtime-seconds), derived from stat() alone, so building the tree reads no file data; a directory's hash is the hash of its sorted child name:hash lines. Two directories therefore share a hash iff every file beneath them has identical size and timestamp — which is what lets the client compare one root hash and descend only where it differs. The tree builder is shared with the server in manifest.js, so both sides can never disagree. A run is stateless: it is driven by sitehoster-sync/lib/sync.js, recomputing everything from stat() each time (an index only skips re-reading unchanged files — never changing the result). Automation runs first: --sync always rewrites every copy from its master before pushing, so un-automated content is never published.

1

Pin the version

Every call uses the /api/v<N> prefix from the client's own shared/version.js. A server on a different version answers 426; the client then reads GET /api/version to say which side to upgrade, not a silent misparse.

2

Compare the root hash

GET /api/v2/tree?path=&depth=0 — if the server's root hash equals the local one, nothing changed and the run is done in a single request. This is the common case.

3

Descend the differences

GET /api/v2/tree?path=<dir>&depth=1 — for each directory whose hash differs, fetch one level and compare child by child. Identical subtrees are skipped entirely; this builds the upload and delete lists.

4

Push new & changed files

PUT /api/v2/file?path=<rel>&mtime=<ms> — streamed with backpressure, so a multi-GB file stays near-constant in memory. The server applies the sent mtime so the hashes match next time.

5

Remove what's gone

DELETE /api/v2/file?path=<rel> — anything on the server but absent locally is deleted. Deletes run first (so a file↔directory type change is handled cleanly), then the uploads.

Done — hashes agree

Because the upload carried the source mtime, the next run's root hashes match on the first request. Nothing stays resident; the client watches no files.

Why it stays correct and cheap

  • No caches on the wire. Hashes are recomputed from stat() each run, on both sides. The client is stateless; the server keeps its tree in memory but rebuilds it from disk on restart, and the recomputed hashes still match because the upload preserved the timestamps.
  • Quick-check semantics. Like rsync's default, a change is detected by a difference in size or whole-second mtime. Editing a file updates its mtime, so real edits are always caught.
  • One small surface. A bearer token guards every call; there is no dashboard, login, session or cookie to attack.
  • The status line carries the outcome. An authenticated call answers with a real status — 200 ok, 400 bad path, 410 absent, 500 failed — but anything without a valid token gets an identical 404 wall, so an unauthenticated probe still can't tell an API is there at all.
  • Versioned, so skew is loud. The routes are /api/v2/…; a request on another version's path answers 426, and the client reads GET /api/version to say “upgrade the client” instead of failing silently.

Errors

With a valid token, a failure is an HTTP status with a short { "error": … } body. Without one, every reply is the same 404 wall.

StatusWhen it appears
400An invalid or unsafe path on a PUT or DELETE (outside a domain folder, a bare top-level file, or a traversal attempt).
410A GET /tree for a path the server does not have. Not an error in the sync — the client reads it as “absent” and uploads the subtree.
426An authenticated request on a different API version's path (/api/v<other>/…). The client reads GET /api/version and reports which side to upgrade.
500A write failed on the server (a failed upload stream or rename).
404The wall: a missing or wrong token, or an unknown route. Identical to a real not-found, so a probe learns nothing.