From 597067689a004e20b0e6bc3aad3fcbeb971dafd7 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:10:03 +0300 Subject: [PATCH] feat: add a platform-aware apps download page Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0. Co-authored-by: Cursor --- .gitea/workflows/ci-ts.yml | 6 +- .gitea/workflows/release.yml | 5 +- api/openapi.yaml | 80 +- apps/desktop/src-tauri/src/lib.rs | 2 +- apps/desktop/src-tauri/src/proxy.rs | 6 +- apps/web/src/app/router.tsx | 13 +- apps/web/src/pages/AppDownloadPage.tsx | 99 ++- apps/web/src/pages/LoginPage.tsx | 2 +- apps/web/src/pages/auth.module.css | 36 +- apps/web/src/shared/lib/platform.test.ts | 17 + apps/web/src/shared/lib/platform.ts | 17 + apps/web/src/test/fakeApi.ts | 22 + apps/web/src/widgets/AppShell.tsx | 8 +- docs/admin-guide.md | 5 +- docs/api.md | 2 + docs/deployment.md | 2 +- docs/desktop.md | 2 + docs/install-guide.md | 2 +- docs/mobile.md | 2 +- docs/web.md | 2 +- packages/api-client/src/generated/schema.ts | 99 +++ packages/i18n/locales/en/auth.json | 38 +- packages/i18n/locales/en/common.json | 2 +- packages/i18n/locales/ru/auth.json | 38 +- packages/i18n/locales/ru/common.json | 2 +- services/api/internal/api/gen/api.gen.go | 770 ++++++++++++------ services/api/internal/appupdate/appupdate.go | 264 ++++-- .../api/internal/appupdate/appupdate_test.go | 25 +- services/api/internal/auth/auth_test.go | 1 + services/api/internal/auth/middleware.go | 6 +- 30 files changed, 1180 insertions(+), 395 deletions(-) create mode 100644 apps/web/src/shared/lib/platform.test.ts create mode 100644 apps/web/src/shared/lib/platform.ts diff --git a/.gitea/workflows/ci-ts.yml b/.gitea/workflows/ci-ts.yml index 06cfa3b..88d05ef 100644 --- a/.gitea/workflows/ci-ts.yml +++ b/.gitea/workflows/ci-ts.yml @@ -57,5 +57,9 @@ jobs: with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} - - run: pnpm --filter @pipu/web exec playwright install --with-deps chromium + # git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps. + - run: | + sudo rm -f /etc/apt/sources.list.d/*git-core* || true + pnpm --filter @pipu/web exec playwright install --with-deps chromium || + pnpm --filter @pipu/web exec playwright install chromium - run: pnpm --filter @pipu/web test:e2e diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index d3ab406..5f013af 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -132,9 +132,10 @@ jobs: # Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace # metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки. if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then - if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then - echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново" + if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then + echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново" ./gradlew --stop || true + find ~/.gradle -name "*.lock" -delete || true rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-* "${gradle[@]}" assembleRelease -x lintVitalRelease else diff --git a/api/openapi.yaml b/api/openapi.yaml index c23e4d7..4fdb1b4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1497,11 +1497,57 @@ paths: $ref: "#/components/schemas/WaveBatch" default: $ref: "#/components/responses/Problem" + /api/v1/app/clients: + get: + operationId: getAppClients + tags: [app] + security: [] # публично: страница /download без входа + summary: Клиенты последнего релиза (Android и десктоп) + description: | + Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG. + Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`. + responses: + "200": + description: Доступные сборки + content: + application/json: + schema: + $ref: "#/components/schemas/AppClients" + default: + $ref: "#/components/responses/Problem" + /api/v1/app/download/{client}/{version}: + get: + operationId: downloadAppClient + tags: [app] + security: [] + summary: Файл клиента (кэшируется на сервере, поддерживает Range) + parameters: + - name: client + in: path + required: true + schema: + $ref: "#/components/schemas/AppClient" + - name: version + in: path + required: true + schema: + type: string + pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$' + responses: + "200": + description: Установщик или APK + content: + application/octet-stream: + schema: + type: string + format: binary + default: + $ref: "#/components/responses/Problem" /api/v1/app/android/latest: get: operationId: getAndroidLatest tags: [app] - security: [] # публично: страница /android раздаёт приложение без входа + security: [] # публично: страница /download раздаёт приложение без входа summary: Последняя версия Android-приложения (из релизов Gitea через сервер) description: | Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению @@ -3446,6 +3492,38 @@ components: like_rate: type: number nullable: true + AppClient: + type: string + enum: [android, windows, linux, macos] + AppClientFile: + type: object + required: [client, filename, size, download_url] + properties: + client: + $ref: "#/components/schemas/AppClient" + filename: + type: string + size: + type: integer + format: int64 + sha256: + type: string + download_url: + type: string + AppClients: + type: object + required: [version, version_code, notes_md, clients] + properties: + version: + type: string + version_code: + type: integer + notes_md: + type: string + clients: + type: array + items: + $ref: "#/components/schemas/AppClientFile" AndroidRelease: type: object required: [version, version_code, notes_md, size, sha256, download_url, min_supported] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 769ee6c..65abeac 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -126,7 +126,7 @@ pub fn run() { let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?; let menu = Menu::with_items(app, &[&open, &quit])?; - let icon: Image = include_image!("../icons/32x32.png"); + let icon: Image = include_image!("icons/32x32.png"); TrayIconBuilder::new() .icon(icon) .menu(&menu) diff --git a/apps/desktop/src-tauri/src/proxy.rs b/apps/desktop/src-tauri/src/proxy.rs index d2eec8f..5114e54 100644 --- a/apps/desktop/src-tauri/src/proxy.rs +++ b/apps/desktop/src-tauri/src/proxy.rs @@ -95,7 +95,9 @@ async fn forward(State(proxy): State, request: Request) -> Response { || origin.as_deref().is_some_and(|s| !allowed_origin(s)) { return StatusCode::FORBIDDEN.into_response(); } let prefix = format!("/{}/api/", proxy.secret); - let Some(tail) = request.uri().path().strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); }; + let path = request.uri().path().to_owned(); + let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); }; + let tail = tail.to_owned(); // Prevent URL normalization escaping the configured API path, including encoded segments. if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e") || tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f") @@ -106,7 +108,7 @@ async fn forward(State(proxy): State, request: Request) -> Response { let mut response = if request.method() == Method::OPTIONS { StatusCode::NO_CONTENT.into_response() } else { - match transfer(&proxy, request, tail.to_owned()).await { + match transfer(&proxy, request, tail).await { Ok(response) => response, Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(), } diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 99c9728..8aa06ee 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -69,8 +69,16 @@ const rootRoute = createRootRouteWithContext()({ errorComponent: RouteError, }); -// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве. -const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage }); +// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве. +const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage }); +const androidAppRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/android', + beforeLoad: () => { + throw redirect({ to: '/download' }); + }, + component: AppDownloadPage, +}); const connectRoute = createRoute({ getParentRoute: () => rootRoute, @@ -262,6 +270,7 @@ const adminSettingsRoute = createRoute({ }); const routeTree = rootRoute.addChildren([ + downloadAppRoute, androidAppRoute, connectRoute, loginRoute, diff --git a/apps/web/src/pages/AppDownloadPage.tsx b/apps/web/src/pages/AppDownloadPage.tsx index 25aba4d..bbcbfc0 100644 --- a/apps/web/src/pages/AppDownloadPage.tsx +++ b/apps/web/src/pages/AppDownloadPage.tsx @@ -1,25 +1,27 @@ import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; import { formatBytes } from '../shared/lib/format'; +import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform'; import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons'; import { Icon } from '../shared/ui/ui'; import { AuthLayout } from './AuthLayout'; import s from './auth.module.css'; /** - * Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку. - * APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление). + * Публичная страница приложений (/download, /android): ссылку можно отправить человеку. + * Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС. */ export function AppDownloadPage() { const { t, i18n } = useTranslation(['auth', 'common']); const session = useSession(); const [copied, setCopied] = useState(false); + const detected = useMemo(() => detectAppClient(), []); const latest = useQuery({ - queryKey: ['android-latest'], - queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null, + queryKey: ['app-clients'], + queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null, staleTime: 10 * 60_000, }); const server = location.origin; @@ -28,39 +30,59 @@ export function AppDownloadPage() { setCopied(true); setTimeout(() => setCopied(false), 1500); }); - const r = latest.data; + const data = latest.data; + const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c])); + const primary = detected ? files[detected] : undefined; + const others = otherAppClients(detected).filter((id) => files[id]); return ( {t('app.to_login')}}> - Android + {detected ? t(`app.platform.${detected}`) : t('app.badge')}

{t('app.title')}

{t('app.subtitle')}

