Compare commits

...
4 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 42d64cd49f fix(mobile): keep Metro out of the native android/ios build directories
ci / api (push) Successful in 52s
ci / recommender (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 32s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 53s
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 57s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 53s
release / verify (push) Successful in 1m11s
release / android + release (push) Canceled after 24m53s
Expo's monorepo config watches all of apps/mobile, so during the release
build Metro crawled and hashed gigabytes of Gradle/CMake intermediates in
android/ and the createBundleReleaseJsAndAssets task appeared to hang.
Run the release build with CI=1 (no file watcher) and NODE_ENV=production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:28:57 +03:00
mrleo1nidandClaude Opus 5.5 58fd82e52b ci(release): build the APK for arm64-v8a only and report disk usage
ci / api (push) Successful in 50s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 28s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / ts (packages + web) (push) Successful in 41s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 52s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 1m20s
release / verify (push) Successful in 1m16s
release / android + release (push) Canceled after 25m58s
The v0.1.2 release ran out of disk while Gradle unpacked the NDK; building
React Native's native code for four ABIs needs several more GB. Print df
before the build and after a failure to make the next shortage obvious.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:48:33 +03:00
mrleo1nidandClaude Opus 5.5 4fa1add80d fix: address project review findings
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / api (push) Successful in 51s
ci / recommender (push) Canceled after 2s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
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 <noreply@anthropic.com>
2026-10-20 00:46:46 +03:00
mrleo1nidandClaude Opus 5.5 1324cad2af ci(release): install explicit Android SDK packages in setup-android
ci / recommender (push) Successful in 36s
ci / ts (packages + web) (push) Successful in 38s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / api (push) Successful in 50s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 43s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 30s
ci / mobile (Metro-бандл Android) (push) Successful in 50s
release / verify (push) Successful in 1m14s
release / android + release (push) Failing after 5m19s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 50s
The default `tools` package no longer exists in the SDK repository, so
sdkmanager failed and the v0.1.1 release job never built the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:30:47 +03:00
38 changed files with 528 additions and 192 deletions

No files matched your search

+4 -1
View File
@@ -31,7 +31,10 @@ jobs:
(cd services/api && go generate ./...) (cd services/api && go generate ./...)
pnpm generate pnpm generate
- name: Проверка, что всё закоммичено - 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: api:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+29 -3
View File
@@ -55,19 +55,37 @@ jobs:
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "17" } with: { distribution: temurin, java-version: "17" }
- uses: android-actions/setup-android@v3 - uses: android-actions/setup-android@v3
with:
# пакета `tools` (значение по умолчанию) в репозитории SDK больше нет — sdkmanager падает;
# build-tools нужны шагу подписи (apksigner), версии — как у React Native 0.79
packages: platform-tools platforms;android-35 build-tools;35.0.0
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm } with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Свободное место
run: df -h / /tmp
- name: Сборка APK - name: Сборка APK
working-directory: apps/mobile working-directory: apps/mobile
env:
# CI=1 — Metro без file watcher; NODE_ENV нужен expo для выбора .env-файлов
CI: "1"
NODE_ENV: production
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
run: | run: |
npx expo prebuild --platform android --clean --no-install npx expo prebuild --platform android --clean --no-install
cd android && ./gradlew --no-daemon assembleRelease cd android && ./gradlew --no-daemon assembleRelease -PreactNativeArchitectures=arm64-v8a
- name: Свободное место после сбоя
if: failure()
run: df -h / /tmp; du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
- name: Подпись APK - name: Подпись APK
env: env:
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -82,17 +100,25 @@ jobs:
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \ "$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src" --ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
rm release.keystore rm release.keystore
else elif [ "$PRERELEASE" = "true" ]; then
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного" echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
cp "$src" "$out" cp "$src" "$out"
else
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
exit 1
fi fi
"$bt/apksigner" verify --print-certs "$out" | head -3 "$bt/apksigner" verify --print-certs "$out" | head -3
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256" sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
echo "APK=$out" >> "$GITHUB_ENV" echo "APK=$out" >> "$GITHUB_ENV"
- name: Changelog - name: Changelog
env:
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
run: | 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}" range="${prev:+$prev..}${GITHUB_REF_NAME}"
{ {
echo "## Pipu Music ${PIPU_VERSION}" echo "## Pipu Music ${PIPU_VERSION}"
+1 -1
View File
@@ -85,7 +85,7 @@ components:
description: Результат проверки каждой зависимости description: Результат проверки каждой зависимости
additionalProperties: additionalProperties:
type: string type: string
description: "`ok` или текст ошибки" description: "`ok` или `fail` (причина — только в логе сервиса)"
ServerInfo: ServerInfo:
type: object type: object
required: [name, version, api_version, supported_locales, min_android_version_code] required: [name, version, api_version, supported_locales, min_android_version_code]
+6 -1
View File
@@ -1,6 +1,6 @@
import type { ConfigContext, ExpoConfig } from 'expo/config'; 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 version = process.env.PIPU_VERSION ?? '0.0.0';
const versionCode = Number(process.env.PIPU_VERSION_CODE ?? '1'); const versionCode = Number(process.env.PIPU_VERSION_CODE ?? '1');
@@ -22,5 +22,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
permissions: ['REQUEST_INSTALL_PACKAGES'], permissions: ['REQUEST_INSTALL_PACKAGES'],
}, },
platforms: ['android'], platforms: ['android'],
plugins: [
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
],
extra: { versionCode }, extra: { versionCode },
}); });
+12 -1
View File
@@ -1,4 +1,15 @@
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths). // Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
const path = require('node:path');
const { getDefaultConfig } = require('expo/metro-config'); const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname); const config = getDefaultConfig(__dirname);
// watchFolders включает весь apps/mobile, а без watchman Metro обходит и хэширует каждый файл.
// android/ и ios/ после prebuild содержат гигабайты промежуточных файлов Gradle/CMake —
// в release-сборке Metro на них «висит». В JS-бандл из этих папок ничего не попадает.
const escape = (p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const nativeDirs = ['android', 'ios'].map((d) => new RegExp(`^${escape(path.join(__dirname, d) + path.sep)}.*`));
config.resolver.blockList = [...[].concat(config.resolver.blockList ?? []), ...nativeDirs];
module.exports = config;
+1
View File
@@ -15,6 +15,7 @@
"@pipu/design-tokens": "workspace:*", "@pipu/design-tokens": "workspace:*",
"@pipu/i18n": "workspace:*", "@pipu/i18n": "workspace:*",
"expo": "~53.0.20", "expo": "~53.0.20",
"expo-build-properties": "~0.14.8",
"expo-constants": "~17.1.7", "expo-constants": "~17.1.7",
"expo-status-bar": "~2.2.3", "expo-status-bar": "~2.2.3",
"i18next": "^25.2.1", "i18next": "^25.2.1",
+10 -1
View File
@@ -17,7 +17,10 @@ export function ConnectScreen() {
const [address, setAddress] = useState(''); const [address, setAddress] = useState('');
const [state, setState] = useState<State>({ kind: 'idle' }); const [state, setState] = useState<State>({ kind: 'idle' });
const checking = state.kind === 'checking';
async function onCheck() { async function onCheck() {
if (checking) return;
const baseUrl = normalizeServerUrl(address); const baseUrl = normalizeServerUrl(address);
if (!baseUrl) { if (!baseUrl) {
setState({ kind: 'invalid' }); setState({ kind: 'invalid' });
@@ -39,6 +42,7 @@ export function ConnectScreen() {
value={address} value={address}
onChangeText={setAddress} onChangeText={setAddress}
onSubmitEditing={onCheck} onSubmitEditing={onCheck}
editable={!checking}
placeholder="https://music.home.lan" placeholder="https://music.home.lan"
placeholderTextColor={colors.text3} placeholderTextColor={colors.text3}
autoCapitalize="none" autoCapitalize="none"
@@ -62,7 +66,12 @@ export function ConnectScreen() {
)} )}
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />} {state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
<Pressable onPress={onCheck} style={({ pressed }) => [styles.button, pressed && { opacity: 0.85 }]}> <Pressable
onPress={onCheck}
disabled={checking}
accessibilityState={{ disabled: checking, busy: checking }}
style={({ pressed }) => [styles.button, (pressed || checking) && { opacity: 0.6 }]}
>
<Text style={styles.buttonText}>{t('server.check')}</Text> <Text style={styles.buttonText}>{t('server.check')}</Text>
</Pressable> </Pressable>
</View> </View>
+1
View File
@@ -13,6 +13,7 @@ RUN pnpm --filter @pipu/web build
FROM nginx:1.28-alpine FROM nginx:1.28-alpine
COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template 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 COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
ARG VERSION=0.0.0-dev ARG VERSION=0.0.0-dev
LABEL org.opencontainers.image.version=${VERSION} LABEL org.opencontainers.image.version=${VERSION}
+8 -7
View File
@@ -3,16 +3,17 @@
# docker compose -f deploy/compose.dev.yaml --profile app up # + api и recommender из исходников # docker compose -f deploy/compose.dev.yaml --profile app up # + api и recommender из исходников
# Затем: веб — `pnpm --filter @pipu/web dev` (проксирует /api на :8080), # Затем: веб — `pnpm --filter @pipu/web dev` (проксирует /api на :8080),
# api без docker — см. README, раздел «Разработка». # api без docker — см. README, раздел «Разработка».
# Порты слушают только 127.0.0.1: у Redis нет пароля, а у dev-Postgres он известен всем.
name: pipu-dev name: pipu-dev
services: services:
postgres: postgres:
image: pgvector/pgvector:pg17 image: pgvector/pgvector:0.8.7-pg17
environment: environment:
POSTGRES_USER: pipu POSTGRES_USER: pipu
POSTGRES_PASSWORD: pipu POSTGRES_PASSWORD: pipu
POSTGRES_DB: pipu POSTGRES_DB: pipu
ports: ["5432:5432"] ports: ["127.0.0.1:5432:5432"]
volumes: [pg-data:/var/lib/postgresql/data] volumes: [pg-data:/var/lib/postgresql/data]
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"] test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"]
@@ -20,8 +21,8 @@ services:
retries: 10 retries: 10
redis: redis:
image: redis:7-alpine image: redis:7.4-alpine
ports: ["6379:6379"] ports: ["127.0.0.1:6379:6379"]
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
@@ -32,7 +33,7 @@ services:
environment: environment:
MEILI_MASTER_KEY: dev-master-key-change-me MEILI_MASTER_KEY: dev-master-key-change-me
MEILI_ENV: development MEILI_ENV: development
ports: ["7700:7700"] ports: ["127.0.0.1:7700:7700"]
volumes: [meili-data:/meili_data] volumes: [meili-data:/meili_data]
api: api:
@@ -45,7 +46,7 @@ services:
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
LOG_LEVEL: debug LOG_LEVEL: debug
ports: ["8080:8080"] ports: ["127.0.0.1:8080:8080"]
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
redis: { condition: service_healthy } redis: { condition: service_healthy }
@@ -59,7 +60,7 @@ services:
DATABASE_URL: postgresql://pipu:pipu@postgres:5432/pipu DATABASE_URL: postgresql://pipu:pipu@postgres:5432/pipu
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
INTERNAL_TOKEN: dev-internal-token INTERNAL_TOKEN: dev-internal-token
ports: ["8000:8000"] ports: ["127.0.0.1:8000:8000"]
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
redis: { condition: service_healthy } redis: { condition: service_healthy }
+15 -8
View File
@@ -45,7 +45,7 @@ services:
args: *version args: *version
env_file: .env env_file: .env
environment: &db-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: volumes:
- *library-ro - *library-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache - ${PIPU_DATA:-/srv/pipu}/cache:/cache
@@ -67,8 +67,9 @@ services:
cap_add: [SYS_ADMIN] cap_add: [SYS_ADMIN]
security_opt: ["apparmor:unconfined"] security_opt: ["apparmor:unconfined"]
environment: environment:
INTERNAL_TOKEN: ${INTERNAL_TOKEN} INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LIBRARY_ROOT: /library LIBRARY_ROOT: /library
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes: volumes:
- type: bind - type: bind
source: ${PIPU_DATA:-/srv/pipu}/library source: ${PIPU_DATA:-/srv/pipu}/library
@@ -81,8 +82,12 @@ services:
image: pipu-music-recommender:${PIPU_VERSION:-dev} image: pipu-music-recommender:${PIPU_VERSION:-dev}
build: *build-rec build: *build-rec
command: ["serve"] command: ["serve"]
env_file: .env # только нужные переменные, а не весь .env с секретами Meilisearch и Gitea
environment: *db-env 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: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
@@ -91,8 +96,10 @@ services:
image: pipu-music-recommender:${PIPU_VERSION:-dev} image: pipu-music-recommender:${PIPU_VERSION:-dev}
build: *build-rec build: *build-rec
command: ["analyze", "--workers", "4"] command: ["analyze", "--workers", "4"]
env_file: .env environment:
environment: *db-env <<: *db-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes: volumes:
- *library-ro - *library-ro
- ${PIPU_DATA:-/srv/pipu}/models:/models - ${PIPU_DATA:-/srv/pipu}/models:/models
@@ -102,7 +109,7 @@ services:
postgres: postgres:
<<: *restart <<: *restart
image: pgvector/pgvector:pg17 image: pgvector/pgvector:0.8.7-pg17
environment: environment:
POSTGRES_USER: pipu POSTGRES_USER: pipu
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@@ -115,7 +122,7 @@ services:
redis: redis:
<<: *restart <<: *restart
image: redis:7-alpine image: redis:7.4-alpine
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--appendonly", "yes"]
volumes: [ "${PIPU_DATA:-/srv/pipu}/redis:/data" ] volumes: [ "${PIPU_DATA:-/srv/pipu}/redis:/data" ]
+6 -1
View File
@@ -12,6 +12,11 @@ DATA="${PIPU_DATA:-/srv/pipu}"
echo "→ каталоги в $DATA" echo "→ каталоги в $DATA"
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups,caddy/data,caddy/config} 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; чтобы точки монтирования видели другие контейнеры, # storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
# каталог должен быть отдельной точкой монтирования с shared propagation. # каталог должен быть отдельной точкой монтирования с shared propagation.
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
@@ -30,7 +35,7 @@ Before=docker.service
[Service] [Service]
Type=oneshot 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 RemainAfterExit=yes
[Install] [Install]
+6
View File
@@ -26,6 +26,8 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
client_max_body_size 16m; client_max_body_size 16m;
include /etc/nginx/snippets/security-headers.conf;
gzip on; gzip on;
gzip_types text/css application/javascript application/json image/svg+xml; 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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $pipu_scheme; 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; } location = /healthz { proxy_pass http://pipu_api; }
@@ -50,11 +54,13 @@ server {
location /assets/ { location /assets/ {
expires 1y; expires 1y;
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+6
View File
@@ -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;
+17
View File
@@ -26,6 +26,23 @@ elif [[ "$REF" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
REF="v$REF" REF="v$REF"
fi 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" echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF" git checkout --quiet "$REF"
+5 -1
View File
@@ -3,6 +3,10 @@
Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner` Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
с доступом к Docker. с доступом к Docker.
Сборке APK нужно ~15 ГБ свободного места на хосте раннера (Android SDK, NDK ~2.5 ГБ, кэш Gradle,
C++-сборка React Native). APK собирается только под `arm64-v8a`. Если job падает с `No space left on device`,
освободите место на хосте раннера: `docker system prune -af` и старые кэши act в `~/.cache/act`.
## Версионирование ## Версионирование
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их - SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
@@ -66,7 +70,7 @@ Docker-образы **не публикуются**: сервер собирае
| Секрет | Назначение | | Секрет | Назначение |
|---|---| |---|---|
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов | | `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` собирается с отладочной подписью. |
## Деплой ## Деплой
+2 -2
View File
@@ -46,8 +46,8 @@
| `api` | `services/api/Dockerfile`, target `api` | Миграции при старте | | `api` | `services/api/Dockerfile`, target `api` | Миграции при старте |
| `storage-agent` | `services/api/Dockerfile`, target `storage-agent` | `CAP_SYS_ADMIN`, `apparmor:unconfined`, библиотека с `propagation: rshared` | | `storage-agent` | `services/api/Dockerfile`, target `storage-agent` | `CAP_SYS_ADMIN`, `apparmor:unconfined`, библиотека с `propagation: rshared` |
| `recommender`, `analyzer` | `services/recommender/Dockerfile` (один образ) | Команды `serve` и `analyze --workers 4` | | `recommender`, `analyzer` | `services/recommender/Dockerfile` (один образ) | Команды `serve` и `analyze --workers 4` |
| `postgres` | `pgvector/pgvector:pg17` | Healthcheck `pg_isready` | | `postgres` | `pgvector/pgvector:0.8.7-pg17` | Healthcheck `pg_isready` |
| `redis` | `redis:7-alpine` | AOF | | `redis` | `redis:7.4-alpine` | AOF |
| `meilisearch` | `getmeili/meilisearch:v1.11` | | | `meilisearch` | `getmeili/meilisearch:v1.11` | |
| `caddy` (из `compose.tls.yaml`) | `caddy:2.10-alpine` | HTTPS на `DOMAIN`, nginx тогда без открытых портов | | `caddy` (из `compose.tls.yaml`) | `caddy:2.10-alpine` | HTTPS на `DOMAIN`, nginx тогда без открытых портов |
+1 -1
View File
@@ -1 +1 @@
export { checkServer, normalizeServerUrl, type ServerCheck } from './server'; export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
+5
View File
@@ -52,6 +52,11 @@ describe('checkServer', () => {
expect(res).toEqual({ status: 'offline', error: 'Failed to fetch' }); 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<Response>(() => {}) });
await expect(checkServer(client, undefined, 20)).resolves.toEqual({ status: 'offline', error: 'timeout' });
});
it('reports offline with the problem code on API errors', async () => { it('reports offline with the problem code on API errors', async () => {
const res = await checkServer(clientReturning(() => json({ status: 500, code: 'common.internal' }, 500))); const res = await checkServer(clientReturning(() => json({ status: 500, code: 'common.internal' }, 500)));
expect(res).toEqual({ status: 'offline', error: 'common.internal' }); expect(res).toEqual({ status: 'offline', error: 'common.internal' });
+25 -2
View File
@@ -25,19 +25,42 @@ export type ServerCheck =
| { status: 'unsupported_app'; info: ServerInfo } | { status: 'unsupported_app'; info: ServerInfo }
| { status: 'offline'; error: string }; | { status: 'offline'; error: string };
/** Сколько ждать ответа сервера, прежде чем считать его недоступным. */
export const SERVER_CHECK_TIMEOUT_MS = 10_000;
/** /**
* Проверяет сервер через GET /api/v1/server-info. * Проверяет сервер через GET /api/v1/server-info.
* appVersionCode — versionCode Android-клиента; для веба не передаётся. * appVersionCode — versionCode Android-клиента; для веба не передаётся.
*/ */
export async function checkServer(client: ApiClient, appVersionCode?: number): Promise<ServerCheck> { export async function checkServer(
client: ApiClient,
appVersionCode?: number,
timeoutMs = SERVER_CHECK_TIMEOUT_MS,
): Promise<ServerCheck> {
// Гонка с таймером, а не только abort: не каждая реализация fetch (Hermes, полифилы)
// надёжно прерывает запрос по сигналу. AbortSignal.timeout не используется — его может не быть в Hermes.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error('timeout'));
}, timeoutMs);
});
try { 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 (!data) return { status: 'offline', error: error ? error.code : 'unknown' };
if (appVersionCode !== undefined && appVersionCode < data.min_android_version_code) { if (appVersionCode !== undefined && appVersionCode < data.min_android_version_code) {
return { status: 'unsupported_app', info: data }; return { status: 'unsupported_app', info: data };
} }
return { status: 'ok', info: data }; return { status: 'ok', info: data };
} catch (e) { } catch (e) {
if (controller.signal.aborted) return { status: 'offline', error: 'timeout' };
return { status: 'offline', error: e instanceof Error ? e.message : String(e) }; return { status: 'offline', error: e instanceof Error ? e.message : String(e) };
} finally {
clearTimeout(timer);
} }
} }
+88 -59
View File
@@ -31,13 +31,16 @@ importers:
version: link:../../packages/i18n version: link:../../packages/i18n
expo: expo:
specifier: ~53.0.20 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: expo-constants:
specifier: ~17.1.7 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: expo-status-bar:
specifier: ~2.2.3 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: i18next:
specifier: ^25.2.1 specifier: ^25.2.1
version: 25.10.10(typescript@5.8.3) version: 25.10.10(typescript@5.8.3)
@@ -46,10 +49,10 @@ importers:
version: 19.0.0 version: 19.0.0
react-i18next: react-i18next:
specifier: ^15.5.3 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: react-native:
specifier: 0.79.6 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: devDependencies:
'@types/react': '@types/react':
specifier: ~19.0.10 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)) version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
jsdom: jsdom:
specifier: ^26.1.0 specifier: ^26.1.0
version: 26.1.0(supports-color@10.2.2) version: 26.1.0
vite: vite:
specifier: ^6.3.5 specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest: vitest:
specifier: ^3.2.4 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: packages/api-client:
dependencies: dependencies:
@@ -134,7 +137,7 @@ importers:
devDependencies: devDependencies:
vitest: vitest:
specifier: ^3.2.4 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: {} packages/design-tokens: {}
@@ -1370,6 +1373,9 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
anser@1.4.10: anser@1.4.10:
resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==}
@@ -1880,6 +1886,11 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
expo-build-properties@0.14.8:
resolution: {integrity: sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==}
peerDependencies:
expo: '*'
expo-constants@17.1.8: expo-constants@17.1.8:
resolution: {integrity: sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==} resolution: {integrity: sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==}
peerDependencies: peerDependencies:
@@ -1943,6 +1954,9 @@ packages:
fast-json-stable-stringify@2.1.0: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-uri@3.1.8:
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
fb-watchman@2.0.4: fb-watchman@2.0.4:
resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==} resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==}
@@ -4454,11 +4468,11 @@ snapshots:
'@expo/sudo-prompt@9.3.2': {} '@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: 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: 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': {} '@expo/ws-tunnel@1.0.6': {}
@@ -4652,13 +4666,13 @@ snapshots:
nullthrows: 1.1.1 nullthrows: 1.1.1
yargs: 17.7.3 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: dependencies:
'@react-native/dev-middleware': 0.79.6 '@react-native/dev-middleware': 0.79.6
chalk: 4.1.2 chalk: 4.1.2
debug: 2.6.9 debug: 2.6.9
invariant: 2.2.4 invariant: 2.2.4
metro: 0.82.5 metro: 0.82.5(supports-color@10.2.2)
metro-config: 0.82.5 metro-config: 0.82.5
metro-core: 0.82.5 metro-core: 0.82.5
semver: 7.8.5 semver: 7.8.5
@@ -4693,12 +4707,12 @@ snapshots:
'@react-native/normalize-colors@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)':
dependencies: dependencies:
invariant: 2.2.4 invariant: 2.2.4
nullthrows: 1.1.1 nullthrows: 1.1.1
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)
optionalDependencies: optionalDependencies:
'@types/react': 19.0.14 '@types/react': 19.0.14
@@ -4995,6 +5009,13 @@ snapshots:
agent-base@7.1.4: {} 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: {} anser@1.4.10: {}
ansi-colors@4.1.3: {} ansi-colors@4.1.3: {}
@@ -5511,39 +5532,45 @@ snapshots:
expect-type@1.4.0: {} 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: dependencies:
'@expo/image-utils': 0.7.6 '@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: 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))(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-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: 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: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@expo/config': 11.0.13 '@expo/config': 11.0.13
'@expo/env': 1.0.7 '@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) 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) 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: transitivePeerDependencies:
- supports-color - 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: 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-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-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: 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 fontfaceobserver: 2.3.0
react: 19.0.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: 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 react: 19.0.0
expo-modules-autolinking@2.1.15: expo-modules-autolinking@2.1.15:
@@ -5560,14 +5587,14 @@ snapshots:
dependencies: dependencies:
invariant: 2.2.4 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: dependencies:
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)
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)
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)
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: dependencies:
'@babel/runtime': 7.29.10 '@babel/runtime': 7.29.10
'@expo/cli': 0.24.24(supports-color@10.2.2) '@expo/cli': 0.24.24(supports-color@10.2.2)
@@ -5575,18 +5602,18 @@ snapshots:
'@expo/config-plugins': 10.1.2 '@expo/config-plugins': 10.1.2
'@expo/fingerprint': 0.13.4(supports-color@10.2.2) '@expo/fingerprint': 0.13.4(supports-color@10.2.2)
'@expo/metro-config': 0.20.18 '@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) 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-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))(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-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))(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))
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)
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)
expo-modules-autolinking: 2.1.15 expo-modules-autolinking: 2.1.15
expo-modules-core: 2.5.0 expo-modules-core: 2.5.0
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)
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)
whatwg-url-without-unicode: 8.0.0-3 whatwg-url-without-unicode: 8.0.0-3
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -5602,6 +5629,8 @@ snapshots:
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-uri@3.1.8: {}
fb-watchman@2.0.4: fb-watchman@2.0.4:
dependencies: dependencies:
bser: 2.1.2 bser: 2.1.2
@@ -5724,7 +5753,7 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
http-proxy-agent@7.0.2(supports-color@10.2.2): http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -5909,13 +5938,13 @@ snapshots:
jsc-safe-url@0.2.4: {} jsc-safe-url@0.2.4: {}
jsdom@26.1.0(supports-color@10.2.2): jsdom@26.1.0:
dependencies: dependencies:
cssstyle: 4.6.0 cssstyle: 4.6.0
data-urls: 5.0.0 data-urls: 5.0.0
decimal.js: 10.6.0 decimal.js: 10.6.0
html-encoding-sniffer: 4.0.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) https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1 is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28 nwsapi: 2.2.28
@@ -6076,7 +6105,7 @@ snapshots:
cosmiconfig: 5.2.1 cosmiconfig: 5.2.1
flow-enums-runtime: 0.0.6 flow-enums-runtime: 0.0.6
jest-validate: 29.7.0 jest-validate: 29.7.0
metro: 0.82.5 metro: 0.82.5(supports-color@10.2.2)
metro-cache: 0.82.5 metro-cache: 0.82.5
metro-core: 0.82.5 metro-core: 0.82.5
metro-runtime: 0.82.5 metro-runtime: 0.82.5
@@ -6163,7 +6192,7 @@ snapshots:
'@babel/parser': 7.29.9 '@babel/parser': 7.29.9
'@babel/types': 7.29.8 '@babel/types': 7.29.8
flow-enums-runtime: 0.0.6 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-babel-transformer: 0.82.5
metro-cache: 0.82.5 metro-cache: 0.82.5
metro-cache-key: 0.82.5 metro-cache-key: 0.82.5
@@ -6176,7 +6205,7 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
metro@0.82.5: metro@0.82.5(supports-color@10.2.2):
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
'@babel/core': 7.29.7 '@babel/core': 7.29.7
@@ -6516,40 +6545,40 @@ snapshots:
react-dom: 19.3.1(react@19.3.1) react-dom: 19.3.1(react@19.3.1)
typescript: 5.8.3 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: dependencies:
'@babel/runtime': 7.29.10 '@babel/runtime': 7.29.10
html-parse-stringify: 3.1.0 html-parse-stringify: 3.1.0
i18next: 25.10.10(typescript@5.8.3) i18next: 25.10.10(typescript@5.8.3)
react: 19.0.0 react: 19.0.0
optionalDependencies: 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 typescript: 5.8.3
react-is@17.0.2: {} react-is@17.0.2: {}
react-is@18.3.1: {} 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: dependencies:
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)
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: dependencies:
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)
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: dependencies:
'@jest/create-cache-key-function': 29.7.0 '@jest/create-cache-key-function': 29.7.0
'@react-native/assets-registry': 0.79.6 '@react-native/assets-registry': 0.79.6
'@react-native/codegen': 0.79.6(@babel/core@7.29.7) '@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/gradle-plugin': 0.79.6
'@react-native/js-polyfills': 0.79.6 '@react-native/js-polyfills': 0.79.6
'@react-native/normalize-colors': 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 abort-controller: 3.0.0
anser: 1.4.10 anser: 1.4.10
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@@ -7050,7 +7079,7 @@ snapshots:
lightningcss: 1.27.0 lightningcss: 1.27.0
terser: 5.51.2 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: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.7 '@vitest/expect': 3.2.7
@@ -7077,7 +7106,7 @@ snapshots:
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 26.6.5 '@types/node': 26.6.5
jsdom: 26.1.0(supports-color@10.2.2) jsdom: 26.1.0
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
+19 -4
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
// Вычисляет версию продукта из git-тега (docs/ci-cd.md#версионирование). // Вычисляет версию продукта из git-тега (docs/ci-cd.md#версионирование).
// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT) // node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT);
// node scripts/version.mjs → берёт GITHUB_REF_NAME, иначе 0.0.0-dev // тег не по формату — ошибка (релиз не должен стать 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), // 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. // поэтому 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 }; 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() { function main() {
const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? ''; let v;
const v = TAG_RE.test(tag) ? parseVersion(tag) : { version: '0.0.0-dev', versionCode: 1, prerelease: true }; try {
v = resolveVersion(process.argv[2], process.env.GITHUB_REF_NAME);
} catch (e) {
process.stderr.write(`${e.message}
`);
process.exit(1);
}
process.stdout.write( process.stdout.write(
`PIPU_VERSION=${v.version}\nPIPU_VERSION_CODE=${v.versionCode}\nPIPU_PRERELEASE=${v.prerelease}\n`, `PIPU_VERSION=${v.version}\nPIPU_VERSION_CODE=${v.versionCode}\nPIPU_PRERELEASE=${v.prerelease}\n`,
); );
+12 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { parseVersion } from './version.mjs'; import { parseVersion, resolveVersion } from './version.mjs';
test('release tag', () => { test('release tag', () => {
assert.deepEqual(parseVersion('v0.1.0'), { version: '0.1.0', versionCode: 10_099, prerelease: false }); 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); 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');
});
+4 -32
View File
@@ -3,13 +3,10 @@ package main
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -63,37 +60,12 @@ func run() error {
Version: buildinfo.Version, Version: buildinfo.Version,
SupportedLocales: cfg.SupportedLocales, SupportedLocales: cfg.SupportedLocales,
MinAndroidVersionCode: cfg.MinAndroidVersionCode, MinAndroidVersionCode: cfg.MinAndroidVersionCode,
}, map[string]system.Checker{ }, log, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping), "postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }), "redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
}) })
srv := &http.Server{ srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
Addr: cfg.HTTPAddr, log.Info("starting", "version", buildinfo.Version)
Handler: server.NewRouter(server.Deps{Log: log, System: sys}), return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
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)
} }
+18 -14
View File
@@ -2,10 +2,11 @@
package main package main
import ( import (
"context"
"log/slog" "log/slog"
"net/http"
"os" "os"
"time" "os/signal"
"syscall"
"pipu/api/internal/buildinfo" "pipu/api/internal/buildinfo"
"pipu/api/internal/platform/config" "pipu/api/internal/platform/config"
@@ -14,21 +15,24 @@ import (
) )
func main() { func main() {
if err := run(); err != nil {
slog.Error("storage-agent stopped", "err", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load[config.StorageAgent]() cfg, err := config.Load[config.StorageAgent]()
if err != nil { if err != nil {
slog.Error("storage-agent: config", "err", err) return err
os.Exit(1)
} }
log := httpx.NewLogger(cfg.LogLevel) log := httpx.NewLogger(cfg.LogLevel)
slog.SetDefault(log)
srv := &http.Server{ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
Addr: cfg.HTTPAddr, defer stop()
Handler: storageagent.NewRouter(cfg.InternalToken, cfg.LibraryRoot),
ReadHeaderTimeout: 10 * time.Second, srv := httpx.NewServer(cfg.HTTPAddr, storageagent.NewRouter(log, cfg.InternalToken, cfg.LibraryRoot))
} log.Info("starting", "version", buildinfo.Version)
log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version) return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
if err := srv.ListenAndServe(); err != nil {
log.Error("storage-agent stopped", "err", err)
os.Exit(1)
}
} }
+22 -22
View File
@@ -474,28 +474,28 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"H4sIAAAAAAAC/7xWX28bRRD/KquFh1hcbIdSQf1WVUD7gBQliJc2SjZ322Sb+8feXkSILMUJapFSGqkg", "H4sIAAAAAAAC/7xW3W4bRRR+ldHARSw2tkOpoL6rKqC9QIoSxE0bJZPdSTLN/jE7GxEiS3GCWqSURioI",
"xANq6Tdwgt0cdn35CrNfgU+CZu/OvnMuITyUJ/t2Z3fm95vfzM4+tQMvDHzuq4h29qnkURj4ETcfyzLY", "cYFa+gabYDdLHG9e4cwr8CTozO7aa2cTwkW5snf3zPn5vu+cOXvUDrww8LmvItrZo5JHYeBH3DwsymDd",
"dLmHf+3AV9xX+JeFoStspkTgt8LM4qMnUeDjXmRvc4/hvw8lf0w79IPW7P5Wthu1inu73a5FHR7ZUoR4", "5R7+tQNfcV/hXxaGrrCZEoHfCnOLj55GgY/fInuLewz/fSj5Bu3QD1oT/638a9Qq/Xa7XYs6PLKlCNEd",
"He1QeKV/hAROYQR9srDyxT1y55PbnzYoWubH8fb7nLlqe1UxFZvvUAYhl0pkcUfTde7HHu08pMEOXbOo", "7VB4rX+EFE7gHBIyt/TFA3Lvk7ufNihaFsfR+0POXLW1rJiKzXMog5BLJfK8o/F77sce7TymwTZdsaja",
"2gs57dBISeFvmSsl/zYWkjtok5+a2QWbT7itaNcqE1H1ZAcOx985EL9D38CYwEQfw18ERpDCgEA6BZcQ", "DTnt0EhJ4W8al5J/GwvJHbQpTk3sgvWn3Fa0a1WBmI5kBw7H35kifofElDGCkT6CvwicQwZ9Atm4uJRA",
"GMBYnxCx9JlvEZhAHy70ASTwDob6gGywWG03hb/LXOGs25I73FeCudEGvYQDOVRMuDWBvIZUn+geTGAI", "H4b6mIiFz3yLwAgSuNT7kMIFDPQ+WWOx2moKf4e5wlm1JXe4rwRzozV6pQ7EUDHh1iTyBjJ9rHswggGM",
"E0hgWLiFMaTwJ6RwRhZwd7p+ASlyD+fQzz7G+jmcoyH09SEMYaxfNOpiCJlkniGFOY7AAJi7XCJLyZjX", "IIVBGRaGkMGfkMEpmcOv4/eXkCH2cAZJ/jDUL+AMDSHRBzCAoX7ZqMshZJJ5BhTmOAITYO5iBSwlY14D",
"UDvLVb4lfMW3uMS9bAVBPWaxq2iHss0gVp1Nl/k79Ib5tLIk1aV1hTNn7yoR2dvc3rkGzzzXG8HOBoEE", "7YSr4pPwFd/kEr/lb7CoDRa7inYoWw9i1Vl3mb9Nb8mnlZNUR+sSZ87udSKyt7i9fUM9s1ivBdtrBFIY",
"xpAQQ9RI9/RhJef1qask7A8Ywrk+Qtb1ITJOjCyQ/qE+yISD6XkLA0hRWefQhzNIdM9IJ0WfZUd1RJeK", "QkrWNphw18hcTql+bqSQkL/3fyH6oMD0HDICpyURA6J7SDycQqp7kDTqaZ4i9w8YwJk+RG/6ANkhJh5S",
"Av1vSeZw58b1YRXM1DG6yuUulw/8x8FlQlko1ne5jETWKvh3zAtdPL67VEeMJ/x15jsyEE5xbP3KckuM", "hZ6MyJDKd9CHDFV4BknhH2WW6Z4+gJTeSEqlgTD+pmQOd27dS1aJYh36y1zucPnI3wiugs9CsbrDZSTy",
"ut9B38g1K7r80L3A4eRudtFiXmMo/rdZSegTy5SnPoTUED3MZD+AgWH8LSQofRjqQ6J7ZsmkglozBEvt", "scK/Y17o4vGdhTpgPOGvMt+RgXDKY6vXtmZqOuECEkND3qDFoQeBw8n93NF80Y/Iz7u8ffSxZVoZOTRA",
"9p07Vo2AfebxKtZQhPGiF0fCrsMcxWEYSMWddTewmZvxNj38kMoY3fpIvVDcK5fN7JJ8gUnJ9vC7xPkc", "D/IW6UPfIP4OUmwTGOiDCZ0DvU+tSQUL7fa9e1aN2H3m8elaQxHG814cCbuu5igOw0Aq7qy6gc3cHLfx",
"by8RCCpHn1SwYetd5d43XDbKMGm7udRs/2vdGdAzt1Yl8XUYr8n1ZY2hN5Hra77Z6SM4xfrTz3IJrHy+", "4cdUxhjWR+iF4l61xSZOihdMSraLzxXMZ3B7hYWgcvTxVG04ppe59w2XjWqZtN1caLb/tUdN0ZOw1hTx",
"+jW5u/yALIswJl8h7U0Cv5kiSdEKEhgRrB6sGjxU9MF5OgIWikUMaYv7jUd+VoZj7KgwwcuwjwYh99EM", "dTXewPVVjWE0UehrdjDqQzjBXtXPCwksfb78Nbm/+IgsijAmXyHsTQK/mSbJ0ApSOCfYPdg1eKicmbNw",
"I84CazQJvDGNdmxehFxx5O+DX4gT2FGLhaLpOc1HSIwSyrA8ixVDLzFZJKBr0dwV7dBbzXbzFsX2q7aN", "BCwU85jSJvcbT/y8DYc4fWFk2v+UzAUh99EMM84TazQJvDWzYGhuj0JxZmg4gR21WCiantN8gsAooQzK",
"HvDO1u5SKzKluFhwtcVVnQL0MZzr40LhuodPwCn2IAJ9fWT6GOJL9A9IFfT1U0gg6RBkBk4JJEVpVfqU", "k1wx9QqSJQFdixahaIfeababdyiOarVl9IA+WzsLrci04nyJ1SZXdQrQR3Cmj0qF6x5eFyc4gwgk+tBc",
"PtEvsHWdTQX2Yo5R65Gve8Yc37uM/rx96edVbvvopb4m4Z0+hiHRJwYENscF/ROM0AFMijMjfLL0s4L8", "Dlhfqn9AqCDRzyCFtEMQGTghkJatNTWn9LF+iaPrdCywlzOIWk983TPmeDfm8BfjS7+YxjbBKPU9CRf6",
"RsY2diYzwzxwaId+yVWpdVnVMejjdvuaEei/jT4lL3XTzxvD62AqFEirvA2zJyN/DutdTWMvzVlYdp7H", "CMfssSkCh+Oc/gnOMQCMyjPneL3p5yX4jRxtnExm33nk0A79kqvK6LKmV6aP2+0b1qX/tiZVotRtSm8N",
"5N6NnEyHgTmJo0jZVmQeg71IcY+u4dWtbTONfV+S2SV27+cm75HaykxYR+5rFKh+avTWI5mK5rn51Uhr", "rv2xUCCbxm2QXxnF1Vkfapx7ZSfDtvM8JndvFWS8OMxIHEXKNiNzGexGint0BV23tszm9n1FZlfQfViY",
"qsOL8hHoX0WAxEHiWvwrmcV7hF8eZurQv8QcXzEvYMIzzEdwgW0QZXa7fet/C+4VDHAELuYnnEh1z0zN", "vEdop/bHOnDfoED1M6O3HslVNIvNr0ZaYx1eVo9Ach0AEpeOG+tfyi3eY/nVxaeu+lfI8TX7AhKe13wI",
"+J6PIK0PfIgT0MSIeS78alZ/zt53OIPJLLcpnOoejPVR0U+y5fN8Eke7Y7JwQ8IatdLAKEzB4/o+jaVL", "lzgGUWZ323f+t+ReQ99sU8Wuhdur7pkNe7xW1SU+wA1oZMQ8k/40qz/n9zucwmjCbQYnugdDfVjOk/z1",
"O7RFu2tT4/18RigOdde6/wQAAP//nG+aXpMNAAA=", "WbG1o90RmbslYI1aaWAWpuHx/R6NpUs7tEW7K2PjvWJHKA91V7r/BAAA//8arOezvw0AAA==",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
@@ -24,9 +24,10 @@ type API struct {
// StorageAgent — конфигурация внутреннего сервиса storage-agent. // StorageAgent — конфигурация внутреннего сервиса storage-agent.
type StorageAgent struct { type StorageAgent struct {
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"` HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
InternalToken string `env:"INTERNAL_TOKEN,required"` InternalToken string `env:"INTERNAL_TOKEN,required,notEmpty"`
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"` LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"` LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
} }
// Load заполняет структуру конфигурации T из окружения. // Load заполняет структуру конфигурации T из окружения.
@@ -24,3 +24,10 @@ func TestLoadAPIRequiresDatabase(t *testing.T) {
_, err := Load[API]() _, err := Load[API]()
assert.ErrorContains(t, err, "DATABASE_URL") assert.ErrorContains(t, err, "DATABASE_URL")
} }
func TestLoadStorageAgentRejectsEmptyToken(t *testing.T) {
t.Setenv("INTERNAL_TOKEN", "")
_, err := Load[StorageAgent]()
assert.ErrorContains(t, err, "INTERNAL_TOKEN")
}
+8 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
"github.com/pressly/goose/v3/lock"
"pipu/api/migrations" "pipu/api/migrations"
) )
@@ -26,12 +27,17 @@ func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
return pool, nil return pool, nil
} }
// Migrate применяет встроенные миграции goose. // Migrate применяет встроенные миграции goose. Advisory-lock в PostgreSQL не даёт
// нескольким экземплярам api применять миграции одновременно.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error { func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
sqlDB := stdlib.OpenDBFromPool(pool) sqlDB := stdlib.OpenDBFromPool(pool)
defer func(d *sql.DB) { _ = d.Close() }(sqlDB) 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 { if err != nil {
return fmt.Errorf("db: migrations: %w", err) return fmt.Errorf("db: migrations: %w", err)
} }
@@ -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)
})
}
@@ -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)
})
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ func NewRouter(d Deps) http.Handler {
} }
r := chi.NewRouter() 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.NotFound(httpx.NotFound)
r.MethodNotAllowed(httpx.MethodNotAllowed) r.MethodNotAllowed(httpx.MethodNotAllowed)
+11 -5
View File
@@ -5,6 +5,7 @@ package storageagent
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -21,8 +22,12 @@ type Mount struct {
Path string `json:"path"` Path string `json:"path"`
} }
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN. // NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN;
func NewRouter(token, libraryRoot string) http.Handler { // при пустом token все запросы к /internal отклоняются.
func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
r := chi.NewRouter() r := chi.NewRouter()
r.NotFound(httpx.NotFound) r.NotFound(httpx.NotFound)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { 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.Route("/internal", func(r chi.Router) {
r.Use(requireToken(token)) 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) mounts, err := listMounts(libraryRoot)
if err != nil { 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 return
} }
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts}) 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") 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") httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token")
return return
} }
@@ -13,7 +13,7 @@ import (
) )
func TestInternalRequiresToken(t *testing.T) { func TestInternalRequiresToken(t *testing.T) {
h := NewRouter("secret", t.TempDir()) h := NewRouter(nil, "secret", t.TempDir())
for _, auth := range []string{"", "Bearer wrong", "secret"} { for _, auth := range []string{"", "Bearer wrong", "secret"} {
rec := httptest.NewRecorder() 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) { func TestListMounts(t *testing.T) {
root := t.TempDir() root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, "src-1"), 0o755)) require.NoError(t, os.Mkdir(filepath.Join(root, "src-1"), 0o755))
@@ -34,7 +46,7 @@ func TestListMounts(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil) req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
req.Header.Set("Authorization", "Bearer secret") 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) require.Equal(t, http.StatusOK, rec.Code)
var body struct{ Mounts []Mount } var body struct{ Mounts []Mount }
+28 -8
View File
@@ -3,6 +3,8 @@ package system
import ( import (
"context" "context"
"log/slog"
"sync"
"time" "time"
"pipu/api/internal/api/gen" "pipu/api/internal/api/gen"
@@ -29,13 +31,17 @@ type Info struct {
// Handler реализует системную часть gen.StrictServerInterface. // Handler реализует системную часть gen.StrictServerInterface.
type Handler struct { type Handler struct {
info Info info Info
log *slog.Logger
checks map[string]Checker checks map[string]Checker
checkTimeout time.Duration checkTimeout time.Duration
} }
// NewHandler создаёт обработчик. checks — именованные проверки для /readyz. // NewHandler создаёт обработчик. checks — именованные проверки для /readyz.
func NewHandler(info Info, checks map[string]Checker) *Handler { func NewHandler(info Info, log *slog.Logger, checks map[string]Checker) *Handler {
return &Handler{info: info, checks: checks, checkTimeout: 2 * time.Second} if log == nil {
log = slog.New(slog.DiscardHandler)
}
return &Handler{info: info, log: log, checks: checks, checkTimeout: 2 * time.Second}
} }
// GetHealthz сообщает, что процесс жив; зависимости не проверяет. // GetHealthz сообщает, что процесс жив; зависимости не проверяет.
@@ -43,21 +49,35 @@ func (h *Handler) GetHealthz(context.Context, gen.GetHealthzRequestObject) (gen.
return gen.GetHealthz200JSONResponse{Status: gen.HealthStatusStatusOk}, nil 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) { func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) (gen.GetReadyzResponseObject, error) {
ctx, cancel := context.WithTimeout(ctx, h.checkTimeout) ctx, cancel := context.WithTimeout(ctx, h.checkTimeout)
defer cancel() defer cancel()
results := make(map[string]string, len(h.checks)) var (
healthy := true mu sync.Mutex
wg sync.WaitGroup
results = make(map[string]string, len(h.checks))
healthy = true
)
for name, c := range h.checks { for name, c := range h.checks {
if err := c.Check(ctx); err != nil { wg.Add(1)
results[name] = err.Error() 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 healthy = false
continue return
} }
results[name] = "ok" results[name] = "ok"
}()
} }
wg.Wait()
if !healthy { if !healthy {
return gen.GetReadyz503JSONResponse{Status: gen.ReadyStatusStatusDegraded, Checks: results}, nil return gen.GetReadyz503JSONResponse{Status: gen.ReadyStatusStatusDegraded, Checks: results}, nil
+2 -2
View File
@@ -20,7 +20,7 @@ func newServer(checks map[string]system.Checker) http.Handler {
Version: "1.2.3", Version: "1.2.3",
SupportedLocales: []string{"ru", "en"}, SupportedLocales: []string{"ru", "en"},
MinAndroidVersionCode: 10203, MinAndroidVersionCode: 10203,
}, checks) }, nil, checks)
return server.NewRouter(server.Deps{System: h}) 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, http.StatusServiceUnavailable, rec.Code)
assert.Equal(t, "degraded", body["status"]) 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"])
}) })
} }
+18 -7
View File
@@ -1,6 +1,8 @@
"""FastAPI-приложение внутреннего API рекомендаций.""" """FastAPI-приложение внутреннего API рекомендаций."""
import asyncio
import hmac import hmac
import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Annotated from typing import Annotated
@@ -11,6 +13,10 @@ from pipu_rec.settings import Settings
Check = Callable[[], Awaitable[None]] 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: 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) 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]: async def healthz() -> dict[str, str]:
return {"status": "ok"} 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") @app.get("/readyz")
async def readyz() -> JSONResponse: async def readyz() -> JSONResponse:
results: dict[str, str] = {} # Проверки параллельные, каждая ограничена таймаутом; причину ошибки пишем только в лог.
for name, check in checks.items(): names = list(checks)
try: outcomes = await asyncio.gather(*(run_check(n, checks[n]) for n in names))
await check() results = dict(zip(names, outcomes, strict=True))
results[name] = "ok"
except Exception as exc:
results[name] = str(exc) or type(exc).__name__
healthy = all(v == "ok" for v in results.values()) healthy = all(v == "ok" for v in results.values())
return JSONResponse( return JSONResponse(
{"status": "ok" if healthy else "degraded", "checks": results}, {"status": "ok" if healthy else "degraded", "checks": results},
+1 -1
View File
@@ -15,7 +15,7 @@ log = logging.getLogger("pipu_rec")
def _serve(settings: Settings) -> None: 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 def check_postgres() -> None:
async with await psycopg.AsyncConnection.connect(settings.database_url, connect_timeout=2) as conn: async with await psycopg.AsyncConnection.connect(settings.database_url, connect_timeout=2) as conn:
+15 -1
View File
@@ -32,7 +32,21 @@ def test_readyz_all_up() -> None:
def test_readyz_dependency_down() -> None: def test_readyz_dependency_down() -> None:
r = client({"postgres": ok, "redis": down}).get("/readyz") r = client({"postgres": ok, "redis": down}).get("/readyz")
assert r.status_code == 503 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: def test_internal_status_requires_token() -> None: