feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s

This commit is contained in:
mrleo1nid committed 2026-10-22 23:01:36 +03:00
1 parent e33abc948c
commit a29a8b18ad
49 files changed
+3575 -324

No files matched your search

+2
View File
@@ -20,6 +20,8 @@ __pycache__/
.ruff_cache/ .ruff_cache/
# env / secrets # env / secrets
deploy/config.local
deploy/.management.lock
.env .env
*.pem *.pem
*.keystore *.keystore
+189
View File
@@ -22,6 +22,104 @@ tags:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/v1/admin/server/updates:
get:
operationId: getServerUpdates
tags: [admin]
parameters:
- in: query
name: source_id
schema: {type: string}
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerUpdates"
default:
$ref: "#/components/responses/Problem"
post:
operationId: startServerUpdate
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerUpdateRequest"
responses:
"202":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerJob"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/update-sources:
put:
operationId: saveServerUpdateSources
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerSources"
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerSources"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/config:
get:
operationId: getServerConfig
tags: [admin]
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfig"
default:
$ref: "#/components/responses/Problem"
patch:
operationId: saveServerConfig
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfigPatch"
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfig"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/config/apply:
post:
operationId: applyServerConfig
tags: [admin]
responses:
"202":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerJob"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/genius: /api/v1/admin/genius:
get: get:
operationId: getGeniusStats operationId: getGeniusStats
@@ -1832,6 +1930,85 @@ components:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
schemas: schemas:
ServerSource:
type: object
required: [id, name, url]
properties:
id: {type: string, maxLength: 64}
name: {type: string, maxLength: 100}
url: {type: string, maxLength: 2048}
ServerSources:
type: object
required: [sources]
properties:
sources:
type: array
maxItems: 20
items: {$ref: "#/components/schemas/ServerSource"}
ServerJob:
type: object
required: [id, kind, state, started_at, finished_at, target, log]
properties:
id: {type: string}
kind: {type: string, enum: [update, config]}
state: {type: string, enum: [running, succeeded, failed]}
started_at: {type: string}
finished_at: {type: string}
target: {type: string}
log: {type: string}
ServerUpdates:
type: object
required: [sources, versions, current_version, job, source_error]
properties:
sources:
type: array
items: {$ref: "#/components/schemas/ServerSource"}
versions:
type: array
items: {type: string}
current_version: {type: string}
source_error: {type: string}
job:
allOf: [{$ref: "#/components/schemas/ServerJob"}]
nullable: true
ServerUpdateRequest:
type: object
required: [source_id, channel]
properties:
source_id: {type: string}
channel: {type: string, enum: [latest, version, main]}
version: {type: string}
ServerConfigEntry:
type: object
required: [key, group, type, secret, readonly, effective, env, local, default_value, source]
properties:
key: {type: string}
group: {type: string}
type: {type: string}
secret: {type: boolean}
readonly: {type: boolean}
effective: {type: string}
env: {type: string, nullable: true}
local: {type: string, nullable: true}
default_value: {type: string}
source: {type: string, enum: [env, local, default]}
ServerConfig:
type: object
required: [entries, revision, restart_required]
properties:
revision: {type: string}
restart_required: {type: boolean}
entries:
type: array
items: {$ref: "#/components/schemas/ServerConfigEntry"}
ServerConfigPatch:
type: object
required: [revision, values]
properties:
revision: {type: string}
values:
type: object
additionalProperties: {type: string, nullable: true}
PlaylistShare: PlaylistShare:
type: object type: object
required: [share_id] required: [share_id]
@@ -2495,6 +2672,9 @@ components:
nullable: true nullable: true
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2550,6 +2730,9 @@ components:
nullable: true nullable: true
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2590,6 +2773,9 @@ components:
maxLength: 64 maxLength: 64
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Сразу выдать аудит загрузок. Может задать только админ description: Сразу выдать аудит загрузок. Может задать только админ
@@ -2616,6 +2802,9 @@ components:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
is_blocked: is_blocked:
type: boolean type: boolean
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин').fill(username);
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
await page.getByRole('button', { name: 'Добавить источник' }).click();
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
const dialog = page.getByRole('dialog');
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.getByLabel('Журнал выполнения')).toContainText('build and restart');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeDisabled();
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
});
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
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 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 page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).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; });
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
});
test('update delegate can update but cannot open configuration', async ({ page }) => {
await login(page, 'update-manager', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
await expect(page).toHaveURL('/');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
});
test('ordinary user is redirected away from server management', async ({ page }) => {
await login(page, 'alice', '/admin/updates');
await expect(page).toHaveURL('/');
});
+12
View File
@@ -9,6 +9,8 @@ import {
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { lazy } from 'react'; import { lazy } from 'react';
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage }))); const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage }))); const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage }))); const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
@@ -274,6 +276,14 @@ const adminSettingsRoute = createRoute({
component: AdminSettingsPage, component: AdminSettingsPage,
}); });
const serverUpdatesRoute = createRoute({
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
beforeLoad: ({ context }) => {
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
},
});
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
downloadAppRoute, downloadAppRoute,
androidAppRoute, androidAppRoute,
@@ -304,6 +314,8 @@ const routeTree = rootRoute.addChildren([
myUploadsRoute, myUploadsRoute,
uploadQueueRoute, uploadQueueRoute,
adminSettingsRoute, adminSettingsRoute,
serverUpdatesRoute,
serverConfigRoute,
]), ]),
]); ]);
@@ -0,0 +1,84 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
export function ConfigPage() {
const { t } = useTranslation('admin');
const session = useSession();
const errorText = useErrorText();
const qc = useQueryClient();
const [draft, setDraft] = useState<Record<string, string | null>>({});
const [baseRevision, setBaseRevision] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [group, setGroup] = useState('all');
const [confirm, setConfirm] = useState(false);
const [saved, setSaved] = useState(false);
const dirty = Object.keys(draft).length > 0;
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
const running = status.data?.job?.state === 'running';
useEffect(() => {
if (!dirty) return;
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener('beforeunload', prevent);
return () => window.removeEventListener('beforeunload', prevent);
}, [dirty]);
useEffect(() => {
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
}, [status.data?.job?.id, status.data?.job?.state, qc]);
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
});
const apply = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
void qc.invalidateQueries({ queryKey: ['server-updates'] });
},
});
const change = (key: string, value: string | null) => {
setBaseRevision((v) => v ?? query.data!.revision);
setDraft((v) => ({ ...v, [key]: value })); setSaved(false); save.reset();
};
if (query.isPending) return <PageSkeleton />;
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
<p className={s.note}>{t('management.config_files_hint')}</p>
<div className={s.configList}>{entries.map((entry) => {
const changed = Object.hasOwn(draft, entry.key);
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
<div className={s.values}><span>{t('management.effective')}: <code>{entry.effective || '—'}</code></span><span>ENV: <code>{entry.env ?? '—'}</code></span></div>
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} value={value} disabled={running || save.isPending} placeholder={entry.secret && entry.local ? t('management.secret_saved') : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
</div>}
</section>;
})}</div>
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
<section className={s.saveBar}>
<div role="status">{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')}</div>
<div className={s.actions}><Button disabled={!dirty || save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={!dirty || running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button><Button disabled={dirty || running || !query.data.restart_required} busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button></div>
</section>
{save.error && <FormError>{errorText(save.error)}</FormError>}
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
<JobPanel job={status.data?.job} reconnecting={status.isError} />
</>}
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
</div>;
}
@@ -0,0 +1,20 @@
import type { components } from '@pipu/api-client';
import { useTranslation } from 'react-i18next';
import s from './management.module.css';
export type Job = components['schemas']['ServerJob'];
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
const { t } = useTranslation('admin');
if (!job) return null;
return <section className={s.card} aria-label={t('management.job')}>
<div className={s.cardHead}>
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
</div>
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
<pre className={s.log} tabIndex={0} aria-label={t('management.log')}>{job.log || t('management.waiting')}</pre>
</section>;
}
@@ -0,0 +1,92 @@
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 { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
type Source = components['schemas']['ServerSource'];
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
export function UpdatesPage() {
const { t } = useTranslation('admin');
const session = useSession();
const qc = useQueryClient();
const errorText = useErrorText();
const [sourceId, setSourceId] = useState('');
const [channel, setChannel] = useState<Channel>('latest');
const [version, setVersion] = useState('');
const [confirm, setConfirm] = useState(false);
const [editing, setEditing] = useState<Source[] | null>(null);
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,
retry: false,
});
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
const running = query.data?.job?.state === '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 } })),
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
});
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 },
})),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', sourceId], (old) => old ? { ...old, job } : old);
void refresh();
},
});
if (query.isPending) return <PageSkeleton />;
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
<Button onClick={() => void query.refetch()} busy={query.isFetching}>{t('management.refresh')}</Button>
</header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
<section className={s.card}>
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
<label className={s.selectLabel}>{t('management.source')}
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</label>
<p className={s.url}>{source?.url}</p>
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button variant="primary" disabled={!source || running || !!query.data.source_error || (channel === 'version' && !version)} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button></div>
</section>
<JobPanel job={query.data.job} reconnecting={query.isError} />
</>}
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
<p>{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}</p>
<p className={s.note}>{t('management.update_warning')}</p>
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
</Dialog>}
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
<p className={s.note}>{t('management.sources_hint')}</p>
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
</div>)}</div>
{save.error && <FormError>{errorText(save.error)}</FormError>}
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
</Dialog>}
</div>;
}
@@ -0,0 +1,34 @@
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
.hero strong.priority { font-size: 19px; }
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
.card h2 { margin: 0; font-size: 18px; }
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
.status[data-state='succeeded'] { color: var(--ok); }
.status[data-state='failed'] { color: var(--danger); }
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
.configList { display: flex; flex-direction: column; gap: 12px; }
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
.configRow[data-changed] { border-color: var(--accent); }
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
.values code { overflow-wrap: anywhere; }
.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; } }
+6
View File
@@ -140,6 +140,11 @@ export function UsersPage() {
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'), label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }), onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
}); });
if (me.role === 'admin' && u.role !== 'admin')
items.push({
label: t(u.manages_updates ? 'users.revoke_updates' : 'users.grant_updates'),
onSelect: () => patch.mutate({ id: u.id, body: { manages_updates: !u.manages_updates } }),
});
if (can('delete')) if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) }); items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items; return items;
@@ -214,6 +219,7 @@ export function UsersPage() {
</td> </td>
<td data-label={t('users.col_role')}> <td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span> <span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.manages_updates && <span className={cx(s.badge, s.role_moderator)}>{t('users.manage_updates')}</span>}
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>} {u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td> </td>
<td data-label={t('users.col_status')}> <td data-label={t('users.col_status')}>
+1
View File
@@ -6,6 +6,7 @@ export function installE2EFetch() {
const api = fakeApi([ const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }), makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'), makeUser('alice', 'user'),
makeUser('update-manager', 'user', { manages_updates: true }),
makeUser('mobile-admin', 'admin', { reviews_uploads: true }), makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }), makeUser('moderator', 'moderator', { reviews_uploads: true }),
]); ]);
+38
View File
@@ -79,12 +79,27 @@ export function fakeApi(initial: FakeUser[]) {
const playlistOwners = new Map<string, string>(); const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>(); const playlists = new Map<string, components['schemas']['Playlist']>();
let managementSources = [{ id: 'origin', name: 'Текущий репозиторий', url: 'https://git.example.org/pipu/music.git' }];
let managementJob: components['schemas']['ServerJob'] | null = null;
let configRevision = 1;
let configPending = false;
const configValues: Record<string, string> = {};
const configEntries: components['schemas']['ServerConfigEntry'][] = [
{ key: 'LOG_LEVEL', group: 'shared', type: 'string', secret: false, readonly: false, effective: 'info', env: 'info', local: null, default_value: 'info', source: 'env' },
{ key: 'TRAIN_HOUR', group: 'recommender', type: 'integer', secret: false, readonly: false, effective: '3', env: null, local: null, default_value: '3', source: 'default' },
{ key: 'GITEA_TOKEN', group: 'api', type: 'string', secret: true, readonly: false, effective: '••••••••', env: null, local: '••••••••', default_value: '', source: 'local' },
];
const serverConfig = () => ({ revision: String(configRevision), restart_required: configPending, entries: configEntries.map((entry) => {
const local = configValues[entry.key] ?? entry.local;
return { ...entry, local, effective: entry.env ?? local ?? entry.default_value, source: entry.env !== null ? 'env' : local !== null ? 'local' : 'default' };
}) });
const me = (u: FakeUser): Me => ({ const me = (u: FakeUser): Me => ({
id: u.id, id: u.id,
username: u.username, username: u.username,
display_name: u.display_name, display_name: u.display_name,
role: u.role, role: u.role,
reviews_uploads: u.reviews_uploads, reviews_uploads: u.reviews_uploads,
manages_updates: u.role === 'admin' || !!u.manages_updates,
locale: u.locale, locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {}, ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password, must_change_password: u.must_change_password,
@@ -107,6 +122,25 @@ export function fakeApi(initial: FakeUser[]) {
calls.push({ method: req.method, path, body }); calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`; const route = `${req.method} ${path}`;
const user = current ? users.get(current) : undefined; const user = current ? users.get(current) : undefined;
if (path.startsWith('/api/v1/admin/server/')) {
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 === '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') {
if (body?.revision !== String(configRevision)) return problem(409, 'management.config_conflict');
for (const [key, value] of Object.entries(body?.values as Record<string, string | null>)) {
if (value === null) delete configValues[key]; else configValues[key] = value;
}
configRevision++; configPending = true; return json(serverConfig());
}
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' };
return json(managementJob, 202);
}
}
switch (route) { switch (route) {
case 'GET /api/v1/server-info': case 'GET /api/v1/server-info':
@@ -323,6 +357,10 @@ export function fakeApi(initial: FakeUser[]) {
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] }); return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
target.username = username; target.username = username;
} }
if (typeof body?.manages_updates === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.manages_updates = body.manages_updates;
}
if (typeof body?.reviews_uploads === 'boolean') { if (typeof body?.reviews_uploads === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden'); if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.reviews_uploads = body.reviews_uploads; target.reviews_uploads = body.reviews_uploads;
+5
View File
@@ -48,6 +48,8 @@ type NavPath =
| '/admin/uploads' | '/admin/uploads'
| '/uploads/new' | '/uploads/new'
| '/uploads/mine' | '/uploads/mine'
| '/admin/updates'
| '/admin/config'
| '/admin/settings' | '/admin/settings'
| '/collection/playlists' | '/collection/playlists'
| '/collection/downloads' | '/collection/downloads'
@@ -197,6 +199,7 @@ function Shell() {
badge={pending} badge={pending}
/> />
)} )}
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{staff && ( {staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded> <NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} /> <NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
@@ -215,8 +218,10 @@ function Shell() {
badge={pending} badge={pending}
/> />
)} )}
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{user.role === 'admin' && ( {user.role === 'admin' && (
<> <>
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} /> <NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} /> <NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</> </>
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# Share a lock with manual deployments and config application.
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
exec 9<.management.lock
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
touch config.local
compose() { docker compose --env-file config.local --env-file .env "$@"; }
# Validate before stopping any container.
compose config --quiet
compose up -d --force-recreate --remove-orphans
echo '→ ждём готовности api'
for _ in $(seq 1 90); do
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
echo 'Конфигурация применена; API готов'
exit 0
fi
sleep 2
done
echo 'API не стал готов: проверьте docker compose logs api' >&2
exit 1
+12 -1
View File
@@ -55,7 +55,7 @@ services:
dockerfile: services/api/Dockerfile dockerfile: services/api/Dockerfile
target: api target: api
args: *version args: *version
env_file: .env env_file: [config.local, .env]
environment: &db-env environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
volumes: volumes:
@@ -63,6 +63,7 @@ services:
- *library-host-ro - *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache - ${PIPU_DATA:-/srv/pipu}/cache:/cache
- ${PIPU_DATA:-/srv/pipu}/updates:/updates - ${PIPU_DATA:-/srv/pipu}/updates:/updates
- ${PIPU_DATA:-/srv/pipu}/management:/management
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads - ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
@@ -112,6 +113,11 @@ services:
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)} INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
TRAIN_HOUR: ${TRAIN_HOUR:-3}
LIBRARY_ROOT: /library
LIBRARY_HOST_ROOT: /library-host
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
@@ -124,6 +130,11 @@ services:
<<: *db-env <<: *db-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
TRAIN_HOUR: ${TRAIN_HOUR:-3}
LIBRARY_ROOT: /library
LIBRARY_HOST_ROOT: /library-host
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
volumes: volumes:
- *library-ro - *library-ro
- *library-host-ro - *library-host-ro
+418
View File
@@ -0,0 +1,418 @@
[
{
"key": "MANAGEMENT_DIR",
"group": "api",
"type": "string",
"default_value": "/management",
"secret": false,
"readonly": true
},
{
"key": "HTTP_ADDR",
"group": "api",
"type": "string",
"default_value": ":8080",
"secret": false,
"readonly": false
},
{
"key": "DATABASE_URL",
"group": "api",
"type": "string",
"default_value": "Compose: POSTGRES_PASSWORD → postgres:5432/pipu",
"secret": true,
"readonly": true
},
{
"key": "REDIS_URL",
"group": "shared",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "LOG_LEVEL",
"group": "shared",
"type": "string",
"default_value": "info",
"secret": false,
"readonly": false
},
{
"key": "MIGRATE_ON_START",
"group": "api",
"type": "boolean",
"default_value": "true",
"secret": false,
"readonly": false
},
{
"key": "SHUTDOWN_TIMEOUT",
"group": "api",
"type": "duration",
"default_value": "15s",
"secret": false,
"readonly": false
},
{
"key": "SUPPORTED_LOCALES",
"group": "api",
"type": "string",
"default_value": "ru,en",
"secret": false,
"readonly": false
},
{
"key": "MIN_ANDROID_VERSION_CODE",
"group": "api",
"type": "integer",
"default_value": "0",
"secret": false,
"readonly": false
},
{
"key": "JWT_PRIVATE_KEY",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "JWT_PREVIOUS_KEYS",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "ACCESS_TOKEN_TTL",
"group": "api",
"type": "duration",
"default_value": "15m",
"secret": true,
"readonly": false
},
{
"key": "COOKIE_SECURE",
"group": "api",
"type": "boolean",
"default_value": "true",
"secret": false,
"readonly": false
},
{
"key": "BOOTSTRAP_ADMIN_USERNAME",
"group": "api",
"type": "string",
"default_value": "admin",
"secret": false,
"readonly": false
},
{
"key": "BOOTSTRAP_ADMIN_PASSWORD",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "LIBRARY_ROOT",
"group": "api",
"type": "string",
"default_value": "/library",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_HOST_ROOT",
"group": "api",
"type": "string",
"default_value": "/library-host",
"secret": false,
"readonly": true
},
{
"key": "STORAGE_AGENT_URL",
"group": "api",
"type": "string",
"default_value": "http://storage-agent:8090",
"secret": false,
"readonly": true
},
{
"key": "INTERNAL_TOKEN",
"group": "shared",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "FFPROBE_PATH",
"group": "api",
"type": "string",
"default_value": "ffprobe",
"secret": false,
"readonly": false
},
{
"key": "FPCALC_PATH",
"group": "api",
"type": "string",
"default_value": "fpcalc",
"secret": false,
"readonly": false
},
{
"key": "STREAM_SIGNING_KEY",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "CACHE_DIR",
"group": "api",
"type": "string",
"default_value": "/cache",
"secret": false,
"readonly": true
},
{
"key": "FFMPEG_PATH",
"group": "shared",
"type": "string",
"default_value": "ffmpeg",
"secret": false,
"readonly": false
},
{
"key": "X_ACCEL",
"group": "api",
"type": "boolean",
"default_value": "true",
"secret": false,
"readonly": false
},
{
"key": "TRANSCODE_CONCURRENCY",
"group": "api",
"type": "integer",
"default_value": "0",
"secret": false,
"readonly": false
},
{
"key": "TRANSCODE_CACHE_LIMIT_GB",
"group": "api",
"type": "integer",
"default_value": "50",
"secret": false,
"readonly": false
},
{
"key": "RECOMMENDER_URL",
"group": "api",
"type": "string",
"default_value": "http://recommender:8000",
"secret": false,
"readonly": true
},
{
"key": "GENIUS_ACCESS_TOKEN",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "GENIUS_REQUEST_INTERVAL",
"group": "api",
"type": "duration",
"default_value": "2s",
"secret": false,
"readonly": false
},
{
"key": "MEILI_URL",
"group": "api",
"type": "string",
"default_value": "http://meilisearch:7700",
"secret": false,
"readonly": false
},
{
"key": "MEILI_MASTER_KEY",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "GITEA_URL",
"group": "api",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "GITEA_REPO",
"group": "api",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "GITEA_TOKEN",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "UPDATES_DIR",
"group": "api",
"type": "string",
"default_value": "/updates",
"secret": false,
"readonly": true
},
{
"key": "UPLOADS_DIR",
"group": "api",
"type": "string",
"default_value": "/uploads",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_RW_ROOT",
"group": "api",
"type": "string",
"default_value": "/library-rw",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_HOST_RW_ROOT",
"group": "api",
"type": "string",
"default_value": "/library-host-rw",
"secret": false,
"readonly": true
},
{
"key": "UPLOADS_ROOT",
"group": "api",
"type": "string",
"default_value": "/uploads",
"secret": false,
"readonly": true
},
{
"key": "PIPU_FILE_UID",
"group": "api",
"type": "integer",
"default_value": "-1",
"secret": false,
"readonly": false
},
{
"key": "PIPU_FILE_GID",
"group": "api",
"type": "integer",
"default_value": "-1",
"secret": false,
"readonly": false
},
{
"key": "PIPU_VERSION",
"group": "deployment",
"type": "string",
"default_value": "dev",
"secret": false,
"readonly": true
},
{
"key": "PIPU_DATA",
"group": "deployment",
"type": "string",
"default_value": "/srv/pipu",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_HOST",
"group": "deployment",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "COMPOSE_FILE",
"group": "deployment",
"type": "string",
"default_value": "compose.yaml:compose.tls.yaml",
"secret": false,
"readonly": true
},
{
"key": "DOMAIN",
"group": "deployment",
"type": "string",
"default_value": "music.example.com",
"secret": false,
"readonly": false
},
{
"key": "ACME_EMAIL",
"group": "deployment",
"type": "string",
"default_value": "admin@example.com",
"secret": false,
"readonly": false
},
{
"key": "HTTP_PORT",
"group": "deployment",
"type": "integer",
"default_value": "80",
"secret": false,
"readonly": false
},
{
"key": "POSTGRES_PASSWORD",
"group": "deployment",
"type": "string",
"default_value": "",
"secret": true,
"readonly": true
},
{
"key": "CONTEXTS_PATH",
"group": "recommender",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "TRAIN_HOUR",
"group": "recommender",
"type": "integer",
"default_value": "3",
"secret": false,
"readonly": false
}
]
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Called explicitly by install.sh. Also usable once on existing installations.
set -euo pipefail
cd "$(dirname "$0")"
DEPLOY="$(pwd -P)"
DATA="${PIPU_DATA:-$(sed -n 's/^PIPU_DATA=//p' .env)}"
DATA="${DATA:-/srv/pipu}"
command -v python3 >/dev/null || { echo 'Install python3 (>=3.10) first' >&2; exit 1; }
python3 -c 'import sys; assert sys.version_info >= (3, 10), "Python >=3.10 required"'
[ -d /etc/systemd/system ] || { echo 'systemd is required for server management' >&2; exit 1; }
mkdir -p "$DATA/management" /var/lib/pipu-management /usr/local/lib/pipu
chown 65532:65532 "$DATA/management"
chmod 700 "$DATA/management" /var/lib/pipu-management
# Copy outside the checkout: switching revisions must not replace a running worker.
install -m 700 server-agent.py /usr/local/lib/pipu/server-agent.py
touch config.local
chmod 600 config.local
if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" config.local; fi
# systemd quoted arguments escape backslashes, quotes and specifiers.
quote_unit() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/%/%%/g'; }
DEPLOY_UNIT="$(quote_unit "$DEPLOY")"
MAILBOX_UNIT="$(quote_unit "$DATA/management")"
cat > /etc/systemd/system/pipu-management.service <<EOF
[Unit]
Description=Pipu Music server management agent
After=docker.service
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/pipu/server-agent.py --deploy-dir "$DEPLOY_UNIT" --mailbox "$MAILBOX_UNIT" --state-dir /var/lib/pipu-management
Restart=on-failure
RestartSec=3
UMask=0077
KillMode=control-group
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable pipu-management.service
systemctl restart pipu-management.service
echo '→ управление сервером: pipu-management.service'
+2
View File
@@ -118,4 +118,6 @@ if [ -n "$LIBRARY_HOST" ]; then
fi fi
fi fi
bash ./install-management.sh
echo "Готово. Сборка и запуск последнего релиза: ./update.sh" echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""Host-side management worker. No network listener or Docker socket in the API.
Only fixed operations cross the shared mailbox. Private state stays outside it.
Run with Python >= 3.10; installed by install-management.sh.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import tempfile
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
class ManagementError(Exception):
def __init__(self, code, status=400):
super().__init__(code)
self.code, self.status = code, status
def atomic(path, value, mode=0o600, preserve_owner=False):
fd, name = tempfile.mkstemp(dir=path.parent, prefix=".management-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as out:
os.chmod(name, mode)
if preserve_owner and path.exists() and hasattr(os, "chown"):
previous = path.stat()
os.chown(name, previous.st_uid, previous.st_gid)
out.write(value)
out.flush()
os.fsync(out.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
def read_env(path):
"""Read literal dotenv values, including the quoted format we write.
Compose performs interpolation; this reader never executes dotenv contents.
"""
values = {}
if not path.exists():
return values
for line in path.read_text(encoding="utf-8").splitlines():
match = re.match(r"^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$", line.strip())
if not match:
continue
key, value = match.groups()
value = value.strip()
if value.startswith("'") and value.endswith("'"):
value = value[1:-1].replace("\\'", "'")
elif value.startswith('"') and value.endswith('"'):
try:
value = json.loads(value)
except ValueError:
value = value[1:-1]
else:
value = value.split(" #", 1)[0].strip()
values[key] = value
return values
def validate_url(url):
if not isinstance(url, str) or len(url) > 2048 or re.search(r"[\s\x00-\x1f]", url):
raise ManagementError("management.source_invalid")
if re.fullmatch(r"git@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+", url):
return
parsed = urlsplit(url)
if (parsed.scheme not in ("https", "http", "ssh") or not parsed.hostname
or parsed.password or parsed.query or parsed.fragment
or (parsed.username and not (parsed.scheme == "ssh" and parsed.username == "git"))
or not parsed.path.strip("/")):
raise ManagementError("management.source_invalid")
def version_key(tag):
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?", tag)
if not match:
return None
major, minor, patch, rc = match.groups()
return int(major), int(minor), int(patch), 1 if rc is None else 0, int(rc or 0)
def now():
return datetime.now(timezone.utc).isoformat()
class Agent:
def __init__(self, deploy, mailbox, private):
self.deploy, self.mailbox, self.private = deploy, mailbox, private
self.lock = threading.Lock()
self.cache = {}
self.job = self.load("job.json", None)
# systemd KillMode=control-group stops descendants too. Never claim success
# after a reboot or interrupted deployment.
if self.job and self.job["state"] == "running":
self.job.update(state="failed", finished_at=now(), log="Agent interrupted; inspect deployment before retrying.")
self.save("job.json", self.job)
def load(self, name, default):
path = self.private / name
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default
def save(self, name, value):
atomic(self.private / name, json.dumps(value, ensure_ascii=False))
def git_environment(self):
return {"GIT_TERMINAL_PROMPT": "0", "GIT_SSH_COMMAND": "ssh -oBatchMode=yes",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "safe.directory", "GIT_CONFIG_VALUE_0": str(self.deploy.parent)}
def command(self, args, timeout=30, **kwargs):
return subprocess.run(args, cwd=self.deploy, timeout=timeout, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
env={**os.environ, **self.git_environment()}, **kwargs).stdout
def sources(self):
sources = self.load("sources.json", None)
if sources is None:
url = self.command(["git", "remote", "get-url", "origin"]).strip()
validate_url(url)
sources = [{"id": "origin", "name": "origin", "url": url}]
self.save("sources.json", sources)
return sources
def refs(self, url, fresh=False):
cached = self.cache.get(url)
if cached and not fresh and time.monotonic() - cached[0] < 60:
return cached[1]
output = self.command(["git", "-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never",
"ls-remote", "--heads", "--tags", "--", url], timeout=20)
refs = dict(line.split()[::-1] for line in output.splitlines() if len(line.split()) == 2)
self.cache[url] = time.monotonic(), refs
return refs
def schema(self):
return json.loads((self.deploy / "config.schema.json").read_text(encoding="utf-8"))
def revision(self):
return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest()
def config(self):
env = read_env(self.deploy / ".env")
local = read_env(self.deploy / "config.local")
entries = []
for item in self.schema():
key = item["key"]
source = "env" if key in env else "local" if key in local else "default"
effective = env.get(key, local.get(key, item["default_value"]))
def display(value):
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})
return {"entries": entries, "revision": self.revision(),
"restart_required": self.load("applied.json", "") != self.revision()}
def save_config(self, body):
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
if body.get("revision") != self.revision():
raise ManagementError("management.config_conflict", 409)
values = body.get("values")
if not isinstance(values, dict) or len(values) > 200:
raise ManagementError("management.config_invalid")
schema = {v["key"]: v for v in self.schema()}
local = read_env(self.deploy / "config.local")
for key, value in values.items():
item = schema.get(key)
if not item or item["readonly"]:
raise ManagementError("management.config_readonly")
if value is None:
local.pop(key, None)
continue
if not isinstance(value, str) or len(value) > 8192 or any(c in value for c in "\r\n\x00"):
raise ManagementError("management.config_invalid")
if item["type"] == "boolean" and value not in ("true", "false", "1", "0"):
raise ManagementError("management.config_invalid")
if item["type"] == "integer" and not re.fullmatch(r"-?\d+", value):
raise ManagementError("management.config_invalid")
if key == "LOG_LEVEL" and value not in ("debug", "info", "warn", "error"):
raise ManagementError("management.config_invalid")
if item["type"] == "integer" and key != "TRAIN_HOUR":
minimum = -1 if key in ("PIPU_FILE_UID", "PIPU_FILE_GID") else 0
if int(value) < minimum:
raise ManagementError("management.config_invalid")
if key == "TRAIN_HOUR" and not -1 <= int(value) <= 23:
raise ManagementError("management.config_invalid")
if key.endswith("_PORT") and not 1 <= int(value) <= 65535:
raise ManagementError("management.config_invalid")
if item["type"] == "duration" and not re.fullmatch(r"(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+", value):
raise ManagementError("management.config_invalid")
local[key] = value
# Single quotes make dollar signs literal to Compose. Newlines are rejected.
content = "# Managed by Pipu. Environment and .env override these values.\n"
content += "".join(k + "='" + v.replace("'", "\\'") + "'\n" for k, v in sorted(local.items()))
path = self.deploy / "config.local"
previous = path.read_text(encoding="utf-8")
atomic(path, content, preserve_owner=True)
try:
self.command([*self.compose(), "config", "--quiet"])
except (subprocess.SubprocessError, OSError):
atomic(path, previous, preserve_owner=True)
raise ManagementError("management.config_invalid") from None
self.audit("config.save", list(values))
return self.config()
def compose(self):
return ["docker", "compose", "--env-file", "config.local", "--env-file", ".env"]
def audit(self, action, details):
# Deliberately no configuration values or credentials in this log.
with (self.private / "audit.jsonl").open("a", encoding="utf-8") as out:
out.write(json.dumps({"at": now(), "actor": getattr(self, "actor", ""), "action": action, "details": details}) + "\n")
def start_job(self, kind, target, command, extra_env=None):
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
job = {"id": str(uuid.uuid4()), "kind": kind, "state": "running", "target": target,
"started_at": now(), "finished_at": "", "log": ""}
self.job = job
self.save("job.json", job)
revision = self.revision()
self.audit(kind + ".start", {"id": job["id"], "target": target})
threading.Thread(target=self.run_job, args=(job, command, extra_env or {}, revision), daemon=True).start()
return dict(job)
def run_job(self, job, command, extra_env, 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.
secrets = [v for k, v in {**read_env(self.deploy / "config.local"), **read_env(self.deploy / ".env")}.items()
if re.search(r"TOKEN|PASSWORD|KEY|SECRET|DATABASE_URL|REDIS_URL", k) and len(v) >= 4]
def redact(text):
for value in secrets:
text = text.replace(value, "[redacted]")
return re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[redacted]@", text)[-64000:]
logpath = self.private / (job["id"] + ".log")
result = 1
try:
with logpath.open("w", encoding="utf-8") as output:
process = subprocess.Popen(command, cwd=self.deploy, stdout=output, stderr=subprocess.STDOUT,
env={**os.environ, **self.git_environment(), **extra_env}, start_new_session=True)
deadline = time.monotonic() + 7200
while process.poll() is None:
if time.monotonic() > deadline:
import signal
os.killpg(process.pid, signal.SIGKILL)
process.wait()
break
with self.lock:
with logpath.open("rb") as output:
output.seek(max(0, logpath.stat().st_size - 128000))
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
self.save("job.json", job)
time.sleep(1)
result = process.returncode
with logpath.open("rb") as output:
output.seek(max(0, logpath.stat().st_size - 128000))
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
if result == 0:
self.save("applied.json", revision)
except (OSError, subprocess.SubprocessError):
result = 1
job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management."
finally:
with self.lock:
job.update(state="succeeded" if result == 0 else "failed", finished_at=now())
self.save("job.json", job)
def handle(self, op, body):
if op == "config.get":
return self.config()
if op == "config.save":
return self.save_config(body)
if op == "config.apply":
return self.start_job("config", "config.local", ["bash", str(self.deploy / "apply-config.sh")])
if op == "sources.save":
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
sources = body.get("sources")
if not isinstance(sources, list) or not 1 <= len(sources) <= 20:
raise ManagementError("management.source_invalid")
ids = set()
for source in sources:
if (not isinstance(source, dict) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", source.get("id", ""))
or not isinstance(source.get("name"), str) or not 1 <= len(source["name"].strip()) <= 100
or source["id"] in ids):
raise ManagementError("management.source_invalid")
validate_url(source.get("url"))
ids.add(source["id"])
self.save("sources.json", sources)
self.audit("sources.save", list(ids))
return {"sources": sources}
sources = self.sources()
selected = next((s for s in sources if s["id"] == (body.get("source_id") or sources[0]["id"])), None)
if not selected:
raise ManagementError("management.source_invalid")
if op == "updates.get":
versions, error = [], ""
try:
refs = self.refs(selected["url"])
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"
return {"sources": sources, "versions": versions, "source_error": error,
"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")
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):
raise ManagementError("management.target_invalid")
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})
raise ManagementError("management.operation_invalid")
def tick(self):
atomic(self.mailbox / "heartbeat", str(time.time()), 0o644)
for path in sorted(self.mailbox.glob("*.request.json")):
if path.is_symlink() or not re.fullmatch(r"[a-f0-9-]{36}\.request\.json", path.name):
continue
response = self.mailbox / path.name.replace(".request.json", ".response.json")
if response.exists():
path.unlink(missing_ok=True)
continue
try:
if path.stat().st_size > 131072:
raise ManagementError("management.request_invalid")
request = json.loads(path.read_text(encoding="utf-8"))
if time.time() - request["created_at"] > 45:
raise ManagementError("management.request_expired", 409)
# Claim before executing: a worker crash must never replay a mutation.
path.unlink(missing_ok=True)
with self.lock:
self.actor = request.get("actor", "")
result = {"data": self.handle(request["operation"], request.get("body") or {})}
except ManagementError as error:
result = {"code": error.code, "status": error.status}
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
result = {"code": "management.agent_error", "status": 502}
atomic(response, json.dumps(result, ensure_ascii=False), 0o644)
path.unlink(missing_ok=True)
for path in self.mailbox.glob("*.response.json"):
if not path.is_symlink() and time.time() - path.stat().st_mtime > 120:
path.unlink(missing_ok=True)
def main():
import fcntl # Linux host only; tests import this module on Windows too.
parser = argparse.ArgumentParser()
parser.add_argument("--deploy-dir", type=Path, required=True)
parser.add_argument("--mailbox", type=Path, required=True)
parser.add_argument("--state-dir", type=Path, required=True)
args = parser.parse_args()
args.state_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
with (args.state_dir / "lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
agent = Agent(args.deploy_dir.resolve(), args.mailbox.resolve(), args.state_dir.resolve())
while True:
# Refresh the installed worker only after a successful deployment has
# persisted its final status. The running worker survives checkout/build.
with agent.lock:
installed = Path(__file__).resolve()
candidate = agent.deploy / "server-agent.py"
if (agent.job and agent.job["kind"] == "update" and agent.job["state"] == "succeeded"
and installed == Path("/usr/local/lib/pipu/server-agent.py") and candidate.is_file()
and candidate.read_bytes() != installed.read_bytes()):
atomic(installed, candidate.read_text(encoding="utf-8"), 0o700)
# The singleton lock is non-inheritable and released by exec.
import sys
os.execv(sys.executable, [sys.executable, str(installed), *sys.argv[1:]])
agent.tick()
time.sleep(0.2)
if __name__ == "__main__":
main()
+147
View File
@@ -0,0 +1,147 @@
"""Host worker tests: python3 -m unittest discover -s deploy -p 'test_*.py'."""
import importlib.util
import json
import subprocess
import tempfile
import unittest
import shutil
import sys
from pathlib import Path
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("server_agent", Path(__file__).with_name("server-agent.py"))
agent_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(agent_module)
Agent, ManagementError = agent_module.Agent, agent_module.ManagementError
class AgentTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.deploy, self.mailbox, self.private = [self.root / n for n in ("deploy", "mailbox", "private")]
for directory in (self.deploy, self.mailbox, self.private):
directory.mkdir()
self.schema = [
{"key": "LOG_LEVEL", "group": "api", "type": "string", "default_value": "info", "secret": False, "readonly": False},
{"key": "GITEA_TOKEN", "group": "api", "type": "string", "default_value": "", "secret": True, "readonly": False},
{"key": "TRAIN_HOUR", "group": "recommender", "type": "integer", "default_value": "3", "secret": False, "readonly": False},
{"key": "PIPU_DATA", "group": "deployment", "type": "string", "default_value": "/srv/pipu", "secret": False, "readonly": True},
]
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
(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.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}])
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()
entries = {v["key"]: v for v in config["entries"]}
self.assertEqual(("warn", "env", "debug"), tuple(entries["LOG_LEVEL"][k] for k in ("effective", "source", "local")))
self.assertEqual(("5", "local"), tuple(entries["TRAIN_HOUR"][k] for k in ("effective", "source")))
self.assertNotIn("top-secret", json.dumps(config))
def test_save_roundtrip_reset_and_conflict(self):
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "7", "GITEA_TOKEN": "a$'\\b#c"}})
self.assertEqual("a$'\\b#c", agent_module.read_env(self.deploy / "config.local")["GITEA_TOKEN"])
self.assertTrue(result["restart_required"])
self.assertNotIn("a$", json.dumps(result))
self.assertNotIn("a$", (self.private / "audit.jsonl").read_text())
with self.assertRaises(ManagementError) as raised:
self.agent.save_config({"revision": "stale", "values": {"TRAIN_HOUR": "8"}})
self.assertEqual(409, raised.exception.status)
self.agent.save_config({"revision": result["revision"], "values": {"TRAIN_HOUR": None}})
self.assertNotIn("TRAIN_HOUR", agent_module.read_env(self.deploy / "config.local"))
def test_invalid_values_and_compose_failure_preserve_original(self):
original = (self.deploy / "config.local").read_bytes()
for values in ({"TRAIN_HOUR": "24"}, {"TRAIN_HOUR": "bad"}, {"UNKNOWN": "x"}, {"PIPU_DATA": "/tmp"}, {"LOG_LEVEL": "x\nOTHER=y"}):
with self.assertRaises(ManagementError):
self.agent.save_config({"revision": self.agent.revision(), "values": values})
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
with patch.object(self.agent, "command", side_effect=subprocess.CalledProcessError(1, "docker")):
with self.assertRaises(ManagementError):
self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
def test_source_validation_rejects_shell_local_and_credentials(self):
for url in ("/tmp/repo", "file:///tmp/repo", "ext::sh -c evil", "--upload-pack=evil", "https://user:secret@example.org/o/r", "https://secret@example.org/o/r", "https://example.org/o/r\narg", "ssh://root@example.org/o/r"):
with self.subTest(url=url), self.assertRaises(ManagementError):
agent_module.validate_url(url)
for url in ("https://example.org/o/r", "http://10.0.0.1:3000/o/r", "git@example.org:o/r.git", "ssh://git@example.org:2222/o/r.git"):
agent_module.validate_url(url)
def test_latest_is_semver_stable_and_pins_peeled_commit(self):
refs = {"refs/tags/v1.9.0": "a" * 40, "refs/tags/v1.10.0": "b" * 40, "refs/tags/v1.10.0^{}": "c" * 40, "refs/tags/v2.0.0-rc.1": "d" * 40}
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job", return_value={}) as start:
self.agent.handle("updates.start", {"source_id": "origin", "channel": "latest"})
self.assertEqual("v1.10.0", start.call_args.args[1])
self.assertEqual("c" * 40, start.call_args.args[3]["PIPU_UPDATE_COMMIT"])
self.assertEqual("refs/tags/v1.10.0", start.call_args.args[3]["PIPU_UPDATE_REF"])
with self.assertRaises(ManagementError):
self.agent.handle("updates.start", {"source_id": "origin", "channel": "version", "version": "--evil"})
def test_main_and_default_source(self):
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job", return_value={}) as start:
self.agent.handle("updates.start", {"source_id": "origin", "channel": "main"})
self.assertEqual("main", start.call_args.args[1])
result = self.agent.handle("updates.get", {"source_id": None})
self.assertEqual("origin", result["sources"][0]["id"])
def test_running_job_blocks_mutations_and_survives_restart_as_failure(self):
self.agent.job = {"state": "running"}
for operation, body in (("updates.start", {"channel": "main"}), ("config.apply", {}), ("sources.save", {}), ("config.save", {})):
with self.subTest(operation=operation), self.assertRaises(ManagementError) as raised:
self.agent.handle(operation, body)
self.assertEqual(409, raised.exception.status)
self.agent.save("job.json", {"state": "running", "id": "old"})
restored = Agent(self.deploy, self.mailbox, self.private)
self.assertEqual("failed", restored.job["state"])
def test_completed_job_redacts_secrets_and_marks_applied_revision(self):
job = {"id": "test", "kind": "config", "state": "running", "started_at": "", "finished_at": "", "target": "config.local", "log": ""}
self.agent.job = job
revision = self.agent.revision()
self.agent.run_job(job, [sys.executable, "-c", "print('top-secret')"], {}, revision)
self.assertEqual("succeeded", job["state"])
self.assertNotIn("top-secret", job["log"])
self.assertIn("[redacted]", job["log"])
self.assertFalse(self.agent.config()["restart_required"])
self.agent.run_job(job, [sys.executable, "-c", "raise SystemExit(2)"], {}, revision)
self.assertEqual("failed", job["state"])
def test_mailbox_discards_stale_request_and_never_executes_it(self):
path = self.mailbox / "00000000-0000-0000-0000-000000000001.request.json"
path.write_text(json.dumps({"operation": "config.apply", "created_at": 1}), encoding="utf-8")
with patch.object(self.agent, "handle") as handle:
self.agent.tick()
handle.assert_not_called()
response = json.loads(path.with_name(path.name.replace("request", "response")).read_text(encoding="utf-8"))
self.assertEqual("management.request_expired", response["code"])
class ComposeTests(unittest.TestCase):
@unittest.skipUnless(shutil.which('docker'), 'Docker Compose CLI unavailable')
def test_real_compose_priorities_and_literal_secret_roundtrip(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / '.env').write_text('POSTGRES_PASSWORD=test-db\nINTERNAL_TOKEN=test-internal\nMEILI_MASTER_KEY=test-meili\nLOG_LEVEL=warn\n', encoding='utf-8')
secret = "literal$VALUE'with\\slash#hash"
(root / 'config.local').write_text("LOG_LEVEL='debug'\nTRAIN_HOUR='7'\nGITEA_TOKEN='" + secret.replace("'", "\\'") + "'\n", encoding='utf-8')
compose = Path(__file__).with_name('compose.yaml').resolve()
result = subprocess.run(['docker', 'compose', '--project-directory', str(root), '-f', str(compose), '--env-file', str(root / 'config.local'), '--env-file', str(root / '.env'), 'config', '--format', 'json'], capture_output=True, text=True, timeout=30)
self.assertEqual(0, result.returncode, 'Docker Compose configuration failed: ' + result.stderr)
services = json.loads(result.stdout)['services']
self.assertEqual('warn', services['api']['environment']['LOG_LEVEL'])
self.assertEqual('7', services['recommender']['environment']['TRAIN_HOUR'])
self.assertEqual('7', services['analyzer']['environment']['TRAIN_HOUR'])
# Canonical Compose output escapes dollars for reuse as YAML.
self.assertEqual(secret.replace('$', '$$'), services['api']['environment']['GITEA_TOKEN'])
self.assertNotIn('GITEA_TOKEN', services['recommender']['environment'])
if __name__ == "__main__":
unittest.main()
+35 -4
View File
@@ -14,6 +14,15 @@ fi
trap 'rm -f "$0"' EXIT trap 'rm -f "$0"' EXIT
cd "$PIPU_DEPLOY_DIR" cd "$PIPU_DEPLOY_DIR"
# Share a lock with manual deployments and config application.
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
exec 9<.management.lock
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
touch config.local
chmod 600 config.local
compose() { docker compose --env-file config.local --env-file .env "$@"; }
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; } [ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз, # Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
@@ -69,7 +78,15 @@ if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [
fi fi
REF="${1:-}" REF="${1:-}"
if [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
[[ "${PIPU_UPDATE_COMMIT:-}" =~ ^[a-f0-9]{40,64}$ ]] || { echo 'Invalid commit' >&2; exit 1; }
[[ "${PIPU_UPDATE_REF:-}" =~ ^refs/(heads/main|tags/v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?)$ ]] || { echo 'Invalid ref' >&2; exit 1; }
git -c protocol.file.allow=never -c protocol.ext.allow=never fetch --quiet -- "$PIPU_UPDATE_SOURCE" "$PIPU_UPDATE_REF"
FETCHED="$(git rev-parse 'FETCH_HEAD^{commit}')"
[ "$FETCHED" = "$PIPU_UPDATE_COMMIT" ] || { echo 'Source changed; select update again' >&2; exit 1; }
else
git fetch --tags --prune --quiet origin git fetch --tags --prune --quiet origin
fi
if [ -z "$REF" ]; then if [ -z "$REF" ]; then
REF="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-rc' | head -1)" REF="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-rc' | head -1)"
@@ -96,26 +113,40 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
fi fi
echo "→ переключаемся на $REF" echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then # Refuse to overwrite local source changes.
git diff --quiet && git diff --cached --quiet || { echo 'Repository has local changes' >&2; exit 1; }
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
git checkout --quiet --detach "$PIPU_UPDATE_COMMIT"
elif git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF" git checkout --quiet "$REF"
git merge --ff-only --quiet "origin/$REF" git merge --ff-only --quiet "origin/$REF"
else else
git checkout --quiet --detach "$REF" git checkout --quiet --detach "$REF"
fi fi
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
if [ "$REF" = main ]; then VERSION="main-$(git rev-parse --short HEAD)"; else VERSION="$REF"; fi
else
VERSION="$(git describe --tags --always --dirty)" VERSION="$(git describe --tags --always --dirty)"
fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)" echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
docker compose build --pull compose config --quiet
docker compose up -d --remove-orphans 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
echo 'Build failed; running containers were not replaced' >&2
exit 1
fi
compose up -d --remove-orphans
docker image prune -f >/dev/null docker image prune -f >/dev/null
echo "→ ждём готовности api" echo "→ ждём готовности api"
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4 # 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4
if docker compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
echo "pipu $VERSION запущен" echo "pipu $VERSION запущен"
exit 0 exit 0
fi fi
+1
View File
@@ -24,6 +24,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) | | [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика | | [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
| [server-management.md](server-management.md) | Управление обновлениями и config.local из админки, установка агента |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные | | [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
## Стек в одном абзаце ## Стек в одном абзаце
+5
View File
@@ -133,3 +133,8 @@ APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релиз
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске | | Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает). Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
## Управление обновлениями и конфигурацией
Разделы **Обновления** и **Конфигурация** используют агент на хосте. Установка для существующего сервера, права, приоритет `.env` над `config.local` и диагностика описаны в [server-management.md](server-management.md).
+3
View File
@@ -97,3 +97,6 @@
делает **единственный** параллельный refresh (single-flight) и повторяет запросы. делает **единственный** параллельный refresh (single-flight) и повторяет запросы.
- Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`; - Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`;
админские разделы — по `role`. админские разделы — по `role`.
Управление обновлениями сервера — право `manages_updates`: у admin всегда, другим его выдаёт и отзывает только admin. Оно даёт доступ к `/admin/updates`, управлению источниками и запуску обновления, но не к конфигурации сервера. `/admin/config` доступен только admin. Подробности: [server-management.md](server-management.md).
+127
View File
@@ -0,0 +1,127 @@
# Управление сервером из веб-интерфейса
В меню админки доступны **Обновления** (`/admin/updates`) и **Конфигурация**
(`/admin/config`). Клиентские APK и desktop-сборки по-прежнему раздаются через
существующий механизм `appupdate`; источники обновления сервера от него независимы.
## Установка на существующий сервер
После получения версии с управлением сервером выполните на Linux-хосте:
```bash
cd /opt/pipu-music/deploy
sudo bash ./install-management.sh
./update.sh main # либо тег версии, содержащей эту возможность
```
Новая установка через `sudo ./install.sh` устанавливает агент автоматически.
Нужны systemd, Python **3.10+**, Git, Bash, `flock` (util-linux) и Docker Compose
**2.24+**. Compose должен поддерживать несколько `--env-file` и тег `!reset`
из существующего TLS-overlay. Агент выполняется от root, чтобы запускать Compose
и обращаться к данным развёртывания; API продолжает работать без root и без Docker socket.
Скрипт создаёт `pipu-management.service`, копирует агент вне Git-checkout в
`/usr/local/lib/pipu/server-agent.py`, создаёт `deploy/config.local` и общий каталог
`${PIPU_DATA}/management` с владельцем UID 65532. Последний монтируется только в API.
Приватные настройки источников, состояние, аудит и журналы лежат в
`/var/lib/pipu-management`, доступном только root. Нужен доступ этого пользователя
к origin; SSH-ключи и known_hosts для приватных репозиториев настройте на хосте.
Пароли и токены в URL источников не принимаются.
После успешного обновления агент автоматически заменяет свою установленную копию
из нового checkout и перезапускается, сохранив итоговый статус. Для ручной установки
нового кода можно повторно запустить `install-management.sh`.
Во время выполняющейся операции не перезапускайте его: systemd остановит дочерние
процессы. После прерывания/перезагрузки задача помечается ошибкой, а не успехом.
## Права
Администратор всегда может управлять обновлениями. В меню действий пользователя
на странице **Пользователи** он может выдать и забрать право **Управление
обновлениями** (`manages_updates`). Право действует независимо от роли и проверяется
на сервере при каждом запросе. Оно позволяет изменять источники, видеть журнал
и запускать обновление. Пользователь с этим правом получает ссылку на обновления,
но не получает доступ к пользователям, конфигурации или остальным действиям админа.
Выдавайте это право доверенным операторам: обновление запускает код выбранного
совместимого репозитория на хосте. Только администраторы могут просматривать,
сохранять и применять конфигурацию. Выдача/отзыв права записываются в `audit_log`;
операции агента с UUID инициатора — в его приватный `audit.jsonl` без значений секретов.
## Обновления
При первом обращении автоматически добавляется текущий `git remote origin`.
Можно создавать, редактировать и удалять источники; хотя бы один должен остаться.
Поддерживаются совместимые Git-репозитории Pipu через HTTP(S) и SSH.
* **Последний релиз** — значение по умолчанию: максимальный стабильный тег SemVer
`vMAJOR.MINOR.PATCH` (также допускается без `v`), без `-rc.N`.
* **Выбрать версию** — конкретный релизный тег, включая release candidate.
* **main** — текущий commit ветки main.
Обновления устанавливаются только по нажатию кнопки и после подтверждения.
Перед запуском агент повторно читает refs и фиксирует SHA; update.sh проверяет,
что полученный commit совпадает. Выбранный источник не перезаписывает origin
и не смешивает его теги с тегами другого репозитория. Git checkout с локальными
изменениями отклоняется. Защита update.sh от downgrade сохраняется: откат
выполняется вручную с `PIPU_ALLOW_DOWNGRADE=1` после проверки совместимости БД.
Выполнение, ограниченный хвост журнала и итоговый статус доступны на странице.
Можно закрыть браузер или переждать перезапуск API: работа продолжается на хосте.
Полные логи остаются приватными; для интерфейса известные секреты маскируются.
Одновременно агент допускает только одну операцию обновления/применения.
Резервная копия и откат схемы БД автоматически не выполняются.
## Конфигурация
Реестр поддерживаемых параметров находится в `deploy/config.schema.json`:
API, общие параметры сервисов, рекомендатель и развёртывание. Добавляя параметр
в конфигурацию сервиса, обновите реестр; для рекомендателя/агента хранилища также
проверьте передачу через Compose. Пути томов, сгенерированный DATABASE_URL и
некоторые служебные параметры показаны для просмотра и меняются на хосте.
Страница не является произвольным редактором nginx/Caddy/YAML-файлов.
Приоритет при запуске через скрипты:
1. Явные переменные окружения Compose и значения из `deploy/.env`.
2. `deploy/config.local`.
3. Встроенные значения по умолчанию.
Для интерполяции Compose скрипты передают `--env-file config.local --env-file .env`.
У API порядок `env_file: [config.local, .env]`; рекомендатель и storage-agent получают
только необходимые им переменные через `environment`. При ручном запуске Compose
сохраняйте эти флаги, иначе параметры интерполяции из config.local не подхватятся.
Страница показывает ожидаемое значение после применения, а не утверждает, что
работающий процесс уже перечитал сохранённый файл. Окружение shell запуска Compose
тоже может перекрывать интерполяцию; для агента используется окружение systemd.
В интерфейсе видны локальное значение, значение из `.env` и источник приоритета.
Если ключ уже есть в `.env`, изменение его в config.local сохраняется, но не
перекрывает `.env` — интерфейс прямо сообщает об этом. Чтобы управлять таким ключом
через страницу, удалите его строку из `.env` на хосте. Секреты не возвращаются в API
открытым текстом: поле без изменений сохраняет прежний секрет, ввод заменяет,
**Сбросить локальное** удаляет только значение из config.local.
Сохранение атомарно, с правами 0600 и проверкой ревизии: устаревшая форма не затрёт
правки другого администратора. Перед сохранением проверяются типы и Compose;
при ошибке прежний файл восстанавливается. Произвольные ключи, переводы строк
и изменение служебных параметров отклоняются. Сохраняемые значения трактуются
буквально: подстановки shell не выполняются.
Кнопка **Применить и перезапустить** отдельно подтверждает пересоздание контейнеров.
Агент запускает `apply-config.sh`, ждёт `/readyz` и сохраняет результат. Если сервисы
не стали готовы, состояние — ошибка; исправьте config.local или восстановите его
на хосте. Автоматического отката конфигурации после неудачного применения нет.
Диагностика:
```bash
sudo systemctl status pipu-management.service
sudo journalctl -u pipu-management.service -n 100
cd /opt/pipu-music/deploy
docker compose --env-file config.local --env-file .env logs api
```
Проверки разработки: `python3 -m unittest discover -s deploy -p 'test_*.py'`,
`go test ./internal/auth ./internal/management` из `services/api`, а также
`pnpm --filter @pipu/web test:e2e e2e/server-management.spec.ts`.
+268
View File
@@ -4,6 +4,70 @@
*/ */
export interface paths { export interface paths {
"/api/v1/admin/server/updates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getServerUpdates"];
put?: never;
post: operations["startServerUpdate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/server/update-sources": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["saveServerUpdateSources"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/server/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getServerConfig"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["saveServerConfig"];
trace?: never;
};
"/api/v1/admin/server/config/apply": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["applyServerConfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/genius": { "/api/v1/admin/genius": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1499,6 +1563,62 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
ServerSource: {
id: string;
name: string;
url: string;
};
ServerSources: {
sources: components["schemas"]["ServerSource"][];
};
ServerJob: {
id: string;
/** @enum {string} */
kind: "update" | "config";
/** @enum {string} */
state: "running" | "succeeded" | "failed";
started_at: string;
finished_at: string;
target: string;
log: string;
};
ServerUpdates: {
sources: components["schemas"]["ServerSource"][];
versions: string[];
current_version: string;
source_error: string;
job: components["schemas"]["ServerJob"] | null;
};
ServerUpdateRequest: {
source_id: string;
/** @enum {string} */
channel: "latest" | "version" | "main";
version?: string;
};
ServerConfigEntry: {
key: string;
group: string;
type: string;
secret: boolean;
readonly: boolean;
effective: string;
env: string | null;
local: string | null;
default_value: string;
/** @enum {string} */
source: "env" | "local" | "default";
};
ServerConfig: {
revision: string;
restart_required: boolean;
entries: components["schemas"]["ServerConfigEntry"][];
};
ServerConfigPatch: {
revision: string;
values: {
[key: string]: string | null;
};
};
PlaylistShare: { PlaylistShare: {
/** Format: uuid */ /** Format: uuid */
share_id: string; share_id: string;
@@ -1859,6 +1979,8 @@ export interface components {
username: string; username: string;
display_name: string | null; display_name: string | null;
role: components["schemas"]["Role"]; role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */ /** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
reviews_uploads: boolean; reviews_uploads: boolean;
locale: string; locale: string;
@@ -1888,6 +2010,8 @@ export interface components {
username: string; username: string;
display_name: string | null; display_name: string | null;
role: components["schemas"]["Role"]; role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */ /** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
reviews_uploads: boolean; reviews_uploads: boolean;
locale: string; locale: string;
@@ -1907,6 +2031,8 @@ export interface components {
username: string; username: string;
display_name?: string; display_name?: string;
role: components["schemas"]["Role"]; role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Сразу выдать аудит загрузок. Может задать только админ */ /** @description Сразу выдать аудит загрузок. Может задать только админ */
reviews_uploads?: boolean; reviews_uploads?: boolean;
}; };
@@ -1922,6 +2048,8 @@ export interface components {
display_name?: string; display_name?: string;
role?: components["schemas"]["Role"]; role?: components["schemas"]["Role"];
is_blocked?: boolean; is_blocked?: boolean;
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя */ /** @description Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя */
reviews_uploads?: boolean; reviews_uploads?: boolean;
}; };
@@ -2563,6 +2691,146 @@ export interface components {
} }
export type $defs = Record<string, never>; export type $defs = Record<string, never>;
export interface operations { export interface operations {
getServerUpdates: {
parameters: {
query?: {
source_id?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerUpdates"];
};
};
default: components["responses"]["Problem"];
};
};
startServerUpdate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ServerUpdateRequest"];
};
};
responses: {
/** @description Server management result */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerJob"];
};
};
default: components["responses"]["Problem"];
};
};
saveServerUpdateSources: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ServerSources"];
};
};
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerSources"];
};
};
default: components["responses"]["Problem"];
};
};
getServerConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerConfig"];
};
};
default: components["responses"]["Problem"];
};
};
saveServerConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ServerConfigPatch"];
};
};
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerConfig"];
};
};
default: components["responses"]["Problem"];
};
};
applyServerConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Server management result */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerJob"];
};
};
default: components["responses"]["Problem"];
};
};
getGeniusStats: { getGeniusStats: {
parameters: { parameters: {
query?: never; query?: never;
+86 -2
View File
@@ -32,7 +32,10 @@
"recs_confirm": "Reset {{username}}'s recommendations? The wave will start learning their taste from scratch and ask for favourite artists again. Likes and history stay.", "recs_confirm": "Reset {{username}}'s recommendations? The wave will start learning their taste from scratch and ask for favourite artists again. Likes and history stay.",
"recs_done": "{{username}}'s recommendations were reset", "recs_done": "{{username}}'s recommendations were reset",
"confirm": "Confirm", "confirm": "Confirm",
"you": "you" "you": "you",
"manage_updates": "Server updates",
"grant_updates": "Grant update management",
"revoke_updates": "Revoke update management"
}, },
"create": { "create": {
"title": "New user", "title": "New user",
@@ -71,7 +74,8 @@
"user.unblock": "User unblocked", "user.unblock": "User unblocked",
"user.password_reset": "Password reset", "user.password_reset": "Password reset",
"user.recommendations_reset": "Recommendations reset", "user.recommendations_reset": "Recommendations reset",
"user.delete": "User deleted" "user.delete": "User deleted",
"user.manages_updates": "Server update permission changed"
} }
}, },
"sources": { "sources": {
@@ -378,5 +382,85 @@
"server_settings": { "server_settings": {
"title": "Server settings", "title": "Server settings",
"subtitle": "Settings that apply to all users" "subtitle": "Settings that apply to all users"
},
"management": {
"server": "Server management",
"updates": "Updates",
"config": "Configuration",
"updates_intro": "Choose a source and version. The server will build and report the result.",
"refresh": "Refresh status",
"current": "Installed version",
"manual": "Updates start manually. The latest stable release is resolved when you start the update.",
"update_target": "Next update",
"sources": "Update sources",
"source": "Source",
"channel": "Update target",
"channel_latest": "Latest release",
"channel_version": "Choose version",
"channel_main": "main",
"hint_latest": "Install the latest stable tag, excluding release candidates.",
"hint_version": "Choose a published tag. Downgrading to an older version requires a manual rollback.",
"hint_main": "The current main branch. May include unreleased changes.",
"version": "Version",
"choose_version": "Choose a version",
"source_unavailable": "Could not read the repository. Check its address, server access and SSH keys.",
"install": "Install update",
"confirm_update": "Update the server?",
"confirm_update_text": "Source: {{source}}. Target: {{target}}.",
"update_warning": "The service will be briefly unavailable during restart. Back up the database first. Database migrations are not rolled back.",
"cancel": "Cancel",
"sources_hint": "Git repositories compatible with Pipu. HTTP(S) or SSH; configure private repository keys on the agent host. Do not include tokens in the URL.",
"source_name": "Name",
"repository": "Git repository URL",
"remove_source": "Remove source",
"add_source": "Add source",
"save": "Save",
"job": "Last operation",
"started": "Started",
"finished": "Finished",
"log": "Execution log",
"waiting": "Waiting for command output…",
"running_hint": "The task runs on the server. You can close this page without interrupting it.",
"reconnecting": "Services are restarting. Waiting to reconnect to the server…",
"state": {
"running": "Running",
"succeeded": "Completed",
"failed": "Failed"
},
"config_intro": "API, recommender and deployment settings in deploy/config.local.",
"priority": "Priority order",
"defaults": "defaults",
"priority_hint": ".env values override config.local. Save and apply are separate actions.",
"search": "Search settings",
"group": "Section",
"all": "All sections",
"groups": {
"api": "API",
"deployment": "Deployment",
"shared": "Shared",
"recommender": "Recommender"
},
"config_files_hint": "Supported settings are listed here. “After applying” is the value from .env, config.local or a default. Fixed paths and service parameters are read-only. Secrets can be replaced; their values stay hidden.",
"value_source": {
"env": "From .env",
"local": "From config.local",
"default": "Default"
},
"effective": "After applying",
"readonly": "Service parameter. Change on the host together with its Compose configuration.",
"secret_saved": "Secret saved",
"will_reset": "The local value will be removed when saved.",
"overridden": "A value in .env takes priority. This local change will not take effect yet.",
"secret_hint": "Leave unchanged to keep the stored secret. Enter a new value to replace it.",
"default": "Default",
"reset": "Reset local value",
"no_results": "No settings found",
"saved": "Changes saved to config.local",
"unsaved": "Unsaved settings: {{count}}",
"pending_restart": "Saved changes are waiting to be applied",
"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."
} }
} }
+3 -1
View File
@@ -62,7 +62,9 @@
"suggest": "Suggest tracks", "suggest": "Suggest tracks",
"my_uploads": "My uploads", "my_uploads": "My uploads",
"upload_queue": "Queue", "upload_queue": "Queue",
"admin_settings": "Settings" "admin_settings": "Settings",
"server_updates": "Updates",
"server_config": "Configuration"
}, },
"profile": { "profile": {
"menu": "Profile menu", "menu": "Profile menu",
+16
View File
@@ -101,5 +101,21 @@
"invalid_tracks": "Tracks must be unique and exist in the library", "invalid_tracks": "Tracks must be unique and exist in the library",
"too_large": "Maximum 2000 tracks and 2000 description characters", "too_large": "Maximum 2000 tracks and 2000 description characters",
"conflict": "This playlist changed on another device. Close the editor and try again with the refreshed data." "conflict": "This playlist changed on another device. Close the editor and try again with the refreshed data."
},
"management": {
"unavailable": "Server management agent is unavailable. Run sudo bash deploy/install-management.sh on the server and check pipu-management.service.",
"timeout": "The agent timed out. Check operation status before retrying.",
"busy": "A server operation is already running. Wait for it to finish.",
"source_invalid": "Check source names and URLs. Embedded passwords and tokens are not supported.",
"source_unavailable": "The repository is unavailable from the server.",
"target_invalid": "The selected version is unavailable or changed. Refresh the list and try again.",
"no_releases": "No stable release tags were found.",
"config_conflict": "Another administrator changed the configuration. Reload the page and repeat your edits.",
"config_invalid": "Invalid value or Compose configuration. Changes were not saved.",
"config_readonly": "This setting cannot be changed in the interface.",
"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."
} }
} }
+86 -2
View File
@@ -32,7 +32,10 @@
"recs_confirm": "Сбросить рекомендации {{username}}? Волна начнёт подбирать музыку с нуля и снова спросит любимых исполнителей. Лайки и история останутся.", "recs_confirm": "Сбросить рекомендации {{username}}? Волна начнёт подбирать музыку с нуля и снова спросит любимых исполнителей. Лайки и история останутся.",
"recs_done": "Рекомендации {{username}} сброшены", "recs_done": "Рекомендации {{username}} сброшены",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"you": "вы" "you": "вы",
"manage_updates": "Обновление сервера",
"grant_updates": "Дать управление обновлениями",
"revoke_updates": "Забрать управление обновлениями"
}, },
"create": { "create": {
"title": "Новый пользователь", "title": "Новый пользователь",
@@ -71,7 +74,8 @@
"user.unblock": "Пользователь разблокирован", "user.unblock": "Пользователь разблокирован",
"user.password_reset": "Сброшен пароль", "user.password_reset": "Сброшен пароль",
"user.recommendations_reset": "Сброшены рекомендации", "user.recommendations_reset": "Сброшены рекомендации",
"user.delete": "Пользователь удалён" "user.delete": "Пользователь удалён",
"user.manages_updates": "Изменены права обновления сервера"
} }
}, },
"sources": { "sources": {
@@ -378,5 +382,85 @@
"server_settings": { "server_settings": {
"title": "Настройки сервера", "title": "Настройки сервера",
"subtitle": "Параметры, которые действуют для всех пользователей" "subtitle": "Параметры, которые действуют для всех пользователей"
},
"management": {
"server": "Управление сервером",
"updates": "Обновления",
"config": "Конфигурация",
"updates_intro": "Выберите источник и версию. Сервер выполнит сборку и покажет результат.",
"refresh": "Обновить статус",
"current": "Установленная версия",
"manual": "Обновления запускаются вручную. Последний стабильный релиз выбирается в момент запуска.",
"update_target": "Следующее обновление",
"sources": "Источники обновлений",
"source": "Источник",
"channel": "Тип обновления",
"channel_latest": "Последний релиз",
"channel_version": "Выбрать версию",
"channel_main": "main",
"hint_latest": "Будет установлен последний стабильный тег без release candidate.",
"hint_version": "Можно выбрать опубликованный тег. Переход на старую версию требует ручного отката.",
"hint_main": "Текущее состояние ветки main. Может содержать ещё не выпущенные изменения.",
"version": "Версия",
"choose_version": "Выберите версию",
"source_unavailable": "Не удалось прочитать репозиторий. Проверьте адрес, доступ сервера и SSH-ключи.",
"install": "Установить обновление",
"confirm_update": "Обновить сервер?",
"confirm_update_text": "Источник: {{source}}. Цель: {{target}}.",
"update_warning": "Во время перезапуска сервис временно недоступен. Перед обновлением сделайте резервную копию базы. Миграции назад не откатываются.",
"cancel": "Отмена",
"sources_hint": "Git-репозитории с совместимой структурой Pipu. HTTP(S) или SSH; для приватных репозиториев настройте ключи на хосте агента. Не вставляйте токены в адрес.",
"source_name": "Название",
"repository": "URL Git-репозитория",
"remove_source": "Удалить источник",
"add_source": "Добавить источник",
"save": "Сохранить",
"job": "Последняя операция",
"started": "Начало",
"finished": "Завершение",
"log": "Журнал выполнения",
"waiting": "Ожидаем вывод команды…",
"running_hint": "Задача выполняется на сервере. Страницу можно закрыть — выполнение продолжится.",
"reconnecting": "Сервисы перезапускаются. Ждём восстановления связи с сервером…",
"state": {
"running": "Выполняется",
"succeeded": "Готово",
"failed": "Ошибка"
},
"config_intro": "Настройки API, рекомендателя и развёртывания в deploy/config.local.",
"priority": "Порядок приоритета",
"defaults": "по умолчанию",
"priority_hint": "Значения из .env перекрывают config.local. Сохранение и применение — отдельные действия.",
"search": "Поиск по названию настройки",
"group": "Раздел",
"all": "Все разделы",
"groups": {
"api": "API",
"deployment": "Развёртывание",
"shared": "Общие",
"recommender": "Рекомендатель"
},
"config_files_hint": "Показаны поддерживаемые параметры. «После применения» — значение из .env, config.local или значение по умолчанию. Фиксированные пути и служебные параметры доступны для просмотра. Секреты можно заменить, их значения скрыты.",
"value_source": {
"env": "Из .env",
"local": "Из config.local",
"default": "По умолчанию"
},
"effective": "После применения",
"readonly": "Служебный параметр. Меняется на хосте с учётом конфигурации Compose.",
"secret_saved": "Секрет сохранён",
"will_reset": "Локальное значение будет удалено при сохранении.",
"overridden": "В .env есть значение: оно имеет приоритет. Локальное изменение пока не вступит в силу.",
"secret_hint": "Оставьте поле без изменений, чтобы сохранить секрет. Введите новое значение для замены.",
"default": "По умолчанию",
"reset": "Сбросить локальное",
"no_results": "Настройки не найдены",
"saved": "Изменения сохранены в config.local",
"unsaved": "Несохранённых параметров: {{count}}",
"pending_restart": "Сохранённые изменения ожидают применения",
"no_changes": "Нет изменений для применения",
"discard": "Отменить правки",
"apply": "Применить и перезапустить",
"apply_warning": "Контейнеры будут пересозданы с сохранённой конфигурацией. Сервис временно недоступен; данные в постоянных томах сохранятся. Значения из .env продолжат иметь приоритет."
} }
} }
+3 -1
View File
@@ -62,7 +62,9 @@
"suggest": "Предложить треки", "suggest": "Предложить треки",
"my_uploads": "Мои предложения", "my_uploads": "Мои предложения",
"upload_queue": "Очередь", "upload_queue": "Очередь",
"admin_settings": "Настройки" "admin_settings": "Настройки",
"server_updates": "Обновления",
"server_config": "Конфигурация"
}, },
"profile": { "profile": {
"menu": "Меню профиля", "menu": "Меню профиля",
+16
View File
@@ -101,5 +101,21 @@
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке", "invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
"too_large": "Максимум 2000 треков и 2000 символов описания", "too_large": "Максимум 2000 треков и 2000 символов описания",
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными." "conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
},
"management": {
"unavailable": "Агент управления сервером недоступен. Выполните sudo bash deploy/install-management.sh на сервере и проверьте pipu-management.service.",
"timeout": "Агент не ответил вовремя. Проверьте статус операции перед повторным запуском.",
"busy": "На сервере уже выполняется операция. Дождитесь завершения.",
"source_invalid": "Проверьте названия и адреса источников. Токены и пароли в URL не поддерживаются.",
"source_unavailable": "Репозиторий недоступен с сервера.",
"target_invalid": "Выбранная версия недоступна или изменилась. Обновите список и повторите.",
"no_releases": "В источнике нет стабильных релизных тегов.",
"config_conflict": "Конфигурация изменена другим администратором. Обновите страницу и повторите правки.",
"config_invalid": "Недопустимое значение или конфигурация Compose. Изменения не сохранены.",
"config_readonly": "Этот параметр нельзя менять через интерфейс.",
"agent_error": "Агент не смог выполнить операцию. Проверьте journalctl -u pipu-management.",
"request_expired": "Запрос устарел. Проверьте статус перед повтором.",
"request_invalid": "Недопустимый запрос управления сервером.",
"operation_invalid": "Операция управления сервером не поддерживается."
} }
} }
+2
View File
@@ -23,6 +23,7 @@ import (
"pipu/api/internal/buildinfo" "pipu/api/internal/buildinfo"
"pipu/api/internal/genius" "pipu/api/internal/genius"
"pipu/api/internal/library" "pipu/api/internal/library"
"pipu/api/internal/management"
"pipu/api/internal/platform/config" "pipu/api/internal/platform/config"
"pipu/api/internal/platform/db" "pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx" "pipu/api/internal/platform/httpx"
@@ -172,6 +173,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Client: &http.Client{}}, libStore, dto, log) Client: &http.Client{}}, libStore, dto, log)
wh.Warm = pb.Prewarm wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{ return server.NewRouter(server.Deps{
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
Genius: genius.NewHandler(geniusSvc), Genius: genius.NewHandler(geniusSvc),
Log: log, Log: log,
System: sys, System: sys,
File diff suppressed because it is too large. Load diff
+14 -3
View File
@@ -78,6 +78,7 @@ type CreateUserInput struct {
Username string Username string
DisplayName string DisplayName string
Role Role Role Role
ManagesUpdates bool
ReviewsUploads bool ReviewsUploads bool
} }
@@ -89,7 +90,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) { if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
return User{}, "", forbidden() return User{}, "", forbidden()
} }
if in.ReviewsUploads && p.Role != RoleAdmin { if (in.ReviewsUploads || in.ManagesUpdates) && p.Role != RoleAdmin {
return User{}, "", forbidden() return User{}, "", forbidden()
} }
username := NormalizeUsername(in.Username) username := NormalizeUsername(in.Username)
@@ -114,7 +115,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
} }
u := User{ u := User{
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role, ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role,
ReviewsUploads: in.ReviewsUploads, Locale: locale, ReviewsUploads: in.ReviewsUploads, ManagesUpdates: in.ManagesUpdates, Locale: locale,
MustChangePassword: true, CreatedAt: s.clock.Now(), MustChangePassword: true, CreatedAt: s.clock.Now(),
} }
err = s.store.WithTx(ctx, func(tx Store) error { err = s.store.WithTx(ctx, func(tx Store) error {
@@ -123,7 +124,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
} }
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID, ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads}, Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads, "manages_updates": in.ManagesUpdates},
}) })
}) })
if errors.Is(err, ErrUsernameTaken) { if errors.Is(err, ErrUsernameTaken) {
@@ -154,6 +155,7 @@ type UserPatch struct {
DisplayName *string DisplayName *string
Role *Role Role *Role
IsBlocked *bool IsBlocked *bool
ManagesUpdates *bool
ReviewsUploads *bool ReviewsUploads *bool
} }
@@ -218,6 +220,15 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
upd.ReviewsUploads = patch.ReviewsUploads upd.ReviewsUploads = patch.ReviewsUploads
audit("user.reviews_uploads", map[string]any{"reviews_uploads": *patch.ReviewsUploads}) audit("user.reviews_uploads", map[string]any{"reviews_uploads": *patch.ReviewsUploads})
} }
if patch.ManagesUpdates != nil {
if p.Role != RoleAdmin {
return forbidden()
}
if *patch.ManagesUpdates != target.ManagesUpdates {
upd.ManagesUpdates = patch.ManagesUpdates
audit("user.manages_updates", map[string]any{"manages_updates": *patch.ManagesUpdates})
}
}
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
if blockChange { if blockChange {
if !CanManageUser(actor, tgt, UserBlock) { if !CanManageUser(actor, tgt, UserBlock) {
+5 -1
View File
@@ -44,6 +44,9 @@ func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObjec
return nil, err return nil, err
} }
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)} in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
if req.Body.ManagesUpdates != nil {
in.ManagesUpdates = *req.Body.ManagesUpdates
}
if req.Body.ReviewsUploads != nil { if req.Body.ReviewsUploads != nil {
in.ReviewsUploads = *req.Body.ReviewsUploads in.ReviewsUploads = *req.Body.ReviewsUploads
} }
@@ -63,7 +66,7 @@ func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObjec
if err != nil { if err != nil {
return nil, err return nil, err
} }
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads} patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads, ManagesUpdates: req.Body.ManagesUpdates}
if req.Body.Role != nil { if req.Body.Role != nil {
r := Role(*req.Body.Role) r := Role(*req.Body.Role)
patch.Role = &r patch.Role = &r
@@ -151,6 +154,7 @@ func AdminUserDTO(u User) gen.AdminUser {
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Role: gen.Role(u.Role), Role: gen.Role(u.Role),
ReviewsUploads: u.ReviewsUploads, ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: updatePermission(u),
Locale: u.Locale, Locale: u.Locale,
IsBlocked: u.IsBlocked, IsBlocked: u.IsBlocked,
MustChangePassword: u.MustChangePassword, MustChangePassword: u.MustChangePassword,
+22
View File
@@ -448,3 +448,25 @@ func TestReviewUploadsGrant(t *testing.T) {
me = e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)) me = e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod))
assert.Equal(t, true, me.body["reviews_uploads"]) assert.Equal(t, true, me.body["reviews_uploads"])
} }
func TestManageUpdatesGrantAndRevoke(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
target := e.addUser("mod", "mod-password-1", auth.RoleModerator)
mod := e.signIn("mod", "mod-password-1")
path := "/api/v1/admin/users/" + target.ID.String()
grant := map[string]any{"manages_updates": true}
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(admin)).body["manages_updates"])
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, path, grant, bearer(mod)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(mod)).code())
r := e.do(http.MethodPatch, path, grant, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, true, r.body["manages_updates"])
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
r = e.do(http.MethodPatch, path, map[string]any{"manages_updates": false}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
r = e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(admin))
require.Equal(t, http.StatusCreated, r.Code, r.Body.String())
}
@@ -317,6 +317,9 @@ func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUp
if upd.ReviewsUploads != nil { if upd.ReviewsUploads != nil {
u.ReviewsUploads = *upd.ReviewsUploads u.ReviewsUploads = *upd.ReviewsUploads
} }
if upd.ManagesUpdates != nil {
u.ManagesUpdates = *upd.ManagesUpdates
}
if upd.RevokeTokens { if upd.RevokeTokens {
u.TokenVersion++ u.TokenVersion++
} }
+3
View File
@@ -232,6 +232,7 @@ func MeDTO(u User) gen.Me {
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Role: gen.Role(u.Role), Role: gen.Role(u.Role),
ReviewsUploads: u.ReviewsUploads, ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: updatePermission(u),
Locale: u.Locale, Locale: u.Locale,
UiPrefs: uiPrefsDTO(u.UIPrefs), UiPrefs: uiPrefsDTO(u.UIPrefs),
MustChangePassword: u.MustChangePassword, MustChangePassword: u.MustChangePassword,
@@ -271,3 +272,5 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Normalize: p.Normalize, Normalize: p.Normalize,
} }
} }
func updatePermission(u User) *bool { v := u.ManagesUpdates || u.Role == RoleAdmin; return &v }
+2
View File
@@ -55,6 +55,7 @@ type User struct {
DisplayName *string DisplayName *string
PasswordHash string PasswordHash string
Role Role Role Role
ManagesUpdates bool
ReviewsUploads bool // аудит очереди на добавление; не следует из роли ReviewsUploads bool // аудит очереди на добавление; не следует из роли
Locale string Locale string
UIPrefs UIPrefs UIPrefs UIPrefs
@@ -96,6 +97,7 @@ type Session struct {
type Principal struct { type Principal struct {
UserID uuid.UUID UserID uuid.UUID
Role Role Role Role
ManagesUpdates bool
ReviewsUploads bool ReviewsUploads bool
SessionID uuid.UUID SessionID uuid.UUID
MustChangePassword bool MustChangePassword bool
+7 -6
View File
@@ -40,7 +40,7 @@ func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error {
const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password, const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password,
must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at, must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at,
reviews_uploads` reviews_uploads, manages_updates`
func scanUser(row pgx.Row) (User, error) { func scanUser(row pgx.Row) (User, error) {
var ( var (
@@ -50,7 +50,7 @@ func scanUser(row pgx.Row) (User, error) {
) )
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs, err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs,
&u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount, &u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount,
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads) &u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads, &u.ManagesUpdates)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound return User{}, ErrNotFound
} }
@@ -82,10 +82,10 @@ func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID)
} }
_, err = s.q.Exec(ctx, ` _, err = s.q.Exec(ctx, `
INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs, INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs,
must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads) must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads, manages_updates)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13)`,
u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs, u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs,
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads) u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads, u.ManagesUpdates)
if isUniqueViolation(err) { if isUniqueViolation(err) {
return ErrUsernameTaken return ErrUsernameTaken
} }
@@ -347,10 +347,11 @@ func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate)
role = COALESCE($4::user_role, role), role = COALESCE($4::user_role, role),
is_blocked = COALESCE($5, is_blocked), is_blocked = COALESCE($5, is_blocked),
reviews_uploads = COALESCE($7, reviews_uploads), reviews_uploads = COALESCE($7, reviews_uploads),
manages_updates = COALESCE($9, manages_updates),
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END, token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username)) RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username, upd.ManagesUpdates))
if isUniqueViolation(err) { if isUniqueViolation(err) {
return User{}, ErrUsernameTaken return User{}, ErrUsernameTaken
} }
+8
View File
@@ -16,6 +16,7 @@ const (
PermSuggestTracks // предлагать треки, видеть свои предложения PermSuggestTracks // предлагать треки, видеть свои предложения
PermReviewUploads // очередь, одобрение, правка метаданных; даёт флаг, не роль PermReviewUploads // очередь, одобрение, правка метаданных; даёт флаг, не роль
PermSystemStatus // статус рекомендательной системы, аудит PermSystemStatus // статус рекомендательной системы, аудит
PermManageUpdates // обновление сервера; admin или выданное право
) )
var permissions = map[Permission][]Role{ var permissions = map[Permission][]Role{
@@ -25,6 +26,7 @@ var permissions = map[Permission][]Role{
PermViewLibraryStatus: {RoleModerator, RoleAdmin}, PermViewLibraryStatus: {RoleModerator, RoleAdmin},
PermSuggestTracks: {RoleModerator, RoleAdmin}, PermSuggestTracks: {RoleModerator, RoleAdmin},
PermSystemStatus: {RoleAdmin}, PermSystemStatus: {RoleAdmin},
PermManageUpdates: {RoleAdmin},
} }
// Can сообщает, есть ли у роли глобальное право. // Can сообщает, есть ли у роли глобальное право.
@@ -40,6 +42,12 @@ func Can(r Role, p Permission) bool {
// Allowed — право конкретного пользователя. Аудит загрузок не вытекает из роли admin. // Allowed — право конкретного пользователя. Аудит загрузок не вытекает из роли admin.
func (p Principal) Allowed(perm Permission) bool { func (p Principal) Allowed(perm Permission) bool {
if perm == PermManageUpdates {
return p.Role == RoleAdmin || p.ManagesUpdates
}
if perm == PermAdminPanel && p.ManagesUpdates {
return true
}
if perm == PermReviewUploads { if perm == PermReviewUploads {
return p.ReviewsUploads return p.ReviewsUploads
} }
@@ -21,6 +21,7 @@ func TestCan(t *testing.T) {
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}}, {PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}}, {PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
{PermSystemStatus, "system status", [3]bool{false, false, true}}, {PermSystemStatus, "system status", [3]bool{false, false, true}},
{PermManageUpdates, "manage updates", [3]bool{false, false, true}},
} }
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin} roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
for _, row := range matrix { for _, row := range matrix {
+1
View File
@@ -369,6 +369,7 @@ func (s *Service) Authenticate(ctx context.Context, access string) (Principal, e
UserID: u.ID, UserID: u.ID,
Role: u.Role, Role: u.Role,
ReviewsUploads: u.ReviewsUploads, ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: u.ManagesUpdates || u.Role == RoleAdmin,
SessionID: claims.SessionID, SessionID: claims.SessionID,
MustChangePassword: u.MustChangePassword, MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials, MustChangeCredentials: u.MustChangeCredentials,
+1
View File
@@ -35,6 +35,7 @@ type UserUpdate struct {
DisplayName **string DisplayName **string
Role *Role Role *Role
IsBlocked *bool IsBlocked *bool
ManagesUpdates *bool
ReviewsUploads *bool ReviewsUploads *bool
RevokeTokens bool RevokeTokens bool
} }
+148
View File
@@ -0,0 +1,148 @@
// Package management authorizes server administration and bridges fixed operations
// to the host worker. The API never receives a Docker socket or executes a shell.
package management
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/platform/apperr"
)
type Bridge interface {
Call(context.Context, string, string, any, any) error
}
type Mailbox struct{ Dir string }
func (m Mailbox) Call(ctx context.Context, actor, operation string, body, out any) error {
heartbeat, err := os.ReadFile(filepath.Join(m.Dir, "heartbeat"))
if err != nil {
return apperr.New(503, "management.unavailable", "host agent is not installed or running")
}
at, err := strconv.ParseFloat(strings.TrimSpace(string(heartbeat)), 64)
if err != nil || time.Since(time.Unix(int64(at), 0)) > 60*time.Second {
return apperr.New(503, "management.unavailable", "host agent heartbeat expired")
}
id := uuid.NewString()
request := filepath.Join(m.Dir, id+".request.json")
response := filepath.Join(m.Dir, id+".response.json")
raw, err := json.Marshal(map[string]any{"operation": operation, "actor": actor, "body": body, "created_at": time.Now().Unix()})
if err != nil {
return err
}
tmp, err := os.CreateTemp(m.Dir, ".request-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err = tmp.Write(raw); err != nil {
tmp.Close()
return err
}
if err = tmp.Close(); err != nil {
return err
}
if err = os.Rename(tmp.Name(), request); err != nil {
return err
}
defer os.Remove(response)
defer os.Remove(request)
ctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return apperr.New(504, "management.timeout", "host agent did not respond; check status before retrying")
case <-ticker.C:
raw, err := os.ReadFile(response)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
var result struct {
Data json.RawMessage `json:"data"`
Code string `json:"code"`
Status int `json:"status"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return err
}
if result.Code != "" {
status := result.Status
if status < 400 || status > 599 {
status = 502
}
return apperr.New(status, result.Code, "host management operation failed")
}
return json.Unmarshal(result.Data, out)
}
}
}
type Handler struct{ bridge Bridge }
func NewHandler(bridge Bridge) *Handler { return &Handler{bridge: bridge} }
func (h *Handler) call(ctx context.Context, admin bool, operation string, body, out any) error {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return apperr.Unauthorized(auth.CodeUnauthorized, "authentication required")
}
if (admin && p.Role != auth.RoleAdmin) || (!admin && !p.Allowed(auth.PermManageUpdates)) {
return apperr.Forbidden(auth.CodeForbidden, "server management permission required")
}
return h.bridge.Call(ctx, p.UserID.String(), operation, body, out)
}
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)
out.CurrentVersion = buildinfo.Version
return gen.GetServerUpdates200JSONResponse(out), err
}
func (h *Handler) SaveServerUpdateSources(ctx context.Context, req gen.SaveServerUpdateSourcesRequestObject) (gen.SaveServerUpdateSourcesResponseObject, error) {
var out gen.ServerSources
err := h.call(ctx, false, "sources.save", req.Body, &out)
return gen.SaveServerUpdateSources200JSONResponse(out), err
}
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)
return gen.StartServerUpdate202JSONResponse(out), err
}
func (h *Handler) GetServerConfig(ctx context.Context, _ gen.GetServerConfigRequestObject) (gen.GetServerConfigResponseObject, error) {
var out gen.ServerConfig
err := h.call(ctx, true, "config.get", nil, &out)
return gen.GetServerConfig200JSONResponse(out), err
}
func (h *Handler) SaveServerConfig(ctx context.Context, req gen.SaveServerConfigRequestObject) (gen.SaveServerConfigResponseObject, error) {
var out gen.ServerConfig
err := h.call(ctx, true, "config.save", req.Body, &out)
return gen.SaveServerConfig200JSONResponse(out), err
}
func (h *Handler) ApplyServerConfig(ctx context.Context, _ gen.ApplyServerConfigRequestObject) (gen.ApplyServerConfigResponseObject, error) {
var out gen.ServerJob
err := h.call(ctx, true, "config.apply", nil, &out)
return gen.ApplyServerConfig202JSONResponse(out), err
}
@@ -0,0 +1,106 @@
package management_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/management"
"pipu/api/internal/server"
)
type bridge struct{ calls int }
func (b *bridge) Call(_ context.Context, _, _ string, _, out any) error {
b.calls++
return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out)
}
func TestServerManagementAuthorization(t *testing.T) {
for _, tc := range []struct {
name string
principal auth.Principal
updates, config int
}{
{"user", auth.Principal{Role: auth.RoleUser}, 403, 403},
{"moderator", auth.Principal{Role: auth.RoleModerator}, 403, 403},
{"delegate", auth.Principal{Role: auth.RoleUser, ManagesUpdates: true}, 200, 403},
{"admin", auth.Principal{Role: auth.RoleAdmin}, 200, 200},
} {
t.Run(tc.name, func(t *testing.T) {
b := &bridge{}
router := server.NewRouter(server.Deps{Management: management.NewHandler(b)})
for _, op := range []struct {
method, path, body string
want int
}{
{"GET", "updates", "", tc.updates},
{"POST", "updates", `{"source_id":"origin","channel":"main"}`, tc.updates},
{"PUT", "update-sources", `{"sources":[]}`, tc.updates},
{"GET", "config", "", tc.config},
{"PATCH", "config", `{"revision":"r","values":{}}`, tc.config},
{"POST", "config/apply", "", tc.config},
} {
request := httptest.NewRequest(op.method, "/api/v1/admin/server/"+op.path, strings.NewReader(op.body))
request.Header.Set("Content-Type", "application/json")
request = request.WithContext(auth.WithPrincipal(request.Context(), tc.principal))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, request)
want := op.want
if op.method == "POST" && want == 200 {
want = 202
}
require.Equal(t, want, rec.Code, rec.Body.String())
}
if tc.updates == 403 && tc.config == 403 {
require.Zero(t, b.calls)
}
})
}
t.Run("anonymous", func(t *testing.T) {
router := server.NewRouter(server.Deps{Management: management.NewHandler(&bridge{})})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/server/config", nil))
require.Equal(t, 401, rec.Code)
})
}
func TestMailboxRoundTripAndUnavailable(t *testing.T) {
dir := t.TempDir()
m := management.Mailbox{Dir: dir}
var out map[string]any
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("1"), 0600))
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte(time.Now().Format("invalid")), 0600))
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
raw, _ := json.Marshal(time.Now().Unix())
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), raw, 0600))
done := make(chan error, 1)
go func() { done <- m.Call(context.Background(), "actor", "config.get", nil, &out) }()
var requests []string
require.Eventually(t, func() bool {
requests, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(requests) == 1
}, time.Second, 10*time.Millisecond)
request, err := os.ReadFile(requests[0])
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(request, &payload))
require.Equal(t, "config.get", payload["operation"])
require.Equal(t, "actor", payload["actor"])
response := strings.Replace(requests[0], ".request.json", ".response.json", 1)
require.NoError(t, os.WriteFile(response, []byte(`{"data":{"revision":"ok"}}`), 0600))
require.NoError(t, <-done)
require.Equal(t, "ok", out["revision"])
remaining, _ := filepath.Glob(filepath.Join(dir, "*.json"))
require.Empty(t, remaining)
}
@@ -10,6 +10,7 @@ import (
// API — конфигурация публичного сервиса api. // API — конфигурация публичного сервиса api.
type API struct { type API struct {
ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"`
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"` HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"` DatabaseURL string `env:"DATABASE_URL,required"`
RedisURL string `env:"REDIS_URL,required"` RedisURL string `env:"REDIS_URL,required"`
+5 -1
View File
@@ -13,6 +13,7 @@ import (
"pipu/api/internal/auth" "pipu/api/internal/auth"
"pipu/api/internal/genius" "pipu/api/internal/genius"
"pipu/api/internal/library" "pipu/api/internal/library"
"pipu/api/internal/management"
"pipu/api/internal/platform/httpx" "pipu/api/internal/platform/httpx"
"pipu/api/internal/playback" "pipu/api/internal/playback"
"pipu/api/internal/search" "pipu/api/internal/search"
@@ -23,6 +24,7 @@ import (
// Deps — модули, реализующие части контракта API. // Deps — модули, реализующие части контракта API.
type Deps struct { type Deps struct {
Management *management.Handler
Genius *genius.Handler Genius *genius.Handler
Log *slog.Logger Log *slog.Logger
System *system.Handler System *system.Handler
@@ -46,6 +48,7 @@ type Deps struct {
// По мере появления модулей (catalog, ...) они встраиваются сюда. // По мере появления модулей (catalog, ...) они встраиваются сюда.
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler. // Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
type ( type (
managementHandler = management.Handler
geniusHandler = genius.Handler geniusHandler = genius.Handler
systemHandler = system.Handler systemHandler = system.Handler
authHandler = auth.Handler authHandler = auth.Handler
@@ -58,6 +61,7 @@ type (
) )
type handlers struct { type handlers struct {
*managementHandler
*geniusHandler *geniusHandler
*systemHandler *systemHandler
*authHandler *authHandler
@@ -89,7 +93,7 @@ func NewRouter(d Deps) http.Handler {
d.TUS.Mount(r) d.TUS.Mount(r)
} }
strict := gen.NewStrictHandlerWithOptions(handlers{geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads}, strict := gen.NewStrictHandlerWithOptions(handlers{managementHandler: d.Management, geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{ []gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError, RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log), ResponseErrorHandlerFunc: httpx.ResponseError(log),
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE users ADD COLUMN manages_updates boolean NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE users DROP COLUMN manages_updates;