# Architecture ## Overview `manage` consists of two halves: the server in this repository, and the client package that gets copied into each served project. One server installation serves **one product** with a manageable number of instances. For another product, `manage` is deployed again. Relevant directories: - `admin/` – UI - `api/v1/` – client interface - `includes/` – shared library - `storage/` – all state, not reachable over the web - `client-package/` – the redistributable folder for projects ## Data flow ```text Project (instance) Manage server ─────────────────── ───────────── manage-client/ bin/manage-client.php ──── check ───► api/v1/manifest.php ──► storage/releases/manifest.json ui/panel.php ──── update ──► api/v1/package.php ──► storage/releases/packages/ lib/*.php ──── backup ──► api/v1/backup.php ──► storage/backups// ──── status ──► api/v1/heartbeat.php ──► storage/instances.json │ ▼ admin/ (password login) ``` The client pulls; the server never pushes. There is no connection from the server to the instance, which makes running it behind NAT and firewalls straightforward. ## Authentication Two separate paths: | Path | Who | Means | |---|---|---| | `admin/` | humans | a password, session, CSRF, rate limiting | | `api/v1/` | instances | instance id + token in two headers, stateless | Tokens are stored as a SHA-256 hash and compared in constant time. The plaintext token appears exactly once, when created or rotated. Details: [client-package/docs/08_PROTOCOL.md](../client-package/docs/08_PROTOCOL.md). ## Instance registry `storage/instances.json` is the link between both modules. It replaces two separate mechanisms from the predecessor solution: the backup server's name list, and the update server's completely missing client identity. ```json { "instances": [ { "id": "example-prod", "label": "City of Freising Production", "enabled": true, "token_hash": "…", "created_at": "…", "token_rotated_at": "…", "last_seen_at": "…", "last_ip": "…", "version": "v1.3.14", "php_version": "8.3.6", "disk_free": 12884901888, "pending_migrations": 0, "last_backup_at": "…", "backup_count": 3, "notes": "" } ] } ``` Every successfully authenticated request updates `last_seen_at` and `last_ip`. The overview stays current this way, even without its own heartbeat. A deleted instance can no longer upload anything; its already-stored backups remain and stay visible in the UI. ## Releases `storage/releases/manifest.json` is the release database; the packages sit next to it in `packages/`: ```json { "latest": "v1.3.0", "releases": { "v1.3.0": { "version": "v1.3.0", "package": "packages/example-orderform-v1.3.0.zip", "sha256": "…", "size": 2199, "published_at": "…" } } } ``` Checksum and size are always computed by the server after upload; they are never taken from the uploader. An upload automatically sets the release as `latest`. The download URL is built from `MANAGE_PUBLIC_URL`, **not** from the `Host` header. The predecessor solution derived it from `HTTP_HOST` — a value controlled by the client. ## Backups ```text storage/backups/ index.json metadata of all backups /backup-YYYYmmdd-HHMMSS[-N].zip ``` After storing a file the server re-checks the checksum and deletes it on mismatch. An existing filename is never overwritten. ### Two retention tiers With the S3 archive active, the local disk works as a fast staging area and the bucket as the complete archive: - **S3**: keeps the most recent `s3_retention` backups per instance (default 365). - **Local**: keeps the most recent `retention` backups (default 30), but **never** deletes a file while its S3 upload is still pending. If S3 is unreachable, the local copies grow past their retention instead of losing the only copy. Failed S3 uploads are retried on the next upload from the same instance, or via the button in the UI. S3 errors never fail a client upload: the local copy already exists. They are logged to `storage/logs/s3.log`. ## Storage Flat files only, no database server. Every write goes through `manageWriteJsonFile()`: first into a `.tmp` file, then `rename()`. That way an aborted request can never leave a half-written index behind. Known limit: concurrent uploads from the same instance can collide while writing `index.json`. With a handful of instances doing nightly backups this is practically excluded; with many concurrent uploads a lock would be needed. ## Logs | File | Content | |---|---| | `storage/logs/access.log` | JSONL: logins, releases, received backups, downloads | | `storage/logs/error.log` | JSONL: failed logins, rejected uploads, internal errors | | `storage/logs/s3.log` | plain text: S3 diagnostics with status, redirects and request id | The JSONL logs rotate past `MANAGE_LOG_MAX_BYTES` and are removed after `MANAGE_LOG_MAX_AGE_SECONDS`. ## Next - [SERVER_SETUP](SERVER_SETUP.md) – installation - [INSTANCE_MANAGEMENT](INSTANCE_MANAGEMENT.md) – instances and tokens - [RELEASING](RELEASING.md) – publishing releases