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
+3604 -353

No files matched your search

+38
View File
@@ -79,12 +79,27 @@ export function fakeApi(initial: FakeUser[]) {
const playlistOwners = new Map<string, string>();
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 => ({
id: u.id,
username: u.username,
display_name: u.display_name,
role: u.role,
reviews_uploads: u.reviews_uploads,
manages_updates: u.role === 'admin' || !!u.manages_updates,
locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password,
@@ -107,6 +122,25 @@ export function fakeApi(initial: FakeUser[]) {
calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`;
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) {
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' }] });
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 (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.reviews_uploads = body.reviews_uploads;