From a6c9fd8a00e38c56c7d066d95cfa8f97ec3d5de3 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:20:59 +0300 Subject: [PATCH] fix(ui): reflect update availability and config changes; expand mobile player metadata --- .gitea/workflows/docker.yml | 4 +- api/openapi.yaml | 16 +- apps/web/e2e/mobile.spec.ts | 10 +- apps/web/e2e/playlists.spec.ts | 4 +- apps/web/e2e/server-management.spec.ts | 67 +- .../features/server-management/ConfigPage.tsx | 22 +- .../server-management/UpdatesPage.tsx | 43 +- .../server-management/management.module.css | 2 + apps/web/src/test/fakeApi.ts | 10 +- apps/web/src/widgets/AppShell.module.css | 2 +- apps/web/src/widgets/PlayerBar.module.css | 25 +- apps/web/src/widgets/PlayerBar.tsx | 6 +- deploy/compose.yaml | 4 +- deploy/config.schema.json | 8 + deploy/server-agent.py | 117 ++- deploy/test_deployment_scripts.py | 3 +- deploy/test_server_agent.py | 57 ++ deploy/update.sh | 8 + docs/server-management.md | 14 + docs/web.md | 5 + packages/api-client/src/generated/schema.ts | 9 + packages/i18n/locales/en/admin.json | 14 +- packages/i18n/locales/en/errors.json | 6 +- packages/i18n/locales/ru/admin.json | 14 +- packages/i18n/locales/ru/errors.json | 6 +- services/api/Dockerfile | 5 +- services/api/internal/api/gen/api.gen.go | 702 ++++++++++-------- services/api/internal/buildinfo/buildinfo.go | 3 + services/api/internal/management/handler.go | 9 +- .../api/internal/management/handler_test.go | 33 +- 30 files changed, 833 insertions(+), 395 deletions(-) diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 5abf9e6..968b3dc 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -47,8 +47,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Установка агента при обновлении (изолированные тесты) - run: python3 -m unittest discover -s deploy -p 'test_deployment_scripts.py' -v + - name: Обновление сервера, агент и конфигурация + run: python3 -m unittest discover -s deploy -p 'test_*.py' -v # Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и # --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`. diff --git a/api/openapi.yaml b/api/openapi.yaml index 5725260..c912fc9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -30,6 +30,15 @@ paths: - in: query name: source_id schema: {type: string} + - in: query + name: channel + schema: {type: string, enum: [latest, version, main], default: latest} + - in: query + name: version + schema: {type: string} + - in: query + name: fresh + schema: {type: boolean, default: false} responses: "200": description: Server management result @@ -1958,7 +1967,7 @@ components: log: {type: string} ServerUpdates: type: object - required: [sources, versions, current_version, job, source_error] + required: [sources, versions, current_version, job, source_error, current_commit, target_version, target_commit, update_state] properties: sources: type: array @@ -1967,6 +1976,10 @@ components: type: array items: {type: string} current_version: {type: string} + current_commit: {type: string} + target_version: {type: string} + target_commit: {type: string} + update_state: {type: string, enum: [available, current, ahead, unknown, unavailable, select_version]} source_error: {type: string} job: allOf: [{$ref: "#/components/schemas/ServerJob"}] @@ -1978,6 +1991,7 @@ components: source_id: {type: string} channel: {type: string, enum: [latest, version, main]} version: {type: string} + expected_commit: {type: string, pattern: '^[a-f0-9]{40,64}$'} ServerConfigEntry: type: object required: [key, group, type, secret, readonly, effective, env, local, default_value, source] diff --git a/apps/web/e2e/mobile.spec.ts b/apps/web/e2e/mobile.spec.ts index ba7ae5c..5a6de45 100644 --- a/apps/web/e2e/mobile.spec.ts +++ b/apps/web/e2e/mobile.spec.ts @@ -102,7 +102,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as await page.getByRole('listitem').first().click(); const player = page.getByRole('contentinfo', { name: 'Плеер' }); await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible(); - expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40); + expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220); + const artist = player.getByRole('link', { name: 'Кино', exact: true }); + await expect(artist).toBeVisible(); + const artistArea = await artist.locator('..').boundingBox(); + expect(artistArea!.width).toBeGreaterThan(220); + const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox(); + const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox(); + expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y); + await page.screenshot({ path: info.outputPath('player-readable.png') }); await fits(page); await page.getByRole('button', { name: 'Меню профиля' }).click(); await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click(); diff --git a/apps/web/e2e/playlists.spec.ts b/apps/web/e2e/playlists.spec.ts index 6b8ed1d..ff3dac5 100644 --- a/apps/web/e2e/playlists.spec.ts +++ b/apps/web/e2e/playlists.spec.ts @@ -34,7 +34,9 @@ for (const width of [320, 1280]) { await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click(); await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована'); const sharedUrl = await page.evaluate(() => navigator.clipboard.readText()); - expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/); + const shared = new URL(sharedUrl); + expect(shared.origin).toBe(new URL(page.url()).origin); + expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/); await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible(); await expect.poll(() => page.evaluate(() => { diff --git a/apps/web/e2e/server-management.spec.ts b/apps/web/e2e/server-management.spec.ts index a521658..d22ed8f 100644 --- a/apps/web/e2e/server-management.spec.ts +++ b/apps/web/e2e/server-management.spec.ts @@ -27,9 +27,9 @@ test('admin selects repository and version, confirms and sees persistent job log await expect(dialog).toContainText('Тестовая репа'); await expect(dialog).toContainText('v1.10.0'); await dialog.getByRole('button', { name: 'Установить обновление' }).click(); - await expect(page.getByRole('status').filter({ hasText: 'Выполняется' })).toBeVisible(); + await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible(); await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart'); - await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true }); }); @@ -37,12 +37,15 @@ test('config keeps secret unchanged, saves local override and applies separately await login(page, 'mobile-admin', '/admin/config'); await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible(); await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue(''); + await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible(); await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug'); await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible(); await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6'); - await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible(); await page.getByRole('button', { name: 'Сохранить', exact: true }).click(); await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible(); await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён'); await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible(); await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; }); @@ -64,3 +67,61 @@ test('ordinary user is redirected away from server management', async ({ page }) await login(page, 'alice', '/admin/updates'); await expect(page).toHaveURL('/'); }); + + +test('installed main and release show no install action; another version can be installed', async ({ page }) => { + await login(page, 'mobile-admin', '/admin/updates'); + await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'main', exact: true }).click(); + await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); + await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click(); + await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible(); + await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0'); + await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); + await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0'); + await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible(); +}); + +test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => { + await login(page, 'mobile-admin', '/admin/updates'); + await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible(); + await page.evaluate(() => { + const original = window.fetch; + window.fetch = async (input, init) => { + const request = new Request(input, init); + if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const response = await original(input, init); + const data = await response.json(); + return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } }); + } + return original(input, init); + }; + }); + await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click(); + await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible(); + await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible(); +}); + +test('saving panel disappears when all edited values return to their original state', async ({ page }) => { + await login(page, 'mobile-admin', '/admin/config'); + await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible(); + const save = page.getByRole('button', { name: 'Сохранить', exact: true }); + const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true }); + await expect(save).not.toBeVisible(); + await expect(apply).not.toBeVisible(); + const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true }); + await field.fill('6'); + await expect(save).toBeVisible(); + await field.clear(); + await expect(save).not.toBeVisible(); + await expect(apply).not.toBeVisible(); + await field.fill('7'); + await page.getByRole('button', { name: 'Отменить правки', exact: true }).click(); + await expect(save).not.toBeVisible(); + await expect(field).toHaveValue(''); +}); diff --git a/apps/web/src/features/server-management/ConfigPage.tsx b/apps/web/src/features/server-management/ConfigPage.tsx index 55a040f..e80b5f3 100644 --- a/apps/web/src/features/server-management/ConfigPage.tsx +++ b/apps/web/src/features/server-management/ConfigPage.tsx @@ -46,8 +46,13 @@ export function ConfigPage() { }, }); const change = (key: string, value: string | null) => { - setBaseRevision((v) => v ?? query.data!.revision); - setDraft((v) => ({ ...v, [key]: value })); setSaved(false); save.reset(); + const entry = query.data!.entries.find((v) => v.key === key)!; + const unchanged = value === null ? entry.local === null + : entry.secret ? value === '' : value === (entry.local ?? ''); + const next = { ...draft }; + if (unchanged) delete next[key]; else next[key] = value; + setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null); + setDraft(next); setSaved(false); save.reset(); }; if (query.isPending) return ; const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? []; @@ -71,10 +76,15 @@ export function ConfigPage() { ; })} {!entries.length &&

