diff --git a/.gitignore b/.gitignore index 003b816..273f648 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,21 @@ .bin/ data-dev/ + +# Local deployment/config secrets +.env +docker-compose.override.yml +example/autoupdate.json + +# Python +__pycache__/ +*.py[cod] + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon? +._* +.Spotlight-V100 +.Trashes +.fseventsd diff --git a/README.md b/README.md index 9b45847..30e59d5 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ environment variables. ## More Docs +- `docs/API.md` - current client API contract, examples, responses, and errors. - `docs/DEPLOYMENT.md` - deployment, reverse proxy, backup, and restore notes. - `deploy/update-server.env.example` - production environment example. - `deploy/Caddyfile.example` - Caddy reverse proxy example. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..2371cd3 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,631 @@ +# Update Server API + +Этот документ описывает текущий публичный HTTP API сервиса. Его можно передать +клиентскому приложению или агенту, который встраивает автообновление. + +Примеры ниже используют: + +```text +Base URL: https://updates.example.com +API key: upsk_replace_me +Project: desktop-app +``` + +В реальной интеграции замени `Base URL`, API key и `project_slug` на свои +значения. + +## Общая модель + +Update Server хранит проекты и релизы: + +- `project` - приложение, продукт или канал обновлений; +- `release` - загруженный файл обновления внутри проекта; +- `api key` - клиентский ключ доступа к API. + +Администратор работает через web UI: + +```text +/admin +/admin/projects +/admin/api-keys +``` + +Клиентские приложения работают через JSON API: + +```text +/api/v1 +``` + +На текущий момент JSON API предназначен для чтения и скачивания обновлений. +Создание проектов, загрузка релизов и создание API keys выполняются через admin +web UI, а не через публичный JSON API. + +## Авторизация + +Защищенные endpoint'ы требуют bearer token: + +```http +Authorization: Bearer +``` + +Пример: + +```bash +curl -i \ + -H "Authorization: Bearer upsk_replace_me" \ + https://updates.example.com/api/v1/projects +``` + +API key должен быть: + +- активным; +- не истекшим по `expires_at`, если срок задан; +- с разрешением `can_download`; +- со scope, который разрешает доступ к нужному проекту. + +Поддерживаемые scope modes: + +```text +all_projects +project_allow_list +project_deny_list +tag_allow_list +tag_deny_list +``` + +Полный API key показывается в admin UI только один раз при создании. Сервер +хранит только hash ключа. + +## Формат JSON + +Все JSON-ответы возвращаются с: + +```http +Content-Type: application/json; charset=utf-8 +``` + +Поля времени возвращаются строкой в формате RFC3339/RFC3339Nano, например: + +```json +"created_at": "2026-06-11T10:15:30Z" +``` + +Относительные URL в ответах, например `download_url`, нужно склеивать с +`Base URL` клиента. + +## Health Check + +### GET /healthz + +Публичная проверка готовности HTTP-сервера и SQLite. + +```bash +curl -i https://updates.example.com/healthz +``` + +Успешный ответ: + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +``` + +```json +{ + "database": "ok", + "service": "Update Server", + "status": "ok", + "timestamp": "2026-06-11T10:15:30Z" +} +``` + +Если база недоступна: + +```http +HTTP/1.1 503 Service Unavailable +``` + +```json +{ + "database": "unavailable", + "error": "database ping failed", + "service": "Update Server", + "status": "degraded", + "timestamp": "2026-06-11T10:15:30Z" +} +``` + +## API Index + +### GET /api/v1 + +Публичный JSON-index текущей версии API. Авторизация не нужна. + +```bash +curl -i https://updates.example.com/api/v1 +``` + +Ответ: + +```json +{ + "auth": { + "header": "Authorization: Bearer ", + "type": "bearer" + }, + "routes": [ + { + "description": "List active projects accessible to the API key.", + "method": "GET", + "path": "/api/v1/projects" + }, + { + "description": "Get the latest active release metadata for an accessible project.", + "method": "GET", + "path": "/api/v1/projects/{projectSlug}/releases/latest" + }, + { + "description": "Get release metadata for an accessible release.", + "method": "GET", + "path": "/api/v1/releases/{releaseID}" + }, + { + "description": "Download the private artifact for an accessible release.", + "method": "GET", + "path": "/api/v1/releases/{releaseID}/download" + } + ], + "service": "Update Server", + "status": "client-api-ready", + "version": "v1" +} +``` + +## List Accessible Projects + +### GET /api/v1/projects + +Возвращает активные проекты, доступные текущему API key. + +Требует: + +```http +Authorization: Bearer +``` + +Пример: + +```bash +curl -i \ + -H "Authorization: Bearer upsk_replace_me" \ + https://updates.example.com/api/v1/projects +``` + +Успешный ответ: + +```json +{ + "projects": [ + { + "id": 1, + "name": "Desktop App", + "slug": "desktop-app", + "description": "Primary desktop updater stream.", + "latest_release_url": "/api/v1/projects/desktop-app/releases/latest" + } + ] +} +``` + +Если доступных проектов нет: + +```json +{ + "projects": [] +} +``` + +## Get Latest Release + +### GET /api/v1/projects/{projectSlug}/releases/latest + +Возвращает latest active release для активного и доступного проекта. + +Latest release выбирается по: + +```text +created_at DESC, id DESC +``` + +Пример: + +```bash +curl -i \ + -H "Authorization: Bearer upsk_replace_me" \ + https://updates.example.com/api/v1/projects/desktop-app/releases/latest +``` + +Успешный ответ: + +```json +{ + "project": { + "id": 1, + "name": "Desktop App", + "slug": "desktop-app", + "description": "Primary desktop updater stream.", + "latest_release_url": "/api/v1/projects/desktop-app/releases/latest" + }, + "release": { + "id": 2, + "version": "1.1.0", + "build": "build-2", + "filename": "desktop-app-1.1.0.zip", + "checksum_sha256": "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7c6e64b2f7110b2", + "size_bytes": 12345678, + "content_type": "application/zip", + "release_notes": "Improved desktop rollout.", + "created_at": "2026-06-11T10:15:30Z", + "metadata_url": "/api/v1/releases/2", + "download_url": "/api/v1/releases/2/download" + } +} +``` + +Если проект не существует, архивирован или недоступен этому key: + +```http +HTTP/1.1 404 Not Found +``` + +```json +{ + "error": "resource not found" +} +``` + +Если проект доступен, но активных релизов нет: + +```http +HTTP/1.1 404 Not Found +``` + +```json +{ + "error": "release not found" +} +``` + +## Get Release Metadata + +### GET /api/v1/releases/{releaseID} + +Возвращает metadata релиза по ID. Релиз должен быть активным, его проект должен +быть активным, и текущий API key должен иметь доступ к проекту релиза. + +Пример: + +```bash +curl -i \ + -H "Authorization: Bearer upsk_replace_me" \ + https://updates.example.com/api/v1/releases/2 +``` + +Успешный ответ: + +```json +{ + "project": { + "id": 1, + "name": "Desktop App", + "slug": "desktop-app", + "description": "Primary desktop updater stream.", + "latest_release_url": "/api/v1/projects/desktop-app/releases/latest" + }, + "release": { + "id": 2, + "version": "1.1.0", + "build": "build-2", + "filename": "desktop-app-1.1.0.zip", + "checksum_sha256": "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7c6e64b2f7110b2", + "size_bytes": 12345678, + "content_type": "application/zip", + "release_notes": "Improved desktop rollout.", + "created_at": "2026-06-11T10:15:30Z", + "metadata_url": "/api/v1/releases/2", + "download_url": "/api/v1/releases/2/download" + } +} +``` + +Если `releaseID` не число: + +```http +HTTP/1.1 400 Bad Request +``` + +```json +{ + "error": "invalid release id" +} +``` + +Если релиз не существует, архивирован или недоступен: + +```http +HTTP/1.1 404 Not Found +``` + +```json +{ + "error": "resource not found" +} +``` + +## Download Release Artifact + +### GET /api/v1/releases/{releaseID}/download + +Скачивает приватный artifact релиза. Скачивание проходит через приложение, чтобы +сервер мог проверить API key, permission и project scope. + +Пример: + +```bash +curl -L \ + -H "Authorization: Bearer upsk_replace_me" \ + -o desktop-app-1.1.0.zip \ + https://updates.example.com/api/v1/releases/2/download +``` + +Успешный ответ: + +```http +HTTP/1.1 200 OK +Content-Disposition: attachment; filename=desktop-app-1.1.0.zip +Content-Type: application/zip +X-Content-Type-Options: nosniff +``` + +Body ответа - bytes загруженного файла. + +Если artifact отсутствует на диске: + +```http +HTTP/1.1 404 Not Found +``` + +```json +{ + "error": "release not found" +} +``` + +Если релиз недоступен: + +```http +HTTP/1.1 404 Not Found +``` + +```json +{ + "error": "resource not found" +} +``` + +## Project Object + +```json +{ + "id": 1, + "name": "Desktop App", + "slug": "desktop-app", + "description": "Primary desktop updater stream.", + "latest_release_url": "/api/v1/projects/desktop-app/releases/latest" +} +``` + +Поля: + +- `id` - numeric project ID. +- `name` - display name. +- `slug` - stable URL identifier. +- `description` - optional description. +- `latest_release_url` - relative URL для latest release lookup. + +## Release Object + +```json +{ + "id": 2, + "version": "1.1.0", + "build": "build-2", + "filename": "desktop-app-1.1.0.zip", + "checksum_sha256": "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7c6e64b2f7110b2", + "size_bytes": 12345678, + "content_type": "application/zip", + "release_notes": "Improved desktop rollout.", + "created_at": "2026-06-11T10:15:30Z", + "metadata_url": "/api/v1/releases/2", + "download_url": "/api/v1/releases/2/download" +} +``` + +Поля: + +- `id` - numeric release ID. +- `version` - версия, введенная администратором при загрузке. +- `build` - build label или build number, если задан. +- `filename` - download filename. +- `checksum_sha256` - SHA-256 загруженного artifact. +- `size_bytes` - размер artifact в bytes. +- `content_type` - MIME type artifact. +- `release_notes` - release notes. +- `created_at` - время загрузки релиза. +- `metadata_url` - relative URL metadata endpoint. +- `download_url` - relative URL download endpoint. + +## Ошибки + +JSON API возвращает ошибки в формате: + +```json +{ + "error": "message" +} +``` + +Основные статусы: + +| Status | Когда | +| --- | --- | +| `400` | Некорректный `releaseID`, например не число. | +| `401` | Нет bearer token, token неверный, key выключен или истек. | +| `403` | API key валиден, но нет `can_download`. | +| `404` | Ресурс не найден, архивирован или недоступен текущему key. | +| `429` | Сработал rate limit client API. | +| `500` | Внутренняя ошибка сервера. | +| `503` | Health check: база недоступна; или API key service недоступен. | + +### 401 Unauthorized + +Missing header: + +```http +WWW-Authenticate: Bearer realm="update-server" +``` + +```json +{ + "error": "missing bearer api key" +} +``` + +Invalid, disabled or expired key: + +```json +{ + "error": "invalid api key" +} +``` + +### 403 Forbidden + +```json +{ + "error": "api key permission denied" +} +``` + +### 429 Too Many Requests + +```http +Retry-After: 3 +``` + +```json +{ + "error": "rate limit exceeded" +} +``` + +По умолчанию client API rate limit настраивается переменными: + +```text +APP_CLIENT_RATE_LIMIT_PER_MINUTE=120 +APP_CLIENT_RATE_LIMIT_BURST=60 +``` + +## Headers + +Для protected API responses сервер добавляет: + +```http +Cache-Control: no-store, private, max-age=0 +Pragma: no-cache +Expires: 0 +Vary: Authorization +X-Robots-Tag: noindex, nofollow +``` + +Также на все ответы добавляются security headers, включая: + +```http +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Referrer-Policy: no-referrer +``` + +Если `APP_BASE_URL` использует `https://`, включается: + +```http +Strict-Transport-Security: max-age=31536000 +``` + +## Типовой Client Flow + +1. Администратор создает проект в `/admin/projects`. +2. Администратор загружает release artifact в проекте. +3. Администратор создает API key в `/admin/api-keys`. +4. Для ключа включает `can_download`. +5. Для ключа выбирает scope, который разрешает доступ к проекту. +6. Клиент запрашивает latest release: + + ```bash + curl -sS \ + -H "Authorization: Bearer upsk_replace_me" \ + https://updates.example.com/api/v1/projects/desktop-app/releases/latest + ``` + +7. Клиент сравнивает `release.version` со своей текущей версией. +8. Если версия новая, клиент скачивает `release.download_url`. +9. Клиент проверяет: + - `checksum_sha256`; + - `size_bytes`; + - при необходимости свою подпись artifact, если она есть в продукте. + +## Python Example + +В репозитории есть минимальный helper: + +```text +example/autoupdate.py +example/update_client.py +``` + +Приватный файл рядом со скриптом: + +```json +{ + "base_url": "https://updates.example.com", + "api_key": "upsk_replace_me", + "project_slug": "desktop-app" +} +``` + +Вызов: + +```python +from autoupdate import ensure_updated_from_config + +ver = "1.0.0" +ensure_updated_from_config(current_version=ver) +``` + +Если `autoupdate.json` отсутствует, helper создаст файл с placeholder values, +не пойдет в сеть и выведет короткое сообщение о настройке конфига. + +## Чего Сейчас Нет В API + +На текущий момент client JSON API не предоставляет: + +- создание/редактирование проектов; +- загрузку релизов; +- создание/редактирование API keys; +- список всех релизов проекта; +- release channels вроде `stable` / `beta`; +- query parameters вроде `platform`, `arch`, `current_version`; +- публичные downloads без API key. + +Эти действия либо выполняются через admin web UI, либо пока являются будущим +расширением API. diff --git a/example/autoupdate.py b/example/autoupdate.py index 2fcb4a4..07f297f 100644 --- a/example/autoupdate.py +++ b/example/autoupdate.py @@ -21,6 +21,12 @@ class UpdateError(RuntimeError): ReleaseHandler = Callable[[Path, dict[str, Any]], None] +DEFAULT_CONFIG_NAME = "autoupdate.json" +DEFAULT_CONFIG = { + "base_url": "https://updates.example.com", + "api_key": "upsk_replace_me", + "project_slug": "example-project", +} def ensure_updated( @@ -37,7 +43,17 @@ def ensure_updated( If `apply_update` is provided, it receives `(downloaded_artifact_path, release_dict)` and is responsible for applying the downloaded artifact before restart. """ - if not api_key or api_key == "upsk_replace_me": + if _is_placeholder_config( + { + "base_url": base_url, + "api_key": api_key, + "project_slug": project_slug, + } + ): + print("autoupdate: configure autoupdate.json to enable updates") + return False + + if not api_key: raise UpdateError("set a real API key before calling ensure_updated") script_path = _current_script_path() @@ -51,6 +67,8 @@ def ensure_updated( if not latest_version or latest_version == current_version: return False + print(f"autoupdate: updating {current_version} -> {latest_version}") + download_url = urljoin(base_url.rstrip("/") + "/", str(release.get("download_url") or "")) if not download_url: raise UpdateError("latest release does not contain a download URL") @@ -75,6 +93,27 @@ def ensure_updated( return True +def ensure_updated_from_config( + current_version: str, + config_path: str | Path | None = None, + apply_update: ReleaseHandler | None = None, +) -> bool: + """ + Load update settings from JSON and run the update check. + + If `config_path` is omitted, `autoupdate.json` is loaded from the same + directory as the currently running script. + """ + config = _load_config(config_path) + return ensure_updated( + base_url=config["base_url"], + api_key=config["api_key"], + project_slug=config["project_slug"], + current_version=current_version, + apply_update=apply_update, + ) + + def _current_script_path() -> Path: main_module = sys.modules.get("__main__") main_file = getattr(main_module, "__file__", None) @@ -83,6 +122,52 @@ def _current_script_path() -> Path: return Path(main_file).resolve() +def _default_config_path() -> Path: + return _current_script_path().with_name(DEFAULT_CONFIG_NAME) + + +def _load_config(config_path: str | Path | None) -> dict[str, str]: + path = ( + Path(config_path).expanduser() + if config_path is not None + else _default_config_path() + ) + try: + with path.open("r", encoding="utf-8") as config_file: + payload = json.load(config_file) + except FileNotFoundError: + _write_default_config(path) + return DEFAULT_CONFIG.copy() + except json.JSONDecodeError as exc: + raise UpdateError(f"update config file is not valid JSON: {path}") from exc + + if not isinstance(payload, dict): + raise UpdateError(f"update config must be a JSON object: {path}") + + config: dict[str, str] = {} + for key in ("base_url", "api_key", "project_slug"): + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise UpdateError(f"update config field {key!r} is required: {path}") + config[key] = value.strip() + + return config + + +def _write_default_config(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as config_file: + json.dump(DEFAULT_CONFIG, config_file, indent=2) + config_file.write("\n") + + +def _is_placeholder_config(config: dict[str, str]) -> bool: + return any( + config.get(key, "").strip() == value + for key, value in DEFAULT_CONFIG.items() + ) + + def _make_request(url: str, api_key: str, accept_json: bool = False) -> Request: headers = { "Authorization": f"Bearer {api_key}", diff --git a/example/update_client.py b/example/update_client.py index 3bf6b3d..dbc0237 100644 --- a/example/update_client.py +++ b/example/update_client.py @@ -1,12 +1,7 @@ -from autoupdate import ensure_updated +from autoupdate import ensure_updated_from_config -ensure_updated( - base_url="http://127.0.0.1:8080", - api_key="upsk_zdQu4gosIJjvIaQfn5A_ux9msFyDfEnc8c29F0ZCTmk", - project_slug="test", - current_version="0.1.3", -) +ver = "0.1.1" +ensure_updated_from_config(current_version=ver) - -print("TEst") +print(f"TEst {ver}")