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/v1/tree
PUT    /api/v1/file
DELETE /api/v1/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. A correctly authenticated call to a real endpoint always returns 200 with a JSON object — the outcome, including “that path is absent”, lives in the body. Anything else — a missing or wrong token, an unknown route, or a version mismatch — returns an identical plain 404, so a probe cannot tell the API is there at all (see Server). The file routes are versioned under /api/v1/; the client discovers the server's version from /api/version and builds the prefix from it, so a version bump fails loudly instead of misparsing.

EndpointPurpose
GET /api/versionReport the server's API version, so the client can detect skew and build its path prefix.
GET /api/v1/treeFetch the Merkle node at a path and depth, so the client can compare and descend only where it differs.
PUT /api/v1/fileUpload one file and stamp it with its source mtime, so both sides hash it identically.
DELETE /api/v1/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 and turn an otherwise opaque 404 into a clear “upgrade the client” message. The client calls it once at the start of a run to build the /api/v<N> path prefix every other request uses — see common/net.js and shared/version.js.

Request

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

Response

A 200 JSON object:

{ "api": 1 }
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/v1/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/v1/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 treated as absent.
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

The tree node. 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.
absentbooleanReturned instead — { "absent": true } — when the path does not exist or is not a legal path. The client reads this as “the server has nothing here”.

PUT /api/v1/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/v1/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 { ok: false }.
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:

{ "ok": true, "size": 1234, "mtime": 1784514159000 }
FieldTypeMeaning
okbooleantrue on success; false with an error string when the path is invalid or the write failed.
sizeintegerThe number of bytes written on disk.
mtimeintegerThe applied modification time in milliseconds (floored to the whole millisecond).
errorstringPresent only when ok is false, e.g. "invalid path".

DELETE /api/v1/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 success — status codes carry no meaning here.

Request

DELETE /api/v1/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 { ok: false }.

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

Response

A 200 JSON object:

{ "ok": true, "deleted": true }
FieldTypeMeaning
okbooleantrue unless the path was invalid (then false with error).
deletedbooleantrue if something was removed; false if the path was already absent.
errorstringPresent only when ok is false.

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 shared/manifest.js, so both sides can never disagree. A run is stateless: it is driven by client/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

Negotiate the version

GET /api/version — read the server's version and build the /api/v<N> prefix all later calls use. A skew turns into a clear “upgrade the client”, not a silent misparse.

2

Compare the root hash

GET /api/v1/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/v1/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/v1/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/v1/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.
  • Status codes say nothing. Every authenticated call returns 200 with an object, even “that path is absent”. Anything else returns an identical 404, so a probe can't tell an API is there at all.
  • Versioned, so skew is loud. The routes are /api/v1/…; a version mismatch hits the 404, then the client checks GET /api/version to say “upgrade the client” instead of failing silently.