{t('management.no_results')}

} -
-
{saved ? t('management.saved') : dirty ? t('management.unsaved', { count: Object.keys(draft).length }) : query.data.restart_required ? t('management.pending_restart') : t('management.no_changes')}
-
-
+ {saved && !dirty &&

{t('management.saved')}

} + {dirty &&
+
{t('management.unsaved', { count: Object.keys(draft).length })}
+
+
} + {!dirty && query.data.restart_required && !running &&
+
{t('management.pending_restart')}
+ +
} {save.error && {errorText(save.error)}} {apply.error && {errorText(apply.error)}} diff --git a/apps/web/src/features/server-management/UpdatesPage.tsx b/apps/web/src/features/server-management/UpdatesPage.tsx index 51911d1..f9bca52 100644 --- a/apps/web/src/features/server-management/UpdatesPage.tsx +++ b/apps/web/src/features/server-management/UpdatesPage.tsx @@ -1,7 +1,7 @@ import type { components } from '@pipu/api-client'; import { unwrap } from '@pipu/core'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; import { useErrorText } from '../../shared/lib/errors'; @@ -22,16 +22,26 @@ export function UpdatesPage() { const [version, setVersion] = useState(''); const [confirm, setConfirm] = useState(false); const [editing, setEditing] = useState(null); + const fresh = useRef(false); + const queryKey = ['server-updates', sourceId, channel, version]; const query = useQuery({ - queryKey: ['server-updates', sourceId], - queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates', { - params: { query: { source_id: sourceId || undefined } }, - })), - refetchInterval: 3000, + queryKey, + queryFn: async () => { + const force = fresh.current; + fresh.current = false; + return unwrap(await session.client.GET('/api/v1/admin/server/updates', { + params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } }, + })); + }, + placeholderData: keepPreviousData, + refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000, retry: false, }); const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0]; const running = query.data?.job?.state === 'running'; + const checking = query.isFetching; + const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown'; + const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !running; const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] }); const save = useMutation({ mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })), @@ -39,18 +49,19 @@ export function UpdatesPage() { }); const start = useMutation({ mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', { - body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined }, + body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined, expected_commit: query.data!.target_commit }, })), onSuccess: (job) => { setConfirm(false); - qc.setQueryData(['server-updates', sourceId], (old) => old ? { ...old, job } : old); + qc.setQueryData(queryKey, (old) => old ? { ...old, job } : old); void refresh(); }, + onError: () => { void refresh(); }, }); if (query.isPending) return ; return
{t('management.server')}

{t('management.updates')}

{t('management.updates_intro')}

- +
{query.error && {errorText(query.error)}} {query.data && <> @@ -68,15 +79,21 @@ export function UpdatesPage() { {channel === 'version' && } {query.data.source_error && {t('management.source_unavailable')}} {start.error && {errorText(start.error)}} -
+
+
+ {t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)} + {query.data.target_version &&

{query.data.target_version} · {query.data.target_commit.slice(0, 12)}

} +
+ {canInstall && } +
} {confirm && !start.isPending && setConfirm(false)}> -

{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}

+

{t('management.confirm_update_text', { source: source?.name, target: query.data?.target_version || version })}

{t('management.update_warning')}

{start.error && {errorText(start.error)}} -
+
} {editing && !save.isPending && setEditing(null)}>

{t('management.sources_hint')}

diff --git a/apps/web/src/features/server-management/management.module.css b/apps/web/src/features/server-management/management.module.css index de1b2be..dd13fc8 100644 --- a/apps/web/src/features/server-management/management.module.css +++ b/apps/web/src/features/server-management/management.module.css @@ -32,3 +32,5 @@ .editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; } .saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; } @media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } } + +.updateAction { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 14px; } diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 04bc5c3..34af4e6 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -126,7 +126,14 @@ export function fakeApi(initial: FakeUser[]) { if (!user) return problem(401, 'auth.unauthorized'); const configs = path.includes('/config'); if (configs ? user.role !== 'admin' : user.role !== 'admin' && !user.manages_updates) return problem(403, 'auth.forbidden'); - if (route === 'GET /api/v1/admin/server/updates') return json({ sources: managementSources, versions: ['v1.10.0', 'v1.9.0', 'v2.0.0-rc.1'], current_version: '1.9.0', source_error: '', job: managementJob }); + if (route === 'GET /api/v1/admin/server/updates') { + const channel = url.searchParams.get('channel') ?? 'latest'; + const selected = url.searchParams.get('version') ?? ''; + const target = channel === 'version' ? selected : channel === 'main' ? 'main' : 'v1.10.0'; + return json({ sources: managementSources, versions: ['v1.10.0', 'v1.9.0', 'v2.0.0-rc.1'], current_version: '1.9.0', source_error: '', job: managementJob, + current_commit: 'a'.repeat(40), target_version: target, target_commit: target === 'v1.9.0' || target === 'main' ? 'a'.repeat(40) : 'b'.repeat(40), + update_state: !target ? 'select_version' : target === 'v1.9.0' || target === 'main' ? 'current' : 'available' }); + } if (route === 'PUT /api/v1/admin/server/update-sources') { managementSources = body?.sources as typeof managementSources; return json({ sources: managementSources }); } if (route === 'GET /api/v1/admin/server/config') return json(serverConfig()); if (route === 'PATCH /api/v1/admin/server/config') { @@ -138,6 +145,7 @@ export function fakeApi(initial: FakeUser[]) { } if (route === 'POST /api/v1/admin/server/updates' || route === 'POST /api/v1/admin/server/config/apply') { managementJob = { id: 'job-1', kind: configs ? 'config' : 'update', state: 'running', target: configs ? 'config.local' : body?.channel === 'version' ? String(body.version) : body?.channel === 'main' ? 'main' : 'v1.10.0', started_at: '2026-10-22T09:00:00Z', finished_at: '', log: '→ build and restart' }; + if (configs) configPending = false; return json(managementJob, 202); } } diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index c710dd1..9a091e0 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -298,7 +298,7 @@ .shell, .shell.island { --side-w: 0px; grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px)); + grid-template-rows: auto minmax(0, 1fr) auto calc(64px + env(safe-area-inset-bottom, 0px)); grid-template-areas: 'top' 'content' 'player' 'mobile'; } diff --git a/apps/web/src/widgets/PlayerBar.module.css b/apps/web/src/widgets/PlayerBar.module.css index d0cdc64..62a15bb 100644 --- a/apps/web/src/widgets/PlayerBar.module.css +++ b/apps/web/src/widgets/PlayerBar.module.css @@ -413,28 +413,29 @@ a.source:hover { } @media (max-width: 767px) { - .bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; } + .bar { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: 'now now' 'controls actions'; + gap: 4px 8px; + min-height: 72px; + padding: 8px 12px; + } .bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto; transform: none; margin: 0 8px; border-radius: 16px; } - .empty { grid-column: 1 / -1; } + .empty { grid-column: 1 / -1; grid-row: 1 / -1; } .placeholder { flex: none; } - .now { gap: 8px; } + .now { grid-area: now; gap: 10px; } + .center { grid-area: controls; justify-self: start; } .now > :first-child { width: 44px !important; height: 44px !important; } .source { display: none; } .controls { gap: 2px; } .play, .icon { width: 44px; height: 44px; } - .right { display: flex; gap: 0; } + .right { grid-area: actions; display: flex; gap: 0; } .right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; } .error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px; padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; } - .drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px)); - max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); } -} - -@media (max-width: 359px) { - .now > :first-child { display: none; } - /* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */ - .shareButton { display: none; } + .drawer, .drawerIsland { bottom: calc(184px + env(safe-area-inset-bottom, 0px)); + max-height: calc(100dvh - 220px - env(safe-area-inset-bottom, 0px)); } } .shareStatus { diff --git a/apps/web/src/widgets/PlayerBar.tsx b/apps/web/src/widgets/PlayerBar.tsx index e0105cf..8c14998 100644 --- a/apps/web/src/widgets/PlayerBar.tsx +++ b/apps/web/src/widgets/PlayerBar.tsx @@ -111,15 +111,15 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) { > {narrow ? ( ) : ( )}
- {track.title} - + {track.title} + artist.name).join(', ')}> {source && diff --git a/deploy/compose.yaml b/deploy/compose.yaml index f61d7e8..073b5c2 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -54,7 +54,9 @@ services: context: .. dockerfile: services/api/Dockerfile target: api - args: *version + args: + <<: *version + REVISION: ${PIPU_COMMIT:-} env_file: [config.local, .env] environment: &db-env DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable diff --git a/deploy/config.schema.json b/deploy/config.schema.json index ec4ce52..6693518 100644 --- a/deploy/config.schema.json +++ b/deploy/config.schema.json @@ -414,5 +414,13 @@ "default_value": "3", "secret": false, "readonly": false + }, + { + "key": "PIPU_COMMIT", + "group": "deployment", + "type": "string", + "default_value": "", + "secret": false, + "readonly": true } ] diff --git a/deploy/server-agent.py b/deploy/server-agent.py index cea33d6..c2c5d45 100644 --- a/deploy/server-agent.py +++ b/deploy/server-agent.py @@ -147,6 +147,13 @@ class Agent: def revision(self): return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest() + def effective_revision(self, ignore_local=False): + env = read_env(self.deploy / ".env") + local = {} if ignore_local else read_env(self.deploy / "config.local") + effective = {item["key"]: env.get(item["key"], local.get(item["key"], item["default_value"])) + for item in self.schema() if not item["readonly"]} + return hashlib.sha256(json.dumps(effective, sort_keys=True).encode()).hexdigest() + def config(self): env = read_env(self.deploy / ".env") local = read_env(self.deploy / "config.local") @@ -159,8 +166,84 @@ class Agent: return "••••••••" if item["secret"] and value else value entries.append({**item, "env": display(env.get(key)), "local": display(local.get(key)), "effective": display(effective), "source": source}) + effective = self.effective_revision() + applied = self.load("applied-effective.json", None) + if applied is None and (not local or self.load("applied.json", None) == self.revision()): + self.save("applied-effective.json", effective) + applied = effective + # Changes shadowed by .env do not change the running configuration. + empty = self.effective_revision(ignore_local=True) return {"entries": entries, "revision": self.revision(), - "restart_required": self.load("applied.json", "") != self.revision()} + "restart_required": effective != (applied or empty)} + + def installed_commit(self, body): + """Trust the running API's build identity, never the checkout or .env. + + The checkout can already point at a failed update. Legacy builds contain + git-describe/main hashes or a release tag, which we resolve locally. + """ + revision = body.get("_running_commit", "") + if re.fullmatch(r"[a-f0-9]{40,64}", revision): + return revision + version = body.get("_running_version", "") + match = re.fullmatch(r"(?:.*-g|main-)([a-f0-9]{7,64})", version) + candidates = [match[1]] if match else [] + if version_key(version): + candidates = ["refs/tags/v" + version.removeprefix("v"), "refs/tags/" + version.removeprefix("v")] + for candidate in candidates: + try: + commit = self.command(["git", "rev-parse", "--verify", candidate + "^{commit}"]).strip() + if re.fullmatch(r"[a-f0-9]{40,64}", commit): + return commit + except (subprocess.SubprocessError, OSError): + pass + return "" + + def update_target(self, refs, body): + channel = body.get("channel") or "latest" + if channel not in ("latest", "version", "main"): + raise ManagementError("management.target_invalid") + if channel == "latest": + tags = [r[10:] for r in refs if r.startswith("refs/tags/") and version_key(r[10:]) and "-rc." not in r] + if not tags: + return "", "" + target = max(tags, key=version_key) + else: + target = "main" if channel == "main" else body.get("version") or "" + if not target: + return "", "" + if target != "main" and not version_key(target): + raise ManagementError("management.target_invalid") + ref = "refs/heads/main" if target == "main" else "refs/tags/" + target + commit = refs.get(ref + "^{}", refs.get(ref, "")) + if not re.fullmatch(r"[a-f0-9]{40,64}", commit): + return target, "" + return target, commit + + def update_state(self, target, commit, current, body): + if body.get("channel") == "version" and not body.get("version"): + return "select_version" + if not commit: + return "unavailable" + if not current: + return "unknown" + if current == commit: + return "current" + try: + self.command(["git", "merge-base", "--is-ancestor", commit, current]) + return "ahead" + except (subprocess.SubprocessError, OSError): + # A selected source's commit may not have been fetched locally yet. + pass + # A stable tag behind a git-describe build is not a newer release. + running = body.get("_running_version", "") + base = re.match(r"v?(\d+\.\d+\.\d+)(?:-rc\.\d+)?", running) + target_key = version_key(target) + if target_key and base: + running_key = version_key(base[0]) + if target_key[:3] < running_key[:3] or (target_key[:3] == running_key[:3] and re.search(r"-\d+-g[0-9a-f]+$", running)): + return "ahead" + return "available" def save_config(self, body): if self.job and self.job["state"] == "running": @@ -233,6 +316,7 @@ class Agent: return dict(job) def run_job(self, job, command, extra_env, revision): + effective_revision = self.effective_revision() # Let the API deliver the accepted job before recreating its container. time.sleep(2) # Capture full output privately, expose only a bounded redacted tail. @@ -267,6 +351,7 @@ class Agent: job["log"] = redact(output.read().decode("utf-8", errors="replace")) if result == 0: self.save("applied.json", revision) + self.save("applied-effective.json", effective_revision) except (OSError, subprocess.SubprocessError): result = 1 job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management." @@ -304,34 +389,32 @@ class Agent: if not selected: raise ManagementError("management.source_invalid") if op == "updates.get": - versions, error = [], "" + versions, error, refs = [], "", {} try: - refs = self.refs(selected["url"]) + refs = self.refs(selected["url"], fresh=bool(body.get("fresh"))) versions = sorted([r.removeprefix("refs/tags/") for r in refs if r.startswith("refs/tags/") and version_key(r[10:])], key=version_key, reverse=True) except (subprocess.SubprocessError, OSError): error = "management.source_unavailable" + current = self.installed_commit(body) + target, commit = self.update_target(refs, body) return {"sources": sources, "versions": versions, "source_error": error, + "current_commit": current, "target_version": target, "target_commit": commit, + "update_state": self.update_state(target, commit, current, body), "current_version": read_env(self.deploy / ".env").get("PIPU_VERSION", ""), "job": self.job} if op == "updates.start": if self.job and self.job["state"] == "running": raise ManagementError("management.busy", 409) - channel = body.get("channel") - if channel not in ("latest", "version", "main"): - raise ManagementError("management.target_invalid") refs = self.refs(selected["url"], fresh=True) - if channel == "latest": - tags = [r[10:] for r in refs if r.startswith("refs/tags/") and version_key(r[10:]) and "-rc." not in r] - if not tags: - raise ManagementError("management.no_releases") - target = max(tags, key=version_key) - else: - target = "main" if channel == "main" else body.get("version", "") - if target != "main" and not version_key(target): - raise ManagementError("management.target_invalid") + target, commit = self.update_target(refs, body) ref = "refs/heads/main" if target == "main" else "refs/tags/" + target - commit = refs.get(ref + "^{}", refs.get(ref)) - if not commit or not re.fullmatch(r"[a-f0-9]{40,64}", commit): + if not commit: raise ManagementError("management.target_invalid") + state = self.update_state(target, commit, self.installed_commit(body), body) + if state != "available": + code = {"current": "management.already_installed", "ahead": "management.downgrade", "unknown": "management.version_unknown"}.get(state, "management.target_invalid") + raise ManagementError(code, 409) + if body.get("expected_commit") and body["expected_commit"] != commit: + raise ManagementError("management.target_changed", 409) return self.start_job("update", target, ["bash", str(self.deploy / "update.sh"), target], {"PIPU_UPDATE_SOURCE": selected["url"], "PIPU_UPDATE_COMMIT": commit, "PIPU_UPDATE_REF": ref}) diff --git a/deploy/test_deployment_scripts.py b/deploy/test_deployment_scripts.py index 318553d..0a86cd9 100644 --- a/deploy/test_deployment_scripts.py +++ b/deploy/test_deployment_scripts.py @@ -116,7 +116,7 @@ class DeploymentTests(unittest.TestCase): target.write_text('echo "setup $*" >> "$TRACE"\nexit ' + str(install_exit) + '\n', encoding="utf-8", newline="\n") self.env["TARGET_INSTALLER"] = target.as_posix() (self.deploy / "install-management.sh").write_text("echo 'old setup must not run' >&2; exit 99\n", encoding="utf-8") - self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; esac\n') + self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; rev-parse) echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;; esac\n') self.stub("docker", 'echo "docker $*" >> "$TRACE"\n') def test_update_installs_target_revision_worker_before_container_operations(self): @@ -127,6 +127,7 @@ class DeploymentTests(unittest.TestCase): self.assertLess(trace.index("git checkout"), trace.index("setup --ensure")) self.assertLess(trace.index("setup --ensure"), trace.index("docker compose")) self.assertIn("up -d --remove-orphans", trace) + self.assertIn("PIPU_COMMIT=" + "a" * 40, (self.deploy / ".env").read_text()) def test_failed_host_setup_stops_update_before_container_operations(self): self.update_fixture(install_exit=1) diff --git a/deploy/test_server_agent.py b/deploy/test_server_agent.py index b91d195..66b628c 100644 --- a/deploy/test_server_agent.py +++ b/deploy/test_server_agent.py @@ -33,8 +33,65 @@ class AgentTests(unittest.TestCase): (self.deploy / "config.local").write_text("", encoding="utf-8") (self.deploy / ".env").write_text("LOG_LEVEL=warn\nGITEA_TOKEN=top-secret\n", encoding="utf-8") self.agent = Agent(self.deploy, self.mailbox, self.private) + self.running_commit = "0" * 40 + self.agent.installed_commit = lambda body: body.get("_running_commit", self.running_commit) self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}]) + def test_unchanged_configuration_needs_no_restart_even_without_saved_baseline(self): + self.assertFalse(self.agent.config()["restart_required"]) + with patch.object(self.agent, "command", return_value=""): + result = self.agent.save_config({"revision": self.agent.revision(), "values": {"LOG_LEVEL": "debug"}}) + self.assertFalse(result["restart_required"], "LOG_LEVEL is overridden by .env") + + def test_returning_to_applied_values_removes_pending_restart(self): + self.assertFalse(self.agent.config()["restart_required"]) + with patch.object(self.agent, "command", return_value=""): + changed = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}}) + self.assertTrue(changed["restart_required"]) + restored = self.agent.save_config({"revision": changed["revision"], "values": {"TRAIN_HOUR": None}}) + self.assertFalse(restored["restart_required"]) + + def test_explicit_default_value_does_not_require_a_restart(self): + self.assertFalse(self.agent.config()["restart_required"]) + with patch.object(self.agent, "command", return_value=""): + result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "3"}}) + self.assertFalse(result["restart_required"]) + + def test_update_check_and_start_both_reject_installed_main_commit(self): + refs = {"refs/heads/main": self.running_commit} + body = {"source_id": "origin", "channel": "main"} + with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job") as start: + self.assertEqual("current", self.agent.handle("updates.get", body)["update_state"]) + with self.assertRaises(ManagementError) as error: + self.agent.handle("updates.start", body) + self.assertEqual("management.already_installed", error.exception.code) + start.assert_not_called() + + def test_check_distinguishes_missing_selection_and_changed_target(self): + with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job") as start: + self.assertEqual("select_version", self.agent.handle("updates.get", {"channel": "version"})["update_state"]) + with self.assertRaises(ManagementError) as error: + self.agent.handle("updates.start", {"channel": "main", "expected_commit": "b" * 40}) + self.assertEqual("management.target_changed", error.exception.code) + start.assert_not_called() + + def test_unknown_installed_commit_and_older_release_cannot_be_installed(self): + with patch.object(self.agent, "refs", return_value={"refs/tags/v1.8.0": "a" * 40}), patch.object(self.agent, "start_job") as start: + for body, state in [({"_running_commit": ""}, "unknown"), ({"_running_version": "1.9.0-1-gbc55528"}, "ahead")]: + self.assertEqual(state, self.agent.handle("updates.get", body)["update_state"]) + with self.assertRaises(ManagementError): + self.agent.handle("updates.start", body) + start.assert_not_called() + + def test_installed_identity_uses_running_build_and_resolves_legacy_describe(self): + with patch.object(self.agent, "command", return_value="a" * 40) as command: + self.assertEqual("b" * 40, Agent.installed_commit(self.agent, {"_running_commit": "b" * 40})) + command.assert_not_called() + self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "1.9.0-1-gbc55528"})) + self.assertEqual("bc55528^{commit}", command.call_args.args[0][-1]) + self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "main-bc55528"})) + self.assertEqual("", Agent.installed_commit(self.agent, {"_running_version": "0.0.0-dev"})) + def test_env_priority_and_secrets_never_returned(self): (self.deploy / "config.local").write_text("LOG_LEVEL=debug\nTRAIN_HOUR=5\n", encoding="utf-8") config = self.agent.config() diff --git a/deploy/update.sh b/deploy/update.sh index 6a40fac..7c9f6ab 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -97,6 +97,7 @@ fi # Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему. CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)" +CURRENT_COMMIT="$(sed -n 's/^PIPU_COMMIT=//p' .env)" TARGET="${REF#v}" core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; } cur_core="$(core "$CURRENT" || true)" @@ -136,6 +137,12 @@ else VERSION="$(git describe --tags --always --dirty)" fi VERSION="${VERSION#v}" +COMMIT="$(git rev-parse HEAD)" +if grep -q '^PIPU_COMMIT=' .env; then + sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$COMMIT|" .env +else + echo "PIPU_COMMIT=$COMMIT" >> .env +fi sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)" @@ -143,6 +150,7 @@ compose config --quiet if ! compose build --pull; then # A build failure leaves running containers untouched; keep their version in .env. sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env + sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$CURRENT_COMMIT|" .env echo 'Build failed; running containers were not replaced' >&2 exit 1 fi diff --git a/docs/server-management.md b/docs/server-management.md index 4b14a60..05d3789 100644 --- a/docs/server-management.md +++ b/docs/server-management.md @@ -76,6 +76,14 @@ cd /opt/pipu-music/deploy && sudo bash ./install-management.sh * **Выбрать версию** — конкретный релизный тег, включая release candidate. * **main** — текущий commit ветки main. +Кнопка установки появляется только если выбранный commit отличается от работающего +и переход не является откатом. Для `main` сравнивается SHA, для релиза — SHA тега. +Одинаковая версия, более новая установленная сборка, ошибка проверки и неопределённый +commit не предлагают установку. «Проверить обновления» обходит кэш refs. +Работающий API передаёт свою встроенную revision; для прежних сборок агент разрешает +hash из git-describe или локальный релизный тег. HEAD checkout и PIPU_VERSION в .env +не считаются доказательством установленного commit, поскольку сборка могла не удаться. + Обновления устанавливаются только по нажатию кнопки и после подтверждения. Перед запуском агент повторно читает refs и фиксирует SHA; update.sh проверяет, что полученный commit совпадает. Выбранный источник не перезаписывает origin @@ -125,6 +133,12 @@ API, общие параметры сервисов, рекомендатель и изменение служебных параметров отклоняются. Сохраняемые значения трактуются буквально: подстановки shell не выполняются. +Панель сохранения появляется только при отличиях от исходных локальных значений. +После возврата прежних значений она исчезает. Сохранённые изменения, влияющие на +итоговую конфигурацию, показывают отдельное действие применения. Пустой config.local, +комментарии, явное значение по умолчанию или значение, перекрытое .env, сами по себе +не требуют перезапуска. + Кнопка **Применить и перезапустить** отдельно подтверждает пересоздание контейнеров. Агент запускает `apply-config.sh`, ждёт `/readyz` и сохраняет результат. Если сервисы не стали готовы, состояние — ошибка; исправьте config.local или восстановите его diff --git a/docs/web.md b/docs/web.md index 768cfe4..d929ab8 100644 --- a/docs/web.md +++ b/docs/web.md @@ -113,3 +113,8 @@ apps/web/src/ элементы); прогресс-бары подписаны через `aria-labelledby`; - выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку; - `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена. + + +На экранах уже 768 px веб-плеер использует два ряда: обложка, название и исполнители +занимают верхний ряд целиком, управление и дополнительные действия — нижний. +Высота строки плеера в каркасе адаптируется к содержимому для вариантов bar и island. diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index ee34b64..a4a3926 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -1586,6 +1586,11 @@ export interface components { sources: components["schemas"]["ServerSource"][]; versions: string[]; current_version: string; + current_commit: string; + target_version: string; + target_commit: string; + /** @enum {string} */ + update_state: "available" | "current" | "ahead" | "unknown" | "unavailable" | "select_version"; source_error: string; job: components["schemas"]["ServerJob"] | null; }; @@ -1594,6 +1599,7 @@ export interface components { /** @enum {string} */ channel: "latest" | "version" | "main"; version?: string; + expected_commit?: string; }; ServerConfigEntry: { key: string; @@ -2695,6 +2701,9 @@ export interface operations { parameters: { query?: { source_id?: string; + channel?: "latest" | "version" | "main"; + version?: string; + fresh?: boolean; }; header?: never; path?: never; diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index 86b2ed2..4699f35 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -461,6 +461,18 @@ "no_changes": "No changes to apply", "discard": "Discard edits", "apply": "Apply and restart", - "apply_warning": "Containers will be recreated with the saved configuration. The service will be briefly unavailable; persistent volume data is preserved. Values from .env will keep taking priority." + "apply_warning": "Containers will be recreated with the saved configuration. The service will be briefly unavailable; persistent volume data is preserved. Values from .env will keep taking priority.", + "check_updates": "Check for updates", + "unsaved_panel": "Unsaved changes", + "availability": { + "checking": "Checking the selected version…", + "running": "Update in progress", + "available": "Update available", + "current": "This version is already installed — no updates", + "ahead": "A newer build is already installed", + "unknown": "Could not identify the installed commit. Update the server manually.", + "unavailable": "The selected version is unavailable", + "select_version": "Choose a version to check" + } } } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 2763c85..e68db65 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -116,6 +116,10 @@ "agent_error": "The agent could not complete the operation. Check journalctl -u pipu-management.", "request_expired": "The request expired. Check operation status before retrying.", "request_invalid": "Invalid server management request.", - "operation_invalid": "Unsupported server management operation." + "operation_invalid": "Unsupported server management operation.", + "already_installed": "This version is already installed. No update is needed.", + "downgrade": "A newer build is installed. Downgrades require a manual rollback.", + "version_unknown": "Could not identify the running server commit. Update it manually.", + "target_changed": "The source changed after checking. Check for updates again." } } diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 1da7a52..b22c579 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -461,6 +461,18 @@ "no_changes": "Нет изменений для применения", "discard": "Отменить правки", "apply": "Применить и перезапустить", - "apply_warning": "Контейнеры будут пересозданы с сохранённой конфигурацией. Сервис временно недоступен; данные в постоянных томах сохранятся. Значения из .env продолжат иметь приоритет." + "apply_warning": "Контейнеры будут пересозданы с сохранённой конфигурацией. Сервис временно недоступен; данные в постоянных томах сохранятся. Значения из .env продолжат иметь приоритет.", + "check_updates": "Проверить обновления", + "unsaved_panel": "Несохранённые изменения", + "availability": { + "checking": "Проверяем выбранную версию…", + "running": "Обновление выполняется", + "available": "Доступно обновление", + "current": "Эта версия уже установлена — обновлений нет", + "ahead": "Установлена более новая сборка", + "unknown": "Не удалось определить установленный commit. Обновите сервер вручную.", + "unavailable": "Выбранная версия недоступна", + "select_version": "Выберите версию для проверки" + } } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index dfc9f3a..5a6f74d 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -116,6 +116,10 @@ "agent_error": "Агент не смог выполнить операцию. Проверьте journalctl -u pipu-management.", "request_expired": "Запрос устарел. Проверьте статус перед повтором.", "request_invalid": "Недопустимый запрос управления сервером.", - "operation_invalid": "Операция управления сервером не поддерживается." + "operation_invalid": "Операция управления сервером не поддерживается.", + "already_installed": "Эта версия уже установлена. Обновление не требуется.", + "downgrade": "Установлена более новая сборка. Откат выполняется вручную.", + "version_unknown": "Не удалось определить commit работающего сервера. Обновите его вручную.", + "target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз." } } diff --git a/services/api/Dockerfile b/services/api/Dockerfile index c5cc5d7..f462be7 100644 --- a/services/api/Dockerfile +++ b/services/api/Dockerfile @@ -9,10 +9,11 @@ COPY services/api/go.mod services/api/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY services/api/ ./ ARG VERSION=0.0.0-dev +ARG REVISION= ENV CGO_ENABLED=0 RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ - go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \ - go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent + go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION} -X pipu/api/internal/buildinfo.Revision=${REVISION}" -o /out/api ./cmd/api && \ + go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION} -X pipu/api/internal/buildinfo.Revision=${REVISION}" -o /out/storage-agent ./cmd/storage-agent # api вызывает ffprobe (сканер), ffmpeg (транскодинг, M3) и fpcalc (отпечатки, M5.5) — нужен не distroless, а alpine. FROM alpine:3.22 AS api diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 160e807..b7c57bf 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -159,9 +159,19 @@ const ( // Defines values for ServerUpdateRequestChannel. const ( - Latest ServerUpdateRequestChannel = "latest" - Main ServerUpdateRequestChannel = "main" - Version ServerUpdateRequestChannel = "version" + ServerUpdateRequestChannelLatest ServerUpdateRequestChannel = "latest" + ServerUpdateRequestChannelMain ServerUpdateRequestChannel = "main" + ServerUpdateRequestChannelVersion ServerUpdateRequestChannel = "version" +) + +// Defines values for ServerUpdatesUpdateState. +const ( + Ahead ServerUpdatesUpdateState = "ahead" + Available ServerUpdatesUpdateState = "available" + Current ServerUpdatesUpdateState = "current" + SelectVersion ServerUpdatesUpdateState = "select_version" + Unavailable ServerUpdatesUpdateState = "unavailable" + Unknown ServerUpdatesUpdateState = "unknown" ) // Defines values for SourceStatus. @@ -285,6 +295,13 @@ const ( Start ControlGeniusJSONBodyAction = "start" ) +// Defines values for GetServerUpdatesParamsChannel. +const ( + GetServerUpdatesParamsChannelLatest GetServerUpdatesParamsChannel = "latest" + GetServerUpdatesParamsChannelMain GetServerUpdatesParamsChannel = "main" + GetServerUpdatesParamsChannelVersion GetServerUpdatesParamsChannel = "version" +) + // Defines values for GetCoverParamsSize. const ( N1200 GetCoverParamsSize = 1200 @@ -1050,9 +1067,10 @@ type ServerSources struct { // ServerUpdateRequest defines model for ServerUpdateRequest. type ServerUpdateRequest struct { - Channel ServerUpdateRequestChannel `json:"channel"` - SourceId string `json:"source_id"` - Version *string `json:"version,omitempty"` + Channel ServerUpdateRequestChannel `json:"channel"` + ExpectedCommit *string `json:"expected_commit,omitempty"` + SourceId string `json:"source_id"` + Version *string `json:"version,omitempty"` } // ServerUpdateRequestChannel defines model for ServerUpdateRequest.Channel. @@ -1060,13 +1078,20 @@ type ServerUpdateRequestChannel string // ServerUpdates defines model for ServerUpdates. type ServerUpdates struct { - CurrentVersion string `json:"current_version"` - Job *ServerJob `json:"job"` - SourceError string `json:"source_error"` - Sources []ServerSource `json:"sources"` - Versions []string `json:"versions"` + CurrentCommit string `json:"current_commit"` + CurrentVersion string `json:"current_version"` + Job *ServerJob `json:"job"` + SourceError string `json:"source_error"` + Sources []ServerSource `json:"sources"` + TargetCommit string `json:"target_commit"` + TargetVersion string `json:"target_version"` + UpdateState ServerUpdatesUpdateState `json:"update_state"` + Versions []string `json:"versions"` } +// ServerUpdatesUpdateState defines model for ServerUpdates.UpdateState. +type ServerUpdatesUpdateState string + // Session defines model for Session. type Session struct { ClientType ClientType `json:"client_type"` @@ -1693,9 +1718,15 @@ type ListHostDirsParams struct { // GetServerUpdatesParams defines parameters for GetServerUpdates. type GetServerUpdatesParams struct { - SourceId *string `form:"source_id,omitempty" json:"source_id,omitempty"` + SourceId *string `form:"source_id,omitempty" json:"source_id,omitempty"` + Channel *GetServerUpdatesParamsChannel `form:"channel,omitempty" json:"channel,omitempty"` + Version *string `form:"version,omitempty" json:"version,omitempty"` + Fresh *bool `form:"fresh,omitempty" json:"fresh,omitempty"` } +// GetServerUpdatesParamsChannel defines parameters for GetServerUpdates. +type GetServerUpdatesParamsChannel string + // ListUploadsParams defines parameters for ListUploads. type ListUploadsParams struct { // Status Статусы через запятую @@ -3165,6 +3196,30 @@ func (siw *ServerInterfaceWrapper) GetServerUpdates(w http.ResponseWriter, r *ht return } + // ------------- Optional query parameter "channel" ------------- + + err = runtime.BindQueryParameter("form", true, false, "channel", r.URL.Query(), ¶ms.Channel) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "channel", Err: err}) + return + } + + // ------------- Optional query parameter "version" ------------- + + err = runtime.BindQueryParameter("form", true, false, "version", r.URL.Query(), ¶ms.Version) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "version", Err: err}) + return + } + + // ------------- Optional query parameter "fresh" ------------- + + err = runtime.BindQueryParameter("form", true, false, "fresh", r.URL.Query(), ¶ms.Fresh) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "fresh", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.GetServerUpdates(w, r, params) })) @@ -12076,316 +12131,319 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+y9/3LcxpU/+iqo2b1VZHZEUrLlJFJt3ZLln7tWzIjy9b0V6Q7BQZNEiAHGAIYS41KV", - "KMaWfemIsTe7m0rWdry5W3ur8s+I4lgjij+q/ATAK+RJbvU53Y1uoBvAkDOkne9WpWJxBoPuPn369Pn5", - "OR822kGnG/jEj6PGlQ8bXTu0OyQmIfx1vRdGQUj/5ZCoHbrd2A38xpVG8mUySI7TB8lR8ix9kPTTR8lh", - "up08t5L9dCt9kG4mR+kDKxkmz6xFn9yLW2140aIFPxoke+l2spdupZ8mg+S5lW6mD+lbksNkmH6cbjea", - "DZeO8kGPhBuNZsO3O6RxpYHvaDQbUXuVdGw6q3ijS7+J4tD1Vxr37zcbr/uxG2+87dBv4SVdO17N3uE6", - "jWYjJB/03JA4jStx2CPy+5aDsGPHjSuNXg+eLL7/HbfjxuLluRl68KX8Qocs2z0vbly5PNdsdOx7bqfX", - "aVy5NEf/cn3866IYx/VjskJCGGgh6IVtMrmFvNf1AttZiO24F73hejHRbfM36cOknz5Mt9LNdNtKHyUD", - "2L5nVvIs6SfH6Q5897jRbJB7XS9wSOPKsu1FRL+BEQym0MeNSQc47e9Dsty40vi72YwdZ/GxaFaeauO+", - "WIwdhvYG/TuKNzz6AV00/fu9iISTItx9+uOoG/gRgXnPh8GSRzr0n+3Aj4kPzGF3u57btikZZ7v4xD/8", - "MqI0/VAaq2zN/L0wYm5Xvko/SYbJk2Q/6VtTN9+4bv305cs/noZdZT+nb7/mdFyfkgKOdRh0SRi7OOl2", - "SOyYOC07Vpbs2DG5ELsdUlx3s+G4UdezN1pIxg8bfs/z7CVKdyRh4QeuU4OezYYbtZa8oL1GHOlALwWB", - "R2yffu/ZUdzyghXXL5tu5Wy8oG17RCMz6LH07RUStXpd+sZIcwz+DGKrn+wmL5IBlVLJwEqOkifJYXKU", - "fZjuJAfJ0Eo34ZTswv/3Z6zkz5ZNt8JKdulXydNkL+lftZK99EG6lTxNhskB/Wqbfpx+nj600ofJUfIi", - "/SzZT47wpxndJMJ0elHcaq/a/gpptUPiED92bS/SU1F+uGtH0d0gNNA7JOsuuUupQQ+djhr/kRwl3yYD", - "OlMq+jfTzeQApMSDZJjswr8+s5IjIS326J+HSd9K9oBoCh1nLHqXUKK9gEe34MVwc8D18iIZWvR/B2LQ", - "5Em6DUOkW/S1g3STEooRbJh+ZCUv0sfJHr9Z6L49S7e0JAwDZImyg3iTPnO/2ehFJOS8X5SmmUD5BUoY", - "8Xju5LBBi3QWLKocCcPOmXe/KR/u/Om5I4gQLP2StGO6LiEm5u0VUhQVQkDXktSZzNGIaUkRqCFA8kSF", - "4dWXaJfjLfU6xWXYYexGceX04ambZJm+qR2sk7DVDrwgVAlQECD5heIve6FXS046vRAuipbydqEK1Bak", - "sRsbBFwc2u21+pt4iz6uW9cGsct2TtZeCucBZ8feIZMoR+gm3yqVMGIRxj2nu1a86M5oD0+9RWdBWiPp", - "Fnqdjh1u/KBOzXgORasd9FBjKx67M+Z2eT5VO/UO25VTiWp55wvboZW+2mn5trcRudHrYRhoNE3CPy7Q", - "PyReCzTzgoIxb8erVkg8O3bXiRUHVrxKrAjMISsMgviq1Q1dOm1r2fWI5S5b9rrtwh41rSBeJeFdNyKW", - "ba35wV0fHwqWrXjVjSyg8kwdhREHbOn5rO6vayvLVXxai91zmyZ+mfFjtih1itJ2NNmeVW72WPQFhXvO", - "TWdgs2AWZlEM0u9/RcLWOgnrXemBT/RCZdl2PcXSkb77oEd6pu/Cnu/Tl2u/jINuSxy0vAWf9EE736HW", - "O1XWH8IfyZFkQ4KWPEj2qJ5NFeotaooku/SJ9KN0M92x/vrgd1ZynBwle6Bt95Nn9P+bVvqIGiv01UOw", - "gB6mn1WfjtwusWVna2T0E8Rqqhsgr1e/m04YuM5N4hE70nCoE9z1qcrNr5qiPej6rajX7QZhjNuRI+kX", - "1K5JN5Mh0mtI7RIwB9MdcIscptuydQhUoVRsNDV75wcxiVod3Tj/DbRlbzlKniZH1tQNO1yjCxAW0oBa", - "SHTYad31F63aly6/ol1l5P6KKDLF9eNXXtbOcZ2EkYvOC3LP7nTB2TI388rMRd2Y7OlWG9xBH1bdl/zl", - "uR9KlGFzFatpqluY3zAtT3S71z2XOWeITy2EXzRsZJRGs3HX9Z3gLthgrt+7R19ptwP5wsuWJ171as/1", - "nBOwF72PDIbkBDas/OxlxBezEtRWFlJK1DdcT3PO2oLgpTeA2Jn7zVPSLvAclM/1rh51H3VuxbPdC0av", - "021FZNqHaHTCwLbq7mRJZJlOf9l345QMfHFaogjrJXedU823TPOv67ns2CvEyKpGNq0wPHTmBOMGeeI1", - "DAZhlhWVtHoLHMHdBY9qZ9Fz3PhaO9YZBzWncVLPm3E6r/ux1tptx4Vbjr5sBj1quqnZfGG257273Ljy", - "i4qjlRHj/p28W59qTaBoUdUCFLUB1dzoTf8iGVpMK3uRfp4c8mDbMfN7PgMVoQ8/eaFRwe43T+Tyr7k/", - "XXuDCicgg+O4dDm2Ny+RFrVAg1aY7U1shyskPqnFxX6Nn49sKYCWCRvT5HygvlKeXrZkha5GfhuLpZQx", - "7rmZSb149VawRnydidRukyhqxfRrrdQj97puSKKW62vDjA+So2TfSr4FXfYwGVr4wgsQC9mHYAE1NzaT", - "QbKfbiWHyR6EiTWGElkOSbSazSQ31O+SF+mOtYj3BmztFYspgotWMsx945BoLQ66i1rXEh1B8BtXKl8l", - "dkhCrebYY7G4sm2+QQrbo5BWGVahKnu/buNeDYI4ikO7ez0LFNwkH/SI7nb0yV1dhEi6FcjdVn2RrDzd", - "VN+um+t1CGnMs0eMs2z3wpBuU+VMSx7QzLR8asAZt3IbfpcsgYXKbQnGMloOuA7C4r2IhMaF5cOsHfve", - "O8RfiVcbV155ufk3E7ysjjJ+wyN32fshrtiHi3CYPsQchKcwEXoB7s9YUmgSvuQ/UiYFnx8kw+TwDGOC", - "0hGAAbT8hVeJPmQfk043CO1wQ+HoHM2+BmFJqbZN9QFKCHDfJEeUZMmhhTRtnFA4SdE9zfLoW4uT1C30", - "9XVqdtlxe1XjMF4fyViZ9+wNeB2ehXtv448uz81VeLTZOOXTu0kiSN7R3Xbd2OSrc3qY/EEM8byQ0JG0", - "rqVvqKqXfgIB7QOLeesepNvJwJq63Zube4lYL81ZyV5ymAyS59NCN9wExx1cnvTo0t9RFfHAgqj8AN/X", - "qIxdiHUpi5BmrCXXvbbXc4jzmhtqzvE7bkxC27OWwTq3una8GhWd+3EQ2ivo3W9ark9f6Porlu159Apu", - "E9+x/Tiasa7bEbEi4kcu/flVyw+sFS9Ysjp0t1x/ZcZ6vdONNyzPjWKr7RE7jCxC50fNx2jGev2eG8X0", - "zZ67RPnUCkk7CJ3IskNihSS2XZ84TWupF9N5uaEVtW2f6geWzXzE8GS05na7xJmx8LaiCwIi0vkwLyb8", - "cqbRzNhYkuQX51gCmPhAK9k5N18scnOz8YZLPMcQdckZ2LL/hHg1rkJ8rInv0e35m8R3e9E13w9imxtN", - "uSPSi1dZCLA4i9Be6RDF/JXUKnJP/4XJ0F4nobvs6tOH8uviA7Nh8KXSK5pi3uZVX8/SvPJhAk6O+vKr", - "QEiNco8hwla5u0E5dZrvvY3Qbeu3Iwr8lYq3s1iR8WsROCnnK5EAyGajvLgwEc3K1XU2FZKbd2whtuOo", - "cr+Kgrod+MvuSi80paa1g8Bzgrt+q+fHrlffrobYhSnZg67V8B0k85gjqoUtln4KAtJ0YXXtXmRaY5f4", - "jjHwVIxKSb+Mg9j29L/r+SXzybtFs00QM5VjRThMNtFsrRKbyVstaCzPQ2yKQuXCFut47C1ie/GqKXaY", - "HQ1uMARrGttAf1C0w7n0ttygV8OJPWrUwhjRFwTexpqZQzrnSjYkf1fJ4sbhMJHpdF4ek7eCKH7NDfXp", - "Go47aoYMT5conxyL4sPrdZN6B1WfkRM2ll2PtGoymJzcMVpmRZVi4qnZCyybu0bugrzscfCXQsbzYjA2", - "CWPigrfUM2UVcoW2bopGln6Pt7LhtbBDZRccfcB0SblR5PorrZJHcNvr7xJWUeDcTaIgS48sjnfXXq/0", - "Brxvr5MbJIarpmBRMVo1+V6I4Tgl8stWSZitWNoxNi0tQwQrrm/2mmW+zapFSc4u0C7XXSmdqVAM1Acf", - "0zA5SAbpA+u7v1xfDYMOsWat9zGy/t2LpvXdX+bde8SzfvLdC7ikZWtIG1Uo8eydyPEiZU/LhNCR8Qb5", - "fhdN/E81w/9UM5zOc+m2uiFZri69cufhsbMtgBCzO135g+5kv+svBXbITRpdurN6uUwknzxfz5U8SV4A", - "R2A2HqTsYcJX8gSZIf2Ef9AH3qAfY3A43cQwMObfQQB4p0689HQ5AKdJY5B/OWK6eoGqq4HbrmT6bM+v", - "4/P3m40V4oekbKvHtHDNmussLHtFpVNL/LspaRtseeUn4LqgXu4crJDWShj0uhpe/U+MwKafYhopXvtP", - "WBIiZ8DPqICkX++KAmFwSeOHGLI5To7hPvkmu1+s9CNRBvzQouye7OFl8iQ5ot9YkAeRPoKcShHiwFkc", - "pZ+AyP0E7zP4cbo9Q20CZnj3fIeErYsvNZqNiy+1Lv600Wxcmmtdov99aa71Ev3vy3Otl+l/L8+1LtP/", - "vjLX6npoiFceKEG1Fq8cyJHuX3A9x8kRvx/AaQ+nWllknmpHyXOJariokNjOu763YapKyJxnjsrllSde", - "jqIUGXOF+CzLjpO1gzJ7mbB/QAZ+LYppzmClWpjN7qWqEI9EgNIDMW/Hq/N2HJNQ48d2oxbx6SqMCsYK", - "uad175ffkfi7pvx+w9zovPQehC5+OUK0TFppxU4XnQo4VMkk5/FGP61hzV5jstZG8R1yMzr7TfX0jcbT", - "JKndbEQs4+w0kAH1N0xETTUajh+TezwLq/I48sfzaTBgoDKjV65yiogdtlfhqvc8whOtVtFX12A1ENrc", - "ieUw6FSUSKry9r333n5t/cdUdX9oJftwQw2Sw/Rh0sdyhn2qWaePqHa+B6l2VDA/ZM8cUiGdPkyGVLJV", - "ScygDekoo5mGwfKy5yplIrLXHb2lpuV2g8gtLRkdoWiIf5DtHejqUWyHMfPbtogPLrc1twtbSNa4Kx4u", - "oqgHK/TcNdDdffYPx43ER/zfuo2lrNKKSAQZvidxCmaPNOWaJ3lXTIdgyW6vXUcWNjnTi7a3vUS8Ep3/", - "wzEt0TjhhdiOifHk1s9GzRPg/h1N3ijz/OCkK2/0SsbE8z2SPpKXlPCKlus7eO/q8iu6xI5bHRYEF3GX", - "5WXKFT7KJU/Li9Fqb3nZMxzKskNVSRl0xzinwrBYD7yeYpT4vc6SRvZLh0DeEE59lYRChjey5as0FAPL", - "3KCs6E4Vt77td3txKcuOxKg5tqz2JapsKa7RueZ4WLQ08ajAseXDfy+5N+M8oZBcbOoWMg6ONHGTx5Tg", - "+tKN/iIrcS6EZlftkLNQIUt4kDwHBWA33UofM2s33Uw30+3kRbIPLk8w05JdMNQ+lbLt1GzD3eQFZByC", - "XZx+nG7JMEqzHTLbZRONNKpGfmMMVt+4oR50u6fTJO9IO2M44rm8FOmoXprTnlVN4uvFS9XZUpzDTmzs", - "KtNBOlDNxf2gR9gTcCPq3TtqVko2lzJWHkcRf5HJT1rHL95ED4UmjUE6K6NpZ+KXpaOaEChOFHSpSIXK", - "Aa7UqOE7ra+UWpa5qriCsl5Sj1ZDd6hbopVnVTGzvFtWBV9RMIVqXPx0T9+Dx0YX2ChIiuJaJuIIhrD4", - "mUF8Sd4GczJEteJrx6stSJuMzHVQ5jBBRj1mt2faQiHWesSirOkmhOuOWak+OhLTT5I+iyZAxYwoKoOi", - "Gp4Hbr7yFUWkJIcjFJnRldfLDRLboJ1g0qxBO7FXTkW4wqaLjBCVoNk01D1jMxALK8skkSD/9Gm3hQhj", - "H7zSomxvHx3BGQTD0Er2oC7JvfgTvwnhRSWgvmj34tUZ11+3PdeR412LevkX266nLw9Id9JN8DqzsC8O", - "C/zylLKTNUW/FZ8f84KCpG+qNnw8XZ7iaMQuHIJrhmtGlDbpR2wQiHYyiMkH4C7vy5nUZSwnpUVrM6hC", - "uxNVVSoWdryQ3ypLb2b0C7ddw14KevGVJc/21xrNuhmxxlTrm8Rux8S5xfPvTn8/niKVT7kHzGl88pzH", - "kWul0ODUuVYlqakG/zHPMa1Kx4J58hpiZrjlvGE6u43/zKBCh9JbK6kEOeQFaci+MMzZMSaQtVcJMydM", - "glk93ovB2iIvSllctl1v0ZpCScYgYtD3mrOQuPwZZNkmQ3rmpyu1u0byJ2pHpVtgXAFWLYsGsowVEDNU", - "gn0L6RpMquD7qXTNvLwlh17K3KXjr4S2o4QSKs82UlFPfShXNQYdKspZGdaNlQx5qSrYpYPkCZWkLGFF", - "TsSBUlpK8nYQrLmknuPxJkssEcApLIunEzgktLFoOld8mu3XQtv258NgJSSRLkPRcUxZ6bzE03E1wdTr", - "+KXluCFpxwFU8hgBw5pWL3L9FWs5CO/aoWNFnh2tkmjGeHHVEiLlSY6+G62e0t23HPR8A22We55nSI3C", - "ZEJDgmWwbqwBWGVgSXo6Qx1UFNsr5KrVZbtpdUnYJj790HKjDITNCnxvw3J6dB304TaBKcmh+Lu2t4bJ", - "+t2QdO2Q/5s/C1mQvu25v6J/6NiKPVsfL0uiEcQ2Rr00gxYbstRGM2WRemvEaTnaCrnXGAu7JLKoqRIT", - "x4oCa9kO5VK4HEvPVBfzZSUSwC3KslUO5aymrFImcZOd02yZnJcyhpMqKMyqMxUGRlnHmbqgtYIcw6wP", - "no8HyYdW+uuknzxPXqTbTYtqqFR1Ve8WSGmE+knIvEueUY06ORQgGgNNJp1OBC5A3BLrQUsTrceAvdjU", - "JqLVRuAsZp6FIVRvthCOvUjhf88UbbiNeS4nvS8xDQXip/1kj94fTQuyFCGV8ZjSeJhuph+BaUmpfYCJ", - "jPTCZ7ktYFAcghH0Ai7cz+pIvxPpdHqL42Owd/YphxwlT6DpwFOGWgcL6cu29B4zjp7jCaibTV+WE29O", - "MK+dLa7NGZuEr7csYZ2RuMhS1ZrxAgnXSXgdark0fhY/Dt1RsvqltxkxUUICAq9Vlkqndc0Z1Dk+ScVp", - "VhikavEG7CFmPbbWba+n9yGS5WXSpgqO/lt/vdYpEWl9hW/WyIY+3B207XqQvCGxnQCCE9pLl7RDEhu+", - "g4tNVjHpevjYTWFb32nWDrrnNo+ujq9eJAuwKUkzl8nc1E+CbZGYdNWGz+txDkr4rtmAIUrNrmpxWOEf", - "ExzMxjIv421/OdDceF23pcWFXNeCQi5xCBrlOGpv+l10ku3inSLBdSAqFlxID3mq5CD9NP2cXTFo2Q3S", - "B+kn9H4XWT488UdJu5c8W6hR5NBNDTn2HddvMayXVh7OruD2gzknByxlG51/7EfXA4dYzHq7wHx9mALO", - "ii+a4CZkCz2iy4MJ0uspfQA4STz7Va7QAEggthMX5+Z++lMt5CgLU2R71nW7vQudXuS2tSCiHFqzhQn6", - "kfJjqmjCWaEsVD8fXuIdE7wqRFzl6hNraoF0/g8STsvLbMzNXJyZq3S0sehHhikoM7BujSV7rWVn8wH6", - "p2BJo+yqFqIhPb8opV00CUVOMwZZeK26VkJ6wYqpal8yg7RF/YpMzgyKqNduE4IWAQPpvWOEZKtZLwIr", - "46OWGivstbgwM9kXxKWiS+uqwlXShaG18WpW0qEEtl/+ySgROROop7wMXWX5qKWQMlVyoe8KpZAPZZ4l", - "BvvMhYertu9j1pzwi9oxgZTQ7BB2bNfXJ7bIFcMjQI5ql4FpKHxGVUuKzNhjZVCnv8QzXy/ymYkJbQoe", - "m7a5Mvu0nGCQzSNVOBk4RnpZs0A4JFNugfoNiSItysyJC1rVuEn+BgK/6QEUsDDLti8n55Q6itgiteBO", - "AygvxNt9mDxTr/jn9LaDaFjSTw6V8JdWFcnV5I6ttNSO4lYvOnW6gTw9teBW16KID5jRr4QPxpE5w1nq", - "5BkzpvsFQbTKSk6/BnWP64asQhTzzx/wclG54hRx5vUVp9bUz95YQM32MH2YDMF1I3Df+slTltB+lBwI", - "bZm/DjgMtOrPprUsdpLoImFwYMLbWbYLCnbYiDwate0xtIQz9CABKL09cAgdM88RrwpQqtyOrHfefvXm", - "tZv/V+utdxduNa3kGHolPkyO0PxANLbPIAaVPqSWQHKUPK3l/zIlE/nLUYvco9pqrWNPHw+6eZQj9fsI", - "LoRar6NUH+Fmk4M/+suN7iI1TsJ122t1XL+nL1r/bbKLXAx0BO8iGGtDlJvUajuU0l1yEUZ6KqBHKTar", - "rE5+yYJ/ddElsl+1OiSKWNS7BmzxSqsbukHoxhuVHjx7ZZ4/Knk+qmfIb727oRuTVkw6XY/p9jkq/39Z", - "9hCy8kMUFuBETz8SDndMFXGCdjTLxNxMx/m7H0+rCUcDgcJZlnJUQ0Nm14bEqsoxUJlcOdg5Apv4rVmQ", - "2gViNaX2peo25+RR7nqDA2O+Pwwxf80lUhTPpxK1qvQ7uf2jiqNTih+tuNGKB5EBeLnOcT7nUyYR8XJ1", - "aajM9GbGGYsSZDABRtSBDC7OakXoG4zGYXIZKjvo5WK1eUw7+tkbCxey0nCASP2Uaj7hXcAHTx/hDZ0p", - "O329OiMV8qoTgZ7FKLXoNc9HHyIwh2ZkAas6ZCCtTEbucMXrEPQ2yC6Bqu3DdHu6aVH2lHop5QfaZAgs", - "ff3s/xc+7ep2zZm2ClJhq7SExpgFxFgv1at5DTLdSp6gyzv9DNFlmDcU3cmQxgQsn+xj3wj41eLLly5Z", - "kC0KSdwzfHYtlkO6eNtPN61FTIecCYkdBf7ijAWWSZY6xeKl6HCXsqXY3S46XsmoDzx1FFrA85PDfNq4", - "bQNqghwXkaqtRaDkBT7XWUibWpy57atwUQYJapBNC4UsLgkoMuj5cQb+iFl6+Vp8KZMpjyV2UvjF8Zgv", - "5h5S41bRz0YXLtH/amteFcrWLRLFRkzvnuMGGfRc4abaVyDkEYQ+OVIUYsjbfs4yCI5Y5qN6nMRtgUb8", - "UIVpp+fn4tzc3LRWRkmkLFaTr+lVRBROoxgk+At8XwE4bA8tWDBpIWfiWQbMn7WoYdJ4cQbiSvxUR/Ei", - "vThlMqKoyOkJVy3MFKXCG8yJafNpMGHiQKZmxiXy3pZwR64O3l8WxoReHsQhsTs/79keuzREfmQvDhrN", - "RhC6K64PseNVd2UVZuS4gIfgBXe1r5SvFumFsb0StZbdEPzlrGyB/qF9Bcfgn5eA0er0EqhoJloP2t+Q", - "pm7zrtyVWVEsiWmsGVAiM1HD0/8KKtpnVl5bo3crO9vWlEYFzGlwcOKb2U/wKjxgWPwDvT7quFG75Qd1", - "mgzXaBO+TOy4F3J61xH9sFlv8J8VO1Ilv+VijkWon4HgolrvVAZjZDF5xvtQ4i3PCkj63M2Zfoz9MncR", - "qyzdBjckVYAQivCQyUJUFJKnIDkPwEHOM7QLF5MGP6gy7lwbwDbLua9HzCz7Xn+HUllhwH/jvkZIek8O", - "GU6WXG3M+ElcO9bUxUtW+mj6quzxQpOkaHsM6nhfqlsQ12PUsm7Yak1hLtMsw2jKDoY0tHyKFXJKeyWd", - "AaNsekM6JaqMWup2ShbIC9upFUnClY1aj3pBz/FJFLW83nJU4xc54tEZifHybzOu8G0n0mmm0RghDQqb", - "XDKdcfgp6qUumt0UWR2i+WYq4rlBt8WsBX7xAfNXlIHrifWTyLCKk1pv4BO2txdITgp5sjPMz7o4y6wF", - "Ps6MUUa3RxxptLhDnSVXbRaiRKiCKFq2HdKKFHSKV5oVMBurQYe0qNxYd/WlTPlmSqglgBh+CiHaXY7M", - "B/U2gNvHQfws1gd6B5wQO3hbUvX4ALTfR+yeewwOIb1uAPPrBIEz9qkdIq4gppWx+/YEE+wES65HWoBU", - "peABAohnEaNaMlyp7PFYB11doqgdtldb7cDz7K6uJiX5hmoR4JZ4JGP0Uo2MXqH7kMS+DzQ4TvbTLeiP", - "9ALuQqDKE8DeSH9NqZhu0UvXTABwDeopwCbKAU20iI97VD/axXDUMd0KeklL+5LN2TiDNvFjlvPGKQyf", - "0BuBLEP6qruyakiOXSUdxaZx7HANsLroL5qNaCOKSccMy7Vuh66tds0OwiV4wQc9V+8muUuWNFyxBFLA", - "jTzbN5TOFSUCJODcILV75RXC7Fui373g+H1egrgJni0WzwX7NTkA5NyqzLAS7O2RUZXNyx6pSeDZLNyN", - "Wkte0F4zpdL/zbYi/ELuP4jFrSDghWO2fldCrD7aKetK2BQC4yArURlYIMao2IC9Gl/fQg2+BMvAxqLc", - "YXI48hLymdqjLKlcItAtgtZ8rFmiRo2n39aFPhTuPhPeN6YtGRIPSxr5/YFDPDwU+An08PFgOcJdLfc8", - "ryk+o5vS+qAXxHbuizgIWp4drpDsI9GdT/q5n+Uws3XXMfpOgwoW9yJu0dbAv4MG9iVNZEZzD2c7U3h3", - "NjFpi6SlVuNv5+0wzlKcYe6U86aIh+rlMpYjIprGDoul8EP1mMotUdU4YyEi+pIdkVdeRlsfooRS5thR", - "Bh6fbtHX9iEeOpzR16/VCEfLlk3lo/IEi0v+p/nX32xa8z97k8vN98nSfJOtOYO2H1g/oSLmc0v2Ku2x", - "vpVUrKIbnXmH6rDmCNZN2U6aE5qDDu9gWAX8PKKUqQ7BFmO1ly5frmjCVKiJ1CSzsZ7y6RYrsRVBeVAl", - "PoXLmdkVPC9uUOjkMqfFJbQvXX5FH6tiZkAlCFfJ8edBIvoqMZjuiOZ8Gh3X5w02KwRA1al/w/WIqQVm", - "pS4Iih74rVHOSKxVyeYjXXZLbtxySBcZotpBsOTGIQtoVz/M9kNmucrJtwOHtOs9eRIUOH5FtjyyrpYe", - "MBZpNjokth07trHEZIWE3dD140aTTqgOgH02RrDcOtVVmvPuVxOcjzC+xG8Gkd5Cx44WFvPLLL7JbGwp", - "qTUZgIrz3V+SJ6AMw+2Uz1g+spJ9apNsUXmiAGwOsHJcam+SbkGnqJFpKRbC8f9PuJIsmJOtYshDteWr", - "0HVleXyitXAOrR2HQImEoGu6SAR/Y4s4bmwy5XjosmVMMugGUeQueaQlzoD+TR9geLTV6wJsjrajCCC0", - "fwvYDRjDAA3hCHw26SOBbQeb9IgTnMO4f5vsobVHL6pPpPJJAVKwy6N0UBVqe15wt5Wflt4wbBN3nTit", - "pQ1mztaAikRr8pTIL9gMoFVf/NK7b6Rp1svpQF4q5HRgvlC9dPHTxr04ABxlhU/UGOEweQaQNK6/gnEw", - "adNFXkbfSreAt6C51sNaZs0psQh5hWKrRAH7OtOyDMFlS80rs6ZymdXHLMJHVeNh8oJKpaaVw9pKdi1l", - "y6brLR8mX9qlcxTTsHjcZY/BLnoIQGiKtDBJ9O7yLvgPwVM7YPYuJnCNA3W9RhaSZP+pKhpvFy6nKKmi", - "s6gZKYe1IGTEFcHVo0wPU8WCrM7lI6sMylLSbvJCX3dJajQAk4ZT1K60F0JR+mu4q3ha8nJGFapmMFrZ", - "7pfkTrnaPg54QskIOK9GsNKtbw5xVtv99MkRjP8RHh0xIlrLEhZx0MrRzzouWh4WHSUqCvtqSLCXlcO6", - "KmE9+V7bFzsfEkjTNYN6Vfg+TuKUKtqHIzlQKjOpxuGvMEfpSzwZI7swTuW1yPZOnxF7Oue43kAoRHI5", - "7BYobUo04QAdT1g+hmBtObOMQSRTVaopRR+4KTAEdU+n1/P1ioRNyEAlysUGeFfM8a6PrUr2fn5ZiDm6", - "DzEpUSsC0Y8MaOyFNQUdYp/QJYu4ElXrPgJ37pD5hfNEmB7djixhOYF3rbboyG/dyD7zKub7eS/Q3VWe", - "23HjkSyZjn0PPf+j/KhLwhbEW6rGq74voHJ9lLFh3BF/lSOuPGtlBoaVFQct0M28UzfL2+I5pC2ArMR5", - "6nbDYJ2I+Iu+oRuGa06Xl1biw4XEwEibl/G1aiWxQiLAzC+WquxCgzgI7QpzpYbbW3MeWCtKQbFqmusF", - "c1E7NbQtqNl4geHZa91FNZ2H+voDnTEFqflmqI8TZvgxTwWJY9df0aoZOq9PLWcUAlQLT9RB3g81gA6C", - "4IMCdpLaiB+xd0CmQwZRihibW9g214DtgVhzpzPCz1s0YpR7VJGe2/PiS4xCLrdeHRmbBk6o5ilTUPeP", - "EIwd8t03dEnYgfoYg6dHk/SloHc/5k5nKfFF9gftptu5QsjZfPl9cjStCwgbDkVNhlQpkc2H5dflV9s3", - "rfZkTv5K3i5rEmPmc9OiikSGiocaWy8vb9yHpyzJNd8RVn9oijtbchYKxZSyN06B72ZVli3WE7jJdQK6", - "uW6nG4QxPof/lj5uMUA5VRVndatqRsddN151QvuuD3iA3RwMX8YrPMR60npNOV2qhrtQPK4j5Pv2OnnV", - "VC8P2cgbtRzry7bnLTGnhqYlwj62CUoOMXENQ/za0hBej95nZfRgoTxhRxe85+iC/xQxs6Gu8QVYYPvJ", - "sElvxsfJEzDDEGW7GPlKhqYM4qBeV9GROqdOCBdZmkMz2ym2CAkjWeyLafON7WZH2v6atLtvmMQbhDhL", - "2kq9oBe3AzVnmNLMI+iBZi2A19xui9ghIObWaHAyQjtic+NIPjMTYW+QOHTbkRaJdQCK2BDbgUglaM+S", - "vvVjizXiLyJ+L21AH059GGOAaiKa6Nnbj7HpI8MwpZ8cQJhrIOx7kRE4tX7RmrWuvbNgzVrswH3Lk53S", - "T+kLp1nKriiSk7JmD9kxPKzbGgmIRJfDKaVxVsOmtmB/K6KPUpWTu5bFKgvaEUoKXh5tXZybkz0iu+a7", - "UB4gionfilfDoLeyWn9igDRhaMnAznMdVHb2Gv08iiSTCSKN08y4ycjB8uYUrbrTbs3E6AyrahlBPYy7", - "kKNz9prmaWhuou7PtFJ3WZKEtU+REJ8Vbf1N4vdd0Zwyp4+0te6K3yMW5RBOzCGImRt2TELX9qyFjc5S", - "AKD8JkT3DCk5A/CtA5YOk7lTuoLIeIuNguYvUUQjkUQN1KnflY/YZDPlo5hWuwDN8M/kxob++s6JVdV8", - "L38eYOJxKBZEqexdxRDXtNaAlqtDNya3WK7C9VWiUy06boQR5hG2U37tDfZ79dRd1DTc1qJXfAHpAwKA", - "B67UZ5muS9WBDO0G0o/Qjs26xvDOINSW/+un/2X9dO5/Uxz6ULQC0QcJ8v3A4OIJg57vtOLQ7RouKLtD", - "8oBNcnSpZgs7cLfx/nXSmPIATXlr7tTaXaM7+OSwZ+KXlTMQjHCKhq1Zr1ZNUtILFX8fLBvY1qfpbwBn", - "6Tjd1vk3WLOcYwBGYZgIoitRP9m/ai2ydqSLWSaPwkFHUqPWQ4SuRISEb7nq1xgFZb8kA+lPCpdKid9Z", - "6pQeGMF3iKF9A89pOs41ok23CriQmuylkcNYUsKImJXY2DIfs8JMC72VFRLp72OZmU/MvNj7pBe68cYC", - "FWvMtCB2SMJrPdwb/OsNLt3/6f1bAGaE1Zvs22w3VuO427hPX+yy3hzFnUDnMmCe0t29+frCLeva/NvW", - "vNvtWTd6kduesZLfI9AYfYpqF9jK6SEgim1LQF5KC4bA7roX2oFDVog/fdvH/oqixwYCgwZd4tPH6Hxx", - "YtMzFgR7oT45S9MEjgAYUbvrznQcBPtikfNGNlc6dQkqnjd9oKIeh2pcabw0MzfzEsPHARLTd86uX5yF", - "0r9Zu+e4IK9YSwK6z5AG8LbTuNJ4x43ia/AEaxJLYhJGxtBJ9sjsO27HxXbVFQ9ex+yd+3cg6NoN/Ag5", - "4dLcHFaU+DFL+7e76PhyA3/2lyyChTdiJQwOXQKkNAF75MPSWJMHpP846VvJt+lW+oAJqr7k5jUNI+ad", - "xYygPwhr3t5I/i17IRbwPGdBEnprTsFGTDflrmzpJrOVP8KGK5B6yTtM3qFvV3dxhfgueiC12/gmid+E", - "JxZiO2btnCdDaXkYDa3nKadSyyW2iB+67dUOdFGMbap+ue2IEQNaJU6jSpVbOoThNUu8HvhxGHg4fgOl", - "EIniVwNnY6TFFVVZNYYLDTjgNPQijOTG4Ua14mjsNKs+yKKL57U9WGPtSBui3YMC+3nuUmiHG7O2b3sb", - "kRvNZk1AzWKFPfs6b4/4Q5Yv8lrqyxne6fyI3jECxEjFjWpaCNCQPkh3wEf9iBWc9Vkjcuvt1zLP0Vik", - "1ZfJgEHW7cIEBxhlY0Gz9BG92wyzBUgJ1JhYtbqCjI2SThJojHFq8RSeNHpEtQLgJv2ab8RpRVy+KRlE", - "neo4a8Sj+pNeDp8oKCxh3kJUXGl6MI4t/oJhpWY1jdIQUMDOgXwLCj8q51sK5iFDdJRZQRSnwggn2vka", - "QuQdfPRvQYbISzmRCBkHX/y/UuPYutuudKA90UavBlF8gcM3G/f6rSCKAbe5sM0mg8vUmwKWRtl/p9iZ", - "Ar+iSvtnEF9tXGmIZp4Q62zwZHyxq/mbf5JcwogA6GE6BlH7c4xFVuRemS8K7CutPrKkAAarJP0ODSfA", - "ibmg8RKciHWygi2T4svO1QIvRJn0AeZlYdrT+yj9PH0IGXZAkaFA3063x7FV3zBYyKN0hwfEnoB4eAGg", - "vA8ZNjtSummJRvU1iY7ITrMhcX2H3Cu7iuEBbA5dJPklczdrSD0EhIN0E8ExueZxjFWl9IYZD1NjCf9m", - "5txUsLKk/tjZjNJtDZMiUfTkCtdJONsW3X1NLKp0AZ4ghyrjaBgUv7cQsAgsM5YGfVKCa4w37qBUibBg", - "r5MCFU5mwNUlACaunbH9de47UM6ks3RlJTr2Nfp1ObdeGjOtoP3f94hQWFh3QeotyBrVmBgabeoF0fhv", - "cnzNxzgXnlYG/17tVVQtenlPy4JmqdP/5KzZ81EC1UmfkyDXCgiICMvTmyjDq91Va7H937B4wnzsWQmv", - "z8T0uaqACXJqbiSdUvylhLv6fFw2S+Gl+caRRT2O003RUgpAjQhI9hkP1VAFUaQ7Yx3DENPfMqTbI0Tp", - "OMS+O/yPzJhOdwAThgUBhd4p4yXCAr5N9lhJH4cDyDltwNLdBUwWhAeD9PiZRjPHAQxJs8gE4z+nulqB", - "M76ezosFfy/SI4YCiPjkPFk875kCYvSYyErHpK6irOma3sGJMCZHyX4hMWAcRP6vkbwI2a2lTnMRfBKL", - "cgsyqcOpBgZJ8XiokeCr1qK/HLF3FdrPWlEchPYKuWCvED+eue0XVjBQWkShbGAgJnuY7yBB+GjapYk+", - "azln3RUFMIS5Ho7A+v1UyDJ03qDjbBdKbAcYes7F2gBpgnXJm9AFL3WBrCUwLo55aC076zcLu/1Q2T0+", - "d1t1r71RvGVMWMx+6Dr3kf0hcV2Tvp25/TO4TcUVnH5kJU8AhyhfmL5rpb/hYLaF+Tal6y4r3GLNvhV3", - "YvG+eg1mK7GbsvEvl3RkGoxrU74q9tQr9HUacZOaZpvEsNK5c2HxSYjpKsqMFsZhssKB+IxJdft9LlsR", - "+vPsMR9g34Ju4QJrQLisy9pvFvfbpGqdgaQ8H2/WGbNRXqUap1icZRmI5d4CTmt8coKkZWMYFSupeSYV", - "0FBdk2UJ7Kdb46B3fhBIER5kKUsTOL29WAvLi0FOUMewhO8Y0iN3WPssXW9Ptp+itacldfaE8MQileH5", - "Xp+FEzzf0+36+I9xYcPP7hhX8do3kj76ucCcUTJZxxK9+vekrx7vT07Cf/VO+WyXVeBe+fBU/Kr1gs3j", - "u8+Sb9iQI/nC5iY0CS0T/QnKClhQWMSHsVW0lCM0Fg2aWdQcI5MBTvDSBhy4L/MTL83XNec9Oa/xhrNj", - "4C9N3lA/OZQQNnaxb89hMrjKs3GeonLDWh8nu9bim6/fsrTx88UZK/kz2pb8jShS535qsSdnoJssK5XS", - "yMmFtu1PVs9p277E3hN17Sq9fw15W0B/JTI9LhnIM1JE+3Mc7OSMGPOSlHELulskOjNrRWpSrJcvg+RZ", - "ugWdE8DNYeXaDA/HJVr4K4dZyoDa8HR4Kj1U1+17MlIk14AdQOOw30bWennfuiwVJvd1tn2/WageewLp", - "fxJCC3N756p2WI6oWl9WFC1QWaUUp0xIxJjruc74PtWUDep4/vcYXTgjTpfVMYbsommDP2oOYBnvR1iD", - "NBnu/yJfQablbKkKbReCPgNIcT5M+iztEju1DOUOTkfJgaJcKNoOA6MeJLtAuU/lPue7Co1nbvvJl8Wk", - "yylE7FYzq7nronDARF0Z+jF2qbCCJlWb6c50ZjfxS56TvtXzRV/ZRZ0LmJWHFU/lWZwIqTZNmwPJ9D9s", - "/zNgFstzhbjj89TK+WPqEeFCEQv/9G0RnrNOdOlWsg+u+QejXh5V8V5q1r3HnhnVNpfhjd5wvRhqbPVZ", - "ERnit9jPSkgT/ZsAS3wcL9JCbxcyNiRUvh9WwngOrLtmuviYCwm+UiPQcCvkm5vo9aGS2Cb7anYJa69F", - "2GI05n3dj914o8otrOlI8llT6bcCCPxPsXGdoiVl6s2+VB0M7RYtFeeLvkKB8Zqxkq+yBmXYiZa1BmTN", - "HMSlAENCN0rm4HtodjRLLbomGtCX2rlNQDOaFOZ8TdRMzTn6KuvFKXvApAiZ6C83Lt2rD/1Z+hqo4/Qj", - "iynNgn+G7ORRJnosJpNunezYAUbxJA9dZZcPlpvNgm3b8oFg7g0hXXAnHoKFwfqPQsoBawH9WNKtVIDs", - "kY7URE/TOabF4EHRcDwrBBonNzN7WYdfrgcBPSXzSgWDp2Bhc60hUvBtEOuNc98ppWQw00sH41N1Gcaz", - "1B6Znx65P6VyzZ16C7FfiaTbFhQc1hMp3bJEg+PFrMvJotzxSNV+Zqybtr9CuANnD775Vr19NW5OeLUQ", - "CiflLIPGSu51G3khoFWEzSDBhpxlUVKhf/EJspd7jhvM/khlYzG/Jde3Yfz8i4v8+1teSax0rjo50zJM", - "kcaVX9wpOjREx6y+gumiDI0t53kHLtAGoWsB9mVmmIYHSV9yi43I4FLoySBe6PeZ0Ta5q+fmeUaNNJju", - "5V4u1uwu+RbE2wHdD66CjSXe/ZUQmwIuWsbAHyo8oy0GK9v8iCgFrpog6wNMFQQpvs9xobN+4+nWjLX4", - "waKF1yMTu8r3FldrBgiXxMJaW1h3iEhMoChxY6Mo4MBdAFOtVQzxgWJSl7fKNwknaNHerMk02Kv9h2ar", - "X6MsQMla31TXomYDHOuY7vPiyysqQ4vFH4Uizj5wL2AqP8VkKzhMPPtVTpW1OObsM9UVav3oR5i+jh1r", - "+smzH/3oqtDZj9kLwC7fhZYwe5ASy/LqoavhIfjb5FyCmds+9OrHW56135eSN7FVppzOuEjP66LSPwfC", - "qIDrzJHPjijRzBmydMMnJL6zAUYS3hfHPAEHlmgqRC+Cvn+mJMyOqeSZbaGIxumx5israEBAa1Jkdcmo", - "YmfrpqKmn49nuX9WUk6NXRWKN5NcgjuSCzgiYYU5/yd+5KXoYS43WBwiwDvDg9kXJ8jCu+oFlLUoF+BB", - "8pRZNazLOt7IzzLrHgfaR1eAkSD0/H/NbRSGa/0UJ7YP0atdGOapMlfWlwTfx9B2wEB9ltlAL7jTIT99", - "5EeRRQHMNePZUdyCndAGVZjHYXJSIxvgnFQ+cQ/WdrM9N+3pZxMp0hkmB+lO0+LXWFPDl/UQ2TJpQmdB", - "4gtdO4ruBqEzukNCPoL6i/dLVjYGwUaq4h1IYS9wB4prWXPjooNMvXOvsq4qeGVvcsx2YxeXZ+DiGaQP", - "wLZ6bDKfb1JSoB7EqDFBZrtFOt0gtMMNMZiO6b4oJdh4rqgnzPYUUjt7/0lYKSTYaRczXaNJcFSxWQZm", - "2kIVG+sQJpyrEBGH3JsdVmaUS//ATLxfQ84u8OA+dC3uW1MgUTE4DuyXPG1a19+4AAJ/H9U0OIN9mMUw", - "fTjdvO3zSiaBy1nWEhtxibMGA/SWEK0OrlrFCWTPfitOR+ZcozfJH6U+H3twH4jOH2iBMRCWBxiBP2Lz", - "5adCI/nFubiZ29paOgZnsE/GV/CS59msV6PKEBX86y31OplOZUqrv8ZQuCd37cAAWu+TFOdLN5VMp/HE", - "csoHyKjXtmPbC1ZOoKFJ/mqZ9N3urO07YeA6s05w1/cC25n9kCHFmnfjNfboNfzpte6awQ/AULCYCc8R", - "aMt8jCz1unGl8X/fvu38w+3bM9J/pi6EbfbP6f/97zWOw9Gs83XfmWGrn+na7TUouQzbq+46ObXH8tr8", - "P4/dO3lt/p/V7ihTyX76G2jWymxnXtfZV+tNB02jA5v7txF2C1xb+zxKqVgG3W7u1Eqs49k8edTktsoM", - "eirRqVl+hBE6IYTpXN50Y2JbrD4d279ytV3E2HMrlspQeY8UCxXzrAyeVxymWzw2nyX9DNPHt31Mf0K3", - "wCA5tOQ2T1ayj/OasQDz8qFaoz3gX3M9H06uBHRJN40p+C9bdrc7g3gjlblTVOIhed9B6k4UmRQGukk8", - "YkfEZKlLdhG9L7OFTsANL4+W7kBolvN9umOx+V7Q7OaONQVuemkbjpJdzlk8svNMOR+VfI49f0v8snLn", - "SzEwDtq00kcAHJiB27HpX6G80bRYHnefwTpA6yLrfdd3grtR05pxyFLTeu3Gm1Sl+LPs2QYV9AWYkL8G", - "NeIg3bYWA88hIS9SZyk324AJ8SmmL25KNRVT9EXWT3LEaloM20wY5gOWs0mvJimFJRlOs/TD6lOh5CHm", - "Mc9Pej663etsZyZ5OLJRdAfjd1JHuEMOyy1IPP6j8YeMdsyVIZ/L9KM8508xbmP8N6B7C2Q/rmT7TCPA", - "A3B/FNWAU62WZtDmj5oVg1pbJIUPvt+qR9COSXwhC1yfLjr6Z40EYQdvEnqIyCZQDnJ/fOpIOWdCF54a", - "9gJ265mkZMAR9OFITcbgWHxR2hdP0EKQqM0stXLAdnj+Gj44aWNtAelirEmVzCoqKzXtNam+wPVNBneC", - "svuQoa+mmzxspBpl02M2+8Y/vzPiiTjoXsiahlbwxa2ge4s3+6wRN4Zmu0rQV1D84hz0scL+vZfnyhsW", - "TzRqCwsysqCMlQKWD1cRWIZJMuDNX1nQknW9GlP49hgdboBjvS31ZWcOKB27MZgAvZ8s3cGQKhXnH5WE", - "nyfKfr14ddYLVlxfTo7JO2yTQfLEmlpEzaJFOeKKdZcsLU7zWW8JjI6QLIckWr2QWZtWsmu1g2DNJdZi", - "1+32WmG8eNufWnwrjrvv+t7GVWuB3o3kqrVgd8iCG5N/XIhDtx1ftebtePUf5akuTje5xi+gk5DsA2uR", - "Dd2KgzUC4R6qTbNrkvmZReaGDD0ihR76rD8WVuYqnn3eLqAQdpK92GgRoVSRA+YcpFt0Tb/tL748d9Gi", - "a5pheAmtdkgc4seu7UF58NesaGkbvYxKl+9jYMdtJDLILRlahRUZ/RRfHwdBq2P7Gy2qknW6caQ1Ad4B", - "HphMEAzefV7xr168eosyRGTS9mDbPuF7yDZs/HreF4wT9ClEGRs+lvW1XpzHuWbnNUDYDn022zv4fU1P", - "Ngs25WNKEDBuFk6uxdoPjBEZ/It0Wz4lItyMSeb7rNj7uRIYq0ukC7bnVRHqmufVo9UXJ43PDcYF2JGR", - "SpQfwOWBfg8EOmSNrvrpR9VEYiLTLPtvasT5E1jV5zKkWIFLpiCe9GRayFnwI+GV1hd2NDRjg0jNA6wX", - "FKPwkBMLAg3FYPyezUjO5O/BVZGTdCzlTtPfDzL3zf+T7ihVUnJyA7pesKIDJPVB+hn9p+w33dXHkoBI", - "TMzotTGkkFQqh5SqRjQevzhm0y2FdjgzAfwlOzc7XAb2cwQfvyT+E2w673VQVFiw36Tx4LSDdTltqTpN", - "Pv2YOZH2UScQWfPwJkyal8vfwPqXEqKxy08zw0yBc76f9K+oNUnYCyqr8jiwFtcXMaEm6xx7mPSnr972", - "ZW+DUj/IkokWI/dXhGohwj1BacZn7q+4/j3ZA/x/XrjWbhPvwk3iuCFpxwYn4/UAm2qPOYl/faRM+zEl", - "7Btf8yuit7Jemptr8qZ2r7zcpH9evDQ3d+dEJpbbsVfI7F2y1D29x+sPIH+h2+bpGpqZDlyu8DLH6u+T", - "pXm9iSMdOrLOgwbGTFhF5rPgFLhzs6on1nxkGxIZdqwpeJ7FGlxnmqO4ylVwWUrFHtwR3yZ76bYGtCyI", - "4tfXhft8/EIbXv7qedStZSOXlAx8DcEjBr89HoMbA6d9ZdOwPbDBmmYhmMtzczI7dT17A1rkK/zUIWXu", - "zhsTRVa4QYwkFCk74yDhfwrteViW1JeRqkNKsdhZQ2prsee2uiFZjhYthEVWzkm6qeIp87TCg2R4NYeN", - "oRQkHrO34yO5gm9TregNMtG8zRvknKxWA4uY0jXHzTnGJM0d0JX3m1ps9SGELqht8GtsuKsoUsBcygmc", - "XQqCOIpDu3tBcn2UyPj/lOOoWJ6w2OlFcau9avsrpOBAkbMw98BLxFISqC6DTan7GYL3EaY2Zxbn0+Ro", - "5raftetnnfcKJmAuP5phh++m21xf4qGbCk1Xi/pC4lc5la5LRJoM1+uG+r76bbDAigkPRZI8E6iWYHc3", - "a5B+fA3OWFIwh6nbTPYLfSZkJ2QuVzjLhBcFY0ynqThKjht57lp5553X+DM/6K6dN4ndjokDkYK6tV3j", - "2N7fqWmndZqHtwPPI6wNdW6/Vt0oDrBW3lT3ct0jdvgWe66Wf+oryTN3NC6smaGEjKjk2T6GaJqsT+fz", - "brPO/YXsYTWmJtPJjNKe0eIHzL5sEfWrEiWSj6lNiporbUziPiWHV4qjd/5HFp10C/84JikU+EuBHTqu", - "v1LaVu/d7LHJ6B3ZANdXA5cD3FcpGy8birplPOGjcTV5FWApDEcn67dbKGcA/x0PL8LdrmgCDGyWb9kx", - "a3+qSMS79nrxjlfqmfRtddq9MCR+3OKPLiJi5A7VR0RA+bBpJfsYoMzC549APVLU6ewlUyKOidGrXGCU", - "ZQNnIazpTG+uVJWvqko3YAwUohn0/eNRqK/D2pRiqAnUCiuDfD81aMU6Ev5halaelcL8jWpj6mu0NLou", - "9+hciGI7LvXjzLMnF+IJg2WqA5kg/ZVG0VcLjdCP4XRCof4g/TT9nLlCJFMCXKeb6c6YMlqU4lzWBivX", - "zFrMiQo1nvDCUg6fwYU2BGtX6YtncL819TfMfE+zTxOA7ZfHGKHN1VkzieI3g+BlcV8mc6lV7r9nR/EF", - "RAy+6/pRHS8rHFbPjeLyjLZ58dSE6U8HMeWXLfTabcCfP2XH0Jw5o089QDQHPqUJ8jx9/bl0dRNrOytS", - "GxivJsCEshVVeib+xBk3q5TdY96EE7DPfLvq1DK4Tj10NgNM8R3DlYPe/TM6fKwt8zlcNt+b0zcbrdoh", - "KTuDN8l6sCZ2ZAEer2Xw/U6uLhTZtgPAJGJg3ywTg2f7sMYR48KH+QoU42cCgCQbMN3CCLPUsBH8h3to", - "h6UfT1/N5yqhxp1uQcAcquxZlkoOOIlZ/VuQqZSN1zjfo6ZvUE738iwFGDKPtpAESI/tYX/NyMtxqQSX", - "iMpCZFpHsPLsh/DB2879q6LEF/gJmnSiB5RpyE/kkp1TZ5QaoZd4CwKW4bxV5D+V/Zg39hEY3IdZCoSZ", - "Qeu5kUJiwxfRLBYvVN644NoGr9lN9tO6yapjzDBgL+NQC/1kH87a49qHaCRUW61vjcRFIkwi3w9fP4IW", - "eGauNebJFLysgHtIZTj1GDEiUcTxWYy2xo2NBf7YJFso4Rj1+nRnPrISr/tYaqQE9hZ/f+acm9IlEU+X", - "u2I4wStPPN7vgvSNs7mRTp3/Pq4GY/ylwtDmgz5mJZ1F0g9KKK/67bUZqIuccFKTdR06IMvIgSDQRwyS", - "6oWFuEODnP866dOv+pAmSK+3LaxJeaJiuOUi11Bmx5FtkhdGXJuZ2/5iG7z/i6jtiHpWBZgi3UZQF6Uy", - "gGEXPGGl2qKWE47ScwbBBVPaxJZX9MOnUFl7DJR5fhUDbUgBpodlD/SljhRZo3hzTa4h/TUXR5mQ5JFG", - "0aJvgZt1CEuAqvfhhBxMv+V8wlFwi5v+nNVFqVNq6hPIBioXMBaF+AZn3KwjFmtLgAV5TNkpja9ExA4x", - "601/nKh0+Ufb87LjBMPn0B0wHVpuisdBGjjSBHZlS4aUBjyjG5pdPWUQX3AYMEP7iFIQqroe8oZKVHof", - "wxRE9xKIK+KxWPTJvbjVhsinvokUrrI20LBZ+krAw5fm5mojD8Nj2vTohg1lOCxBmv3FqmPpv3gVNKuP", - "vaMd8ocUPMa9wJzaqF6HxfEUEWmBjzEIKetZ7ERoToncJk5f/vCvmBjKzglGMOAKoHfGIWTecfxgQAsC", - "fW8P1cpmDrRYspBZcYJobmgleyCjn4JERwEPlQ19aBA5sNgf9Lz1sdlXun3bn/ruLyurS07sf/fC+uvH", - "X1jf/YVdICBbvnsxbURKxvojuRsKz8GU1gidX9tBGJJ2TJwWnICS08h6q53FoTxfdobubUyPeDauzj1f", - "pNsgPvcgooypziKqTpVrmbtRdu8lR9ZL2pJtqkw06XdyiQKg+FzObJGjZLfylITrJLzg+suB+YjQiT9T", - "O+2I+6DPWhmqrgq4Ga5YWFlHWVSg0UjdKDFnWoZ4eqwqJ32Ac4THD1hzPOXiVGFIeIljAV6EF9+zjGCw", - "H34DtxW06dX0aE13pg160QLQ621KrokyqBhFb5DtQixUtHE8yil146+LqRxS+JlyIE8y/21EMenk+E91", - "XEWZ56osWA5+M+e8Qw3J13gMIT97ExDsVf8M62A7lH12ffwMFXHeL0T4u3I53ZbckoSBlY+rfStWDmXQ", - "6OpS8Dbcw+T4YlPKzE89VeJHnD6Nk5fxwensapnTAHRpHLWQSgch4fbJd4YpqbceisJI6+IlK300fdVS", - "ixwPENlOrXdEg3zxA4CLOFmt4xWLZWcyDZ43tyk2txUAbkz/YsbDHlUY3u32IvwdA37qazUGoBI4Dcdf", - "Rtk7FVt83xssNeu0Uym9P4D2P+/ZHpXl59Oxafx3UNYMSnPqakCK6RM/WBxgiVeS6UNDbzL/dzTJ/pkw", - "wttOdNYB2JpQRhzCBowbZrRItkTSvyraUcuekMwv9+n40CWkSWEDZZ6cmm5ZrgOYDkdwT2XADAqSgtTr", - "rbKgNxcpMiklXNRNdpvKtmiMhJ0gjpNEztkV4ru9qJKqb+JjE6QtjnCdvUpD4+t2e5U4lrcRuu3Isn3H", - "sn0/iBGJ/arlk3USWh17jdAvrV4XlQQrFAm1kyOo1OTbUI34O9Q7jrPKZ+gUq6CzAhJT3ItQr5mKe5F1", - "cWZuZq6pRmbfev3aa9asNX/t1vW3IDkb3e1DtFVfedlK/iP5fBpBk1EFyky3XajaGICO+NcHv7vtL7Kp", - "z3zQIz3SWu553qI1a4mPexEJWx/0gtjG75pWssczDnDC/EnRxXtRgE+IgfsW+jV3QVkalLRkOssOyefU", - "mkmaAevSZEy5lmraWU+m8USZCl2ZeENi1i4HlVdmXKotc8SeHllTog1Z06rXak/fwjgLA6okkBmdXTIS", - "njiArgkgs3QrQ14epp8nB0pnUfpbepymoJEQePqOIHzwgHUY6lvvdol/bf7t6WLx+PtuvOqE9l2p1XCN", - "4jqR54NeSolscFPvglFxBH7+MVbg5bOL4MR9LhCNZMBudBhgFIJFMNWwR7aDYxWSsx3XJxUx96y35oiN", - "U+B3C7Ed96I3XC+mJsEPzNGftRMeoRehZnfH0ofwPxD9VPP+dOcUx78bkvYqaa+V3Jdf5t3+eZGEzo4Z", - "a+GtaxcuXX4FTHrRZlw25XmfcmaTsC4IU07QjviEZjrO372sOfnzbJ5n0eqVj3WuzV6zSXwP2r1+zbhO", - "8lcjJBBXQCCYv8djRhiAf4be5ROzJqg6ZdowEurn8NjE9wOH0W3DHzm8JwtuP+NZBohgJ26bcWzEH5je", - "lm/RzWAFJTwwcbOcfAMiQnyzE+CGHfLTuEAfrH0ZU4I8Gu9Ni53yh9JNa5aVwkI/oJTExu8c6KYuZXo+", - "p42WN9+Dr5E4r5G2O5aktW5IB4ld/HU76OGPBOTznM4FJ4mtX7DfZAH4YOmXpK0XLf/NILjGlLCZT7Xg", - "Sg6DN5K73eh3DVtCsa37hCFxsIQMzBvqn4DR79rrZDboxqWph2+S+H17nbzbjSedeigPUyMDaFw5biir", - "huiLz3Uw4fhcPCAokqzdiz/xWQ9tlu9SVQgNxJYTPQ1p57EdAsEndMXTV8MYevzMi2MdSMJ9K0tflLo1", - "i9LdR6eD9Mu2+Et4GVoh3/2F6pHp46yv39Z3L+rvWmW26Ou+I7au8i749/NI2hT9DIurPl2eOPeY6wqW", - "JszO4Ki7d+Z6ajmD5/Bhx8bOueLzTNmius6sRn6xPCULKiMeCueEcOlwVDvExMF+nDx4O+LRmPXpNozc", - "Y1RlJK1U/Bm5N2mh+DPGQufHMl+Pl0+gRJ5F6lU+hNyELYZEBu0mpUQFqpMwjO5tBZ8saxuhZ4tVYnvx", - "6q/KFIm32COTxCmCIdDvUgJX+TEKRQuDhOWhxn+DOKJINDqWX6BmHso5LSGxnY1SatzEJyaL8+NslNCC", - "QQQ+YykFm1lGFfPhy13NKFdennvpzCb3FWa0ixKbYtayduIDZOBBcfple/wvYFYeYXd3vtNHyRPMueGh", - "ZClFm6WFbltTNck3rWUUdU4fNpaIHZLwWi9epVOkAhGT8lCQ9kKvcaUxC4KSvetDkVeA77zfFJ8ABLj0", - "d4co30ITXukDz10KKTWkj3hwSvqI5Q1Kn4hIuvzDLMFH+hSEhTyFbrdx/879/z8AAP//taHzkUKXAQA=", + "H4sIAAAAAAAC/+y9fXPcxpU3+lVQs3uryOyIpGTJiaXauiXLr7tWzIjy9b0VaYfgoEkixABjAEOJcalK", + "FGPLvnTE2JvdTSWxHW/u1t6q/DOiONaI4kuVPwHwFfJJnupzuhvdQDeAIWcoO89WpWJxBoPuPn369Hn9", + "nQ8b7aDTDXzix1Hj8oeNrh3aHRKTEP661gujIKT/ckjUDt1u7AZ+43Ij+TIZJMfp/eQoeZreT/rpw+Qw", + "3U6eWcl+upXeTzeTo/S+lQyTp9aiT+7GrTa8aNGCHw2SvXQ72Uu30k+TQfLMSjfTB/QtyWEyTD9OtxvN", + "hktH+aBHwo1Gs+HbHdK43MB3NJqNqL1KOjadVbzRpd9Ecej6K41795qN1/3YjTfedui38JKuHa9m73Cd", + "RrMRkg96bkicxuU47BH5fctB2LHjxuVGrwdPFt//jttxY/Hy3Aw9+FJ+oUOW7Z4XNy5fmms2OvZdt9Pr", + "NC5fmKN/uT7+dV6M4/oxWSEhDLQQ9MI2mdxC3ut6ge0sxHbci95wvZjotvmb9EHSTx+kW+lmum2lD5MB", + "bN9TK3ma9JPjdAe+e9RoNsjdrhc4pHF52fYiot/ACAZT6OPGpAOc9vchWW5cbvzdbMaOs/hYNCtPtXFP", + "LMYOQ3uD/h3FGx79gC6a/v1eRMJJEe4e/XHUDfyIwLznw2DJIx36z3bgx8QH5rC7Xc9t25SMs1184h9+", + "EVGafiiNVbZm/l4YMbcrX6WfJMPkcbKf9K2pG29cs165eOnH07Cr7Of07VedjutTUsCxDoMuCWMXJ90O", + "iR0Tp2XHypIdOybnYrdDiutuNhw36nr2RgvJ+GHD73mevUTpjiQs/MB1atCz2XCj1pIXtNeIIx3opSDw", + "iO3T7z07iltesOL6ZdOtnI0XtG2PaGQGPZa+vUKiVq9L3xhpjsGfQWz1k93keTKgUioZWMlR8jg5TI6y", + "D9Od5CAZWukmnJJd+P/+jJX82bLpVljJLv0qeZLsJf0rVrKX3k+3kifJMDmgX23Tj9PP0wdW+iA5Sp6n", + "nyX7yRH+NKObRJhOL4pb7VXbXyGtdkgc4seu7UV6KsoPd+0ouhOEBnqHZN0ldyg16KHTUeOPyVHybTKg", + "M6WifzPdTA5AStxPhsku/OszKzkS0mKP/nmY9K1kD4im0HHGoncJJdpzeHQLXgw3B1wvz5OhRf93IAZN", + "HqfbMES6RV87SDcpoRjBhulHVvI8fZTs8ZuF7tvTdEtLwjBAlig7iDfoM/eajV5EQs77RWmaCZSfo4QR", + "j+dODhu0SGfBosqRMOycefeb8uHOn57bggjB0i9IO6brEmJi3l4hRVEhBHQtSZ3JHI2YlhSBGgIkT1QY", + "Xn2JdjneUq9TXIYdxm4UV04fnrpBlumb2sE6CVvtwAtClQAFAZJfKP6yF3q15KTTC+GiaClvF6pAbUEa", + "u7FBwMWh3V6rv4k36eO6dW0Qu2znZO2lcB5wduwdMolyhG7yrVIJIxZh3HO6a8WL7oz28NRbdBakNZJu", + "odfp2OHGD+rUjOdQtNpBDzW24rE7Y26X51O1U++wXTmVqJZ3vrAdWumrnZZvexuRG70ehoFG0yT84wL9", + "Q+K1QDMvKBjzdrxqhcSzY3edWHFgxavEisAcssIgiK9Y3dCl07aWXY9Y7rJlr9su7FHTCuJVEt5xI2LZ", + "1pof3PHxoWDZilfdyAIqz9RRGHHAlp7P6v66trJcxae12D23aeKXGT9mi1KnKG1Hk+1Z5WaPRV9QuOeF", + "6QxsFszCLIpB+v0vSdhaJ2G9Kz3wiV6oLNuup1g60ncf9EjP9F3Y8336cu2XcdBtiYOWt+CTPmjnO9R6", + "p8r6A/gjOZJsSNCSB8ke1bOpQr1FTZFklz6RfpRupjvWX+//1kqOk6NkD7TtfvKU/n/TSh9SY4W+eggW", + "0IP0s+rTkdsltuxsjYx+glhNdQPk9ep30wkD17lBPGJHGg51gjs+Vbn5VVO0B12/FfW63SCMcTtyJP2C", + "2jXpZjJEeg2pXQLmYLoDbpHDdFu2DoEqlIqNpmbv/CAmUaujG+e/gbbsLUfJk+TImrpuh2t0AcJCGlAL", + "iQ47rbv+olX7wqWXtauM3F8SRaa4fvzyRe0c10kYuei8IHftThecLXMzL8+c143Jnm61wR30YdV9yV+e", + "+6FEGTZXsZqmuoX5DdPyRLd7zXOZc4b41EL4ecNGRmk0G3dc3wnugA3m+r279JV2O5AvvGx54lWv9lzP", + "OQF70fvIYEhOYMPKz15GfDErQW1lIaVEfcP1NOesLQheegOInbnXPCXtAs9B+Vzv6lH3UedWPNu9YPQ6", + "3VZEpn2IRicMbKvuTpZElun0l303TsnAF6clirBectc51XzLNP+6nsuOvUKMrGpk0wrDQ2dOMG6QJ17D", + "YBBmWVFJq7fAEdxd8Kh2Fj3Hja+2Y51xUHMaJ/W8Gafzuh9rrd12XLjl6Mtm0KOmm5rNF2Z73rvLjcs/", + "rzhaGTHu3c679anWBIoWVS1AURtQzY3e9M+TocW0sufp58khD7YdM7/nU1AR+vCT5xoV7F7zRC7/mvvT", + "tTeocAIyOI5Ll2N78xJpUQs0aIXZ3sR2uELik1pc7Nf4+ciWAmiZsDFNzgfqK+XpZUtW6Grkt7FYShnj", + "vjAzqRev3gzWiK8zkdptEkWtmH6tlXrkbtcNSdRyfW2Y8X5ylOxbybegyx4mQwtfeA5iIfsQLKDmxmYy", + "SPbTreQw2YMwscZQIsshiVazmeSG+m3yPN2xFvHegK29bDFFcNFKhrlvHBKtxUF3UetaoiMIfuNK5avE", + "Dkmo1Rx7LBZXts3XSWF7FNIqwypUZe/XbdyrQRBHcWh3r2WBghvkgx7R3Y4+uaOLEEm3ArnTqi+Slaeb", + "6tt1c70GIY159ohxlu1eGNJtqpxpyQOamZZPDTjjZm7D75AlsFC5LcFYRssB10BYvBeR0LiwfJi1Y999", + "h/gr8Wrj8ssXm38zwcvqKOM3PHKXvR/iin24CIfpA8xBeAIToRfg/owlhSbhS/4jZVLw+UEyTA7PMCYo", + "HQEYQMtfeJXoQ/Yx6XSD0A43FI7O0exrEJaUattUH6CEAPdNckRJlhxaSNPGCYWTFN3TLI++tThJ3UJf", + "X6dmlx23VzUO4/WRjJV5z96A1+FZuPs2/ujS3FyFR5uNUz69GySC5B3dbdeNTb46p4fJH8QQzwsJHUnr", + "WvqGqnrpJxDQPrCYt+5+up0MrKlbvbm5l4j10pyV7CWHySB5Ni10w01w3MHlSY8u/R1VEQ8siMoP8H2N", + "ytiFWJeyCGnGWnLdbXs9hzivuaHmHL/jxiS0PWsZrHOra8erUdG5HwehvYLe/abl+vSFrr9i2Z5Hr+A2", + "8R3bj6MZ65odESsifuTSn1+x/MBa8YIlq0N3y/VXZqzXO914w/LcKLbaHrHDyCJ0ftR8jGas1++6UUzf", + "7LlLlE+tkLSD0IksOyRWSGLb9YnTtJZ6MZ2XG1pR2/apfmDZzEcMT0ZrbrdLnBkLbyu6ICAinQ/zYsIv", + "ZxrNjI0lSX5+jiWAiQ+0kp1z8/kiNzcbb7jEcwxRl5yBLftPiFfjKsTHmvge3Z6/SXy3F131/SC2udGU", + "OyK9eJWFAIuzCO2VDlHMX0mtInf1X5gM7XUSusuuPn0ovy4+MBsGXyq9oinmbV71tSzNKx8m4OSoL78K", + "hNQo9xgibJW7G5RTp/ne2wjdtn47osBfqXg7ixUZvxaBk3K+EgmAbDbKiwsT0axcXWdTIbl5xxZiO44q", + "96soqNuBv+yu9EJTalo7CDwnuOO3en7sevXtaohdmJI96FoN30EyjzmiWthi6acgIE0XVtfuRaY1donv", + "GANPxaiU9Ms4iG1P/7ueXzKfvFs02wQxUzlWhMNkE83WKrGZvNWCxvI8xKYoVC5ssY7H3iK2F6+aYofZ", + "0eAGQ7CmsQ30B0U7nEtvyw16NZzYo0YtjBF9QeBtrJk5pHOuZEPyd5UsbhwOE5lOL8pj8lYQxa+5oT5d", + "w3FHzZDh6RLlk2NRfHi9blLvoOozcsLGsuuRVk0Gk5M7RsusqFJMPDV7gWVz18hdkJc9Dv5SyPiiGIxN", + "wpi44C31TFmFXKGtm6KRpd/jrWx4LexQ2QVHHzBdUm4Uuf5Kq+QR3Pb6u4RVFDh3kyjI0iOL492x1yu9", + "Ae/b6+Q6ieGqKVhUjFZNvhdiOE6J/LJVEmYrlnaMTUvLEMGK65u9Zplvs2pRkrMLtMt1V0pnKhQD9cHH", + "NEwOkkF63/ruL9dWw6BDrFnrfYysf/e8aX33l3n3LvGsn3z3HC5p2RrSRhVKPHsncrxI2dMyIXRkvE6+", + "30UT/1PN8D/VDKfzXLqtbkiWq0uv3Hl47GwLIMTsTlf+oDvZ7/pLgR1yk0aX7qxeLhPJJ8/XcyWPk+fA", + "EZiNByl7mPCVPEZmSD/hH/SBN+jHGBxONzEMjPl3EADeqRMvPV0OwGnSGORfjpiuXqDqauC2K5k+2/Nr", + "+Py9ZmOF+CEp2+oxLVyz5joLy15R6dQS/25K2gZbXvkJuCaolzsHK6S1Ega9roZX/xMjsOmnmEaK1/5j", + "loTIGfAzKiDp17uiQBhc0vghhmyOk2O4T77J7hcr/UiUAT+wKLsne3iZPE6O6DcW5EGkDyGnUoQ4cBZH", + "6Scgcj/B+wx+nG7PUJuAGd493yFh6/xLjWbj/Eut8680mo0Lc60L9L8vzbVeov+9ONe6SP97aa51if73", + "5blW10NDvPJACaq1eOVAjnT/ius5To74/QBOezjVyiLzVDtKnklUw0WFxHbe9b0NU1VC5jxzVC6vPPFy", + "FKXImCvEZ1l2nKwdlNnLhP0DMvBrUUxzBivVwmx2L1WFeCQClB6IeTtenbfjmIQaP7YbtYhPV2FUMFbI", + "Xa17v/yOxN815fcb5kbnpfcgdPHLEaJl0kordrroVMChSiY5jzf6aQ1r9hqTtTaK75Cb0dlvqqdvNJ4m", + "Se1mI2IZZ6eBDKi/YSJqqtFw/Jjc5VlYlceRP55PgwEDlRm9cpVTROywvQpXvecRnmi1ir66BquB0OZO", + "LIdBp6JEUpW377339mvrP6aq+wMr2YcbapAcpg+SPpYz7FPNOn1ItfM9SLWjgvkBe+aQCun0QTKkkq1K", + "YgZtSEcZzTQMlpc9VykTkb3u6C01LbcbRG5pyegIRUP8g2zvQFePYjuMmd+2RXxwua25XdhCssZd8XAR", + "RT1Yoeeuge7us384biQ+4v/WbSxllVZEIsjwPYlTMHukKdc8ybtiOgRLdnvtGrKwyZletL3tJeKV6Pwf", + "jmmJxgkvxHZMjCe3fjZqngD3bmvyRpnnByddeaNXMiae75H0kbykhFe0XN/Be1eXX9EldtzqsCC4iLss", + "L1Ou8FEueVpejFZ7y8ue4VCWHapKyqA7xjkVhsV64PUUo8TvdZY0sl86BPKGcOqrJBQyvJEtX6WhGFjm", + "BmVFt6u49W2/24tLWXYkRs2xZbUvUWVLcY3ONcfDoqWJRwWOLR/+e8m9GecJheR8U7eQcXCkiZs8pgTX", + "l270F1mJcyE0u2qHnIUKWcKD5BkoALvpVvqIWbvpZrqZbifPk31weYKZluyCofaplG2nZhvuJs8h4xDs", + "4vTjdEuGUZrtkNkum2ikUTXyG2Ow+sYN9aDbPZ0meVvaGcMRz+WlSEf1wpz2rGoSX89fqM6W4hx2YmNX", + "mQ7SgWou7gc9wp6AG1Hv3lGzUrK5lLHyOIr4i0x+0jp+8SZ6KDRpDNJZGU07E78sHdWEQHGioEtFKlQO", + "cKVGDd9pfaXUssxVxRWU9ZJ6tBq6Q90SrTyripnl3bIq+IqCKVTj4qd7+h48NrrARkFSFNcyEUcwhMXP", + "DOJL8jaYkyGqFV87Xm1B2mRkroMyhwky6jG7PdMWCrHWIxZlTTchXHfMSvXRkZh+kvRZNAEqZkRRGRTV", + "8Dxw85WvKCIlORyhyIyuvF6uk9gG7QSTZg3aib1yKsIVNl1khKgEzaah7hmbgVhYWSaJBPmnT7stRBj7", + "4JUWZXv76AjOIBiGVrIHdUnu+Z/4TQgvKgH1RbsXr864/rrtuY4c71rUy7/Ydj19eUC6k26C15mFfXFY", + "4JcnlJ2sKfqt+PyYFxQkfVO14aPp8hRHI3bhEFwzXDOitEk/YoNAtJNBTN4Hd3lfzqQuYzkpLVqbQRXa", + "naiqUrGw44X8Vll6M6NfuO0a9lLQiy8veba/1mjWzYg1plrfIHY7Js5Nnn93+vvxFKl8yj1gTuOT5zyO", + "XCuFBqfOtSpJTTX4j3mOaVU6FsyT1xAzwy3nDdPZbfxnBhU6lN5aSSXIIS9IQ/aFYc6OMYGsvUqYOWES", + "zOrxXgzWFnlRyuKy7XqL1hRKMgYRg77XnIXE5c8gyzYZ0jM/XandNZI/UTsq3QLjCrBqWTSQZayAmKES", + "7FtI12BSBd9PpWvm5S059FLmLh1/JbQdJZRQebaRinrqQ7mqMehQUc7KsG6sZMhLVcEuHSSPqSRlCSty", + "Ig6U0lKSt4NgzSX1HI83WGKJAE5hWTydwCGhjUXTueLTbL8W2rY/HwYrIYl0GYqOY8pK5yWejqsJpl7D", + "Ly3HDUk7DqCSxwgY1rR6keuvWMtBeMcOHSvy7GiVRDPGi6uWEClPcvTdaPWU7r7loOcbaLPc8zxDahQm", + "ExoSLIN1Yw3AKgNL0tMZ6qCi2F4hV6wu202rS8I28emHlhtlIGxW4HsbltOj66APtwlMSQ7F37G9NUzW", + "74aka4f83/xZyIL0bc/9Jf1Dx1bs2fp4WRKNILYx6qUZtNiQpTaaKYvUWyNOy9FWyL3GWNglkUVNlZg4", + "VhRYy3Yol8LlWHqmupgvK5EAblGWrXIoZzVllTKJm+ycZsvkvJQxnFRBYVadqTAwyjrO1AWtFeQYZn3w", + "fDxIPrTSXyX95FnyPN1uWlRDpaqrerdASiPUT0LmXfKUatTJoQDRGGgy6XQicAHillgPWppoPQbsxaY2", + "Ea02Amcx8ywMoXqzhXDsRQr/R6Zow23McznpfYlpKBA/7Sd79P5oWpClCKmMx5TGw3Qz/QhMS0rtA0xk", + "pBc+y20Bg+IQjKDncOF+Vkf6nUin01scH4O9s0855Ch5DE0HnjDUOlhIX7al95hx9AxPQN1s+rKceHOC", + "ee1scW3O2CR8vWUJ64zERZaq1owXSLhOwmtQy6Xxs/hx6I6S1S+9zYiJEhIQeK2yVDqta86gzvFJKk6z", + "wiBVizdgDzHrsbVuez29D5EsL5M2VXD03/rrtU6JSOsrfLNGNvTh7qBt14PkDYntBBCc0F66pB2S2PAd", + "XGyyiknXw8duCtv6drN20D23eXR1fPUiWYBNSZq5TOamfhJsi8SkqzZ8Xo9zUMJ3zQYMUWp2VYvDCv+Y", + "4GA2lnkZb/vLgebG67otLS7kuhYUcolD0CjHUXvT76KTbBfvFAmuA1Gx4EJ6wFMlB+mn6efsikHLbpDe", + "Tz+h97vI8uGJP0raveTZQo0ih25qyLHvuH6LYb208nB2BbcfzDk5YCnb6PxjP7oWOMRi1ts55uvDFHBW", + "fNEENyFb6BFdHkyQXk/pfcBJ4tmvcoUGQAKxnTg/N/fKK1rIURamyPas63Z75zq9yG1rQUQ5tGYLE/Qj", + "5cdU0YSzQlmofj68xDsmeFWIuMrVJ9bUAun8XySclpfZmJs5PzNX6Whj0Y8MU1BmYN0aS/Zay87mA/RP", + "wZJG2VUtREN6flFKu2gSipxmDLLwWnWthPSCFVPVvmQGaYv6FZmcGRRRr90mBC0CBtJ72wjJVrNeBFbG", + "Ry01VthrcWFmsi+IS0WX1lWFq6QLQ2vj1aykQwlsX/zJKBE5E6invAxdZfmopZAyVXKh7wqlkA9lniUG", + "+8yFh6u272PWnPCL2jGBlNDsEHZs19cyErnbRXWzHXRY+60uT9hu/MvP7XPLc+deuf3hxbnmyxfv/X2j", + "Cs19BNBSLSEwkYWvqYookRm9LFtOMbGWPVKGp/oLFCz1wquZLNLm+bGVmcu/T8tuhapbhFMsIQF7oowC", + "KP5aBVElfFENQUkq8lcJYDb2fOgMAP+Sn4yIR9rZgLfN4NYjFX8ZDpP0suJ24+bmtqWZZ5wCjfJkzVFI", + "z6pRpEXwOXGxsBqTyt/u4JM+gOIg5jXoy4lPpU44vpU64KwBlG6i5jRMnqrq0zOqSUCkMeknh0poUavm", + "5eqdx1a2a0dxqxedOpVDnp5azKxr/8QHzOhXwgfjyEriLHXybCTT3Y0AZWXlvF+DKs31blZ9i7n993kp", + "rlzNixj++mpea+qnbyyg1XCYPkiG4BYTmHr95AkrFjhKDoQlwl8HHAYWy2fTWhY7SeSWMKg14Uku2wUF", + "l21EHo3a9hja7Rn6uwBM4R44246ZV45XXCgVhEfWO2+/euPqjf+n9da7CzebVnIMfSgfJEdo2iHS3WcQ", + "30sfUCsrOUqe1PItmhK1/OWoRe5SS6DWsaePB908gpT6fQT3YK3XUaqPcKHLgTX9nU53kRp+4brttTqu", + "39MDAvwm2UUuBjqC5xYM4SHKTWoRH0qpRLnoLT0V0P8VG4FWJxZlgdW6yB3Zr1odEkUso6AGJPRKqxu6", + "QejGG5XeUXtlnj8qeZWqZ8hvvTuhG5NWTDpdjykjOSr//1lmFrLyAxQWEKBIPxLBDEzDcYJ2NMvE3EzH", + "+bsfT6vJXAOBcFqWzlXD+mDXhsSqyjFQmVw52DkCm/itWZDaBWI1pdaw6jbn5FHueoMDY74/DPkUmkuk", + "KJ5PJWpV6Xdy21IVR6cUP1pxoxUPIrvyUp3j/IJPmUTES9VltzLTmxlnLEqQwfIZUQcyuI+rFaFvMNKJ", + "iXuo7KAHkdU9Mu3op28snMvK7gF+9lOq+YR3AHs9fYg3dKbs9PXqjFQkrU4E+kGj1KLXPB99iKAnmpEF", + "ZO2QAeAyGbnDFa9D0Nsgcwcq4g/T7emmRdlT6lOVH2iTodv09bP/3/i0q9s1Z9oqSDOu0hIaYxYQY71U", + "r+Q1yHQreYzhhPQzRO5hnmZ01UOKGLB8so89OeBXixcvXLAgExcS5Gf47FosP3fxlp9uWouYajoTEjsK", + "/MUZCyyTLC2NxaIxmCFlorG7XXQTkxE1eFoutNfnJ4fFC3DbBtQEOS6igFuLQMlzfK6zkJK2OHPLV6G4", + "DBLUIJsWChlyEghn0PPjDFgTMyDzOAdSllgep+2k0JbjMV/M/bnGraKfjS5cov/V1rwqlK2bJIqNeOk9", + "xw0yWL/CTbWvwPMjwH9ypCjEkBP/jGVnHLGsUvU4idsCjfihCoFPz8/5ubm5aa2MkkhZrNRf06uIKJxG", + "MUjwF/i+AijbHlqwYNJCPsrTrOlB1v6HSePFGYjZ8VMdxYv04pTJiKIipydcsTALlwpvMCemzafBhDcE", + "WbAZl8h7W8IdOYwBf1kYE3p5EIfE7vysZ3vs0hCe3V4cNJqNIHRXXB/i8qvuyirMyHEBa8IL7mhfKV8t", + "0gtjeyVqLbshxCJYSQj9Q/sK3t9gXgKdq9OnoaJRa722CYYSAJt3PK/MOGMJYmPNLsv850We/jdQ0T6z", + "8toavVvZ2bamNCpgToODE9/MfoJX4QHrczDQ66OOG7VbflCngXONFuzLxI57Iad3HdEPm/UG/1mx21fy", + "Gy7mWPT/KQguqvVOZRBRFpNnvMcn3vKsOKfP3Zzpx9iLdBdx4NJtcENSBQhhHg+ZLERFIXkCkvMAHOQ8", + "+71wMWmwmSpj+rXBgbN6hnrEzCob9HcolRUGbD3ua4SCguSQYZDJldyMn8S1Y02dv2ClD6evyB4vNEmK", + "tsegjvelur1zPUYt6zSu1mvmsvgy/KvsYEhDN5V4mUROaa+kM2CUTW9Ip0SVUUvdTskCOWgAtSJJuLJR", + "61Ev6Dk+iaKW11uOavwiRzw6IzFe/m3GFb7tRDrNNBojXERhk0umMw4/Rb20ULObIqvxNN9MRaw86GRp", + "iwadxQfMX1EGrifWTyLDKk5qvYE5AOCIB1qgZCnkyc4wP+viLMM4fGaMMro94iiuxR3qLLlqIxYlQhVE", + "0bLtkFakIH+83KyAMFkNOqRF5ca6qy8TyzeqQi0BxPATCNHuctRDqGUCTEQOkGixHts74ITYwduSqscH", + "oP0+ZPfcI3AI6XUDmF8nCJyxT+0QMRsxZY/dtyeYYCdYcj3SAhQwBWsRAFKL+N+S4Uplj8e6E+uScO2w", + "vdpqB55nd3X1Psk3VIsAt8RDGf+YamT0Ct2HAoF9oMFxsp9uQe+p53AXAlUeA65J+itKxXSLXrpmAoBr", + "UE8BNlEOFqNF09yj+tEuhqOO6VbQS1ral2zOxhm0iR+zfEJOYfiE3ghkGVKD3ZVVQ+LxKukoNo1jh2uA", + "g0Z/0WxEG1FMOmbIs3U7dG21I3kQLsELPui5ejfJHbKk4YolkAJu5Nm+oSyxKBEgLeQ6qd2HsBBm32Ld", + "yUCZQo7f5+Wdm+DZYvFcsF+TA0Alrsq6K8E1Hxmx2rzskRowns3C3ai15AXtNVOZwt9sm8cv5N6OWDgM", + "Al44Zut3fMTKrp2yjo9NITAOsvKfgQVijIoN2Kvx9YTUYHew7HYseB4mhyMvIZ8FP8qSyiUC3SJoe8ga", + "UWrUePptXVhJ4e4zYalj2pIhJbOkSeLvOXzGA4FNQQ8fD5YjlNhyz/Oa4jO6Ka0PekFs576Ig6Dl2eEK", + "yT4SnQ+ln/tZfjhbdx2j7zSIa3Ev4hZtDWxBOs1WSYOe0dzD2c4U3p1NTNoiaanV2OZ5O4yzFGeY2+W8", + "KeKhermMpZ6IVLLDYin8UD2icktUjM5YiDa/ZEfk5Yto60OUUMocO8qA+dMt+to+xEOHM/rawBrhaNmy", + "qXxUnmBxyf80//qbTWv+p29yufk+WZpvsjVnbQMG1k+oiPnckr1Ke6wnKBWr6EZn3qE6rDmCdVO2k+Zk", + "8aDDu0NWgWqPKGWqQ7DFWO2FS5cqGlwV6k01yWysX3+6xcqXRVAeVIlP4XJmdgXPixsUuuTMaTEf7QuX", + "XtbHqpgZUAlwVnL8eZCIvkoMpjuiOZ9Gx/V589IKAVB16t9wPWJqL1qpC4KiB35rlDMSa1Wy+UiX3ZIb", + "txzSRYaodhAsuXHIAtrVD7P9kFmucvLtwCHtek+eBGGPX5Etj6yrZR2MRZqNDoltx45tLN9ZIWE3dP24", + "0aQTqtMcIBsjWG6d6irNeferCc5HGF/iN4Ofb6FjRws5+mUW32Q2tpTUmgxAxfnuL8ljUIbhdspnLB9Z", + "yT61SbaoPFHASwdYlS+1jkm3oAvXyLQUC+G9FU64kiyYk61iyEO15avQdbx5dKK1cA6tHYdAiYSAdrpI", + "BH9jizhubDLleOiyZUwy6AZR5C55pCXOgP5NH2B4tNXrAiSRtlsLoN9/C7gYGMMADeEIfDbpQ4EbCJv0", + "kBOcQ+R/m+yhtUcvqk+k0lQBALHLo3RQcWt7XnCnlZ+W3jBsE3edOK2lDWbO1oDhRGvylKg62GihVV/8", + "0rtvpGnWy+lAXirkdGC+UL108dPGvTi4HmWFT9QY4TB5CnA/rr+CcTBp00VeRt9Kt4C3oHHZg1pmzSlx", + "HkWBU4kC9nWmZRmCy5aaV2ZN5TKrj1mEj6rGw+Q5lUpNK4djluxaypZN11s+TL60A+oopmHxuMseg130", + "EIDQFGlhkujdZQmW6QPw1A6YvYsJXONAtK+RhSTZf6qKxluxyylKqugsakbKYS0IGXFFcPUo08NUsSCr", + "c/nIKoMJlbSbvNDXXZIaDcCk4RS1K+2FUJT+Gu4qnpa8nFGFqhnoV7b7JblTrraPA/pRMgJeVJNd6dY3", + "hzir7X765AjG/wiPjhgRrWUJizho5ehnHRctD4uOEhWFfTUk2MvKYV2VsJ58r+2LnQ8JpOmaAdMqfB8n", + "cUoV7cORHCiVmVTj8FeYo/QlnoyRXRin8lpke6fPiD2dc1xvIBQiuRzSDJQ2JZpwgI4nLB9DILycWcbg", + "p6kq1ZSiD9wUGIK6p9Pr+XpFwiZkoBLlYgMsMeZ418dWJXs/vyzEc92HmJSoFYHoRwbi9tyagu67j+mS", + "RVyJqnUfgTt3yPzCeSJMj25HlrCcwBJX25/kt25kn3kV8/2sF+juKs/tuPFIlkzHvoue/1F+1CVhC+It", + "VeNV3xdQuT7K2DDuiL/KEVeetTIDw8qKgxboZt6pG+UtBx3SFiBh4jx1u2GwTkT8Rd8sD8M1p8tLK/Hh", + "QmJgpM3L+Fq1klghEfQjKJaq7ELzPQjtCnOlhttbcx5Ym09BsWqa6wVzUTs1tISo2dSC9QrQuotqOg/1", + "9Qc6YwpS883oqyfM8GOeChLHrr+iVTN0Xp9azigE/xaeqIO8H2oA3RnBBwXsJLVoP2LvgEyHDP4V8Uu3", + "sCWxAdsDcfxOZ4S/aNGIUe5RRXpuz4svMQq53Hp1ZGwaOKGap0xB3T9AMHbId9/QgWIH6mMMnh5N0peC", + "jP6IO52lxBfZH7SbbucKIWfz5ffJ0bQuIGw4FDUZUqVENh+WX5dfbd+02pM5+St5u6wBj5nPTYsqEhkq", + "Hmpsvby8cR+esiTXfLdd/aEp7mzJWSgUU8reOAUanVVZtli/5SbXCejmup1uEMb4HP5b+rjFwPpUVZzV", + "raoZHXfceNUJbUDJIne7OYjDjFd4iPWk9ZpyulQNd6F4XEfI9+118qqpXh6ykTdqOdaXbc9bYk4NTbuJ", + "fWzBlBxi4hqG+LWlIbwevc/K6MFCecyOLnjP0QX/KeKRQ13jc7DA9pNhk96Mj5LHYIYhgnkx8pUMTRnE", + "Qb2OrSN1pZ0Q5rQ0h2a2U2wREv602BfT5htb+Y60/TVpd88wiTcIcZa0lXpBL24Has4wpZlH0APN2iuv", + "ud0WsUNAI67RPGaEVs/mppx8ZibCXidx6LYjLcrtABSxIbZakUrQniZ968cWoMIPNGjqSxvQ41Qfxhig", + "mogmevb2Y2yoyfBh6ScHEOYaCPteZAROrZ+3Zq2r7yxYsxY7cN/yZKf0U/rCaZayK4rkpKzZQ3YMD+u2", + "nQIi0eVwSmmc1bCpLdjfiuijVOXkrmWxyoJ2hJKCl0db5+fmZI/IrvkulAeIYuK34tUw6K2s1p8YIE0Y", + "2l2w81wH8Z69Rj+PIslkgkjjNDNuMnKwvDlFq+60WzMxOsOqWkZQD+Mu5OicvaZ5GpqbqPtTrdRdliRh", + "7VMkxGfOIaG5Q7RTeVc0/szpI22tu+J3iEU5hBNzCGLmuh2T0LU9a2GjsxRAwwMTWn6GQp2BI9cBoofJ", + "3C5dQWS8xUbplCBRRCORRA3Uqd+Vj9hkM+WjmFa7ENvhGd3YlPV1ae11dZ8chgEPMPE4FAuiVPYFY4hr", + "WmtAy9WhG5ObLFfh2irRqRYdN8II8wjbKb/2Ovu9eurOa5qZa9ErvoD0AQHAA1fq00zXpepAhnYD6Udo", + "x2YdeXjXFWrL//XT/7Jemfs/FIc+FK1A9EGC0z8wuHjCoOc7rTh0u4YLyu6QPGCTHF2q2R4Q3G28N6A0", + "pjxAU96a27V21+gOPjnsmfhl5QwEI5yiGW7WB1eTlPRc7W0Alg1s65P014CzdJxu6/wbrBHRMQCjMEwE", + "0fGpn+xfsRYZJvhilsmjcNCR1AT3EKErESHhW676NUbpYFCSgfQnhUulxO8sdUoPjOA7xNAag+c0Heea", + "/KZbBVxITfbSyGEsKWFEzEpsbJmPWWGmhd7KCon097HMzCdmXuwr0wvdeGOBijVmWhA7JOHVHu4N/vUG", + "l+7/9P5NADPC6k32bbYbq3HcbdyjL3ZZ35PiTqBzGTBP6e7eeH3hpnV1/m1r3u32rOu9yG3PWMnvEGiM", + "PkW1C2yT9QAQxbYlIC+lvUVgd91z7cAhK8SfvuVj70rRvwSBQYMu8eljdL44sekZC4K9UJ+cpWkCRwCM", + "qN11ZzoOgn2xyHkjmyudugTDzxtqUFGPQzUuN16amZt5ieHjAInpO2fXz89C6d+s3XMQw521e6D7DGkA", + "bzuNy4133Ci+Ck+wBrwkJmFkDJ1kj8y+43ZcbAVe8eA1zN65dxuCrt3Aj5ATLszNYUWJH7O0f7uLji83", + "8Gd/wSJYeCNWwuDQJUBKE7BHPiyNNXlA+o+TvpV8m26l95mg6ktuXtMwYt5ZzAh6r7DG+I3k37MXYgHP", + "MxYkobfmFGzEdFPueJduMlv5I2xmA6mXvHvnbfp2dRdXiO+iB1K7jW+S+E14YiG2Y9YqezKUlofR0Hqe", + "ciq1XGKL+KHbXu1Ah8rYpuqX244YMaAN5TSqVLmlQxhes8RrgR+HgYfjN1AKkSh+NXA2RlpcUZVVY7jQ", + "3AROQy/CSG4cblQrjsYuvuqDLLr4orYHa6wdaUO0e1BgP89dCu1wY9b2bW8jcqPZrMGqWaywZ1/nrSd/", + "yPJFXkt9OcO7yB/RO0aAGKm4UU0LARrS++kO+KgfsoKzPmvybr39WuY5Gou0+jIZMMi6XZjgAKNsLGiW", + "PqR3m2G2ACmBGhOrVleQsVHSSQKNMU4tnsKTRo+oVgDcoF/zjTitiMs3fIOoUx1njXhUf9LL4RMFhSXM", + "W4iKK00PxrHFXzCs1KymURoCCtg5kG9B4UflfEvBPGSIjjIriOJUGOFEO19DiLyDj/4tyBB5KScSIePg", + "i/9Paspbd9uV7r4n2ujVIIrPcfhm416/FUQx4DYXttlkcJl6U8DSKPvvFDtT4FdUaf8M4quNyw3RKBVi", + "nQ2ejC92NX/zT5JLGBEAPUzHIGp/jrHIitwr80WBfaXVR5YUwGCVpN+h4QQ4Mec0XoITsU5WsGVSfNm5", + "WuCFKJM+wLwsTHt6H6afpw8gww4oMhTo2+n2OLbqGwYLeZTu8IDYYxAPzwGU9wHDZkdKN61O4FBiBWFN", + "oiOy02xIXN8hd8uuYngAG28XSX7B3CkcUg8B4SDdRHBMrnkcY1UpvWHGw9RYwr+ZOTcVrCyp93g2o3Rb", + "w6RIFD25wnUSzrZF52QTiyodlifIoco4GgbF7y0ELALLjKVBn5TgGuONOyhVIizY66RAhZMZcHUJgIlr", + "Z2x/vfAdKGfSWbqyEh37Kv26nFsvjJlW0PXwe0QoLKw7J7VUZI1qTAyNNvWC6Bw4Ob7mY7wQnlYG/17t", + "VVQtenm3z4JmqdP/5KxZsxLY1P+Ydx6VfypokDVWHb3TqmlAqU/yqHNdDkm0qp8pwD9q4OZvT5zP+Fa9", + "oOtLKxYhDi5Pb6LHXO3XW+uw/w0LZcxCn5VQCk1HPVcLMUFOzY2kMwW+lNBmn43LUiu8NN8us6i9crop", + "ulkBnhJh2D7jASqqFoskb6zeGGLSX4bve4TYJIfYbYj/kbkQ0h1AwmGhT6FtyyiRsIBvkz1WyMhBEHKu", + "KrDvdwGJBkHRoChgptHMcQDDDy0ywfjPqa5C4owv5RfFgr8TSSFDAb98cp4snvdM7TL6iWRVa1JXUdZq", + "Tu/WRfCWo2S/kA4xDiL/10i+k+zWUqe5CJ6YRbnxmtTXVQP+pPh51Pj3FWvRX47YuwpNd60oDkJ7hZyz", + "V4gfz9zyCysYKI2xUDYw6JY9zPKQgIs0TeJEd7mci/KyApPCHC5HYPN/KmQZuqzQXbgLhcUDDLjnIoyA", + "r8F6A07ogpd6X9YSGOfHPLSWnfWbhT2OqOwen5OxusPgKD5CJixmP3Sde8j+kK6vSVrPgh0ZyKjiAE8/", + "spLHgL6UL8fftdJfcwjfwnyb0nWXlauxFueKE7V4X70Gs5XYTdn4iyV9qAbj2pSvip0EC92sRtykptkS", + "M6x07oWw+CTEdBVlRgteMVnhQFTKpLr9LpejCV2J9pjns29Bj3SBsCAc9WVNR4v7bVK1zkBSvhgf3hmz", + "UV6lGqdYnGV5l+U+Ek5rfHKCpGVjGBUrqWUoFdBQU5TlRuynW+Ogd34QSIweZIlaEzi9vVgLRoyhXVDH", + "sHDxGJJCd1jTMF1HU7afoqGpJfUzhaDMIpXh+Q6nhRM839Pt+viPcWHDz+4YV/HaN5I++rlA2lHyd8cS", + "s/uPpK8e709Own/1Tvlsl9UdX/7wVPyq9YLN47vPkm/YkCP5wuYmNAktE/0JiilYKFxExbFBtpQZNRYN", + "mlnUHBmUwWzwgg4cuC/zEwck0LUkPjmv8Ta7Y+AvTbZUPzmUcEV2sVvRYTK4wnOQnqBywxo+J7vW4puv", + "37S0WQOLM1byZ7Qt+RtRpM69YrEnZ6CHLisQ08jJhbbtT1bPadu+xN4Tde0qHY8N2WpAfyUePy4ZyPNw", + "RNN3HOzkjBjzQpxxC7qbJDoza0VqzayXL4PkaboF/SLAzWHlmisPxyVa+CuHWaKE2uZ1eCo9VNfjfDJS", + "JNd2HqDysMtI1nB637oklWP3dbZ9v1momXsMSY8SLg1ze+dqlVhmrFpVVxQtUE+mlORMSMSYq9jO+D7V", + "FEvqeP53GF04I06X1TGGZ5Nt3BBu2c3RMx/LeD/CyqvJcP8X+bo5LWdLtXe7EPQZQGL3YdJnyabYn2Yo", + "9606Sg4U5ULRdhgE9yDZBcp9Knd331VoPHPLT74spppOIU65mk/OXReFAyaq6dCPsUuFFbTm2kx3pjO7", + "iV/ynPStni+66S7qXMCsKK54Ks/iREgVedrMT6b/YdOjAbNYninEHZ+nVs6aU48IF4pY7qhvBvGM9d9L", + "t5J9cM3fH/XyqIr3UrPuPfbMqLa5DOr0huvFUFmsT5HIcM7FflYCuejfBAjq43iRFnC8kPshYRH+sNLk", + "cxDlNZPkx1w+8ZUagYZbId/SRa8PlcQ22VezS1hxLsIWozHv637sxhtVbmFNH5bPmkqXGeg78ATb9Sla", + "Uqbe7Es10dBk0lLRzegrFPCyGSv5KmvLhv13WUNE1sJCXAowJPTgZA6+B2ZHs9SYbKIBfamJ3QQ0o0kh", + "7dfECtWco6+yDqSyB0yKkImueuPSvfrQlaavAXhOP7KY0iz4Z8hOHmWiR2Iy6dbJjh0gM0/y0FX2NmEZ", + "6SzYti0fCObeENIFd+IBWBis6yqkHLDG148k3UqFBR/pSE30NL3AtBg8KBqOZ+VP4+RmZi/rUNv10Ken", + "ZF6pTPIULGyusEQKvg1ivfHCd0oplMz00sH4VF2GbC01heanR+7KqVxzp95C7NIi6bYFBYd1gkq3LNHW", + "eTHr7bIo93lStZ8Z64btrxDuwNmDb75Vb1+NmxNeLYTCSTnLoLGSu91GXghoFWEzNLIhU1sUkuhffILC", + "vZ7jBrM/UtlYzG/J9W0YP//iIv/+htdPK/26Ts60DEmlcfnnt4sODdEnrK8g2ShDY6N93ncMtEHo1YDd", + "qBmS40HSl9xiIzK4FHoyiBf6fWa0Te7qufEio0YaJPtyLxdr8Zd8C+LtgO4HV8HGEu/+SohNAZItI/8P", + "FZ7RlsCVbX5ElLJeTZD1PqYKghTf52jYWZf1dGvGWvxg0cLrkYld5XuLqzUDBIliYa0trLZE/ClQlLix", + "URRw4C6AqdYqAflAMaklPK+XL9a206ExfbMm02CH+h+arX6VsgAla31TXYsVDiC0Y7rPiy+vqIctFn8U", + "Slf7wL2AJP0Ek63gMPHsVzlV1uJIu09VV6j1ox9h+jr26eknT3/0oytCZz9mLwC7fBca4exBSizLq4de", + "jofgb5NzCWZu+ckf8VnmsD5K70vJm9ggVE5nXKTndVHpGgRhVECz5nhvR5Ro5gxZuuETEt/ZACMJ7/Nj", + "noADSzSV3xeh7j9TEmbHVOjNtlBE4/QI+5UVNCCgNSmyumRUsbN1U1HTz8ez3D8rKafGXhLFm0kuPB7J", + "BRyRsMKc/xM/8lL0MJcbLA4RoLzhweyLE2ThXfUcylqUC/AgecKsGtZbHm/kp5l1jwPtoyvASBB6/r/m", + "NgpD836CE9uH6NUuDPNEmSvrxoLvYxhDYKA+zWyg59zpkJ8+8qPIogDmmvHsKG7BTmiDKszjMDmpkQ3w", + "glQ+cQ/WdrM9M+3pZxMp0hkmB+lO0+LXWFPDl/Vw6DJpQmdB4nNdO4ruBKEzukNCPoL6i/dLVjYGwUaq", + "4h1IYS9wB4prWXPjooNMvXOvsF4yeGVvcqR6Y++ap+DiGaT3wbZ6ZDKfb1BSoB7EqDFBZrtJOt0gtMMN", + "MZiO6b4oJdh4rqjHzPYUUjt7/0lYKSTYXxgzXaNJcFSxRQhm2kIVG+uLJpyrEBGH3JsdVmaUS//ATLxf", + "Qc4u8OA+9GruW1MgUTE4DuyXPGla1944BwJ/H9U0OIN9mMUwfTDdvOXzSiaBRlrWCBzRmLO2CvSWEA0e", + "rljFCWTPfitOR+ZcozfJH6TuJntwH4h+J2iBMeiZ+xiBP2Lz5adCI/nFubiR29paOgZnsE/GV/CS59ms", + "Q6XKEBX86y31OplOZUqrv8qwxyd37cAAWu+TFOdLN5VMp/HEcsoHyKjXtmPbC1ZOoKFJ/mqZ9N3urO07", + "YeA6s05wx/cC25n9kGEgmHfjNfboVfzp1e6awQ/AsL8K2ApmHyNLvW5cbvzLrVvOP9y6NSP9Z+pc2Gb/", + "nP4//17jOBzNOl/3nRm2+pmu3V6Dksuwvequk1N7LK/O//PYvZNX5/9Z7Qkzleynv4YWtcx25nWdfbXe", + "dNA0OrC5fxvBxsC1tc+jlIpl0O3mTq3EOgx2o8RtlRn0VKJTs/wII3RCCNO5vOnGxLZYfTo2veVqu4ix", + "51YslaHyzjAWKuZZGTyvOEy3eGw+S/oZpo9u+Zj+hG6BQXJoyc2trGQf5zVjAdLnA7VGe8C/5no+nFwJ", + "3pNuGlPwL1p2tzuDKCuVuVNU4iF53+GgJhPEY4WBbhCP2BExWeqSXUTvy2yhE3DDy6OlOxCa5Xyf7lhs", + "vuc0u7ljTYGbXtqGo2SXcxaP7DxVzkcln2On4xK/rNzvUwyMgzat9CHAJWaQfmz6lylvNC2Wx91nsA7Q", + "sMl63/Wd4E7UtGYcstS0Xrv+JlUp/ix7tkEFfQ4m5K9AjThIt63FwHNIyIvUWcrNNmBCfIrpi5tSTcUU", + "fZH1kxyxmhZDdBOG+YDlbNKrSUphSYbTLP2w+lQoeYh5pPeTno9u9xrbmUkejmwU3cH4rdQH75CDkQsS", + "j/9o/D6jHXNlyOcy/SjP+VOM2xj/DejeAtmPK9k+0wjwANwbRTXgVKulGbT5o2bFoNYWSeGD77fqEbRj", + "Ep/LAteni47+WSNB2MGbhB4isgmUg9wfnzpSzpnQe6iGvYA9iiYpGXAEfThSkzE4Fl+U9sUTtBAkajNL", + "rRymHp6/ig9O2lhbQLoYa1Ils4rKSk1TUaovcH2TwZ2g7D5kmLPpJg8bqUbZ9JjNvvHP74x4Ig6657JW", + "qRV8cTPo3uQtTmvEjaHFsB5R7/wcdO/CrsWX5srbNE80agsLMrKgjJUClg9XEViGSTLgLW9Z0JL1+hpT", + "+PYYHW6A3r0tdaNnDigduzGYAL2fLN3BkCoV5x+VhJ8nyn69eHXWC1ZcX06OyTtsk0Hy2JpaRM2iRTni", + "snWHLC1O81lvCYyOkAB047nM2rSSXasdBGsusRa7brfXCuPFW/7U4ltx3H3X9zauWAv0biRXrAW7Qxbc", + "mPzjQhy67fiKNW/Hq/8oT3Vxusk1fgGdhGQfWIts6FYcrBEI91Btml2TzM8sMjdk6BEp9NBnXcGwMlfx", + "7PMmCYWwk+zFRosIpYocMOfQ5KJX/C1/8eLceYuuaYbhJbTaIXGIH7u2B+XBX7OipW30Miq9zY+BHbeR", + "yCC3ZGgVVmT0Cr4+DoJWx/Y3WlQl63TjSGsCvAM8MJkgGLz7RcW/evHqTcoQkUnbg237hO8h27Dx63lf", + "ME7QpxBlbPhI1td6cR7dm53XAGE79Nls7+D3NT3ZLNiUjylBwLhZOLkWa7owRjz0L9Jt+ZSIcDMmme+z", + "Yu9nSmCsLpHO2Z5XRairnlePVl+cND43GBdgR0YqUX4Alwf6PRDokLX36qcfVROJiUyz7L+hEeePYVWf", + "y5BiBS6ZgnjS42khZ8GPhFdaX9jR0IIOIjX3sV5QjMJDTiwINBSD8Xs2IzmTvwdXRE7SsZQ7TX8/yNw3", + "/2+6o1RJyckN6HrBig6Q1AfpZ/Sfst90Vx9LAiIxMaPXxpBCUqkcUqq6mcf4xTGbbim0w5kJ4C/Zudnh", + "MrCfI/j4JfGfYNN5h4eiwoJdNo0Hpx2sy2lL1Wny6cfMibSPOoHImoc3YdK8XP4G1r+UEI29jZoZZgqc", + "8/2kf1mtScIOWFmVx4G1uL6ICTVZv9zDpD995ZYvexuU+kGWTLQYub8kVAsR7glKMz5zf8X178oe4P/7", + "3NV2m3jnbhDHDUk7NjgZrwXYSnzMSfzrI2Xajylh3/iaXxK9lfXS3JwAV3/5YpP+ef7C3NztE5lYbsde", + "IbN3yFL39B6v34P8hR6jp2vjZjpwucLLHKu/T5bm9SaOdOjIOg8aGDNhFZnPglPgzs2qnljLlW1IZNix", + "puB5FmtwnWmO4ipXwWUpFXtwR3yb7KXbGtCyIIpfXxfu8/ELbXj5qy+ibi0buaRk4GsIHjH47fEY3Bg4", + "7Subhk2RDdY0C8FcmpuT2anr2RtLdntN5acOKXN3Xp8ossJ1YiShSNkZBwn/U2jPw7KkvoxUHVKKxc7a", + "cFuLPbfVDclytGghLLJyTtJNFU+ZpxUeJMMrOWwMpSDxmL0dH8kVfJtqRa+TieZtXicvyGo1sIgpXXPc", + "nGNM0twBXXm/qcVWH0LogtoGv8I2w4oiBcylnMDZpSCIozi0u+ck10eJjP9POY6K5QmLnV4Ut9qrtr9C", + "Cg4UOQtzD7xELCWB6jLYirufIXgfYWpzZnE+SY5mbvnM6sv6DRZMwFx+NMMO3023ub7EQzcVmq4W9YXE", + "r3IqXZOINBmu1w31ffXbYIEVEx6KJHkqUC3B7m7WIP342rqxpGAOU7eZ7Bf6TMhOyFyucJYJLwrGmE5T", + "cZQcN/LctfJ+Q6/xZ37QvUpvELsdEwciBXVru8axvb9V007rtExvB55HWPPt3H6tulEcYK28qe7lmkfs", + "8C32XC3/1FeSZ+5oXFgzQwkZUcmzfQTRNFmfzufd8hwdTfawGlOT6WRGac9o8QNmX7aI+lWJEsnH1CZF", + "zZU2JnGfksMrxdE7/yOLTrqFfxiTFAr8pcAOHddfKW0m+G722GT0jmyAa6uBywHuq5SNi4aibhlP+Ghc", + "rW0FWArD0cm6DBfKGcB/x8OLcLcrmgADm+VbdsyavioS8Y69XrzjlXomfVuddi8MiR+3+KOLiBi5Q/UR", + "EVA+bFrJPgYos/D5Q1CPFHU6e8mUiGNi9CoXGGXZwFkIazrTmytV5Suq0g0YA4VoBn3/eBTqa7A2pRhq", + "ArXCyiDfTw1asY6Ef5ialWelMH+j2pj6Gi2Nrss9Ouei2I5L/Tjz7MmFeMJgmepAJkh/pT32lUL792M4", + "nVCoP0g/TT9nrhDJlADX6Wa6M6aMFqU4l7XByrXwFnOiQo0nvLCUw6dwoQ3B2lX64hncb039DTPf0+zT", + "BGD75TFGaHN11kyi+M0geFncl8lcapX779lRfA4Rg++4flTHywqH1XOjuDyjbV48NWH600FM+WULvXYb", + "8OdP2TE0Z87oUw8QzYFPaYI8T1//Qrq6ibWdFakNjFcTYELZiio9E3/ijJtVyu4xb8IJ2Ge+XXVqGVyn", + "HjqbAab4tuHKQe/+GR0+1pb5BVw235vTNxut2iEpO4M3yHqwJnZkAR6vZfD9Vq4uFNm2A8AkYmDfLBOD", + "Z/uwxhHjwof5ChTjpwKAJBsw3cIIs9SwEfyHe2iHpR9PX8nnKqHGnW5BwByq7FmWSg44iVn9W5CplI3X", + "eLFHTd+gnO7lWQowZB5tIQmQHtvD/oqRl+NSCS4RlYXItI5g5dkP4YO3nXtXRIkv8BM06UQPKNOQH8sl", + "O6fOKDVCL/EWBCzDeavIfyr7MW/sQzC4D7MUCDOD1nMjhcSGL6JZLF6ovHHBtQ1esxvsp3WTVceYYcBe", + "xqEW+sk+nLVHtQ/RSKi2Wt8aiYtEmES+H75+BC3wzFxrzJMpeFkB95DKcOoxYkSiiOOzGG2N6xsL/LFJ", + "tlDCMer16c58ZCVe97HUSAnsLf7+zDk3pUsini53xXCCV554vN8F6RtncyOdOv99XA3G+EuFoc0HfcRK", + "OoukH5RQXvXbazNQFznhpCbrOnRAlpEDQaCPGCTVcwtxhwY5/3XSp1/1IU2QXm9bWJPyWMVwy0WuocyO", + "I9skz424NjO3/MU2eP8XUdsR9awKMEW6jaAuSmUAwy54zEq1RS0nHKVnDIILprSJLa/oh0+gsvYYKPPs", + "CgbakAJMD8se6EsdKbJG8eaaXEP6ay6OMiHJI42iRd8CN+sQlgBV78MJOZh+w/mEo+AWN/0Zq4tSp9TU", + "J5ANVC5gLArxDc64WUcs1pYAC/KYslMaX4mIHWLWm/44Uenyj7bnZccJhs+hO2A6tNwUj4M0cKQJ7MqW", + "DCkNeEY3NLt6wiC+4DBghvYRpSBUdT3gDZWo9D6GKYjuJRBXxGOx6JO7casNkU99EylcZW2gYbP0lYCH", + "L8zN1UYehse06dENG8pwWII0+4tVx9J/8SpoVh97WzvkDyl4jHuBObVRvQ6L4yki0gIfYxBS1rPYidCc", + "ErlNnL784d8wMZSdE4xgwBVA74xDyLzj+MGAFgT63h6qlc0caLFkIbPiBNHc0Er2QEY/AYmOAh4qG/rQ", + "IHJgsT/oeetjs690+5Y/9d1fVlaXnNj/7rn114+/sL77C7tAQLZ893zaiJSM9UdyNxSegymtETq/toMw", + "JO2YOC04ASWnkfVWO4tD+WLZGbq3MT3i6bg693yRboP43IOIMqY6i6g6Va5l7kbZvZccWS9pS7apMtGk", + "38klCoDicymzRY6S3cpTEq6T8JzrLwfmI0In/lTttCPugz5rZai6KuBmuGxhZR1lUYFGI3WjxJxpGeLp", + "kaqc9AHOER4/YM3xlItThSHhJY4FeBFefM8ygsF++DXcVtCmV9OjNd2ZNuhFC0Cvtym5JsqgYhS9QbYL", + "sVDRxvEop9SNvy6mckjhZ8qBPMn8txHFpJPjP9VxFWWeq7JgOfjNnBcdaki+xmMI+dmbgGCv+mdYB9uh", + "7LPr42eoiPN+IcLflcvptuSWJAysfFztW7FyKINGV5eCt+EeJscXm1JmfuqpEj/i9GmcvIwPTmdXy5wG", + "oEvjqIVUOggJt0++M0xJvfVQFEZa5y9Y6cPpK5Za5HiAyHZqvSMa5IsfAFzEyWodL1ssO5Np8Ly5TbG5", + "rQBwY/oXMx72qMLwbrcX4e8Y8FNfqzEAlcBpOP4yyt6p2OL73mCpWaedSun9AbT/Wc/2qCx/MR2bxn8H", + "Zc2gNKeuBqSYPvGDxQGWeCWZPjT0JvN/R5PsnwkjvO1EZx2ArQllxCFswLhhRotkSyT9K6IdtewJyfxy", + "n44PXUKaFDZQ5smp6ZblOoDpcAT3VAbMoCApSL3eKgt6c5Eik1LCRd1kt6lsi8ZI2AniOEnknF0hvtuL", + "Kqn6Jj42QdriCNfYqzQ0vma3V4ljeRuh244s23cs2/eDGJHYr1g+WSeh1bHXCP3S6nVRSbBCkVA7OYJK", + "Tb4N1Yi/Rb3jOKt8hk6xCjorIDHFvQj1mqm4F1nnZ+Zm5ppqZPat16++Zs1a81dvXnsLkrPR3T5EW/Xl", + "i1byx+TzaQRNRhUoM912oWpjADriX+//9pa/yKY+80GP9Ehrued5i9asJT7uRSRsfdALYhu/a1rJHs84", + "wAnzJ0UX70UBPiEG7lvo19wFZWlQ0pLpLDskv6DWTNIMWJcmY8q1VNPOejKNJ8pU6MrEGxKzdjmovDLj", + "Um2ZI/b0yJoSbciaVr1We/oWxlkYUCWBzOjskpHwxAF0TQCZpVsZ8vIw/Tw5UDqL0t/S4zQFjYTA03cE", + "4YP7rMNQ33q3S/yr829PF4vH33fjVSe070ithmsU14k8H/RSSmSDm3oXjIoj8POPsQIvn10EJ+5zgWgk", + "A3ajwwCjECyCqYY9sh0cq5Cc7bg+qYi5Z701R2ycAr9biO24F73hejE1CX5gjv6snfAIvQg1uzuWPoR/", + "RPRTzfvTnVMc/25I2qukvVZyX36Zd/vnRRI6O2ashbeunrtw6WUw6UWbcdmU533KmU3CuiBMOUE74hOa", + "6Th/d1Fz8ufZPM+i1Ssf64U2e80m8T1o9/o14zrJX42QQFwBgWD+Ho8ZYQD+KXqXT8yaoOqUacNIqJ/B", + "YxPfDxxGtw1/4PCeLLj9lGcZIIKduG3GsRG/Z3pbvkU3gxWU8MDEzXLyDYgI8c1OgOt2yE/jAn2w9mVM", + "CfJwvDctdsofSjetWVYKC/2AUhIbv3Ogm7qU6fmcNlrefA++RuK8RtruWJLWuiEdJHbx1+2ghz8SkM9z", + "OhecJLZ+zn6TBeCDpV+Qtl60/DeD4BpTwmY+1YIrOQzeSO52o981bAnFtu4ThsTBEjIwb6h/Aka/Y6+T", + "2aAbl6Yevkni9+118m43nnTqoTxMjQygceW4oawaoi8+18GE43PxgKBIsnbP/8RnPbRZvktVITQQW070", + "NKSdx3YIBJ/QFU9fDWPo8TPPj3UgCfetLH1R6tYsSncfng7SL9viL+FlaIV89xeqR6aPsr5+W989r79r", + "ldmir/uO2LrKu+A/XkTSpuhnWFz16fLEucdcV7A0YXYGR93dM9dTyxk8hw87NnbOFZ9nyhbVdWY18ovl", + "KVlQGfFAOCeES4ej2iEmDvbj5MHbEY/GrE+3YeQeoyojaaXiT8ndSQvFnzIWenEs8/V4+QRK5FmkXuVD", + "yE3YYkhk0G5SSlSgOgnD6N5W8MmythF6tlglthev/rJMkXiLPTJJnCIYAv0uJXCVH6NQtDBIWB5q/HeI", + "I4pEo2P5BWrmoZzTEhLb2Silxg18YrI4P85GCS0YROBTllKwmWVUMR++3NWMcuWluZfObHJfYUa7KLEp", + "Zi1rJz5ABh4Up1+2x/8KZuURdnfnO32UPMacGx5KllK0WVrotjVVk3zTWkZR5/RhY4nYIQmv9uJVOkUq", + "EDEpDwVpL/QalxuzICjZuz4UeQX4zntN8QlAgEt/d4jyLTThlT7w3KWQUkP6iAenpI9Y3qD0iYikyz/M", + "EnykT0FYyFPodhv3bt/7XwEAAP//fKFn+ZSZAQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/buildinfo/buildinfo.go b/services/api/internal/buildinfo/buildinfo.go index 01efa3b..5e38a1f 100644 --- a/services/api/internal/buildinfo/buildinfo.go +++ b/services/api/internal/buildinfo/buildinfo.go @@ -4,3 +4,6 @@ package buildinfo // Version — версия продукта (SemVer). В релизной сборке: // go build -ldflags "-X pipu/api/internal/buildinfo.Version=1.2.3" var Version = "0.0.0-dev" + +// Revision is the full source commit embedded by the deployment build. +var Revision = "" diff --git a/services/api/internal/management/handler.go b/services/api/internal/management/handler.go index 491aed7..6e1300e 100644 --- a/services/api/internal/management/handler.go +++ b/services/api/internal/management/handler.go @@ -112,7 +112,9 @@ func (h *Handler) call(ctx context.Context, admin bool, operation string, body, func (h *Handler) GetServerUpdates(ctx context.Context, req gen.GetServerUpdatesRequestObject) (gen.GetServerUpdatesResponseObject, error) { var out gen.ServerUpdates - err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId}, &out) + err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId, + "channel": req.Params.Channel, "version": req.Params.Version, "fresh": req.Params.Fresh, + "_running_version": buildinfo.Version, "_running_commit": buildinfo.Revision}, &out) out.CurrentVersion = buildinfo.Version return gen.GetServerUpdates200JSONResponse(out), err } @@ -125,7 +127,10 @@ func (h *Handler) SaveServerUpdateSources(ctx context.Context, req gen.SaveServe func (h *Handler) StartServerUpdate(ctx context.Context, req gen.StartServerUpdateRequestObject) (gen.StartServerUpdateResponseObject, error) { var out gen.ServerJob - err := h.call(ctx, false, "updates.start", req.Body, &out) + err := h.call(ctx, false, "updates.start", map[string]any{ + "source_id": req.Body.SourceId, "channel": req.Body.Channel, "version": req.Body.Version, + "expected_commit": req.Body.ExpectedCommit, "_running_version": buildinfo.Version, "_running_commit": buildinfo.Revision, + }, &out) return gen.StartServerUpdate202JSONResponse(out), err } diff --git a/services/api/internal/management/handler_test.go b/services/api/internal/management/handler_test.go index 8b70cec..9fb0c6d 100644 --- a/services/api/internal/management/handler_test.go +++ b/services/api/internal/management/handler_test.go @@ -13,17 +13,46 @@ import ( "github.com/stretchr/testify/require" "pipu/api/internal/auth" + "pipu/api/internal/buildinfo" "pipu/api/internal/management" "pipu/api/internal/server" ) -type bridge struct{ calls int } +type bridge struct { + calls int + body any +} -func (b *bridge) Call(_ context.Context, _, _ string, _, out any) error { +func (b *bridge) Call(_ context.Context, _, _ string, body, out any) error { b.calls++ + b.body = body return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out) } +func TestUpdatesUseRunningBuildIdentity(t *testing.T) { + b := &bridge{} + router := server.NewRouter(server.Deps{Management: management.NewHandler(b)}) + for _, method := range []string{"GET", "POST"} { + body := `{"source_id":"origin","channel":"main","_running_commit":"forged","_running_version":"forged"}` + request := httptest.NewRequest(method, "/api/v1/admin/server/updates?channel=main&fresh=true", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request = request.WithContext(auth.WithPrincipal(request.Context(), auth.Principal{Role: auth.RoleAdmin})) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, request) + require.Less(t, rec.Code, 300, rec.Body.String()) + payload, err := json.Marshal(b.body) + require.NoError(t, err) + var values map[string]any + require.NoError(t, json.Unmarshal(payload, &values)) + require.Equal(t, buildinfo.Version, values["_running_version"]) + require.Equal(t, buildinfo.Revision, values["_running_commit"]) + require.Equal(t, "main", values["channel"]) + if method == "GET" { + require.Equal(t, true, values["fresh"]) + } + } +} + func TestServerManagementAuthorization(t *testing.T) { for _, tc := range []struct { name string