- {latest.isPending ? null : r ? ( + {latest.isPending ? null : data ? ( <> - - - - {t('app.download')} - - {t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)} - - - -
    -
  1. {t('app.step_install')}
  2. -
  3. - {t('app.step_server')} - -
  4. -
  5. {t('app.step_updates')}
  6. -
-
- {t('app.checksum')} - {r.sha256} -
+ {primary ? ( + + ) : detected ? ( +
{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}
+ ) : null} + {(primary || others.length > 0) && ( +
    + {detected &&
  1. {t(`app.step_install.${detected}`)}
  2. } +
  3. + {t('app.step_server')} + +
  4. + {detected &&
  5. {t(`app.step_updates.${detected}`)}
  6. } +
+ )} + {primary?.sha256 && ( +
+ {t('app.checksum')} + {primary.sha256} +
+ )} + {others.length > 0 && ( +
+

{t('app.other')}

+ {others.map((id) => { + const file = files[id]!; + return ( + + {t(`app.download.${id}`)} + + {t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)} + + + ); + })} +
+ )} + {!primary && others.length === 0 &&
{t('app.unavailable')}
} ) : (
{t('app.unavailable')}
@@ -68,3 +90,16 @@ export function AppDownloadPage() {
); } + +function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) { + const { t } = useTranslation('auth'); + return ( + + + + {t(`app.download.${client}`)} + {meta} + + + ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 496f6fe..27a946e 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -47,7 +47,7 @@ export function LoginPage() { {desktop ? null : ( <> {' · '} - {t('app.link')} + {t('app.link')} )} diff --git a/apps/web/src/pages/auth.module.css b/apps/web/src/pages/auth.module.css index 2fafcbc..671c256 100644 --- a/apps/web/src/pages/auth.module.css +++ b/apps/web/src/pages/auth.module.css @@ -165,7 +165,7 @@ color: var(--text2); } -/* Страница Android-приложения (/android). */ +/* Страница приложений (/download). */ .download { display: flex; align-items: center; @@ -238,6 +238,40 @@ font-family: var(--font-mono); } +.otherClients { + display: flex; + flex-direction: column; + gap: 8px; +} + +.otherTitle { + margin: 8px 0 0; + color: var(--text3); + font-size: 12px; + font-weight: 600; +} + +.downloadAlt { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-radius: var(--radius-lg); + border: 1px solid var(--line2); + background: var(--surface2); + color: var(--text); + font-size: 14px; + font-weight: 600; + text-decoration: none; +} + +.downloadAlt span { + color: var(--text3); + font-size: 12px; + font-weight: 500; +} + .link { align-self: center; } diff --git a/apps/web/src/shared/lib/platform.test.ts b/apps/web/src/shared/lib/platform.test.ts new file mode 100644 index 0000000..75a83fd --- /dev/null +++ b/apps/web/src/shared/lib/platform.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { detectAppClient, otherAppClients } from './platform'; + +describe('detectAppClient', () => { + it('picks the native client from the user agent', () => { + expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android'); + expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows'); + expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos'); + expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux'); + expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull(); + }); + + it('lists the remaining platforms after the detected one', () => { + expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']); + }); +}); diff --git a/apps/web/src/shared/lib/platform.ts b/apps/web/src/shared/lib/platform.ts new file mode 100644 index 0000000..488d188 --- /dev/null +++ b/apps/web/src/shared/lib/platform.ts @@ -0,0 +1,17 @@ +export type AppClient = 'android' | 'windows' | 'linux' | 'macos'; + +const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android']; + +/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */ +export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null { + if (/Android/i.test(ua)) return 'android'; + if (/iPhone|iPad|iPod/i.test(ua)) return null; + if (/Windows/i.test(ua)) return 'windows'; + if (/Mac OS X|Macintosh/i.test(ua)) return 'macos'; + if (/Linux|X11/i.test(ua)) return 'linux'; + return null; +} + +export function otherAppClients(primary: AppClient | null): AppClient[] { + return ORDER.filter((c) => c !== primary); +} diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index c8bd29f..1a41487 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -116,6 +116,28 @@ export function fakeApi(initial: FakeUser[]) { min_android_version_code: 0, bootstrap_required: [...users.values()].some((u) => u.must_change_credentials), }); + case 'GET /api/v1/app/clients': + return json({ + version: '1.3.0', + version_code: 1_030_099, + notes_md: '', + clients: [ + { client: 'windows', filename: 'pipu-music_1.3.0_x64-setup.exe', size: 20_000_000, download_url: '/api/v1/app/download/windows/1.3.0' }, + { client: 'linux', filename: 'pipu-music_1.3.0_amd64.AppImage', size: 18_000_000, download_url: '/api/v1/app/download/linux/1.3.0' }, + { client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' }, + { client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' }, + ], + }); + case 'GET /api/v1/app/android/latest': + return json({ + version: '1.3.0', + version_code: 1_030_099, + notes_md: '', + size: 12_000_000, + sha256: 'ab'.repeat(32), + download_url: '/api/v1/app/android/download/1.3.0', + min_supported: 0, + }); case 'POST /api/v1/auth/login': { const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password); if (!u) return problem(401, 'auth.invalid_credentials'); diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 2e704b3..2ecbfa6 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -6,6 +6,7 @@ import { Suspense, useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuthState, useSession, useUser } from '../app/session'; +import { useDesktop } from '../desktop'; import { useCollectionCounts } from '../entities/collection'; import { moreIcon, @@ -25,7 +26,7 @@ import { dislikeFillIcon, dislikeIcon, settingsIcon, - androidIcon, + downloadIcon, chevronRightIcon, storageFillIcon, storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons'; @@ -155,6 +156,7 @@ function Shell() { const { t } = useTranslation(); const user = useUser(); const session = useSession(); + const desktop = useDesktop(); const navigate = useNavigate(); const name = user.display_name ?? user.username; const staff = user.role !== 'user'; @@ -252,7 +254,9 @@ function Shell() { } items={[ { label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) }, - { label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) }, + ...(!desktop + ? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }] + : []), { label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() }, ]} /> diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 4a5e5b9..c84b387 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -102,8 +102,9 @@ прочитайте заметки к релизу на странице релизов Gitea. Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK. -Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза, -адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля. +Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда): +сборка под систему посетителя (APK, Windows, Linux, macOS), остальные платформы ниже, адрес сервера +для копирования и короткая инструкция. Та же страница — внизу экрана входа и в меню профиля. ## 7. Наблюдение diff --git a/docs/api.md b/docs/api.md index 20b2170..00b82ea 100644 --- a/docs/api.md +++ b/docs/api.md @@ -106,6 +106,8 @@ ### Мобильное обновление — [mobile.md](mobile.md#самообновление) | Метод | Путь | Описание | |---|---|---| +| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS | +| GET | `/app/download/{client}/{version}` | файл клиента (прокси / кэш релиза Gitea) | | GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` | | GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) | diff --git a/docs/deployment.md b/docs/deployment.md index aec33ee..5f65264 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -76,7 +76,7 @@ | `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | | | `TRANSCODE_CACHE_LIMIT_GB` | `50` | | | `TRANSCODE_CONCURRENCY` | `2` | | -| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` | +| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` | | `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища | | `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) | | `LOG_LEVEL` | `info` | | diff --git a/docs/desktop.md b/docs/desktop.md index d2aaaca..65dc253 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -13,3 +13,5 @@ pnpm --filter @pipu/desktop bundle ``` На Windows получается NSIS-установщик, на Linux — AppImage, deb и rpm. Зависимости Linux: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer. + +Готовые файлы с последнего релиза Gitea сервер отдаёт на публичной странице `/download`: посетителю сразу предлагается сборка его ОС, остальные платформы и Android — ниже. diff --git a/docs/install-guide.md b/docs/install-guide.md index eb7e6f7..ab2c3b5 100644 --- a/docs/install-guide.md +++ b/docs/install-guide.md @@ -246,7 +246,7 @@ curl -s https://music.example.com/readyz и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`) `install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение — -ссылка `https://<ваш-домен>/android`. +ссылка `https://<ваш-домен>/download`. > Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится. > Поэтому нужен домен с Let's Encrypt, как в этой инструкции. diff --git a/docs/mobile.md b/docs/mobile.md index 451325e..f97961f 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -99,7 +99,7 @@ ## Самообновление -Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция. +Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция. Приложение распространяется только через Gitea Releases (не Google Play). diff --git a/docs/web.md b/docs/web.md index 18afa56..768cfe4 100644 --- a/docs/web.md +++ b/docs/web.md @@ -22,7 +22,7 @@ ``` apps/web/src/ ├── app/ # провайдеры, роутер, глобальные стили, гарды -├── pages/ # login, setup-admin, setup-password, home, search, artist, album, +├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album, │ # collection/{playlists,history,likes,dislikes}, playlist, track, settings, │ # admin/{users,sources,library,upload,upload-queue,settings} ├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 6b1d35d..be4604a 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -1293,6 +1293,44 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/app/clients": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Клиенты последнего релиза (Android и десктоп) + * @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG. + * Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`. + */ + get: operations["getAppClients"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/app/download/{client}/{version}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Файл клиента (кэшируется на сервере, поддерживает Range) */ + get: operations["downloadAppClient"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/app/android/latest": { parameters: { query?: never; @@ -2249,6 +2287,22 @@ export interface components { early_skip_rate: number | null; like_rate: number | null; }; + /** @enum {string} */ + AppClient: "android" | "windows" | "linux" | "macos"; + AppClientFile: { + client: components["schemas"]["AppClient"]; + filename: string; + /** Format: int64 */ + size: number; + sha256?: string; + download_url: string; + }; + AppClients: { + version: string; + version_code: number; + notes_md: string; + clients: components["schemas"]["AppClientFile"][]; + }; AndroidRelease: { /** @example 0.6.1 */ version: string; @@ -4189,6 +4243,51 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + getAppClients: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Доступные сборки */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AppClients"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + downloadAppClient: { + parameters: { + query?: never; + header?: never; + path: { + client: components["schemas"]["AppClient"]; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Установщик или APK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/octet-stream": string; + }; + }; + default: components["responses"]["Problem"]; + }; + }; getAndroidLatest: { parameters: { query?: never; diff --git a/packages/i18n/locales/en/auth.json b/packages/i18n/locales/en/auth.json index 04ce974..b786fb9 100644 --- a/packages/i18n/locales/en/auth.json +++ b/packages/i18n/locales/en/auth.json @@ -22,17 +22,41 @@ "logout": "Sign out" }, "app": { - "link": "Android app", - "title": "pipu for Android", - "subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.", - "download": "Download APK", + "link": "Download apps", + "badge": "Apps", + "title": "pipu on your devices", + "subtitle": "Clients for this server: phone, desktop and the web. The build for your system comes first.", + "platform": { + "android": "Android", + "windows": "Windows", + "linux": "Linux", + "macos": "macOS" + }, + "download": { + "android": "Download APK", + "windows": "Download for Windows", + "linux": "Download AppImage", + "macos": "Download for macOS" + }, "version": "version {{version}}", - "step_install": "Open the downloaded file. If Android asks, allow installs from this source.", + "other": "Other platforms", + "step_install": { + "android": "Open the downloaded file. If Android asks, allow installs from this source.", + "windows": "Run the installer and follow the steps. pipu will appear in the Start menu.", + "linux": "Make the file executable and open it. For a .deb, install the package.", + "macos": "Open the disk image and drag pipu into Applications." + }, "step_server": "Launch pipu and enter the server address:", "copy_server": "Copy the server address", - "step_updates": "Sign in with your account. The app offers new versions by itself.", + "step_updates": { + "android": "Sign in with your account. The app offers new versions by itself.", + "windows": "Sign in with your account. Get a new version from this page.", + "linux": "Sign in with your account. Get a new version from this page.", + "macos": "Sign in with your account. Get a new version from this page." + }, "checksum": "SHA-256 checksum", - "unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).", + "unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).", + "unavailable_platform": "The latest release does not include a build for {{platform}} yet.", "to_login": "Sign in to the web app" } } diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 27d5022..94977bf 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -41,7 +41,7 @@ "menu": "Profile menu", "settings": "Settings", "logout": "Sign out", - "android_app": "Android app" + "download_apps": "Download apps" }, "roles": { "admin": "Administrator", diff --git a/packages/i18n/locales/ru/auth.json b/packages/i18n/locales/ru/auth.json index 32fac38..e932ca2 100644 --- a/packages/i18n/locales/ru/auth.json +++ b/packages/i18n/locales/ru/auth.json @@ -22,17 +22,41 @@ "logout": "Выйти" }, "app": { - "link": "Приложение для Android", - "title": "pipu для Android", - "subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.", - "download": "Скачать APK", + "link": "Скачать приложения", + "badge": "Приложения", + "title": "pipu на ваших устройствах", + "subtitle": "Клиенты для этого сервера: телефон, компьютер и веб. Сначала предлагается сборка под вашу систему.", + "platform": { + "android": "Android", + "windows": "Windows", + "linux": "Linux", + "macos": "macOS" + }, + "download": { + "android": "Скачать APK", + "windows": "Скачать для Windows", + "linux": "Скачать AppImage", + "macos": "Скачать для macOS" + }, "version": "версия {{version}}", - "step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.", + "other": "Другие платформы", + "step_install": { + "android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.", + "windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.", + "linux": "Сделайте файл исполняемым и откройте его. Для .deb — установите пакет.", + "macos": "Откройте образ и перетащите pipu в Программы." + }, "step_server": "Запустите pipu и введите адрес сервера:", "copy_server": "Скопировать адрес сервера", - "step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.", + "step_updates": { + "android": "Войдите своим логином. Новые версии приложение предложит установить само.", + "windows": "Войдите своим логином. Новую версию скачайте с этой страницы.", + "linux": "Войдите своим логином. Новую версию скачайте с этой страницы.", + "macos": "Войдите своим логином. Новую версию скачайте с этой страницы." + }, "checksum": "Контрольная сумма SHA-256", - "unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).", + "unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).", + "unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.", "to_login": "Войти в веб-версию" } } diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 32d2d91..c979a3f 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -41,7 +41,7 @@ "menu": "Меню профиля", "settings": "Настройки", "logout": "Выйти", - "android_app": "Приложение для Android" + "download_apps": "Скачать приложения" }, "roles": { "admin": "Администратор", diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index b5d3ba6..5612981 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -28,6 +28,14 @@ const ( BearerAuthScopes = "bearerAuth.Scopes" ) +// Defines values for AppClient. +const ( + AppClientAndroid AppClient = "android" + AppClientLinux AppClient = "linux" + AppClientMacos AppClient = "macos" + AppClientWindows AppClient = "windows" +) + // Defines values for AuthTokensTokenType. const ( Bearer AuthTokensTokenType = "Bearer" @@ -35,9 +43,9 @@ const ( // Defines values for ClientType. const ( - Android ClientType = "android" - Desktop ClientType = "desktop" - Web ClientType = "web" + ClientTypeAndroid ClientType = "android" + ClientTypeDesktop ClientType = "desktop" + ClientTypeWeb ClientType = "web" ) // Defines values for HealthStatusStatus. @@ -319,6 +327,26 @@ type AndroidRelease struct { VersionCode int `json:"version_code"` } +// AppClient defines model for AppClient. +type AppClient string + +// AppClientFile defines model for AppClientFile. +type AppClientFile struct { + Client AppClient `json:"client"` + DownloadUrl string `json:"download_url"` + Filename string `json:"filename"` + Sha256 *string `json:"sha256,omitempty"` + Size int64 `json:"size"` +} + +// AppClients defines model for AppClients. +type AppClients struct { + Clients []AppClientFile `json:"clients"` + NotesMd string `json:"notes_md"` + Version string `json:"version"` + VersionCode int `json:"version_code"` +} + // Artist defines model for Artist. type Artist struct { AlbumCount int `json:"album_count"` @@ -1688,6 +1716,12 @@ type ServerInterface interface { // Последняя версия Android-приложения (из релизов Gitea через сервер) // (GET /api/v1/app/android/latest) GetAndroidLatest(w http.ResponseWriter, r *http.Request) + // Клиенты последнего релиза (Android и десктоп) + // (GET /api/v1/app/clients) + GetAppClients(w http.ResponseWriter, r *http.Request) + // Файл клиента (кэшируется на сервере, поддерживает Range) + // (GET /api/v1/app/download/{client}/{version}) + DownloadAppClient(w http.ResponseWriter, r *http.Request, client AppClient, version string) // Исполнитель // (GET /api/v1/artists/{id}) GetArtist(w http.ResponseWriter, r *http.Request, id EntityId) @@ -2054,6 +2088,18 @@ func (_ Unimplemented) GetAndroidLatest(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNotImplemented) } +// Клиенты последнего релиза (Android и десктоп) +// (GET /api/v1/app/clients) +func (_ Unimplemented) GetAppClients(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Файл клиента (кэшируется на сервере, поддерживает Range) +// (GET /api/v1/app/download/{client}/{version}) +func (_ Unimplemented) DownloadAppClient(w http.ResponseWriter, r *http.Request, client AppClient, version string) { + w.WriteHeader(http.StatusNotImplemented) +} + // Исполнитель // (GET /api/v1/artists/{id}) func (_ Unimplemented) GetArtist(w http.ResponseWriter, r *http.Request, id EntityId) { @@ -3438,6 +3484,54 @@ func (siw *ServerInterfaceWrapper) GetAndroidLatest(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } +// GetAppClients operation middleware +func (siw *ServerInterfaceWrapper) GetAppClients(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetAppClients(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DownloadAppClient operation middleware +func (siw *ServerInterfaceWrapper) DownloadAppClient(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "client" ------------- + var client AppClient + + err = runtime.BindStyledParameterWithOptions("simple", "client", chi.URLParam(r, "client"), &client, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "client", Err: err}) + return + } + + // ------------- Path parameter "version" ------------- + var version string + + err = runtime.BindStyledParameterWithOptions("simple", "version", chi.URLParam(r, "version"), &version, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "version", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DownloadAppClient(w, r, client, version) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetArtist operation middleware func (siw *ServerInterfaceWrapper) GetArtist(w http.ResponseWriter, r *http.Request) { @@ -5074,6 +5168,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/app/android/latest", wrapper.GetAndroidLatest) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/app/clients", wrapper.GetAppClients) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/app/download/{client}/{version}", wrapper.DownloadAppClient) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/artists/{id}", wrapper.GetArtist) }) @@ -6269,6 +6369,74 @@ func (response GetAndroidLatestdefaultApplicationProblemPlusJSONResponse) VisitG return json.NewEncoder(w).Encode(response.Body) } +type GetAppClientsRequestObject struct { +} + +type GetAppClientsResponseObject interface { + VisitGetAppClientsResponse(w http.ResponseWriter) error +} + +type GetAppClients200JSONResponse AppClients + +func (response GetAppClients200JSONResponse) VisitGetAppClientsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetAppClientsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetAppClientsdefaultApplicationProblemPlusJSONResponse) VisitGetAppClientsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type DownloadAppClientRequestObject struct { + Client AppClient `json:"client"` + Version string `json:"version"` +} + +type DownloadAppClientResponseObject interface { + VisitDownloadAppClientResponse(w http.ResponseWriter) error +} + +type DownloadAppClient200ApplicationoctetStreamResponse struct { + Body io.Reader + ContentLength int64 +} + +func (response DownloadAppClient200ApplicationoctetStreamResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/octet-stream") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + +type DownloadAppClientdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response DownloadAppClientdefaultApplicationProblemPlusJSONResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetArtistRequestObject struct { Id EntityId `json:"id"` } @@ -7816,6 +7984,12 @@ type StrictServerInterface interface { // Последняя версия Android-приложения (из релизов Gitea через сервер) // (GET /api/v1/app/android/latest) GetAndroidLatest(ctx context.Context, request GetAndroidLatestRequestObject) (GetAndroidLatestResponseObject, error) + // Клиенты последнего релиза (Android и десктоп) + // (GET /api/v1/app/clients) + GetAppClients(ctx context.Context, request GetAppClientsRequestObject) (GetAppClientsResponseObject, error) + // Файл клиента (кэшируется на сервере, поддерживает Range) + // (GET /api/v1/app/download/{client}/{version}) + DownloadAppClient(ctx context.Context, request DownloadAppClientRequestObject) (DownloadAppClientResponseObject, error) // Исполнитель // (GET /api/v1/artists/{id}) GetArtist(ctx context.Context, request GetArtistRequestObject) (GetArtistResponseObject, error) @@ -8972,6 +9146,57 @@ func (sh *strictHandler) GetAndroidLatest(w http.ResponseWriter, r *http.Request } } +// GetAppClients operation middleware +func (sh *strictHandler) GetAppClients(w http.ResponseWriter, r *http.Request) { + var request GetAppClientsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetAppClients(ctx, request.(GetAppClientsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetAppClients") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetAppClientsResponseObject); ok { + if err := validResponse.VisitGetAppClientsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DownloadAppClient operation middleware +func (sh *strictHandler) DownloadAppClient(w http.ResponseWriter, r *http.Request, client AppClient, version string) { + var request DownloadAppClientRequestObject + + request.Client = client + request.Version = version + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DownloadAppClient(ctx, request.(DownloadAppClientRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DownloadAppClient") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DownloadAppClientResponseObject); ok { + if err := validResponse.VisitDownloadAppClientResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetArtist operation middleware func (sh *strictHandler) GetArtist(w http.ResponseWriter, r *http.Request, id EntityId) { var request GetArtistRequestObject @@ -10343,273 +10568,278 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+x9/28cx5Xnv9KY3QPI7IikZMtJJCwOsmzH3rViRpTPd4h0w+ZMkexVT/e4u4cSYwgg", - "xTiyj4q49mZ3g83Gjje32APyy4jiiCOKXwD/Bd3/Qv6SQ71XVV3VXdXdQ/ZQ9mKBIBZneqqrXr169b5+", - "3ieNtt/t+R7xorBx5ZNGzw7sLolIAH9d7wehH9B/dUjYDpxe5Phe40oj/n08jE+Sjfg43k824kHyKD5K", - "tuMXVnyQbCUbyWZ8nGxY8SjetxY9cj9qtWGgRQt+NIz3ku14L9lKPo+H8Qsr2Uwe0lHio3iU/CrZbjQb", - "Dn3Lx30SrDeaDc/uksaVBo7RaDbC9irp2nRW0XqPfhNGgeOtNB48aDbe9iInWn+vQ7+FQXp2tJqO4XQa", - "zUZAPu47Aek0rkRBn8jjLftB144aVxr9PjyZH/99p+tEYvDMDF34Uh6wQ5btvhs1rlyeaza69n2n2+82", - "rlyao385Hv51UbzH8SKyQgJ40YLfD9pkcgv5sOf6dmchsqN++I7jRkS3zd8kD+NB8jDZSjaTbSt5FA9h", - "+/ateD8exCfJDnz3pNFskPs91++QxpVl2w2JfgNDeJlCHyciXeC0vwzIcuNK4y9mU3acxcfCWXmqjQdi", - "MXYQ2Ov07zBad+kHdNH07w9DEkyKcA/oj8Oe74UE5j0f+Esu6dJ/tn0vIh4wh93ruU7bpmSc7eETf/V3", - "IaXpJ9K7itbMx4U3Znblq+SzeBQ/jQ/igTV1853r1o9fv/zDadhV9nM6+rVO1/EoKeBYB36PBJGDk24H", - "xI5Ip2VHypI7dkQuRE6X5NfdbHScsOfa6y0k4ycNr++69hKlO5Iw9wOnU4GezYYTtpZcv32XdKQDveT7", - "LrE9+r1rh1HL9Vccr2i6pbNx/bbtEo3MaDa6/TBqtVdtb4W02gHpEC9ybDfUT0d+uGeH4T0/MEw8IGsO", - "uRe2+sC9oeZ0/Wt8HD+Ph8lDC2ToZrIZH8Jx24hH8S7867EVH4tjt0f/PIoHVrwXH8dP40G8G7+Mh1Rs", - "xsMZiwplK9mEj/aSLRgYRDDI6ZfxyKL/OxQvjZ8m2/CKZIsOO0w24wP6YPI4PohHyadW/DJ5Eu9xEU0F", - "9H6yle6hvFYfaVvE0TfpMw+ajX5IAs5EebGUnsyf41EVj2dYkL00T2ex1wpvGXbOvPtN+ZRk2fCOIIK/", - "9HekHdF1ifM2b6+Q/JkTkq6SyEsPr0beSTdqhZOYJSq8Xh1Euxx3qd/NL8MOIieMSqcPT90ky3Sktr9G", - "glbbd/1AJUDuJGYXir/sB24lgdPpByBxW8ro4k6tLJEiJzJIiiiw23erb+It+rhuXevELto5WQ3InQec", - "HRtDJlGG0E2+VSphxCKMe053LX9jnNMennmLzoO0RtIt9LtdO1j/Xp2aeg5Fq+33UfXJH7tz5nZ5PmU7", - "9T7blTOJannnc9uhlb7aaXm2ux46IVNz8yxEv/8FCVprJKgmDn2P6Ddk2XZcRd2Svvu4T/qm74K+59HB", - "tV9Gfq9FgkBrLX4TD0Cz2aEmBFV0HsIf8bGkyIKGMYz3qI5ClZGteI8qOPSJ5NNkM9mx/rzxGys+iY/j", - "PdBUBvE+/f+mlTxKHsbHdOgRWJAPk8flGmFmX9iy0zUy+gliNdUNkNer381O4Dudm8QldqjRBjr+PY+q", - "K/yY5pVSx2uF/V7PDyLcjgxJv6Q6YbIZj5BeI6rTWVQrTHbANjtKtuHP+Cg+jneRKpSKjaZm7zw/ImGr", - "q3vPfwBt2SjH8bP42Jq6YQd36QKEdjmk2iV97bROdISr9qXLb2hXGTq/IIr4cbzojde1c1wjQeigBUXu", - "290eWHxzM2/MXNS9kz3daoNN+kmZrOGDZ34oUYbNVaymqW5hdsO0PCEugczJpgKkSIBWFNEGpbpUQuvk", - "LlOx5alVkKzi/sqL1DMtwThD7Sz6HSe61o784NTTOK2JYpzO216kVQvaUY6l6WAzaHropmbzhdmu+8Fy", - "48rPSy6nlBgP7mQdCVREglSlcgSk8pCKaXqsqbXIRPDL5Iv4iLv3TpiBuA/yYAA/eamRtw+ap3IyVNyf", - "nr1OTx6QodNx6HJsd14iLYp8wxWQ7k1kByskaunfWnrBsl/j52ObYXClwMY0OR+oQ8rTS5es0NXIb7WY", - "oCnjvjIbtB+t3vLvEk+nD7XbJAxbEf1aK/XI/Z4TkLDleFrH5kZ8HB9Y8XO4uI7ikYUDXqD3XXwAXhWq", - "W2zGw/gg2YqP4j1wTGu0IrIckHA1nUnmVb+JXyY71mLbdYiHW3vFslE9WLTiUeabDgnvRn5vUauD0zcI", - "fiMetc9/3niT2AGR6afKsbJtvkFy26OQVnmtQlU2vm7j3vT9KIwCu3c99ajcJB/3ie7+88g9nStNuhXI", - "vVZ1kaw83VRH1831Ovh+5tkjxlm2+0FAt6l0pgUPaGZaPDXgjFuZDb9HlkAdBRZqgFCnLKPlgOsgLD4M", - "SWBcWNax27Xvv0+8lWi1ceWN1zVDlvs1v+G+QiveTbbpJYKezAHcKKPkIYYPniUbyRbcJAczluQMhS/5", - "j+A4okvy2ILPD6maf45eSImX4AXajUKZrPe2R6Tb8wM7WFdYI0Ozr0HqUKpt04uVEgKMHmru0AVbSNPG", - "KU+55E/ULI+Omp+kbqFvrxEvetOO2qv5dZI1HkSsdL/Mu/Y6DAfmjn3/PfzR5bm5Ehuavad4ejdJCHE3", - "3bXRi0wWbqePcRti8CAGhL5Ja5B9Q3Wm5DNwoR9azMbdSLbjoTV1uz839xqxXpuz4j1q3MYvpoWStQnm", - "LtxCu/EQfkd1rUML4gBDHK9R6i0R61IWIc1YR653HOJ23uZGe9bj19FbE8v0R+XHBh9r4ji6l79LbDda", - "Nbk7QvE5F3v+XY2Ey7yU/Ur7OieM/GCdctmp7QIqJ8fUaMFmqugo1qmI6Sv5WAWLq0Ptk+n0qvS+d/0w", - "essJ9N65jjOuQxQCwKUMy8LEMLxuUu87S1Q+Go6LcH1pzotLWhUZLCBuyzDbZiOEjIBqQ+UOI85BHkR6", - "W7Nh9mTJy66DvxQyvioGY5Mw+lrdpb4piGQzP23pjav6cx9wD7VhWNgh2IWCBwxfdZ0wdLyVVsEjuO3V", - "dwmzT3DuJlGQRsPy77tnr5WqYh/Za+QGiQKnHeavM0arJt8L8TpOieyyVRKmK5Z2jE1LyxD+iuOZdf/U", - "QitblKSyQ+LGmtMmQrXOJVENIB9qFB/Gw2TD+vZP11cDv0usWesjx+v498JvXzatb/8079wnrvWjb182", - "mrJ2flHSlGTfSIF9ciqtVwqWy4TQkfEG+W4nm/xXFsh3JgvEafUCslye++XMw2PnmzgiZne2tBHdEfnA", - "W/LtoMPiaLowsSqlJxKHzyaUxU/jl8ARGImDcB0Ge+KnyAzJZ/yDAfAG/Rh9xckmeoUx9gb+4J0q7tNX", - "F9WQfzlmmD9L1RXiBaRow2qavmbmVaaXDpGXTZk3iH83pcuXLa+Yj6+v+k6bmLi55XRU+pTuuOwLqETx", - "0jsxHfG1MueCNOnC5c/b0eq8HUUk8DQacdgiHmV+46WwQu7nZz5Xpt7i75ry+Ia50XnpzacefjmGn0Za", - "acnu5C0qfFXBJOdRCp/VqmDDmFTVvte1o/aq3u1jsCHS35RP36g5TpLazUbIgoZnyTOvvmHCX6e5lbyI", - "Wlwo0kuPI388G8kA7Zxp/HJGT0jsoL0K4tl1CY+VraKjosFyVrTu7+XA75akA6pX4YcfvvfW2g+puvXQ", - "ig8grWEYHyUP4wGmnxxQbSh5RDWqPYiWntBH2TNH8TH470bUGCmTcn4bIgrj6cX+8rLrKGk9klRhriLT", - "cnt+6BSmR+LlUjUfLLN3oF+FkR1EzGnVIh74G+46PdhCchdMiX6IqlfYhxW6zl3Qtzz2j44Tio/4v3Ub", - "S1mlFZIQcjVO4xFJH5EWru6K6RAs2e2715GFTZ7EvOFhLxG3QE/7pKYlGidMTXliPLnVEwqyBHhwRxP6", - "Z2YvTrpUESxlTDzfY+kQWUkJQ7Qcr4P3rs6z3yN21Ooyr7dwOi8vU67wUC65Wl4MV/vLy67hUBYdqlLK", - "9HudMtO5dIw13+0rKqjX7y5pZL90COQN4dRXSShkeCNdvkpD8WKZG5QV3Snj1ve8Xj8qZNmxGDXDluWO", - "FJUtxTU616yHRQtDXjmOLX79d5J7U84TCsnFpm4hdXCkiZtcpgRXl270F2k6by6YW0sJgG6lOq3rjrQK", - "w3FQ9BeFrS/NaflaE+e/eAn1RPG3KZB1FmNOmQ7Sgd7yzsd9wp6A20Nv+MqrlOdStO11JHfnGeK0+d3Z", - "kerxk6qbX1YSUyHN9qxeGWoPhep0cipmQaprhRuvam5olmnEzLIOILU8Rqn6qnBd0T39EB4bX8zgkc4L", - "GZmIY5hv4mcGQSLZyOb4Zbm6ZkerLYjuh+YETLNDMqUeszbTOy4XHjlmgZFkE9JNT1hBwBAckcln8YD5", - "LSFVT2SzQjYfz5sxX1TK9VkQdg1EJkmpoL9BIhvu1LtOr2dy/kT2ypkIl9t0EcRVCZpOQ90zNgOxsKLg", - "r1TdrM8OycUyBlDSIfKFD+g+yIUeIyveg4RI5+KPvCYEMpQY2KLdj1ZnHG/Ndp2O7Flf1Mu/yHZcfTpV", - "spNsQmoNBEb4a4FfnlF2sqbot+LzE56AFQ9Mac5PtOUNadDWWKY9AocCeAzoqEfJdvIpewnEVVg1/UZ8", - "nGzGg0az2t0kZe9okx4CuxuWpUjndjzNu9FIb2aqCmdTw17y+9GVJdf27jYq5uYUZATdJHY7Ip1bPGXm", - "7PfjGbJvlHvAnHkjz7mO9AiFBmdOj2g2Ij+y3TG8nvh8eQYFzJMXLzBzI+PD0Vkb/GcGZTaQRi2lEn0u", - "Lw3ZF4Y5d4w5H+1VwhR7k2BWj/eif3eRJ/EtLtuOu2hNoSRjhWjoMVTzV3e5/IEQ7TDZgNKsTUPhVEae", - "/CEexvvJFkTjAJbDYhAsuzASiBkqwZ5DYJhJFRyfStfUN1lw6KVkO/r+lcDuKA7w0rONVNRTH/Lkja7y", - "kjx6VlFnxSOeI3/VoiuPn1JJykLj8SD5InlopTn8lORt37/rkGruspsshM3pYHe6kOlOLevAxmqNTNZ7", - "ul8LbdubD/yVgIS6pKJOx5R2Wl35Ks4Q8pxw9YzuomW/7xlmudx3XUM6BGbiGLKT/DXTunuB3yZhWL3e", - "VHon+JrHvQ78FntlofVRQVymZaJAFWU66k5wkipvl5feZJyRvp7TLCWs2PgiZY2yn/F08c3L6UlwcqjI", - "YlA/j614l8omK/llPIhfxC+T7aZFdSKqLKnSDIpCIcMZskrifarDxUeiXmyoyRLRHboFiO9gxnZhNl4N", - "9dhNbZJF5ar8fFZFEEB+dQuxjvIU/udUtQP5vwdVD89AQmPCD8SZBvEelVhNCzJwIE3nhNJ4lGwmn4Ix", - "Q6l9iEk69IpBIwhV2CNQu1+CiH9c5ZSfSovQ67i/Ag37gHLIcfwUEL2esWpsWMhAtt72mDr+Ak9A1ZTL", - "osRJcxZi5ZRCbSbFJPx8RVmNjMR5lirXxRZIsEaC97xlX3N4ek5LWzq9pq2bXuKFWy05dUQrNHbRwttF", - "9pRqc7CWFHj7YXxM/0uZ+vPkC8atqJYMk43kMyoqRGCVx1qV7DTJLEPhlAEAMKSidR2vxSqkWtla8JzN", - "CnOOD1lmE1qu7EfX/Q6xmOpxgRmqmCkFjJzsNMHGZQs9psuDCVJOTzagupBV86QK3zDZgEI6thMX5+Z+", - "/GNtVT7zsaV71nN6/Qvdfui0tXX2vPq8hXlsofJjemfR14JqXD1tTOIdEwJBsqOsLR5YUwuk+z9IMC0v", - "szE3c3FmrtRKZK67tCBfZmDdGgv2WsvO+gMU8kXWlPirGqtZyoGyepjsiNMDRn81HYbVIWorkIaQPYpc", - "OYr3VdZ8QXcJXBDxgCrGks9Be4Qyucu1peDaYdTqh2f28crTUxOTdchd/IUp/Qr4oI7AAWep0wcMMAXf", - "VMFWlFH8NYgpLtNYAjCmqmzwbGA5oRghRPQJxdbUT99ZQIl8RM1H0F5EceKAmrLwxXF8KKQ8Hw44DG6D", - "x9NaFpsgRADseti2a0ANFK5hTcnmHqg1J0z/4TlAIhH2Mf3bev+9N29eu/m/Wu9+sHCrSY32LTDFj/Hm", - "w6q/x2C7Ux0cPQSVtDhTEMZbDlvkPhWUlU4ufdyHZYXG4ULQMSoNR6lePRaiGM3abBLYRXovBmu22+o6", - "Xj/SOWTiv493kRGBjqAjg54wQtFHFYYjKUyQ8cxQxgYYW8QzLQ8apE6TqoU06a9aXRKGzFtYAWdipdUL", - "HD9wovVSPdRemeePSh7b8hnyi+te4ESkFZFuz2UhrQyV/18adUFWfojnHUzB5FNhNqKLveO3w1kmqWa6", - "nb/44bQaqBmKau+iUE2FWB+T/BKrKsdAZXLlYGcIbOK3Zk7w5ojVlBBu1W3OyKPMDQUHxnwFGHylmntA", - "g56qCLBKuQD6pAFFopxRgmglhvaEi+Dn5Son8hUfFImIl8tzuWW+Ne99LaoIKhJn1UTm9VX/5erIN+gW", - "wrgaqhxoI7FkWqaj/PSdhQvJpxwHHKrpP6f6R3APMFmSR3jJpirHQK9USJn36kQAmRoFD72p+dtHWP2k", - "ebOowB+xen4m5na4+nME2hM41pOt+ISajtNNi7KnBFaXfdEmA1MbaGf/PT+wKsXnTNSGQH7ZXd2o+YzX", - "erVdzepxyVb8FH0eyWOswmPmMPoTIAgDXBsfINwW/Grx9UuXLIh1QwrKDJ9di0XAF297yaa1iMHcmYDY", - "oe8tzlig4qeBH+Z7Q4+LFOthN6xABZS00gEPfANWP2d+5tTAbRtSXf4kj0tiLQIlL/C5zkLQZ3HmtqfW", - "pxqEoEG8LORiUD3iddD53fX7HjrG0fsNMcZs/YsUh8kWL58W76EeI8KopteuKJ+PRlqghVXWf0pUnlsk", - "jIwILv2O46e17rnL5kABDELIofhYUUsh6+QF80Yfs7itepyEwEdreKSC8tDzc3Fubm5aK6MkUuYrOO7q", - "FTUUTuOYBfgLHC9DhD8icB/GA9D/vp/CMKXIfkwaL86AY5Gf6jBapHefTEYUFZmr/qqFcW4qvEGpnzaf", - "BlPVIcSZUy6R97aAOzK1J96yUOn18iAKiN39Wd922aUhorv9yG80G37grDgepD2sOiurMKOOAzVIrn9P", - "O6R8tUgDRvZK2Fp2AihbYklX9A/tEBxxaV4qIK+CHFV8HCsCORmSbGyO+l4aYWMBsVqjafaa7TDuyfH0", - "P4KW9djKKlz0bmVn25rSaHEZJQxOfDP9CV6Fhwx5aahXKTtO2G55fhUQ6wow9MvEjvoBp3cV0Q+b9Q7/", - "WR7IM/57LuZYiGIfBBdVXKfA7QdwABaTZxyrF295lv424P7C5FeIKbyLNd3JNvjzqAK0AbLwiMlCVBTi", - "ZyA5D8HTzPNLcheTpma3NPBQGTEnzRiqRsw0d0h/h1JZYaiT5x4/SNmJj0D4UQ0q2Uy245d4cwA/iWvH", - "mrp4yUoeTV+V/U5oVeTNh2EVH0g5FHs1Ri1CW1czojNRy7QuOj0Y0qvlU6yQU9or6QwYZdM70ilRZdRS", - "r1uwQF5MQg1BEqysV3rU9fsdj4Rhy+0jJEXJLzLEozMS78uOZlzhe51Qp5mGNZYR5Ta5YDp1uBqqhcHN", - "noY0i9p8M+UhkQGkOm2xkH/A/BVl4Gpi/TQyrOSkVnvxKdsniOpphTzpGeZnXZxl1mIBZ8Yoo9sjjsiS", - "36HukkOUwg4l1OOH4bLdIa1QqQh7o1lS2rbqd0mLyo01R5+ImYXORC0BxPAziHXuggn/gmUL0s93k+34", - "KbaoQ6z8HXBC7OBtSdXjQ9B+H7F77gn4dPS6Acyv6/ud2qd2hK0SMK+A3benmGDXX3Jc0oLq8EBWVgHs", - "JA+KJRmuVPa4DKJfk4EHaVuttu+6di8kWmNsFxNAICV2V07hoFfoASREHQANTuKDZAvQMF/CXQhUeQq9", - "5pJfUiomW/TSNRMAvHt6CrCJ8iJCzUT/gdqCdDZg9pzQraCXtLQv6ZyNM2gTL2JJD5zC8Am9EcgyPXuB", - "s7KqNwaiVdJVbJqOHdyF+nj6i2YjXA8j0jWXwq/ZgWPj2RMpvMESDPAxvTi0PyRLGq5YAinghK7tGRJ/", - "8xIB8hZvkMoQw7l49ZboCSI4/oAnUG+CZ4tFVcF+jQ8BYagMqLgA7Gts9CnzssfCVj6fhZe11CvHJPtS", - "hm3GHHeQlMLDWR3MGVNCd4rAnJvi5B2meYNDC+QBPX9y15Ia4J41ZWYslw1z80fx0dhLyOa8jbOk4qNF", - "twgQjRnGtEYfpt9Wxe0QfjMTwBgm0hhgLArwj/+FV3o9FGVUlIt57BdrtZf7rtsUn9FNaX3c9yM780Xk", - "+y3XDlZI+pEANZZ+7qXZYGzdVayns5S0R/2wcmMtnGarAP51PD9rujO5sdOJSVskLbUcKixr0HCW4gxz", - "p5g3RWxQL+AwRxyL6nZYUIIfqifJQynVfMZCCLYlOyRvvI5GM0TMpFym4xStLtmiww4gNjia0ScVVwjN", - "yiZC6aPyBPNL/pv5t3/StOZ/+hMuNz8iS/NNtuYUS29o/YiKGAywcPfMHoP7pmIV/dHMzVKFNccwE4p2", - "0oyB6ne7TLsvQy0bU8qUxzLzQc9Lly+XwCfnEtU1uVmsp02yxeoeRIAa7uTPQetmCjpP8xrmMFjnJtTy", - "quD482iL0pFKd0QzzoGu47E/L5YIgLJT/47j6lIT+9GqX9qLADUmcACjnJFYq5TNx7rslpyo1SE9ZIhy", - "S3vJiQIWGS5/mO2HzHKlk2/7HdKu9uRpwCD4FdlyyRqiTXGlXjQt65LI7tiRjUVKKyToBY4XNZp0Qnea", - "VXqs8nf4y60zXaUZN3k5wfkb6ktFZvh+LfSQ6ODgAKKZBQqZsSrlaMZDUHG+/VP8FJRhuJ2yObTHVnxA", - "lXtodg/a/m6ylTwB6QLlPBKearIFGM9j01IshINXnnIlaVQkXcWIxzyLV6GDgX1yqrVwDq3s0EeJhNgL", - "Opc+H7FFOk5ksol4DLBljNb3/DB0llzSEmdAP9LHGGds9XtQPavZin9DeMHnUFCHwQDQEI7B+ZE8EhAX", - "sEmPOME5BuHzeA+rXOlF9ZlUiCIqx3Z5uAvqa2zX9e+1stPSWlSkTZw10mktrbN8ngqIMWhNnrHsFJEs", - "W9XFL737xppmteQI5KVccgQm3lTLfj5rAInjQFBW+EwNto3ifSuM7BXHW8GAkrTpIsFhYCVb2A30iEqk", - "ap3sVs6I5cJa1RUoYF+nWpYhSmupCVrWVCZR+ISFyqhqPIpfUqnUtDIl9/GupWzZ9BiN/Ar7a4xjGuaP", - "u+wx2EUPAQhNkV8lid5d3jzoIbg8h8zexUyoOiADK6TzSPafqqI1uY4n5fqoojOvGSmHNSdkxBXB1aNU", - "D1PFgqzO5XqaI6KNpN1khb7uktRoACYNJ69daS+EvPTXcFf+tGTljCpUzZhUst0vyZ1itb0OlBLJCHhV", - "LVykW98cKyy3++mTYxj/Yzw6ZmixkiUsAoqlbz/vAGNxfHGc8CLsqyHZXFYOq6qE1eR7ZV/sfEAg39WM", - "tFDi+ziNUypvH47lQClNSaqnRbcp3F3gyRjbhXEmr0W6d/rU0rM5x/UGQi4kyrEQQGlTogmH6HjCaihE", - "0MiYZQwpjapSTSn6wE2BEah7Or2er1dkPmraBQIIAXO864OUkr2fXRZCDx3Q2SmdC2X0h5fWFLSkeUqX", - "LOJKVK37FNy5I+YXzhJhenw7soDlBOydii+b3bqxfeZlzPezvq+7q1yn60RjWTJd+z56/sf5UY8E0BW3", - "Vfa+8vsCaqnHeTe8d8xfZYgrz1qZgWFl+Zfm6GbeqZvFPR06pC3QOcV56vUCf42I+Iu+GwGGa86W4FXg", - "w4UMu1Cb4PC1aiWxihyAzszXfOxCdwMI7QpzpYLbW99zENIEOcXKaa4XzHnt1IBeWhF/lcFaat1FFZ2H", - "+kR+nTEFOe5m2KZTpsoxTwWJIsdb0aoZOq9PJWcU4tQJT9Rh1g81hPYX4IMCdpL6lh2zMQA7IMWNQuAj", - "kPwyAp2CNgGYkmc0wl+1aMQo97giPbPn+UGMQi6zXh0ZmwZOKOcpU1D3dxCMHfHdN4Cl7kChicHTo8me", - "UkD8nnCnM0CRxc/oh7I/aDfZzlQUzmaryePjaV1A2HAoKjKkSol0PixRLbvagWm1p3Pyl/J2EVa0mc9N", - "i8oTGUoHKmy9vLy6D09Rtmi2nZH+0OR3tuAs5KoSZW8cA/Fjf2C5Yos1tGpynYBurtPt+UGEz+G/pY9b", - "y7bj5lRxVgCqZnTcc6LVTmDf8wBZqZcBNEp5hYdYT1v4eNpWizpCfmSvkTdNteOQ1rteybG+bLvuEnNq", - "aJBRDxAtPD7CxDUM8WtrLHht9oCVlIOF8pQdXfCeowv+cwQyhALBl2CBHcSjJvbDfApmGEIf5iNf8ciU", - "iutXa4kzVtufCYHVSXNopjvFFiG34+X7Ytp8Y6+ksba/Iu0eGCbxDiGdJW3Jm9+P2r6afEtp5hL0QLP+", - "VXedXovYgbueNq0qwjkeo5eWuesJn5mJsLx5sg7TbsgayB5karn244H1QwvgJIcaGMaldWgiow9jDFFN", - "RBM9HR1wKnc5GhzvLotIYyMVWH1q7aI1a117f8GatdiBe86TnZLP6YDTLPdVVJsJ/HZqq+AxPKqKkA5E", - "ossRbabzJhNsagv2tyT6KJULOXfTWGVOO0JJweuMrYtzc7JHZNd8F8ovCCPitaLVwO+vrFafGEA2GNB0", - "2XmuApXJhtHPI08ymSDSe5opNxk5WN6cvFV31q2ZGJ1hVS0jOoZxFzJ0TodpnoXmJur+VCt1lyVJWPkU", - "CfFZ3gFUO5UPRI+ajD7S1rorfovoiCM4MUcgZm7YEQkc27UW1rtLPiCl5gh/l6yrgJkpTHOx0KU/bOJk", - "7hSuIDTeYs4Y3fUlimgkkigmOvNY2YhNOlP+FtNqF6CT47nc2NAcsnNqVTXbiJIHmDJdREsh7BmAmNYa", - "0HJ14ETkFstVuL5KdKpF1wkxwjzGdsrD3mC/V0/dRU23OC0MxJeQPiCQbLB3e6rrUnUghY05wmbf1I5N", - "obw5XDO15f/8+b9bP577b4pDH5CRIPoggeceGlw8gd/3Oq0ocHqGC8rukizykRxdqtjJAtxtvI2F9E75", - "BU15a+5U2l2jO/j0EGDil6UzEIxwhr5NacsmTVLSSxXJGCwb2NZnya8BsOgk2db5NxiC+QkgjDBwAQEV", - "P4gPrlqLrCvRYprJo3DQsdSv6QiRGBFq4DlX/Rrj4BUXZCD9QeFSKfE7TZ3SIwx4HWIAwuY5TSeZflTJ", - "Vg7mUJO9NHYYS0oYEbMSG1vkY1aYaaG/skJC/X0sM/OpmRdEersfONH6AhVrzLQgdkCCa33cG/zrHS7d", - "/+ajW4AKhGWQ7Nt0N1ajqNd4QAd2GMp5fifQuQwQnnR3b769cMu6Nv+eNe/0+taNfui0Z6z4t4jYRZ+i", - "2gXi6z8EaK5tCRFLAbP27Z5zoe13yArxpm972GYl7QwNOJd+j3j0MTpfnNj0jAXBXij0TdM0gSMAFdPu", - "OTPdDqJmsch5I50rnbqEg83hs6mox1c1rjRem5mbeY0BzQCJ6ZizaxdnoWvJrN3vOCCvVgj8h+4zpAG8", - "12lcabzvhNE1eIL1iiIRCUJj6CR9ZPZ9p+tg17qSB69j9s6DOxB07fleiJxwaW5ONHLFtH+7h44vx/dm", - "/45FsPBGLMWToUuAlCZgj2xYGmvygPS/igdW/DzZSjaYoBpIbl7Ta8S805gRIK2zHo6N+J/SAbGA5wUL", - "ktBbcwo2Yropt8pINpmt/ClC10PqJW80c4eOru6i6ywFdrA+a3u2ux46dEoRax/ph5p9vUm/vsYebpyR", - "8tkOPeCirdQfhT+qkQ6abVJBu5gPP3kkgSVCCEnBrK5j975kCH1pAZD0Cqj25AiQudsRb7ItBWmL4YjJ", - "MDuikgvewBhC2ne2vUU7n/b8MR7k9/HRt3mTmO/xgZaXUv1c8waDx/FBHXzxf6XWN1W3Xemhc6qNXvXD", - "6ELHKdnrd/0wesvRbbNJOzHhksPSKPvv5FHJ8St6wz2GYETjSkO0I4HAQINnropdzeoLk+QSRgTArNEx", - "iIrNXousyAyZraAZKDDvaQSNgXlIv0MtA9AJLmhU6lOxTlrdoOWbnxAuIhZ41vakDzCvodCe3kfJF8lD", - "SEcBiowE5muyXcdWfcPAyI6THe49fgri4SVAQT5koL5I6aYlmrtVJDriicwGRHSuNV3F8AC2t8qT/JK5", - "Hxfk6UA5cLKJkGzJDu/YscUKUwf1MDXWu26mngAFoUXq8JXOKNnWMCkSRU8uzCeYlfAmTFyayWqZIJtm", - "3qTj099LADwv6hIjuUGzrTjypOV0u4NNk7VJGX9kBfWPualB90yE6zEPZ4ThmxTy6Jj3EMs0FOP3W7ID", - "NY3MiBWsION9wAKex3ssJZWXs2T0KLh8dqGmEMvbIb1jptHMcACDVMkzAfhe3vQ76xPaf8x1eaCqsSwh", - "7D8dC/5WuPdGApHq9DyZP++QV1GsxCywZyZIXQlAX29zYBnecXyQc2zVQeR/H+tib4orJNNlFtSERRlO", - "Xmo4oynjVZQQ1ZNx1Vr0lkM2Vq6hjxVGfmCvkAv2CvGimdtebgVDBSscZQMrwttDf53avDALfS8w8zP6", - "8xWl4I1pA8eimyDKMtSnUJfdhRTxIbpOVN5CkBzW8WAygkNuylFJYFys+dVadtZvFsI+U9ldnwZc3jdh", - "HAWWCYvZT5zOA2R/SLzQpB+klngKF6NYZ8mnVvwU6mhHubbLya85GFNuvk3puksTD1n7NEXDz99Xb8Fs", - "JXZTNv71AmjuYV2b8lW+uUIO4HvMTWoa1TPTSudeCYtPQkyXUWY8zwqTFR1wmZhUt99mom0A1LzH1PKB", - "Bf3XRK2MsCKLWqnk99ukap2DpHwlqtV5s1FWpapTLM6yCFqh4SRojU9OkLTsHUbFSuqiQgU0ZIdh68Ed", - "yqjJVh30zr4EQtzD1OU+gdPbj7SwUuh3BHUMU1BPILy3w3DUdU1e2H6KHi+W1OIFPAaLVIZnm77kTvB8", - "X7fr9R/j3Iaf3zEu47VvJH1UdB1XI7G1OJT+OR6ox/uz0/BftVM+22MZ5Fc+ORO/al1S8zj2efINeyXP", - "n3g1HMQmoWWiP0BaDPPTCpcttv2SusbUokEzi5pjvLCCKZ6a85S3y075iZeW6Lo0nZ7XeOehGvhLE8qD", - "9sKiQmwXAZyP4uFVHiB7hsoN64EV71qLP3n7lqV1aS/OWPEf0bbkI6JInfuxxZ6cgbZCLNVPIycX2rY3", - "WT2nbXsSez/Qe3pre1XaBMoQSpXaOzNncV0ykAeJRCs7fNnpGTHiKVV1C7pbJDw3a0XqVqWXL8N4P9kC", - "5E9wc1iZflOjukQLH3IktcpXOt+MzqSH6tq+TUaKZDrxAegB4sWmPbgOrMtSYv1AZ9sPmrnsx6cQkZcq", - "DJnbO5N1luzEh5iVJuVH5kULZAYqyVUTEjHmfMRzvk81aa86nv8tRhfOidNldYxVJmr6IY4bli/i/RBz", - "6CbD/V9mMyC1nC1lUe5C0GcI7ROO4gHLhECk4ZGMQA5d2iXlQtF2GJjaMN4Fyn0uN7zbVWg8c9uLf5/P", - "g5hCxDm1BTR3XeQOmMiLRD/GLhVWvLXqdGo38UtedMbse6LB0KLOBczSG/On8jxOhJRbqU1LYPofwlcP", - "mcXyQiFufZ5aOaSrHhHR3BQSV/Wwni9YS4JkKz4A1/zGuJdHWbyXmnUfimba450juTz3HceNIEf8E22i", - "SopYJ/aztCRPPxJg4dUxkBY6LpdJI6FKfL9yuDJgcxUzuGrO7ftKjUCzlrcqOK9eHyqIbbKvZpewdkCE", - "LcZj3re9yInWy9zCGkTdx00FLxgQJJ9h4wVFS0rVmwMpu53+YNdS69TpEEoZ+owVf5UC7GNLItbaItsF", - "FV4JbUmYg++h2dEsQcxPNKAvtSOYgGY0KczEiqgvmnNE9wpTZF/KHjApQib6I9Slew0AX3iggepKPrWY", - "0iz4Z8ROHmWiJ2Iyydbpjh1gbE3y0JWi1LJ0KRZs25YPBHNvCOmCO/EQLAzWPwdSDlgvsCeSbqUCvI11", - "pCZ6ml5hWgweFA3Hs9zcOrmZ2cs6/D09iM0ZmVfK4T8DC5vT/5GC74FYb7zynVKy+FO9dFifqsswyqQ+", - "Wfz0yP1VlGvuzFuIeLuSbptTcBimd7IF+THgulxMUXoXZcRuVfuZsW7a3grhDpw9+Oa5evtq3JwwtBAK", - "p+Usg8ZK7vcaWSGgVYTNIFf6gUNnpXDgU2SV9zuOP/sDlY3F/JYcz4b3Zwd+YO6qrCCvn55pWU1c48rP", - "7+QdGgLxfaDUJCqvxt6DHEEetEFA3cS+YgyT4zAeSG6xMRlcCj0ZxAv9PjXaJnf13HyVUSMNJmGxl4s1", - "a4ifg3g7pPvBVbBa4t1fCbEp4M5kDMeRwjPa/OyizQ+JUnOiCbJuYKogSPEDjmuW9stLtmasxY8XLbwe", - "mdhVvre4WjPEcl8W1trCUgCsJAZFiRsbeQEH7gKYak666QTLx4pJXdwz0SScoMVgsyLTYK/B75utfo2y", - "ACVrdVNdi/oGcEI13ef5wUuKNXj1pNGl+jU0dD1GTLBnmGwFh4lnv8qpshbHTNpXXaHWD36A6euIuDyI", - "93/wg6tCZz9hA4BdvguQxtBUlufVQ1eOI/C3ybkEM7c96DWJtzxrHyklb2KrFzmdcZGe10UF/xnCqIBL", - "xiv3jynRzBmydMMnJL7TF4wlvC/WPIEOLNFUG5YHLXysJMzWVIXEtlBE4/RYiaU1wCCgNSmyumRUsbNV", - "U1GTL+pZ7h+VlFMjKmj+ZpIO73gu4JAEJeb8H/iRl6KHphauUK+PB3MgTpCFd9VLKGtRLsDD+BmzaliX", - "QLyR91PrHl90gK4AI0Ho+f+a2ygMl+0ZTuwAolfYI/yZ2m4WcXVxPHDFoRoABce5pr2Z6SM/iiwKYK4Z", - "1w6jFuyENqiS9jqemNLXOY3UmMA9WNnN9sK0p48nUqQD3Z+bFr/Gmhq+rIYokEoTOgsSXejZYXjPDzrj", - "OyTkI6i/eKXuyrug4h1KYS9wB4prWXPjooNMvXOvMlRgvLI3OeagEYV4H1w8w2QDbKsnJvP5JiUF6kGM", - "GhNktluk2/MDO1gXL9Mx3ZeFBKvnisq0GJfHPw0rBQQ7RWGmazgJjsqDvWKmLVSxMYR74VyFiDjk3uyw", - "MqNM+gdm4v0ScnaBBw+g69bAmgKJisFxYL/4WdO6/s4FEPgHosv3U1CMoaRxunnb45VMAlemqKUb4mql", - "AJn0lhBQnVet/ATSZ5+L05E61+hN8jsJp3YP7gOBXIsWGKuL3sAI/DGbLz8VGskvzsXNzNZW0jE4g31W", - "X8FLlmfTXiMqQ5Twr7vU76Y6lSmt/hpDkZvctQMv0HqfpDhfsqlkOtUTyyl+QUq9th3Zrr9yCg1N8lfL", - "pO/1Zm2vE/hOZ7bj3/Nc3+7MfsKQjsy78RZ79Br+9FrvrsEPwIApmAnPEZSKfIws9bpxpfG/b9/u/NXt", - "2zPSf6YuBG32z+n//pcax+F41vma15lhq5/p2e27UHIZtFedNXJmj+W1+b+t3Tt5bf5vVXTfqfgg+TU0", - "G2K2M6/rHKj1psOm0YHN/duIhAGurQMepVQsg14vc2ol1nFtnjxqclulBj202zykEhUidEII07n8xImI", - "bbH6dGxfxNV2EWPPrFgqQ+UYvxYq5mkZPK84hBb14JhNk35GyZPbHqY/oVsAmvJKMOVWfIDzmrHi34vX", - "iBrtIf+a6/lwchFwiO6CRTeNKfivW3avN4NNA0tzp6jEQ/K+j9SdpOTDF90kLrFDYrLUJbuI3pfpQifg", - "hpffluxAaJbzfbJjsfle0OzmjjUFbnppG47jXc5ZPLKzr5yPYj4H0NIK1xPvuje5XcI36L3fmgSVWkwf", - "7cATvJAkajPFoBg2D56/hg9OWjdYQLoYS6CkW5zqj7o+3DvWFBdvrLoetcojhr9DlWR0Oqg6wHTNWkb9", - "8zsnnoj83oW0x0IJX9zye7d4b4QKYQroTaLEGATFL84B7C+2O7k8V9zfZaJBAliQkQXl0ny4aDnQIQto", - "Qo/47TR/ZpeBBNcULThB+w6QzLalNlbM3tGxG6tKNXXaRg8+FdafFkQ7Jsp+/Wh11vVXHE+OxWb9A/Ew", - "fmpNLbLuhpQjrlj3yNLiNJ/1ligJD8hyQMLVC6lyY8W7Vtv37zrEWuw5vX4riBZve1OL70ZR7wPPXb9q", - "LdCbkly1FuwuWXAi8tcLUeC0o6vWvB2t/rU81cXpJr8fBVIHkn1oLbJXtyL/LgHvIlVpmJLI3BoiUChX", - "ukuergGDE8ZCMMWRxAEjc15O2WmCFzBKFTk+w2HaRJOp297i63MXLbqmGVae22oHpEO8yLFdqEb7muXI", - "b6NRqzRFOgF23EYig9ySK/lZTvuPcfjI91td21tvUeOj24tCrUb2PvDAZHyuMParcrf2o9VblCH0ZWx/", - "hHM7TD7je8g2rH6t70vGCfqIdcqGT2R9rR9lkc7YefWxSlyfPPE+fl/RccJ8m1kXJsQnmrmTazEAyhqx", - "4b5MtuVTIqIbmNN4wGoLXyh+2KpEumC7bhmhrrluNVp9eVp38LCu+vCUVCLbFS4PLJdEXC2GCzxIPi0n", - "EhOZZtl/UyPOn8KqvpARbHJcMgXuy6fTQs6C2YJX2sCaYjIcsKvBMbiB5SniLdzDyXyOI/Eyfs+mJGfy", - "9/CqCIGfSKl69PdDXnC0l/yfZEdJypdjaWj/YgIxSOrD5DH9p2ym7+pdl0AkJmb02hhSSKrMQEqVA5vW", - "L47ZdAsric9NAP+enZsdLgMHGYLXL4n/AJvO0S7zCgvC8xsPTttfk6Pk5VmZya+YtY6NqtMkTRgJczTl", - "agsICkj5d4jz3ExL9OGcH8SDK2oKPHhkpKTiQ2txbRHjt2mjjaN4MH31ticjyyvlKix2vRg6vyBUC+G5", - "tUAzPnNvxfHuyw6H/3nhWrtN3As3SccJSDsy+Hyu+9iDqOac0bWxEjtryg81DvMLoreyXpuba/LuMW+8", - "3qR/Xrw0N3fnVCaW07VXyOw9stQ7e/rpv4D8heYElIvqP3CZOp8Mq39Elub1Jo506MgafXGBjfK1KvOZ", - "LxT8bGmSPYOf3Ya42Y41Bc+zpsROZ5qDBspFF2kEbw/uiOfxXrKtwcjxw+htnONkhDYM/uarKJNI31yQ", - "oQp9vznaaz0Gd6ZtJmwadlMxWNPWFBVk1uW5OZmdeq69jm0jZX7CPowmd+eNiRby3iBGEooIcR0k/Deh", - "PY+KckhSUnVJIfQv699jLfadVi8gy+GihSicyjlJNlX4Tp7FchiPrmZKsZX6lxM2Oj6SqS80lSbdIBNN", - "E7pBXpHVamARU3ZQ3ZxjzAnaAV35oKmF8h0B0Cu1DX6J/UkURQqYSzmBs0u+H4VRYPcuSK6PAhn/b3I+", - "HWbDLnb7YdRqr9reCsk5UOSknz3wErEIGNVlsIfPIAWMPcZMutTifBYfz9z20u5mrPdCzgTMpOMxqNrd", - "ZJvrSzxwWaLpakEGSPQmp9J1iUiT4Xrdq76rfhvM52fCQ5Ek+wJEDezuZgXS1wdxz3LQOCrSZnyQgzWX", - "nZCZ1LQ08VLUJzCdpuQosR7BhdCNb/Fnvtd9W24Sux2RDkQKqpYS1LG9v1GznKr0Wmr7rkvaMKfsfq06", - "YeRjaaYpzfq6S+zgXfZcJf/UV5Jn7rguaIORBMSlpHU9gWiarE9n07zSRme5ZDU1pibTyQwKnNLie8y+", - "bBHVi2AkkteEyq+m5hlzBs/I4aXi6P3/kkWn3cLf1SSFfG/Jt4OO462A1tXXbNSCvUY+SB+bjN6RvuD6", - "qu9wPOUyZeN1Qw2hDF95XFebH1Gbz2Ab0o5LuexZ8N/x8CLc7YomwLAN+ZadsAY4ikS8Z6/l73glfV7f", - "xaHdDwLiRS3+6CIClO1QfUQElI+aVnyAAco0fP4I1CNFnU4HmRJxTNaEXw2MsuSzNIQ1nerNparyVVXp", - "hpLWXDSDjl+PQn0d1qbk3k+gNE15yXdTg1asI+EfpmbleSnM36g2pr4kQKPrco/OhTBiHWRNV8w8e3Ih", - "mjA2m/oiE4K00irsaq4V3gmcTqgLHSafJ18wV4hkSoDrdDPZqSmjRakFY11XMu3MxJyoUOMJLyzhdh8u", - "tBFYu0obJoP7ram/Yeb7mn2aAEq0/I4xuqqcN5MofjMIXub3ZTKXWun+u3YYXUCAynuOF1bxssJhdZ0w", - "Ks5omxdPTZj+LjQx1eeXLfTbbYA7PiVxDeaMPvUAi4f5lCbI83T4V9JESKztvEhtYLyK9czKVpTpmfiT", - "Tt2sUnSPuRNOwD737apSVON0qoEBGVAx7xiuHPTun9Phw5e9isvmFZ6+gFrGju+Fs5jPXHoIwdsFhvRN", - "9tOq+Ws1Bh3ZYLzYbxAfgILzpFGVhcfCVdOa2yTKE2ESKUA4/BgXw7lZ28y5ITLHlPJSKTO/mosjJGHI", - "K4SN6seN9QX+2CRB/PEd1TpFpmZzgSOulrIJgf7Ax0/t9SldXuF0sXXGCV564m+SNf8uEaRvnM99cOaU", - "2LpaXPBBhe7NX/qE1TjmST8soLzqytMmpS1ywkltPqEenCPH5DxZV4xZA5bcxpf5kD9lAAovLaySH2bc", - "X9R6xRx7LMIBZHjFvY0FjY8QMhww3g2JZBmP5IQOrPQWLWwCOCxGYK09hJqjCZlqX+Xr81P8ANhKTfkJ", - "K8R/DtPboNPTtSbnpbEpQP+g0A3Jum8bWYyeuL+2XamTLG4ktvjmwXvMGpRblSCEf4psh70y4lG8Fw94", - "4iO0IHjGgBeg9B4TGSnbHkDxw0MOc08l2glMQWBKg/s92YYeaB65H7XaECDQQ/vzTuoV4d/MEkmCg7s0", - "N1cZDw4e02YRNmzIVmd5hOwvVkRG/8WLBVkZ2R3tK79PMRbcC0w9C6v1vampub8Ojg599YOSfvSscb/U", - "vEOfJfyPUhdj7uiDaFyypemWDi03XkBz/KP4uJmBkpOS3lkOr2g5Y8V7UFH+jN4lgG4Dq3gJ6QYHdGT8", - "g5437I68kWzf9qa+/dPK6lIn8r59af35V19a3/6JlSRDQfq3L6eN+HUoj2SMap6qJK0R+nG1/SAg7Yh0", - "WnACCk4j63hxHofy1bIz9NRg9+V+XXjqXybbID73IPCCGYEi+EQVTpm7UXbvxcfWa/qrZUiV0tfUTN7j", - "eLdpXU718+N4t/SUBGskuOB4y775iNCJ76v45+I+GLAGM9hf/JeIgoM3wxULC1Aoi/KCQblHEKYWyoX3", - "TzJdzAFkBx4/ZC1L5AY/cv48ROkNGBS8RpUlzoFO/Wu4raB5mqZzVrIzbVB6FoBe71FyTZRBxVv0Rsou", - "hAxEc53jDBpH/enjpa8UWoy8KRn+Ww8j0s3wHwBZ11HIoaBtCwM1i6JcUCw2ElUd1sVLVvJo+qqlVmgc", - "IgqEWqyBpsPix1DrerpCjSsW072ZXsWBoPONoAQECLsVmUq3R8X4B71+yFuZA4LJQCvHgUrg3qi/BqR/", - "JsPwuw5G3qwCPVx4qoH2P+vbLj1hrwbdvH7JkAKna06dAQ2oPGrFPJZLPA1eH8D5CfPUhZPsNQNveK8T", - "nrf3uCIOA6+/5z2303A15pQNrorWbVLTGMkr8Hl9pbHSpLDZGM+sSbYspwMFqfTylqtKlTJQqS9CaTVS", - "xqdtCttwUTfZbSraohoJO0EQCql/myHz/zd4TZ6kVUbQBEhpAAioB1E/xGt4KuqH1sWZuZm5pgrE++7b", - "196yZq35a7euvwuJUOibGqHC+8brVvyv8RfTiIeFN3aq/+1ChuQQlNA/b/zmtrfIpj7zcZ/0SWu577qL", - "1qwlPu6HJGh93PcjG79rUlNsC70wOGH+pGjQtigKPcWLBxY6R3bRcVOAtn2eza9eEeq2NAMGwG1Mb5Lq", - "xxjcdj3u2xzgNu81xZCQUddiGqqKhiz29NiaEgjzTataFwV9d6rUv66SQGZ0JhMlqDgAOBGgIckWz03c", - "iEfJF/Gh0jSG/pYepynAiAZ3wTH4PDcYePTA+qBHvGvz703nC7U+cqLVTmDfk7pIVUhkf4heBRTVTZls", - "cLHsgg58DM7CGrPdxVu5T56euC8EeoCMxYZWB7oyWWhgpICspztYq5Cc7ToeKQlm1dzr8j97R0jd7tbS", - "YuJfEWlMM36yc4bj3wtI2hLbhIad8R1mRRLiS85YC+9eu3Dp8htggYoOcrLlyVvQMRWaAVxOdfx2yCc0", - "0+38xeuakz/P5nkeXXz4u15pH590Et+BTj68BbDaGHuQKiAQftvjjudnwBP76KI6NWuCqlOkEiOhfgaP", - "TXw/8DW6bfgdh9LCsB1QgOWRsOpjvG3q2Ih/YXpbtvtaUavYU29ASIhntllv2AE/jQv0wcqXMSXIo3pv", - "WmyCOJJuWrOsFAblIaUk9vTjReVVKdP3OG20vPkhfI3EeYu0nVqyQdQ2rm2/jz8S8IpzOo+R0qEVf1Op", - "Q+t/8N7r9ai42XgtV3IYlIAMZKzfNUT7Zlv3Gat6ZVHdfbxSTsHo9+w1Muv3osKcnp+Q6CN7jXzQiyad", - "0yO/pkKOQF3JIyirRug6zkA2cywMHlUQQX7n4o881h6NBc3Lio6A2HIGlV6sLER2AASf0BVPh4Z36LGq", - "Ltb6IgljpSgvSGrEJcpkHp0NPifd4t/DYGiFfPsnqkcmT9KWDVvfvqy+a6VpWG97HbF1pXfBP7+KbCjR", - "qiK/6mYtvZl1ycETZufrdKD7566nFjN4BoutNnbOFHqlyhbVdWY18oslO1hUJUoeCueEcOmk/bFBeYJW", - "Kzw6PObRmPXoNkyiQ/JPyf1JC8WfMhZ6dSzzdb18AuVoyRakKKh8CLhCvIsndBKRMrSpTsLwMLcVLJAU", - "olnPFqvEdqPVXxQpEu+yRyaJCQCvQL9LATTUr1AoWhjTKo6M/ROEvUS2wok8gJq+JAfGA2J31gupcROf", - "mGxNfWe9gBYMjmefRcA307QM5sPnvS14YOny3GvnNrmvoJhSxMo1qY/aiQ+RgYf56Rft8T+AWXmMjfv4", - "Th/HT7Gim0c+U5nLc8u2ramK5JvWMoo6p08aS8QOSHCtH63SKVKBiJk9KEj7gdu40pgFQcnG+kSEwXHM", - "B03xCcBtSn93ifIt9FeSPnCdpYBSQ/qIB6ekj1jykfSJCPzKP0yLCKRPQVjIU+j1Gg/uPPj/AQAA//86", - "zW9kz1wBAA==", + "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", + "AQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/appupdate/appupdate.go b/services/api/internal/appupdate/appupdate.go index 7266ebc..5dddbb5 100644 --- a/services/api/internal/appupdate/appupdate.go +++ b/services/api/internal/appupdate/appupdate.go @@ -1,5 +1,5 @@ -// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление): -// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK. +// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление): +// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы. package appupdate import ( @@ -25,7 +25,7 @@ import ( "pipu/api/internal/platform/httpx" ) -// CodeUnavailable — обновления не настроены или релиза с APK нет. +// CodeUnavailable — обновления не настроены или релиза с клиентами нет. const CodeUnavailable = "app.update_unavailable" const cacheTTL = 10 * time.Minute @@ -73,14 +73,22 @@ type release struct { Assets []asset `json:"assets"` } -// Latest — сведения о последней версии. +type clientFile struct { + Kind, Name, URL, ChecksumURL, SHA256 string + Size int64 +} + +// Latest — сведения о последней версии и файлах клиентов. type Latest struct { Version string VersionCode int Notes string - Size int64 - SHA256 string - apkURL string + Files map[string]clientFile +} + +func (l *Latest) android() (clientFile, bool) { + f, ok := l.Files["android"] + return f, ok && f.SHA256 != "" } // Handler реализует app-часть gen.StrictServerInterface. @@ -92,7 +100,7 @@ type Handler struct { mu sync.Mutex cached *Latest cachedAt time.Time - dl sync.Mutex // одна загрузка APK за раз + dl sync.Mutex } // NewHandler создаёт обработчик. @@ -100,7 +108,6 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler { return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log} } -// configured — адрес и репозиторий заданы, и это не заглушка из .env.example. func (h *Handler) configured() bool { return h.cfg.GiteaURL != "" && h.cfg.Repo != "" && !strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/") @@ -125,7 +132,70 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) { return res, nil } -// latest — последний релиз с APK (кэш на 10 минут). +func classifyAsset(name string) string { + n := strings.ToLower(name) + if strings.HasSuffix(n, ".sha256") { + return "" + } + switch { + case strings.HasSuffix(n, ".apk"): + return "android" + case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"): + return "windows" + case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"): + return "linux" + case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"): + return "macos" + default: + return "" + } +} + +func assetRank(kind, name string) int { + n := strings.ToLower(name) + switch kind { + case "windows": + if strings.Contains(n, "setup") { + return 2 + } + return 1 + case "linux": + switch { + case strings.HasSuffix(n, ".appimage"): + return 3 + case strings.HasSuffix(n, ".deb"): + return 2 + default: + return 1 + } + default: + return 1 + } +} + +func pickClientFiles(assets []asset) map[string]clientFile { + sums := map[string]string{} + for _, a := range assets { + if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") { + sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL + sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL + } + } + out := map[string]clientFile{} + for _, a := range assets { + kind := classifyAsset(a.Name) + if kind == "" { + continue + } + cur, ok := out[kind] + if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) { + continue + } + out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]} + } + return out +} + func (h *Handler) latest(ctx context.Context) (*Latest, error) { if !h.configured() { return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured") @@ -142,7 +212,7 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) { if err != nil { h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err) if h.cached != nil { - return h.cached, nil // устаревший ответ лучше, чем никакого + return h.cached, nil } return nil, apperr.NotFound(CodeUnavailable, "release is unavailable") } @@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) { if err != nil { return nil, apperr.NotFound(CodeUnavailable, err.Error()) } - name := "pipu-music-" + version + ".apk" - var apk, sum *asset - for i := range rel.Assets { - switch rel.Assets[i].Name { - case name: - apk = &rel.Assets[i] - case name + ".sha256": - sum = &rel.Assets[i] + files := pickClientFiles(rel.Assets) + if len(files) == 0 { + return nil, apperr.NotFound(CodeUnavailable, "release has no client builds") + } + if apk, ok := files["android"]; ok { + if apk.ChecksumURL == "" { + delete(files, "android") + } else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil { + h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err) + delete(files, "android") + } else { + apk.SHA256 = digest + files["android"] = apk + } + if len(files) == 0 { + return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet") } } - if apk == nil || sum == nil { - return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet") - } - sres, err := h.get(ctx, sum.URL) + h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files} + h.cachedAt = time.Now() + return h.cached, nil +} + +func (h *Handler) readDigest(ctx context.Context, url string) (string, error) { + sres, err := h.get(ctx, url) if err != nil { - return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable") + return "", err } raw, err := io.ReadAll(io.LimitReader(sres.Body, 256)) _ = sres.Body.Close() if err != nil { - return nil, err + return "", err } digest := strings.ToLower(strings.Fields(string(raw) + " x")[0]) if len(digest) != 64 { - return nil, apperr.NotFound(CodeUnavailable, "malformed checksum") + return "", fmt.Errorf("malformed checksum") } - h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL} - h.cachedAt = time.Now() - return h.cached, nil + return digest, nil +} + +func (h *Handler) fileURL(kind, version string) string { + return "/api/v1/app/download/" + kind + "/" + version +} + +// GetAppClients реализует gen.StrictServerInterface. +func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) { + l, err := h.latest(ctx) + if err != nil { + return nil, err + } + order := []string{"windows", "macos", "linux", "android"} + clients := make([]gen.AppClientFile, 0, len(l.Files)) + for _, kind := range order { + f, ok := l.Files[kind] + if !ok { + continue + } + item := gen.AppClientFile{ + Client: gen.AppClient(kind), + Filename: f.Name, + Size: f.Size, + DownloadUrl: h.fileURL(kind, l.Version), + } + if f.SHA256 != "" { + item.Sha256 = &f.SHA256 + } + clients = append(clients, item) + } + return gen.GetAppClients200JSONResponse{ + Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients, + }, nil } // GetAndroidLatest реализует gen.StrictServerInterface. @@ -193,15 +305,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe if err != nil { return nil, err } + f, ok := l.android() + if !ok { + return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet") + } return gen.GetAndroidLatest200JSONResponse{ - Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256, + Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256, DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported, }, nil } -// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза. -func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) { - dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk") +func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) { + dst := filepath.Join(h.cfg.Dir, f.Name) if _, err := os.Stat(dst); err == nil { return dst, nil } @@ -213,12 +328,12 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) { if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil { return "", err } - res, err := h.get(ctx, l.apkURL) + res, err := h.get(ctx, f.URL) if err != nil { return "", err } defer func() { _ = res.Body.Close() }() - tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*") + tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*") if err != nil { return "", err } @@ -231,26 +346,53 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) { if err := tmp.Close(); err != nil { return "", err } - if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 { - return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256) + if f.SHA256 != "" { + if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 { + return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256) + } } if err := os.Rename(tmp.Name(), dst); err != nil { return "", err } - h.cleanup(dst) + h.cleanup(f.Name) return dst, nil } -// cleanup оставляет в кэше только текущий APK. -func (h *Handler) cleanup(keep string) { - matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk")) +func (h *Handler) cleanup(keepName string) { + matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*")) + ver := versionFromName(keepName) for _, m := range matches { - if m != keep { + base := filepath.Base(m) + if ver != "" && strings.Contains(base, ver) { + continue + } + if base != keepName { _ = os.Remove(m) } } } +func versionFromName(name string) string { + m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name) + if m == nil { + return "" + } + return m[1] +} + +func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) { + f, ok := l.Files[kind] + if !ok || (kind == "android" && f.SHA256 == "") { + return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable") + } + path, err := h.ensure(ctx, f) + if err != nil { + h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind) + return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file") + } + return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil +} + // DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия. func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) { l, err := h.latest(ctx) @@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi if req.Version != l.Version { return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available") } - path, err := h.ensure(ctx, l) - if err != nil { - h.log.ErrorContext(ctx, "app update: apk download failed", "err", err) - return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK") - } - return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil + return h.serveFile(ctx, l, "android") } -type apkResponse struct { +// DownloadAppClient реализует gen.StrictServerInterface. +func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) { + l, err := h.latest(ctx) + if err != nil { + return nil, err + } + if req.Version != l.Version { + return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available") + } + return h.serveFile(ctx, l, string(req.Client)) +} + +type fileResponse struct { path string name string req *http.Request } -// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки). -func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error { +func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error { + return a.write(w) +} + +func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error { + return a.write(w) +} + +func (a fileResponse) write(w http.ResponseWriter) error { f, err := os.Open(a.path) if err != nil { return err @@ -288,7 +444,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro if a.req == nil { return errors.New("appupdate: request missing in context") } - w.Header().Set("Content-Type", "application/vnd.android.package-archive") + ctype := "application/octet-stream" + if strings.HasSuffix(strings.ToLower(a.name), ".apk") { + ctype = "application/vnd.android.package-archive" + } + w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`) http.ServeContent(w, a.req, a.name, info.ModTime(), f) return nil diff --git a/services/api/internal/appupdate/appupdate_test.go b/services/api/internal/appupdate/appupdate_test.go index 835a367..371d114 100644 --- a/services/api/internal/appupdate/appupdate_test.go +++ b/services/api/internal/appupdate/appupdate_test.go @@ -16,6 +16,22 @@ import ( "github.com/stretchr/testify/require" ) +func TestPickClientFiles(t *testing.T) { + files := pickClientFiles([]asset{ + {Name: "pipu-music-1.3.0.apk", Size: 10}, + {Name: "pipu-music-1.3.0.apk.sha256", Size: 65, URL: "https://gitea/sum"}, + {Name: "pipu-music_1.3.0_x64-setup.exe", Size: 20}, + {Name: "pipu-music_1.3.0_amd64.deb", Size: 11}, + {Name: "pipu-music_1.3.0_amd64.AppImage", Size: 30}, + {Name: "pipu-music_1.3.0_aarch64.dmg", Size: 40}, + }) + assert.Equal(t, "pipu-music-1.3.0.apk", files["android"].Name) + assert.Equal(t, "https://gitea/sum", files["android"].ChecksumURL) + assert.Equal(t, "pipu-music_1.3.0_x64-setup.exe", files["windows"].Name) + assert.Equal(t, "pipu-music_1.3.0_amd64.AppImage", files["linux"].Name) + assert.Equal(t, "pipu-music_1.3.0_aarch64.dmg", files["macos"].Name) +} + func TestVersionCode(t *testing.T) { cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099} for tag, want := range cases { @@ -60,17 +76,18 @@ func TestLatestAndDownload(t *testing.T) { require.NoError(t, err) assert.Equal(t, "0.6.1", l.Version) assert.Equal(t, 60_199, l.VersionCode) - assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256) + android := l.Files["android"] + assert.Equal(t, hex.EncodeToString(sum[:]), android.SHA256) - path, err := h.ensure(context.Background(), l) + path, err := h.ensure(context.Background(), android) require.NoError(t, err) got, _ := os.ReadFile(path) assert.Equal(t, apk, got) // Подменённый файл не пройдёт сверку. require.NoError(t, os.Remove(path)) - l.SHA256 = "00" + l.SHA256[2:] - _, err = h.ensure(context.Background(), l) + android.SHA256 = "00" + android.SHA256[2:] + _, err = h.ensure(context.Background(), android) assert.ErrorContains(t, err, "checksum mismatch") _, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk")) assert.True(t, os.IsNotExist(statErr)) diff --git a/services/api/internal/auth/auth_test.go b/services/api/internal/auth/auth_test.go index 006353d..b7d3121 100644 --- a/services/api/internal/auth/auth_test.go +++ b/services/api/internal/auth/auth_test.go @@ -566,5 +566,6 @@ func TestPublicRoutes(t *testing.T) { assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code) assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public") + assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public") assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code) } diff --git a/services/api/internal/auth/middleware.go b/services/api/internal/auth/middleware.go index 4a9aefa..4d65472 100644 --- a/services/api/internal/auth/middleware.go +++ b/services/api/internal/auth/middleware.go @@ -55,8 +55,9 @@ var publicRoutes = map[string]bool{ "GET /api/v1/server-info": true, "POST /api/v1/auth/login": true, "POST /api/v1/auth/refresh": true, - // Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента. + // Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения. "GET /api/v1/app/android/latest": true, + "GET /api/v1/app/clients": true, } // Что разрешено, пока пользователь обязан сменить учётные данные / пароль. @@ -93,7 +94,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler // Аудио открывает