From 4fa1add80d2183bd50de6f23d5e4eff39fa1e367 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:46:46 +0300 Subject: [PATCH] fix: address project review findings Security and robustness: - storage-agent rejects an empty INTERNAL_TOKEN (config notEmpty + middleware) - api takes the client IP only from X-Real-IP (chi RealIP trusted True-Client-IP) - /readyz reports ok/fail and logs the cause; checks run in parallel with a timeout - storage-agent shuts down gracefully; shared httpx.NewServer/Serve with IdleTimeout - goose migrations take a Postgres advisory lock - recommender readyz: per-check timeout, redis socket timeouts Clients: - Android release allows cleartext HTTP for LAN servers (expo-build-properties) - checkServer times out after 10s; connect button is disabled while checking Deploy and CI: - install.sh hands cache/uploads/updates/models to the non-root service users - update.sh refuses downgrades unless PIPU_ALLOW_DOWNGRADE=1 - recommender/analyzer get only the env they need; required secrets fail fast - nginx security headers and CSP; strip True-Client-IP - dev compose binds ports to 127.0.0.1; postgres/redis images pinned - version.mjs fails on a malformed explicit tag instead of releasing 0.0.0-dev - contract check catches untracked generated files - final releases fail without a release keystore; changelog skips rc tags Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci.yml | 5 +- .gitea/workflows/release.yml | 13 +- api/openapi.yaml | 2 +- apps/mobile/app.config.ts | 7 +- apps/mobile/package.json | 1 + apps/mobile/src/ConnectScreen.tsx | 11 +- apps/web/Dockerfile | 1 + deploy/compose.dev.yaml | 15 +- deploy/compose.yaml | 23 ++- deploy/install.sh | 7 +- deploy/nginx/default.conf.template | 6 + deploy/nginx/security-headers.conf | 6 + deploy/update.sh | 17 ++ docs/ci-cd.md | 2 +- docs/deployment.md | 4 +- packages/core/src/index.ts | 2 +- packages/core/src/server.test.ts | 5 + packages/core/src/server.ts | 27 +++- pnpm-lock.yaml | 147 +++++++++++------- scripts/version.mjs | 23 ++- scripts/version.test.mjs | 13 +- services/api/cmd/api/main.go | 36 +---- services/api/cmd/storage-agent/main.go | 38 +++-- services/api/internal/api/gen/api.gen.go | 44 +++--- .../api/internal/platform/config/config.go | 9 +- .../internal/platform/config/config_test.go | 7 + services/api/internal/platform/db/db.go | 10 +- .../api/internal/platform/httpx/server.go | 57 +++++++ .../internal/platform/httpx/server_test.go | 41 +++++ services/api/internal/server/router.go | 2 +- services/api/internal/storageagent/router.go | 16 +- .../api/internal/storageagent/router_test.go | 16 +- services/api/internal/system/handler.go | 42 +++-- services/api/internal/system/handler_test.go | 4 +- services/recommender/pipu_rec/api/app.py | 25 ++- services/recommender/pipu_rec/cli.py | 2 +- services/recommender/tests/test_app.py | 16 +- 37 files changed, 503 insertions(+), 199 deletions(-) create mode 100644 deploy/nginx/security-headers.conf create mode 100644 services/api/internal/platform/httpx/server.go create mode 100644 services/api/internal/platform/httpx/server_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ddb5a2a..863206d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,7 +31,10 @@ jobs: (cd services/api && go generate ./...) pnpm generate - name: Проверка, что всё закоммичено - run: git diff --exit-code -- services/api/internal/api/gen packages + # status, а не diff: diff не видит новые неотслеживаемые файлы + run: | + changes="$(git status --porcelain -- services/api/internal/api/gen packages)" + if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi api: runs-on: ubuntu-latest diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 99c1a7a..aead00c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -72,6 +72,7 @@ jobs: - name: Подпись APK env: + PRERELEASE: ${{ needs.verify.outputs.prerelease }} KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} @@ -86,17 +87,25 @@ jobs: "$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \ --ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src" rm release.keystore - else + elif [ "$PRERELEASE" = "true" ]; then echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного" cp "$src" "$out" + else + echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)" + exit 1 fi "$bt/apksigner" verify --print-certs "$out" | head -3 sha256sum "$out" | cut -d' ' -f1 > "$out.sha256" echo "APK=$out" >> "$GITHUB_ENV" - name: Changelog + env: + PRERELEASE: ${{ needs.verify.outputs.prerelease }} run: | - prev="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)" + # для финального релиза — изменения с прошлого финального, а не с последнего rc + exclude=() + [ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*') + prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)" range="${prev:+$prev..}${GITHUB_REF_NAME}" { echo "## Pipu Music ${PIPU_VERSION}" diff --git a/api/openapi.yaml b/api/openapi.yaml index d3428c1..a12b289 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -85,7 +85,7 @@ components: description: Результат проверки каждой зависимости additionalProperties: type: string - description: "`ok` или текст ошибки" + description: "`ok` или `fail` (причина — только в логе сервиса)" ServerInfo: type: object required: [name, version, api_version, supported_locales, min_android_version_code] diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 6b8ba67..e7ecbf4 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,6 +1,6 @@ import type { ConfigContext, ExpoConfig } from 'expo/config'; -// Версию проставляет CI из git-тега (scripts/version.mjs): PIPU_VERSION=1.2.3, PIPU_VERSION_CODE=10203. +// Версию проставляет CI из git-тега (scripts/version.mjs): PIPU_VERSION=1.2.3, PIPU_VERSION_CODE=1020399. const version = process.env.PIPU_VERSION ?? '0.0.0'; const versionCode = Number(process.env.PIPU_VERSION_CODE ?? '1'); @@ -22,5 +22,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ permissions: ['REQUEST_INSTALL_PACKAGES'], }, platforms: ['android'], + plugins: [ + // Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка + // Android по умолчанию запрещает незашифрованный HTTP. + ['expo-build-properties', { android: { usesCleartextTraffic: true } }], + ], extra: { versionCode }, }); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1d5573b..c88c772 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -15,6 +15,7 @@ "@pipu/design-tokens": "workspace:*", "@pipu/i18n": "workspace:*", "expo": "~53.0.20", + "expo-build-properties": "~0.14.8", "expo-constants": "~17.1.7", "expo-status-bar": "~2.2.3", "i18next": "^25.2.1", diff --git a/apps/mobile/src/ConnectScreen.tsx b/apps/mobile/src/ConnectScreen.tsx index a7cafa1..2f6142a 100644 --- a/apps/mobile/src/ConnectScreen.tsx +++ b/apps/mobile/src/ConnectScreen.tsx @@ -17,7 +17,10 @@ export function ConnectScreen() { const [address, setAddress] = useState(''); const [state, setState] = useState({ kind: 'idle' }); + const checking = state.kind === 'checking'; + async function onCheck() { + if (checking) return; const baseUrl = normalizeServerUrl(address); if (!baseUrl) { setState({ kind: 'invalid' }); @@ -39,6 +42,7 @@ export function ConnectScreen() { value={address} onChangeText={setAddress} onSubmitEditing={onCheck} + editable={!checking} placeholder="https://music.home.lan" placeholderTextColor={colors.text3} autoCapitalize="none" @@ -62,7 +66,12 @@ export function ConnectScreen() { )} {state.kind === 'done' && state.status === 'offline' && } - [styles.button, pressed && { opacity: 0.85 }]}> + [styles.button, (pressed || checking) && { opacity: 0.6 }]} + > {t('server.check')} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index b5ec7a0..eb92000 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -13,6 +13,7 @@ RUN pnpm --filter @pipu/web build FROM nginx:1.28-alpine COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template +COPY deploy/nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf COPY --from=build /repo/apps/web/dist /usr/share/nginx/html ARG VERSION=0.0.0-dev LABEL org.opencontainers.image.version=${VERSION} diff --git a/deploy/compose.dev.yaml b/deploy/compose.dev.yaml index 3d114e7..cf108d0 100644 --- a/deploy/compose.dev.yaml +++ b/deploy/compose.dev.yaml @@ -3,16 +3,17 @@ # docker compose -f deploy/compose.dev.yaml --profile app up # + api и recommender из исходников # Затем: веб — `pnpm --filter @pipu/web dev` (проксирует /api на :8080), # api без docker — см. README, раздел «Разработка». +# Порты слушают только 127.0.0.1: у Redis нет пароля, а у dev-Postgres он известен всем. name: pipu-dev services: postgres: - image: pgvector/pgvector:pg17 + image: pgvector/pgvector:0.8.7-pg17 environment: POSTGRES_USER: pipu POSTGRES_PASSWORD: pipu POSTGRES_DB: pipu - ports: ["5432:5432"] + ports: ["127.0.0.1:5432:5432"] volumes: [pg-data:/var/lib/postgresql/data] healthcheck: test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"] @@ -20,8 +21,8 @@ services: retries: 10 redis: - image: redis:7-alpine - ports: ["6379:6379"] + image: redis:7.4-alpine + ports: ["127.0.0.1:6379:6379"] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -32,7 +33,7 @@ services: environment: MEILI_MASTER_KEY: dev-master-key-change-me MEILI_ENV: development - ports: ["7700:7700"] + ports: ["127.0.0.1:7700:7700"] volumes: [meili-data:/meili_data] api: @@ -45,7 +46,7 @@ services: DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable REDIS_URL: redis://redis:6379/0 LOG_LEVEL: debug - ports: ["8080:8080"] + ports: ["127.0.0.1:8080:8080"] depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } @@ -59,7 +60,7 @@ services: DATABASE_URL: postgresql://pipu:pipu@postgres:5432/pipu REDIS_URL: redis://redis:6379/0 INTERNAL_TOKEN: dev-internal-token - ports: ["8000:8000"] + ports: ["127.0.0.1:8000:8000"] depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 2fbdea7..114de9f 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -45,7 +45,7 @@ services: args: *version env_file: .env environment: &db-env - DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD}@postgres:5432/pipu?sslmode=disable + DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable volumes: - *library-ro - ${PIPU_DATA:-/srv/pipu}/cache:/cache @@ -67,8 +67,9 @@ services: cap_add: [SYS_ADMIN] security_opt: ["apparmor:unconfined"] environment: - INTERNAL_TOKEN: ${INTERNAL_TOKEN} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)} LIBRARY_ROOT: /library + LOG_LEVEL: ${LOG_LEVEL:-info} volumes: - type: bind source: ${PIPU_DATA:-/srv/pipu}/library @@ -81,8 +82,12 @@ services: image: pipu-music-recommender:${PIPU_VERSION:-dev} build: *build-rec command: ["serve"] - env_file: .env - environment: *db-env + # только нужные переменные, а не весь .env с секретами Meilisearch и Gitea + environment: + <<: *db-env + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)} + LOG_LEVEL: ${LOG_LEVEL:-info} depends_on: postgres: { condition: service_healthy } @@ -91,8 +96,10 @@ services: image: pipu-music-recommender:${PIPU_VERSION:-dev} build: *build-rec command: ["analyze", "--workers", "4"] - env_file: .env - environment: *db-env + environment: + <<: *db-env + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + LOG_LEVEL: ${LOG_LEVEL:-info} volumes: - *library-ro - ${PIPU_DATA:-/srv/pipu}/models:/models @@ -102,7 +109,7 @@ services: postgres: <<: *restart - image: pgvector/pgvector:pg17 + image: pgvector/pgvector:0.8.7-pg17 environment: POSTGRES_USER: pipu POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -115,7 +122,7 @@ services: redis: <<: *restart - image: redis:7-alpine + image: redis:7.4-alpine command: ["redis-server", "--appendonly", "yes"] volumes: [ "${PIPU_DATA:-/srv/pipu}/redis:/data" ] diff --git a/deploy/install.sh b/deploy/install.sh index d281830..639d2e3 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -12,6 +12,11 @@ DATA="${PIPU_DATA:-/srv/pipu}" echo "→ каталоги в $DATA" mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups,caddy/data,caddy/config} +# Каталоги, куда пишут сервисы без root: api (distroless nonroot, uid 65532) и analyzer (uid 10001). +# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш. +chown 65532:65532 "$DATA"/{cache,cache/transcode,cache/covers,updates,uploads} +chown 10001:10001 "$DATA/models" + # storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры, # каталог должен быть отдельной точкой монтирования с shared propagation. if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then @@ -30,7 +35,7 @@ Before=docker.service [Service] Type=oneshot -ExecStart=/bin/sh -c 'findmnt -n $DATA/library || mount --bind $DATA/library $DATA/library; mount --make-rshared $DATA/library' +ExecStart=/bin/sh -c 'findmnt -n "$DATA/library" || mount --bind "$DATA/library" "$DATA/library"; mount --make-rshared "$DATA/library"' RemainAfterExit=yes [Install] diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index 8e04d8f..fe7317d 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -26,6 +26,8 @@ server { root /usr/share/nginx/html; client_max_body_size 16m; + include /etc/nginx/snippets/security-headers.conf; + gzip on; gzip_types text/css application/javascript application/json image/svg+xml; @@ -37,6 +39,8 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $pipu_scheme; + # api берёт IP клиента только из X-Real-IP; остальные заголовки клиент может подделать + proxy_set_header True-Client-IP ""; } location = /healthz { proxy_pass http://pipu_api; } @@ -50,11 +54,13 @@ server { location /assets/ { expires 1y; + include /etc/nginx/snippets/security-headers.conf; add_header Cache-Control "public, immutable"; try_files $uri =404; } location / { + include /etc/nginx/snippets/security-headers.conf; add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; } diff --git a/deploy/nginx/security-headers.conf b/deploy/nginx/security-headers.conf new file mode 100644 index 0000000..ab6baf9 --- /dev/null +++ b/deploy/nginx/security-headers.conf @@ -0,0 +1,6 @@ +# Заголовки безопасности для SPA. Подключается через include: add_header внутри location +# отменяет унаследованные с уровня server, поэтому include повторяется там, где есть свои add_header. +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header X-Frame-Options "DENY" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; diff --git a/deploy/update.sh b/deploy/update.sh index 97e0369..45788e2 100644 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -26,6 +26,23 @@ elif [[ "$REF" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then REF="v$REF" fi +# Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему. +CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)" +TARGET="${REF#v}" +core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; } +cur_core="$(core "$CURRENT" || true)" +tgt_core="$(core "$TARGET" || true)" +# сравниваем только MAJOR.MINOR.PATCH: переход 0.2.0-rc.1 → 0.2.0 откатом не считается +if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] && + [ "$(printf '%s\n%s\n' "$cur_core" "$tgt_core" | sort -V | head -1)" = "$tgt_core" ]; then + echo "внимание: $CURRENT → $TARGET — это откат. Миграции БД не откатываются;" >&2 + echo " сделайте резервную копию (docs/deployment.md) и проверьте, что старая версия совместима со схемой." >&2 + if [ "${PIPU_ALLOW_DOWNGRADE:-}" != 1 ]; then + echo " Чтобы продолжить: PIPU_ALLOW_DOWNGRADE=1 ./update.sh $REF" >&2 + exit 1 + fi +fi + echo "→ переключаемся на $REF" if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then git checkout --quiet "$REF" diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 756866d..48652c3 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -66,7 +66,7 @@ Docker-образы **не публикуются**: сервер собирае | Секрет | Назначение | |---|---| | `RELEASE_TOKEN` | Создание релиза и загрузка ассетов | -| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. | +| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. | ## Деплой diff --git a/docs/deployment.md b/docs/deployment.md index 08eef97..3077071 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -46,8 +46,8 @@ | `api` | `services/api/Dockerfile`, target `api` | Миграции при старте | | `storage-agent` | `services/api/Dockerfile`, target `storage-agent` | `CAP_SYS_ADMIN`, `apparmor:unconfined`, библиотека с `propagation: rshared` | | `recommender`, `analyzer` | `services/recommender/Dockerfile` (один образ) | Команды `serve` и `analyze --workers 4` | -| `postgres` | `pgvector/pgvector:pg17` | Healthcheck `pg_isready` | -| `redis` | `redis:7-alpine` | AOF | +| `postgres` | `pgvector/pgvector:0.8.7-pg17` | Healthcheck `pg_isready` | +| `redis` | `redis:7.4-alpine` | AOF | | `meilisearch` | `getmeili/meilisearch:v1.11` | | | `caddy` (из `compose.tls.yaml`) | `caddy:2.10-alpine` | HTTPS на `DOMAIN`, nginx тогда без открытых портов | diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 79c0e16..0e73f59 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1 @@ -export { checkServer, normalizeServerUrl, type ServerCheck } from './server'; +export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server'; diff --git a/packages/core/src/server.test.ts b/packages/core/src/server.test.ts index 071d918..1c12900 100644 --- a/packages/core/src/server.test.ts +++ b/packages/core/src/server.test.ts @@ -52,6 +52,11 @@ describe('checkServer', () => { expect(res).toEqual({ status: 'offline', error: 'Failed to fetch' }); }); + it('reports offline when the server does not answer in time', async () => { + const client = createApiClient({ baseUrl: 'https://music.test', fetch: () => new Promise(() => {}) }); + await expect(checkServer(client, undefined, 20)).resolves.toEqual({ status: 'offline', error: 'timeout' }); + }); + it('reports offline with the problem code on API errors', async () => { const res = await checkServer(clientReturning(() => json({ status: 500, code: 'common.internal' }, 500))); expect(res).toEqual({ status: 'offline', error: 'common.internal' }); diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 04cbba8..5d4f972 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -25,19 +25,42 @@ export type ServerCheck = | { status: 'unsupported_app'; info: ServerInfo } | { status: 'offline'; error: string }; +/** Сколько ждать ответа сервера, прежде чем считать его недоступным. */ +export const SERVER_CHECK_TIMEOUT_MS = 10_000; + /** * Проверяет сервер через GET /api/v1/server-info. * appVersionCode — versionCode Android-клиента; для веба не передаётся. */ -export async function checkServer(client: ApiClient, appVersionCode?: number): Promise { +export async function checkServer( + client: ApiClient, + appVersionCode?: number, + timeoutMs = SERVER_CHECK_TIMEOUT_MS, +): Promise { + // Гонка с таймером, а не только abort: не каждая реализация fetch (Hermes, полифилы) + // надёжно прерывает запрос по сигналу. AbortSignal.timeout не используется — его может не быть в Hermes. + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error('timeout')); + }, timeoutMs); + }); try { - const { data, error } = await client.GET('/api/v1/server-info'); + const { data, error } = await Promise.race([ + client.GET('/api/v1/server-info', { signal: controller.signal }), + timeout, + ]); if (!data) return { status: 'offline', error: error ? error.code : 'unknown' }; if (appVersionCode !== undefined && appVersionCode < data.min_android_version_code) { return { status: 'unsupported_app', info: data }; } return { status: 'ok', info: data }; } catch (e) { + if (controller.signal.aborted) return { status: 'offline', error: 'timeout' }; return { status: 'offline', error: e instanceof Error ? e.message : String(e) }; + } finally { + clearTimeout(timer); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6867fc..be2cfab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,13 +31,16 @@ importers: version: link:../../packages/i18n expo: specifier: ~53.0.20 - version: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2) + version: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2) + expo-build-properties: + specifier: ~0.14.8 + version: 0.14.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)) expo-constants: specifier: ~17.1.7 - version: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)) + version: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)) expo-status-bar: specifier: ~2.2.3 - version: 2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + version: 2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) i18next: specifier: ^25.2.1 version: 25.10.10(typescript@5.8.3) @@ -46,10 +49,10 @@ importers: version: 19.0.0 react-i18next: specifier: ^15.5.3 - version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3) + version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(typescript@5.8.3) react-native: specifier: 0.79.6 - version: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + version: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) devDependencies: '@types/react': specifier: ~19.0.10 @@ -108,13 +111,13 @@ importers: version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)) jsdom: specifier: ^26.1.0 - version: 26.1.0(supports-color@10.2.2) + version: 26.1.0 vite: specifier: ^6.3.5 version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) + version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) packages/api-client: dependencies: @@ -134,7 +137,7 @@ importers: devDependencies: vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) + version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) packages/design-tokens: {} @@ -1370,6 +1373,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} @@ -1880,6 +1886,11 @@ packages: react: '*' react-native: '*' + expo-build-properties@0.14.8: + resolution: {integrity: sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==} + peerDependencies: + expo: '*' + expo-constants@17.1.8: resolution: {integrity: sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==} peerDependencies: @@ -1943,6 +1954,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fb-watchman@2.0.4: resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==} @@ -4454,11 +4468,11 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + '@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)': dependencies: - expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) + expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) react: 19.0.0 - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) '@expo/ws-tunnel@1.0.6': {} @@ -4652,13 +4666,13 @@ snapshots: nullthrows: 1.1.1 yargs: 17.7.3 - '@react-native/community-cli-plugin@0.79.6': + '@react-native/community-cli-plugin@0.79.6(supports-color@10.2.2)': dependencies: '@react-native/dev-middleware': 0.79.6 chalk: 4.1.2 debug: 2.6.9 invariant: 2.2.4 - metro: 0.82.5 + metro: 0.82.5(supports-color@10.2.2) metro-config: 0.82.5 metro-core: 0.82.5 semver: 7.8.5 @@ -4693,12 +4707,12 @@ snapshots: '@react-native/normalize-colors@0.79.6': {} - '@react-native/virtualized-lists@0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)': + '@react-native/virtualized-lists@0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.0.0 - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) optionalDependencies: '@types/react': 19.0.14 @@ -4995,6 +5009,13 @@ snapshots: agent-base@7.1.4: {} + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + anser@1.4.10: {} ansi-colors@4.1.3: {} @@ -5511,39 +5532,45 @@ snapshots: expect-type@1.4.0: {} - expo-asset@11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + expo-asset@11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): dependencies: '@expo/image-utils': 0.7.6 - expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2) - expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)) + expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2) + expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)) react: 19.0.0 - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) transitivePeerDependencies: - supports-color - expo-constants@17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)): + expo-build-properties@0.14.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)): + dependencies: + ajv: 8.20.0 + expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2) + semver: 7.8.5 + + expo-constants@17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)): dependencies: '@expo/config': 11.0.13 '@expo/env': 1.0.7 - expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2) - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) transitivePeerDependencies: - supports-color - expo-file-system@18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)): + expo-file-system@18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)): dependencies: - expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2) - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) - expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): + expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): dependencies: - expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2) + expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2) fontfaceobserver: 2.3.0 react: 19.0.0 - expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): + expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): dependencies: - expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2) + expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2) react: 19.0.0 expo-modules-autolinking@2.1.15: @@ -5560,14 +5587,14 @@ snapshots: dependencies: invariant: 2.2.4 - expo-status-bar@2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + expo-status-bar@2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): dependencies: react: 19.0.0 - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) - react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) - react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) + react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) - expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2): + expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2): dependencies: '@babel/runtime': 7.29.10 '@expo/cli': 0.24.24(supports-color@10.2.2) @@ -5575,18 +5602,18 @@ snapshots: '@expo/config-plugins': 10.1.2 '@expo/fingerprint': 0.13.4(supports-color@10.2.2) '@expo/metro-config': 0.20.18 - '@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) babel-preset-expo: 13.2.5(@babel/core@7.29.7)(supports-color@10.2.2) - expo-asset: 11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) - expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)) - expo-file-system: 18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)) - expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) - expo-keep-awake: 14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) + expo-asset: 11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) + expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)) + expo-file-system: 18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)) + expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) + expo-keep-awake: 14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) expo-modules-autolinking: 2.1.15 expo-modules-core: 2.5.0 react: 19.0.0 - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) - react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) + react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) whatwg-url-without-unicode: 8.0.0-3 transitivePeerDependencies: - '@babel/core' @@ -5602,6 +5629,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-uri@3.1.8: {} + fb-watchman@2.0.4: dependencies: bser: 2.1.2 @@ -5724,7 +5753,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2(supports-color@10.2.2): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@10.2.2) @@ -5909,13 +5938,13 @@ snapshots: jsc-safe-url@0.2.4: {} - jsdom@26.1.0(supports-color@10.2.2): + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2(supports-color@10.2.2) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.28 @@ -6076,7 +6105,7 @@ snapshots: cosmiconfig: 5.2.1 flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.82.5 + metro: 0.82.5(supports-color@10.2.2) metro-cache: 0.82.5 metro-core: 0.82.5 metro-runtime: 0.82.5 @@ -6163,7 +6192,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.82.5 + metro: 0.82.5(supports-color@10.2.2) metro-babel-transformer: 0.82.5 metro-cache: 0.82.5 metro-cache-key: 0.82.5 @@ -6176,7 +6205,7 @@ snapshots: - supports-color - utf-8-validate - metro@0.82.5: + metro@0.82.5(supports-color@10.2.2): dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -6516,40 +6545,40 @@ snapshots: react-dom: 19.3.1(react@19.3.1) typescript: 5.8.3 - react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3): + react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(typescript@5.8.3): dependencies: '@babel/runtime': 7.29.10 html-parse-stringify: 3.1.0 i18next: 25.10.10(typescript@5.8.3) react: 19.0.0 optionalDependencies: - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) typescript: 5.8.3 react-is@17.0.2: {} react-is@18.3.1: {} - react-native-edge-to-edge@1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + react-native-edge-to-edge@1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): dependencies: react: 19.0.0 - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) - react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0): dependencies: react: 19.0.0 - react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0) + react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2) - react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0): + react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2): dependencies: '@jest/create-cache-key-function': 29.7.0 '@react-native/assets-registry': 0.79.6 '@react-native/codegen': 0.79.6(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.79.6 + '@react-native/community-cli-plugin': 0.79.6(supports-color@10.2.2) '@react-native/gradle-plugin': 0.79.6 '@react-native/js-polyfills': 0.79.6 '@react-native/normalize-colors': 0.79.6 - '@react-native/virtualized-lists': 0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + '@react-native/virtualized-lists': 0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -7050,7 +7079,7 @@ snapshots: lightningcss: 1.27.0 terser: 5.51.2 - vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): + vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -7077,7 +7106,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.6.5 - jsdom: 26.1.0(supports-color@10.2.2) + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less diff --git a/scripts/version.mjs b/scripts/version.mjs index 39ed929..7b445fa 100644 --- a/scripts/version.mjs +++ b/scripts/version.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node // Вычисляет версию продукта из git-тега (docs/ci-cd.md#версионирование). -// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT) -// node scripts/version.mjs → берёт GITHUB_REF_NAME, иначе 0.0.0-dev +// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT); +// тег не по формату — ошибка (релиз не должен стать 0.0.0-dev) +// node scripts/version.mjs → берёт GITHUB_REF_NAME, если это релизный тег, иначе 0.0.0-dev // // Android versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N), // поэтому 1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1. @@ -23,9 +24,23 @@ export function parseVersion(tag) { return { version, versionCode, prerelease: rc !== null }; } +const DEV = { version: '0.0.0-dev', versionCode: 1, prerelease: true }; + +export function resolveVersion(argTag, refName) { + if (argTag !== undefined) return parseVersion(argTag); + const ref = refName ?? ''; + return TAG_RE.test(ref) ? parseVersion(ref) : DEV; +} + function main() { - const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? ''; - const v = TAG_RE.test(tag) ? parseVersion(tag) : { version: '0.0.0-dev', versionCode: 1, prerelease: true }; + let v; + try { + v = resolveVersion(process.argv[2], process.env.GITHUB_REF_NAME); + } catch (e) { + process.stderr.write(`${e.message} +`); + process.exit(1); + } process.stdout.write( `PIPU_VERSION=${v.version}\nPIPU_VERSION_CODE=${v.versionCode}\nPIPU_PRERELEASE=${v.prerelease}\n`, ); diff --git a/scripts/version.test.mjs b/scripts/version.test.mjs index b231177..dfa3df3 100644 --- a/scripts/version.test.mjs +++ b/scripts/version.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { parseVersion } from './version.mjs'; +import { parseVersion, resolveVersion } from './version.mjs'; test('release tag', () => { assert.deepEqual(parseVersion('v0.1.0'), { version: '0.1.0', versionCode: 10_099, prerelease: false }); @@ -23,3 +23,14 @@ test('rejects malformed tags', () => { assert.throws(() => parseVersion(tag), undefined, tag); } }); + +test('explicit tag must be a release tag', () => { + assert.throws(() => resolveVersion('v1.2.3-beta', undefined)); + assert.equal(resolveVersion('v1.2.3', 'main').version, '1.2.3'); +}); + +test('branch builds fall back to the dev version', () => { + assert.equal(resolveVersion(undefined, 'main').version, '0.0.0-dev'); + assert.equal(resolveVersion(undefined, undefined).version, '0.0.0-dev'); + assert.equal(resolveVersion(undefined, 'v2.0.0').version, '2.0.0'); +}); diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index b11d401..278d767 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -3,13 +3,10 @@ package main import ( "context" - "errors" "log/slog" - "net/http" "os" "os/signal" "syscall" - "time" "github.com/redis/go-redis/v9" @@ -63,37 +60,12 @@ func run() error { Version: buildinfo.Version, SupportedLocales: cfg.SupportedLocales, MinAndroidVersionCode: cfg.MinAndroidVersionCode, - }, map[string]system.Checker{ + }, log, map[string]system.Checker{ "postgres": system.CheckerFunc(pool.Ping), "redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }), }) - srv := &http.Server{ - Addr: cfg.HTTPAddr, - Handler: server.NewRouter(server.Deps{Log: log, System: sys}), - ReadHeaderTimeout: 10 * time.Second, - } - return serve(ctx, log, srv, cfg.ShutdownTimeout) -} - -func serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error { - errCh := make(chan error, 1) - go func() { - log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - errCh <- err - } - close(errCh) - }() - - select { - case err := <-errCh: - return err - case <-ctx.Done(): - } - - log.Info("shutting down") - shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - return srv.Shutdown(shutdownCtx) + srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys})) + log.Info("starting", "version", buildinfo.Version) + return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout) } diff --git a/services/api/cmd/storage-agent/main.go b/services/api/cmd/storage-agent/main.go index 4159b02..6fe40f3 100644 --- a/services/api/cmd/storage-agent/main.go +++ b/services/api/cmd/storage-agent/main.go @@ -2,10 +2,11 @@ package main import ( + "context" "log/slog" - "net/http" "os" - "time" + "os/signal" + "syscall" "pipu/api/internal/buildinfo" "pipu/api/internal/platform/config" @@ -14,21 +15,24 @@ import ( ) func main() { - cfg, err := config.Load[config.StorageAgent]() - if err != nil { - slog.Error("storage-agent: config", "err", err) - os.Exit(1) - } - log := httpx.NewLogger(cfg.LogLevel) - - srv := &http.Server{ - Addr: cfg.HTTPAddr, - Handler: storageagent.NewRouter(cfg.InternalToken, cfg.LibraryRoot), - ReadHeaderTimeout: 10 * time.Second, - } - log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version) - if err := srv.ListenAndServe(); err != nil { - log.Error("storage-agent stopped", "err", err) + if err := run(); err != nil { + slog.Error("storage-agent stopped", "err", err) os.Exit(1) } } + +func run() error { + cfg, err := config.Load[config.StorageAgent]() + if err != nil { + return err + } + log := httpx.NewLogger(cfg.LogLevel) + slog.SetDefault(log) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + srv := httpx.NewServer(cfg.HTTPAddr, storageagent.NewRouter(log, cfg.InternalToken, cfg.LibraryRoot)) + log.Info("starting", "version", buildinfo.Version) + return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout) +} diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 9f62940..6eaa730 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -474,28 +474,28 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/7xWX28bRRD/KquFh1hcbIdSQf1WVUD7gBQliJc2SjZ322Sb+8feXkSILMUJapFSGqkg", - "xANq6Tdwgt0cdn35CrNfgU+CZu/OvnMuITyUJ/t2Z3fm95vfzM4+tQMvDHzuq4h29qnkURj4ETcfyzLY", - "dLmHf+3AV9xX+JeFoStspkTgt8LM4qMnUeDjXmRvc4/hvw8lf0w79IPW7P5Wthu1inu73a5FHR7ZUoR4", - "He1QeKV/hAROYQR9srDyxT1y55PbnzYoWubH8fb7nLlqe1UxFZvvUAYhl0pkcUfTde7HHu08pMEOXbOo", - "2gs57dBISeFvmSsl/zYWkjtok5+a2QWbT7itaNcqE1H1ZAcOx985EL9D38CYwEQfw18ERpDCgEA6BZcQ", - "GMBYnxCx9JlvEZhAHy70ASTwDob6gGywWG03hb/LXOGs25I73FeCudEGvYQDOVRMuDWBvIZUn+geTGAI", - "E0hgWLiFMaTwJ6RwRhZwd7p+ASlyD+fQzz7G+jmcoyH09SEMYaxfNOpiCJlkniGFOY7AAJi7XCJLyZjX", - "UDvLVb4lfMW3uMS9bAVBPWaxq2iHss0gVp1Nl/k79Ib5tLIk1aV1hTNn7yoR2dvc3rkGzzzXG8HOBoEE", - "xpAQQ9RI9/RhJef1qask7A8Ywrk+Qtb1ITJOjCyQ/qE+yISD6XkLA0hRWefQhzNIdM9IJ0WfZUd1RJeK", - "Av1vSeZw58b1YRXM1DG6yuUulw/8x8FlQlko1ne5jETWKvh3zAtdPL67VEeMJ/x15jsyEE5xbP3KckuM", - "ut9B38g1K7r80L3A4eRudtFiXmMo/rdZSegTy5SnPoTUED3MZD+AgWH8LSQofRjqQ6J7ZsmkglozBEvt", - "9p07Vo2AfebxKtZQhPGiF0fCrsMcxWEYSMWddTewmZvxNj38kMoY3fpIvVDcK5fN7JJ8gUnJ9vC7xPkc", - "by8RCCpHn1SwYetd5d43XDbKMGm7udRs/2vdGdAzt1Yl8XUYr8n1ZY2hN5Hra77Z6SM4xfrTz3IJrHy+", - "+jW5u/yALIswJl8h7U0Cv5kiSdEKEhgRrB6sGjxU9MF5OgIWikUMaYv7jUd+VoZj7KgwwcuwjwYh99EM", - "I84CazQJvDGNdmxehFxx5O+DX4gT2FGLhaLpOc1HSIwSyrA8ixVDLzFZJKBr0dwV7dBbzXbzFsX2q7aN", - "HvDO1u5SKzKluFhwtcVVnQL0MZzr40LhuodPwCn2IAJ9fWT6GOJL9A9IFfT1U0gg6RBkBk4JJEVpVfqU", - "PtEvsHWdTQX2Yo5R65Gve8Yc37uM/rx96edVbvvopb4m4Z0+hiHRJwYENscF/ROM0AFMijMjfLL0s4L8", - "RsY2diYzwzxwaId+yVWpdVnVMejjdvuaEei/jT4lL3XTzxvD62AqFEirvA2zJyN/DutdTWMvzVlYdp7H", - "5N6NnEyHgTmJo0jZVmQeg71IcY+u4dWtbTONfV+S2SV27+cm75HaykxYR+5rFKh+avTWI5mK5rn51Uhr", - "qsOL8hHoX0WAxEHiWvwrmcV7hF8eZurQv8QcXzEvYMIzzEdwgW0QZXa7fet/C+4VDHAELuYnnEh1z0zN", - "+J6PIK0PfIgT0MSIeS78alZ/zt53OIPJLLcpnOoejPVR0U+y5fN8Eke7Y7JwQ8IatdLAKEzB4/o+jaVL", - "O7RFu2tT4/18RigOdde6/wQAAP//nG+aXpMNAAA=", + "H4sIAAAAAAAC/7xW3W4bRRR+ldHARSw2tkOpoL6rKqC9QIoSxE0bJZPdSTLN/jE7GxEiS3GCWqSURioI", + "cYFa+gabYDdLHG9e4cwr8CTozO7aa2cTwkW5snf3zPn5vu+cOXvUDrww8LmvItrZo5JHYeBH3DwsymDd", + "5R7+tQNfcV/hXxaGrrCZEoHfCnOLj55GgY/fInuLewz/fSj5Bu3QD1oT/638a9Qq/Xa7XYs6PLKlCNEd", + "7VB4rX+EFE7gHBIyt/TFA3Lvk7ufNihaFsfR+0POXLW1rJiKzXMog5BLJfK8o/F77sce7TymwTZdsaja", + "DTnt0EhJ4W8al5J/GwvJHbQpTk3sgvWn3Fa0a1WBmI5kBw7H35kifofElDGCkT6CvwicQwZ9Atm4uJRA", + "H4b6mIiFz3yLwAgSuNT7kMIFDPQ+WWOx2moKf4e5wlm1JXe4rwRzozV6pQ7EUDHh1iTyBjJ9rHswggGM", + "IIVBGRaGkMGfkMEpmcOv4/eXkCH2cAZJ/jDUL+AMDSHRBzCAoX7ZqMshZJJ5BhTmOAITYO5iBSwlY14D", + "7YSr4pPwFd/kEr/lb7CoDRa7inYoWw9i1Vl3mb9Nb8mnlZNUR+sSZ87udSKyt7i9fUM9s1ivBdtrBFIY", + "QkrWNphw18hcTql+bqSQkL/3fyH6oMD0HDICpyURA6J7SDycQqp7kDTqaZ4i9w8YwJk+RG/6ANkhJh5S", + "hZ6MyJDKd9CHDFV4BknhH2WW6Z4+gJTeSEqlgTD+pmQOd27dS1aJYh36y1zucPnI3wiugs9CsbrDZSTy", + "scK/Y17o4vGdhTpgPOGvMt+RgXDKY6vXtmZqOuECEkND3qDFoQeBw8n93NF80Y/Iz7u8ffSxZVoZOTRA", + "D/IW6UPfIP4OUmwTGOiDCZ0DvU+tSQUL7fa9e1aN2H3m8elaQxHG814cCbuu5igOw0Aq7qy6gc3cHLfx", + "4cdUxhjWR+iF4l61xSZOihdMSraLzxXMZ3B7hYWgcvTxVG04ppe59w2XjWqZtN1caLb/tUdN0ZOw1hTx", + "dTXewPVVjWE0UehrdjDqQzjBXtXPCwksfb78Nbm/+IgsijAmXyHsTQK/mSbJ0ApSOCfYPdg1eKicmbNw", + "BCwU85jSJvcbT/y8DYc4fWFk2v+UzAUh99EMM84TazQJvDWzYGhuj0JxZmg4gR21WCiantN8gsAooQzK", + "k1wx9QqSJQFdixahaIfeababdyiOarVl9IA+WzsLrci04nyJ1SZXdQrQR3Cmj0qF6x5eFyc4gwgk+tBc", + "Dlhfqn9AqCDRzyCFtEMQGTghkJatNTWn9LF+iaPrdCywlzOIWk983TPmeDfm8BfjS7+YxjbBKPU9CRf6", + "CMfssSkCh+Oc/gnOMQCMyjPneL3p5yX4jRxtnExm33nk0A79kqvK6LKmV6aP2+0b1qX/tiZVotRtSm8N", + "rv2xUCCbxm2QXxnF1Vkfapx7ZSfDtvM8JndvFWS8OMxIHEXKNiNzGexGint0BV23tszm9n1FZlfQfViY", + "vEdop/bHOnDfoED1M6O3HslVNIvNr0ZaYx1eVo9Ach0AEpeOG+tfyi3eY/nVxaeu+lfI8TX7AhKe13wI", + "lzgGUWZ323f+t+ReQ99sU8Wuhdur7pkNe7xW1SU+wA1oZMQ8k/40qz/n9zucwmjCbQYnugdDfVjOk/z1", + "WbG1o90RmbslYI1aaWAWpuHx/R6NpUs7tEW7K2PjvWJHKA91V7r/BAAA//8arOezvw0AAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go index 473705e..612c64e 100644 --- a/services/api/internal/platform/config/config.go +++ b/services/api/internal/platform/config/config.go @@ -23,10 +23,11 @@ type API struct { // StorageAgent — конфигурация внутреннего сервиса storage-agent. type StorageAgent struct { - HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"` - InternalToken string `env:"INTERNAL_TOKEN,required"` - LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"` - LogLevel string `env:"LOG_LEVEL" envDefault:"info"` + HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"` + InternalToken string `env:"INTERNAL_TOKEN,required,notEmpty"` + LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"` + LogLevel string `env:"LOG_LEVEL" envDefault:"info"` + ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"` } // Load заполняет структуру конфигурации T из окружения. diff --git a/services/api/internal/platform/config/config_test.go b/services/api/internal/platform/config/config_test.go index f011043..3a4ba73 100644 --- a/services/api/internal/platform/config/config_test.go +++ b/services/api/internal/platform/config/config_test.go @@ -24,3 +24,10 @@ func TestLoadAPIRequiresDatabase(t *testing.T) { _, err := Load[API]() assert.ErrorContains(t, err, "DATABASE_URL") } + +func TestLoadStorageAgentRejectsEmptyToken(t *testing.T) { + t.Setenv("INTERNAL_TOKEN", "") + + _, err := Load[StorageAgent]() + assert.ErrorContains(t, err, "INTERNAL_TOKEN") +} diff --git a/services/api/internal/platform/db/db.go b/services/api/internal/platform/db/db.go index a9e2c94..da1571d 100644 --- a/services/api/internal/platform/db/db.go +++ b/services/api/internal/platform/db/db.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/lock" "pipu/api/migrations" ) @@ -26,12 +27,17 @@ func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) { return pool, nil } -// Migrate применяет встроенные миграции goose. +// Migrate применяет встроенные миграции goose. Advisory-lock в PostgreSQL не даёт +// нескольким экземплярам api применять миграции одновременно. func Migrate(ctx context.Context, pool *pgxpool.Pool) error { sqlDB := stdlib.OpenDBFromPool(pool) defer func(d *sql.DB) { _ = d.Close() }(sqlDB) - provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS) + locker, err := lock.NewPostgresSessionLocker() + if err != nil { + return fmt.Errorf("db: migrations lock: %w", err) + } + provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS, goose.WithSessionLocker(locker)) if err != nil { return fmt.Errorf("db: migrations: %w", err) } diff --git a/services/api/internal/platform/httpx/server.go b/services/api/internal/platform/httpx/server.go new file mode 100644 index 0000000..b859102 --- /dev/null +++ b/services/api/internal/platform/httpx/server.go @@ -0,0 +1,57 @@ +package httpx + +import ( + "context" + "errors" + "log/slog" + "net" + "net/http" + "strings" + "time" +) + +// NewServer создаёт http.Server с общими таймаутами. WriteTimeout не задаётся: +// стриминг аудио и загрузки держат ответ открытым дольше любого разумного предела. +func NewServer(addr string, h http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: h, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } +} + +// Serve запускает сервер и корректно останавливает его при отмене ctx. +func Serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error { + errCh := make(chan error, 1) + go func() { + log.Info("listening", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + close(errCh) + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + } + + log.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return srv.Shutdown(shutdownCtx) +} + +// RealIP подставляет в RemoteAddr адрес клиента из X-Real-IP, который выставляет nginx. +// В отличие от middleware.RealIP из chi, не доверяет True-Client-IP и X-Forwarded-For: +// их клиент может прислать сам, а nginx пропускает их дальше без изменений. +func RealIP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(ip) != nil { + r.RemoteAddr = ip + } + next.ServeHTTP(w, r) + }) +} diff --git a/services/api/internal/platform/httpx/server_test.go b/services/api/internal/platform/httpx/server_test.go new file mode 100644 index 0000000..1bebd8e --- /dev/null +++ b/services/api/internal/platform/httpx/server_test.go @@ -0,0 +1,41 @@ +package httpx + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRealIP(t *testing.T) { + cases := []struct { + name string + headers map[string]string + want string + }{ + {"no headers", nil, "192.0.2.1:1234"}, + {"x-real-ip", map[string]string{"X-Real-IP": "203.0.113.7"}, "203.0.113.7"}, + {"invalid x-real-ip", map[string]string{"X-Real-IP": "evil"}, "192.0.2.1:1234"}, + {"spoofed true-client-ip", map[string]string{"True-Client-IP": "1.1.1.1"}, "192.0.2.1:1234"}, + {"spoofed x-forwarded-for", map[string]string{"X-Forwarded-For": "1.1.1.1"}, "192.0.2.1:1234"}, + { + "x-real-ip wins over spoofed headers", + map[string]string{"X-Real-IP": "203.0.113.7", "True-Client-IP": "1.1.1.1"}, + "203.0.113.7", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got string + h := RealIP(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { got = r.RemoteAddr })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + for k, v := range tc.headers { + req.Header.Set(k, v) + } + h.ServeHTTP(httptest.NewRecorder(), req) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index 88526a3..dfe1447 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -35,7 +35,7 @@ func NewRouter(d Deps) http.Handler { } r := chi.NewRouter() - r.Use(middleware.RequestID, middleware.RealIP, httpx.AccessLog(log), middleware.Recoverer) + r.Use(middleware.RequestID, httpx.RealIP, httpx.AccessLog(log), middleware.Recoverer) r.NotFound(httpx.NotFound) r.MethodNotAllowed(httpx.MethodNotAllowed) diff --git a/services/api/internal/storageagent/router.go b/services/api/internal/storageagent/router.go index 92cae2f..290747b 100644 --- a/services/api/internal/storageagent/router.go +++ b/services/api/internal/storageagent/router.go @@ -5,6 +5,7 @@ package storageagent import ( "crypto/subtle" "encoding/json" + "log/slog" "net/http" "os" "path/filepath" @@ -21,8 +22,12 @@ type Mount struct { Path string `json:"path"` } -// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN. -func NewRouter(token, libraryRoot string) http.Handler { +// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN; +// при пустом token все запросы к /internal отклоняются. +func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler { + if log == nil { + log = slog.New(slog.DiscardHandler) + } r := chi.NewRouter() r.NotFound(httpx.NotFound) r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { @@ -30,10 +35,11 @@ func NewRouter(token, libraryRoot string) http.Handler { }) r.Route("/internal", func(r chi.Router) { r.Use(requireToken(token)) - r.Get("/mounts", func(w http.ResponseWriter, _ *http.Request) { + r.Get("/mounts", func(w http.ResponseWriter, r *http.Request) { mounts, err := listMounts(libraryRoot) if err != nil { - httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", err.Error()) + log.ErrorContext(r.Context(), "list mounts", "err", err) + httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", "cannot list mounts") return } writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts}) @@ -46,7 +52,7 @@ func requireToken(token string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") - if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 { + if !ok || token == "" || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 { httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token") return } diff --git a/services/api/internal/storageagent/router_test.go b/services/api/internal/storageagent/router_test.go index 74cb165..b9073b6 100644 --- a/services/api/internal/storageagent/router_test.go +++ b/services/api/internal/storageagent/router_test.go @@ -13,7 +13,7 @@ import ( ) func TestInternalRequiresToken(t *testing.T) { - h := NewRouter("secret", t.TempDir()) + h := NewRouter(nil, "secret", t.TempDir()) for _, auth := range []string{"", "Bearer wrong", "secret"} { rec := httptest.NewRecorder() @@ -26,6 +26,18 @@ func TestInternalRequiresToken(t *testing.T) { } } +func TestInternalRejectsEverythingWithEmptyToken(t *testing.T) { + h := NewRouter(nil, "", t.TempDir()) + + for _, auth := range []string{"", "Bearer ", "Bearer x"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil) + req.Header.Set("Authorization", auth) + h.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code, "auth=%q", auth) + } +} + func TestListMounts(t *testing.T) { root := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(root, "src-1"), 0o755)) @@ -34,7 +46,7 @@ func TestListMounts(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil) req.Header.Set("Authorization", "Bearer secret") - NewRouter("secret", root).ServeHTTP(rec, req) + NewRouter(nil, "secret", root).ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var body struct{ Mounts []Mount } diff --git a/services/api/internal/system/handler.go b/services/api/internal/system/handler.go index 617a746..303413f 100644 --- a/services/api/internal/system/handler.go +++ b/services/api/internal/system/handler.go @@ -3,6 +3,8 @@ package system import ( "context" + "log/slog" + "sync" "time" "pipu/api/internal/api/gen" @@ -29,13 +31,17 @@ type Info struct { // Handler реализует системную часть gen.StrictServerInterface. type Handler struct { info Info + log *slog.Logger checks map[string]Checker checkTimeout time.Duration } // NewHandler создаёт обработчик. checks — именованные проверки для /readyz. -func NewHandler(info Info, checks map[string]Checker) *Handler { - return &Handler{info: info, checks: checks, checkTimeout: 2 * time.Second} +func NewHandler(info Info, log *slog.Logger, checks map[string]Checker) *Handler { + if log == nil { + log = slog.New(slog.DiscardHandler) + } + return &Handler{info: info, log: log, checks: checks, checkTimeout: 2 * time.Second} } // GetHealthz сообщает, что процесс жив; зависимости не проверяет. @@ -43,21 +49,35 @@ func (h *Handler) GetHealthz(context.Context, gen.GetHealthzRequestObject) (gen. return gen.GetHealthz200JSONResponse{Status: gen.HealthStatusStatusOk}, nil } -// GetReadyz проверяет все зависимости; 503, если хотя бы одна недоступна. +// GetReadyz параллельно проверяет все зависимости; 503, если хотя бы одна недоступна. +// /readyz доступен снаружи, поэтому текст ошибки пишется только в лог, а в ответ — "fail". func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) (gen.GetReadyzResponseObject, error) { ctx, cancel := context.WithTimeout(ctx, h.checkTimeout) defer cancel() - results := make(map[string]string, len(h.checks)) - healthy := true + var ( + mu sync.Mutex + wg sync.WaitGroup + results = make(map[string]string, len(h.checks)) + healthy = true + ) for name, c := range h.checks { - if err := c.Check(ctx); err != nil { - results[name] = err.Error() - healthy = false - continue - } - results[name] = "ok" + wg.Add(1) + go func() { + defer wg.Done() + err := c.Check(ctx) + mu.Lock() + defer mu.Unlock() + if err != nil { + h.log.WarnContext(ctx, "readiness check failed", "check", name, "err", err) + results[name] = "fail" + healthy = false + return + } + results[name] = "ok" + }() } + wg.Wait() if !healthy { return gen.GetReadyz503JSONResponse{Status: gen.ReadyStatusStatusDegraded, Checks: results}, nil diff --git a/services/api/internal/system/handler_test.go b/services/api/internal/system/handler_test.go index c7e9cb6..715de9e 100644 --- a/services/api/internal/system/handler_test.go +++ b/services/api/internal/system/handler_test.go @@ -20,7 +20,7 @@ func newServer(checks map[string]system.Checker) http.Handler { Version: "1.2.3", SupportedLocales: []string{"ru", "en"}, MinAndroidVersionCode: 10203, - }, checks) + }, nil, checks) return server.NewRouter(server.Deps{System: h}) } @@ -68,7 +68,7 @@ func TestReadyz(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, rec.Code) assert.Equal(t, "degraded", body["status"]) - assert.Equal(t, map[string]any{"postgres": "ok", "redis": "connection refused"}, body["checks"]) + assert.Equal(t, map[string]any{"postgres": "ok", "redis": "fail"}, body["checks"]) }) } diff --git a/services/recommender/pipu_rec/api/app.py b/services/recommender/pipu_rec/api/app.py index 1452fde..a6eb689 100644 --- a/services/recommender/pipu_rec/api/app.py +++ b/services/recommender/pipu_rec/api/app.py @@ -1,6 +1,8 @@ """FastAPI-приложение внутреннего API рекомендаций.""" +import asyncio import hmac +import logging from collections.abc import Awaitable, Callable from typing import Annotated @@ -11,6 +13,10 @@ from pipu_rec.settings import Settings Check = Callable[[], Awaitable[None]] +log = logging.getLogger(__name__) + +CHECK_TIMEOUT_S = 2.0 + def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> FastAPI: app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None) @@ -26,15 +32,20 @@ def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> Fa async def healthz() -> dict[str, str]: return {"status": "ok"} + async def run_check(name: str, check: Check) -> str: + try: + await asyncio.wait_for(check(), CHECK_TIMEOUT_S) + except Exception as exc: + log.warning("readiness check %s failed: %r", name, exc) + return "fail" + return "ok" + @app.get("/readyz") async def readyz() -> JSONResponse: - results: dict[str, str] = {} - for name, check in checks.items(): - try: - await check() - results[name] = "ok" - except Exception as exc: - results[name] = str(exc) or type(exc).__name__ + # Проверки параллельные, каждая ограничена таймаутом; причину ошибки пишем только в лог. + names = list(checks) + outcomes = await asyncio.gather(*(run_check(n, checks[n]) for n in names)) + results = dict(zip(names, outcomes, strict=True)) healthy = all(v == "ok" for v in results.values()) return JSONResponse( {"status": "ok" if healthy else "degraded", "checks": results}, diff --git a/services/recommender/pipu_rec/cli.py b/services/recommender/pipu_rec/cli.py index 4719259..9e78dc8 100644 --- a/services/recommender/pipu_rec/cli.py +++ b/services/recommender/pipu_rec/cli.py @@ -15,7 +15,7 @@ log = logging.getLogger("pipu_rec") def _serve(settings: Settings) -> None: - rdb = aioredis.Redis.from_url(settings.redis_url) + rdb = aioredis.Redis.from_url(settings.redis_url, socket_connect_timeout=2, socket_timeout=2) async def check_postgres() -> None: async with await psycopg.AsyncConnection.connect(settings.database_url, connect_timeout=2) as conn: diff --git a/services/recommender/tests/test_app.py b/services/recommender/tests/test_app.py index a88310c..67be128 100644 --- a/services/recommender/tests/test_app.py +++ b/services/recommender/tests/test_app.py @@ -32,7 +32,21 @@ def test_readyz_all_up() -> None: def test_readyz_dependency_down() -> None: r = client({"postgres": ok, "redis": down}).get("/readyz") assert r.status_code == 503 - assert r.json()["checks"]["redis"] == "connection refused" + assert r.json()["checks"]["redis"] == "fail" + + +def test_readyz_dependency_hangs(monkeypatch) -> None: # type: ignore[no-untyped-def] + import asyncio + + from pipu_rec.api import app as app_module + + async def hang() -> None: + await asyncio.sleep(10) + + monkeypatch.setattr(app_module, "CHECK_TIMEOUT_S", 0.05) + r = client({"postgres": ok, "redis": hang}).get("/readyz") + assert r.status_code == 503 + assert r.json()["checks"] == {"postgres": "ok", "redis": "fail"} def test_internal_status_requires_token() -> None: