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
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.
| Endpoint | Purpose |
|---|---|
GET /api/version | Report the server's API version, so the client can detect skew and build its path prefix. |
GET /api/v2/tree | Fetch the Merkle node at a path and depth, so the client can compare and descend only where it differs. |
PUT /api/v2/file | Upload one file and stamp it with its source mtime, so both sides hash it identically. |
DELETE /api/v2/file | Delete 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
| Method | GET |
|---|---|
| Path | /api/version |
| Auth | Authorization: Bearer <token> |
| Parameters | None. |
| Body | None. |
Response
A 200 JSON object:
{ "api": 2 }
| Field | Type | Meaning |
|---|---|---|
api | integer | The 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>
example.com directory one level deep.| Parameter | In | Meaning |
|---|---|---|
path | query | The 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. |
depth | query | 0 = 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…" }
}
}
depth=1. At depth=0 the children map is omitted.| Field | Type | Meaning |
|---|---|---|
type | string | "dir" or "file". |
hash | string | The node's Merkle hash — for a file, sha256(size:mtime); for a directory, sha256 of its sorted name:childhash lines. Equal hashes ⇒ identical subtrees. |
children | object | Present 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>
| Parameter | In | Meaning |
|---|---|---|
path | query | The relative file path. It must sit inside a domain folder; a bare top-level file, or an unsafe path, is rejected with 400. |
mtime | query | The source modification time in milliseconds; the server applies it to the written file so the hashes agree. |
| (body) | body | The raw file bytes, streamed with backpressure. |
Method PUT, plus Authorization: Bearer <token>.
Response
A 200 JSON object describing the write:
{ "size": 1234, "mtime": 1784514159000 }
| Field | Type | Meaning |
|---|---|---|
size | integer | The number of bytes written on disk. |
mtime | integer | The 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>
| Parameter | In | Meaning |
|---|---|---|
path | query | The 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 }
| Field | Type | Meaning |
|---|---|---|
deleted | boolean | true 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.
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.
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.
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.
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.
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 —
200ok,400bad path,410absent,500failed — but anything without a valid token gets an identical404wall, 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 answers426, and the client readsGET /api/versionto 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.
| Status | When it appears |
|---|---|
400 | An invalid or unsafe path on a PUT or DELETE (outside a domain folder, a bare top-level file, or a traversal attempt). |
410 | A 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. |
426 | An authenticated request on a different API version's path (/api/v<other>/…). The client reads GET /api/version and reports which side to upgrade. |
500 | A write failed on the server (a failed upload stream or rename). |
404 | The wall: a missing or wrong token, or an unknown route. Identical to a real not-found, so a probe learns nothing. |