diff --git a/.gitignore b/.gitignore index 273f648..003b816 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,2 @@ .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 30e59d5..9b45847 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,6 @@ 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 deleted file mode 100644 index 2371cd3..0000000 --- a/docs/API.md +++ /dev/null @@ -1,631 +0,0 @@ -# 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 07f297f..2fcb4a4 100644 --- a/example/autoupdate.py +++ b/example/autoupdate.py @@ -21,12 +21,6 @@ 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( @@ -43,17 +37,7 @@ 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 _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: + if not api_key or api_key == "upsk_replace_me": raise UpdateError("set a real API key before calling ensure_updated") script_path = _current_script_path() @@ -67,8 +51,6 @@ 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") @@ -93,27 +75,6 @@ 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) @@ -122,52 +83,6 @@ 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 dbc0237..3bf6b3d 100644 --- a/example/update_client.py +++ b/example/update_client.py @@ -1,7 +1,12 @@ -from autoupdate import ensure_updated_from_config +from autoupdate import ensure_updated -ver = "0.1.1" +ensure_updated( + base_url="http://127.0.0.1:8080", + api_key="upsk_zdQu4gosIJjvIaQfn5A_ux9msFyDfEnc8c29F0ZCTmk", + project_slug="test", + current_version="0.1.3", +) -ensure_updated_from_config(current_version=ver) -print(f"TEst {ver}") + +print("TEst")