feat(admin): transfer user accounts and show local cover coverage
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
This commit is contained in:
1 parent
0a4cd4bff1
commit
f1528de948
22 files changed
+1661
-313
No files matched your search
@@ -460,6 +460,61 @@ paths:
|
|||||||
$ref: "#/components/schemas/CreatedUser"
|
$ref: "#/components/schemas/CreatedUser"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/admin/sources/{id}/cover-stats:
|
||||||
|
get:
|
||||||
|
operationId: getSourceCoverStats
|
||||||
|
tags: [admin]
|
||||||
|
summary: Покрытие аудиофайлов локальными обложками (admin)
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SourceId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Доступные аудиофайлы без исключённых папок; внешние обложки не учитываются
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SourceCoverStats"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/admin/users/export:
|
||||||
|
get:
|
||||||
|
operationId: exportUsers
|
||||||
|
tags: [admin]
|
||||||
|
summary: Экспорт учётных записей с ролями и хэшами паролей (admin)
|
||||||
|
description: Только учётные записи и права доступа, без рекомендаций, истории, лайков и сессий. Ответ не кэшируется.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Файл переноса пользователей
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserArchive"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/admin/users/import:
|
||||||
|
post:
|
||||||
|
operationId: importUsers
|
||||||
|
tags: [admin]
|
||||||
|
summary: Импорт учётных записей с сохранением паролей (admin)
|
||||||
|
description: |
|
||||||
|
Атомарное добавление пользователей из экспорта, до 10000 записей и 16 MiB.
|
||||||
|
Существующие логины пропускаются без изменений. Новые записи получают новые ID.
|
||||||
|
Рекомендации, история, лайки и сессии не импортируются.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserArchive"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Результат импорта
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserImportResult"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/admin/users/{id}:
|
/api/v1/admin/users/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/UserId"
|
- $ref: "#/components/parameters/UserId"
|
||||||
@@ -2730,6 +2785,70 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
new_password:
|
new_password:
|
||||||
type: string
|
type: string
|
||||||
|
SourceCoverStats:
|
||||||
|
type: object
|
||||||
|
required: [files_total, files_with_cover, albums_pending]
|
||||||
|
properties:
|
||||||
|
files_total:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
files_with_cover:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Файлы, альбом которых имеет готовую локальную обложку (встроенную или из папки)
|
||||||
|
albums_pending:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
UserArchive:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [version, users]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: integer
|
||||||
|
enum: [1]
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
maxItems: 10000
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/UserArchiveEntry"
|
||||||
|
UserArchiveEntry:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [username, display_name, role, password_hash, is_blocked, must_change_password, must_change_credentials, reviews_uploads, manages_updates]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 64
|
||||||
|
role:
|
||||||
|
$ref: "#/components/schemas/Role"
|
||||||
|
password_hash:
|
||||||
|
type: string
|
||||||
|
maxLength: 512
|
||||||
|
description: Argon2id PHC. Секретные данные; файл нужно хранить в защищённом месте.
|
||||||
|
is_blocked:
|
||||||
|
type: boolean
|
||||||
|
must_change_password:
|
||||||
|
type: boolean
|
||||||
|
must_change_credentials:
|
||||||
|
type: boolean
|
||||||
|
reviews_uploads:
|
||||||
|
type: boolean
|
||||||
|
manages_updates:
|
||||||
|
type: boolean
|
||||||
|
UserImportResult:
|
||||||
|
type: object
|
||||||
|
required: [created, skipped]
|
||||||
|
properties:
|
||||||
|
created:
|
||||||
|
type: integer
|
||||||
|
skipped:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
AdminUser:
|
AdminUser:
|
||||||
type: object
|
type: object
|
||||||
required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
async function login(page: Page, username = 'mobile-admin', target = '/admin/users') {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
|
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||||
|
await page.getByLabel('Логин', { exact: true }).fill(username);
|
||||||
|
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
|
||||||
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [320, 1280]) {
|
||||||
|
test(`user transfer and cover counts at ${width}px`, async ({ page }, info) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await login(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const original = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const req = new Request(input, init);
|
||||||
|
const path = new URL(req.url).pathname;
|
||||||
|
if (path === '/api/v1/admin/users/export') return new Response(JSON.stringify({
|
||||||
|
version: 1, users: [{ username: 'alice', role: 'user', password_hash: '$argon2id$test' }],
|
||||||
|
}), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (path === '/api/v1/admin/users/import') {
|
||||||
|
const archive = await req.json();
|
||||||
|
if (archive.users[0].password_hash !== '$argon2id$test') throw new Error('lost hash');
|
||||||
|
return new Response(JSON.stringify({ created: 1, skipped: ['alice'] }), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Экспорт пользователей', exact: true }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe('pipu-users.json');
|
||||||
|
await page.getByRole('button', { name: 'Импорт пользователей', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Импорт пользователей' });
|
||||||
|
const file = dialog.getByLabel('Файл пользователей (.json)');
|
||||||
|
await file.setInputFiles({ name: 'bad.json', mimeType: 'application/json', buffer: Buffer.from('{}') });
|
||||||
|
await expect(dialog.getByRole('alert')).toContainText('Некорректный файл');
|
||||||
|
await expect(dialog.getByRole('button', { name: 'Импортировать', exact: true })).toBeDisabled();
|
||||||
|
await file.setInputFiles({ name: 'users.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({
|
||||||
|
version: 1, users: [{ username: 'new-user', role: 'moderator', password_hash: '$argon2id$test' }],
|
||||||
|
})) });
|
||||||
|
await expect(dialog).toContainText('Учётных записей в файле: 1');
|
||||||
|
await page.screenshot({ path: info.outputPath('user-import.png') });
|
||||||
|
await dialog.getByRole('button', { name: 'Импортировать', exact: true }).click();
|
||||||
|
await expect(dialog.getByRole('status')).toContainText('Создано: 1. Пропущено существующих: 1.');
|
||||||
|
await expect(dialog.getByRole('status')).toContainText('alice');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
if (width < 768) {
|
||||||
|
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
|
||||||
|
} else await page.evaluate(() => {
|
||||||
|
history.pushState({}, '', '/admin/sources');
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('article')).toContainText('Локальные обложки: 87 из 120 файлов');
|
||||||
|
await expect(page.getByRole('article')).toContainText('Альбомов ожидают обработки: 3');
|
||||||
|
await page.screenshot({ path: info.outputPath('source-covers.png') });
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('moderator cannot see user transfer controls', async ({ page }) => {
|
||||||
|
await login(page, 'moderator');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Экспорт пользователей', exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Импорт пользователей', exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, 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, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import { downloadUserArchive, MAX_ARCHIVE_BYTES, parseUserArchive, type UserArchive } from './transfer';
|
||||||
|
|
||||||
|
export function UserTransfer() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [archive, setArchive] = useState<UserArchive | null>(null);
|
||||||
|
const [fileError, setFileError] = useState(false);
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
|
const exporting = useMutation({
|
||||||
|
mutationFn: async () => downloadUserArchive(unwrap(await session.client.GET('/api/v1/admin/users/export'))),
|
||||||
|
});
|
||||||
|
const importing = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/users/import', { body: archive! })),
|
||||||
|
onSuccess: () => { setArchive(null); void queryClient.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||||
|
});
|
||||||
|
const busy = importing.isPending || reading;
|
||||||
|
const close = () => { if (!busy) { setOpen(false); setArchive(null); } };
|
||||||
|
|
||||||
|
const read = async (file: File | undefined) => {
|
||||||
|
setArchive(null);
|
||||||
|
setFileError(false);
|
||||||
|
importing.reset();
|
||||||
|
if (!file) return;
|
||||||
|
setReading(true);
|
||||||
|
try {
|
||||||
|
if (file.size > MAX_ARCHIVE_BYTES) throw new Error('size');
|
||||||
|
setArchive(parseUserArchive(await file.text()));
|
||||||
|
} catch { setFileError(true); }
|
||||||
|
finally { setReading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Button busy={exporting.isPending} onClick={() => exporting.mutate()}>{t('transfer.export')}</Button>
|
||||||
|
<Button onClick={() => { importing.reset(); setFileError(false); setOpen(true); }}>{t('transfer.import')}</Button>
|
||||||
|
<FormError>{exporting.error ? errorText(exporting.error) : null}</FormError>
|
||||||
|
{open && <Dialog title={t('transfer.import')} onClose={close} footer={<>
|
||||||
|
<Button variant="ghost" disabled={busy} onClick={close}>{t('common:form.close')}</Button>
|
||||||
|
<Button variant="primary" busy={importing.isPending} disabled={!archive || busy} onClick={() => importing.mutate()}>
|
||||||
|
{t('transfer.apply')}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<p className={s.note}>{t('transfer.description')}</p>
|
||||||
|
<p className={s.note}>{t('transfer.sensitive')}</p>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('transfer.file')}</span>
|
||||||
|
<input type="file" accept=".json,application/json" disabled={busy} onChange={(e) => void read(e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
{archive && <p className={s.note}>{t('transfer.preview', { count: archive.users.length })}</p>}
|
||||||
|
<FormError>{fileError ? t('transfer.invalid') : importing.error ? errorText(importing.error) : null}</FormError>
|
||||||
|
{importing.data && <div role="status">
|
||||||
|
<p>{t('transfer.result', { count: importing.data.created, skipped: importing.data.skipped.length })}</p>
|
||||||
|
{importing.data.skipped.length > 0 && <p className={s.note}>{importing.data.skipped.join(', ')}</p>}
|
||||||
|
</div>}
|
||||||
|
</Dialog>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseUserArchive } from './transfer';
|
||||||
|
|
||||||
|
describe('user archive preview', () => {
|
||||||
|
const entry = { username: 'alice', role: 'user', password_hash: '$argon2id$hash' };
|
||||||
|
it('accepts a versioned export and a UTF-8 BOM', () => {
|
||||||
|
const archive = { version: 1, users: [entry] };
|
||||||
|
expect(parseUserArchive(`\uFEFF${JSON.stringify(archive)}`)).toEqual(archive);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
'{}', '{"version":2,"users":[]}', '{"version":1,"users":{}}',
|
||||||
|
JSON.stringify({ version: 1, users: [{ ...entry, role: 'owner' }] }),
|
||||||
|
JSON.stringify({ version: 1, users: [{ username: 'alice', role: 'user' }] }),
|
||||||
|
JSON.stringify({ version: 1, users: Array(10001).fill(entry) }),
|
||||||
|
])('rejects unsupported or malformed input', (text) => {
|
||||||
|
expect(() => parseUserArchive(text)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
export type UserArchive = components['schemas']['UserArchive'];
|
||||||
|
export const MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Only previews metadata; the server validates every account before writing. */
|
||||||
|
export function parseUserArchive(text: string): UserArchive {
|
||||||
|
const data: unknown = JSON.parse(text.replace(/^\uFEFF/, ''));
|
||||||
|
if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 ||
|
||||||
|
!('users' in data) || !Array.isArray(data.users) || data.users.length > 10000) throw new Error('archive');
|
||||||
|
for (const entry of data.users) {
|
||||||
|
if (!entry || typeof entry !== 'object' || typeof entry.username !== 'string' ||
|
||||||
|
!['admin', 'moderator', 'user'].includes(entry.role) || typeof entry.password_hash !== 'string') {
|
||||||
|
throw new Error('archive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data as UserArchive;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadUserArchive(archive: UserArchive) {
|
||||||
|
const url = URL.createObjectURL(new Blob([JSON.stringify(archive, null, 2)], { type: 'application/json' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'pipu-users.json';
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
@@ -75,6 +75,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
|||||||
const [test, setTest] = useState<TestResult | null>(null);
|
const [test, setTest] = useState<TestResult | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const id = source.id;
|
const id = source.id;
|
||||||
|
const covers = useQuery({
|
||||||
|
queryKey: ['source-cover-stats', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}/cover-stats', { params: { path: { id } } })),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||||
|
|
||||||
const scan = useMutation({
|
const scan = useMutation({
|
||||||
@@ -144,6 +149,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
|||||||
</div>
|
</div>
|
||||||
<div className={l.cardFoot}>
|
<div className={l.cardFoot}>
|
||||||
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
|
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
|
||||||
|
{covers.data && <span className={s.note} role="status">
|
||||||
|
{t('sources.cover_files', { count: covers.data.files_with_cover, total: covers.data.files_total })}
|
||||||
|
{covers.data.albums_pending > 0 && ` · ${t('sources.cover_pending', { count: covers.data.albums_pending })}`}
|
||||||
|
</span>}
|
||||||
|
<FormError>{covers.error ? errorText(covers.error) : null}</FormError>
|
||||||
{test && (
|
{test && (
|
||||||
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
|
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
|
||||||
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
|
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState, type FormEvent } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../../app/session';
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { UserTransfer } from '../../features/users/UserTransfer';
|
||||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
import { formatRelative } from '../../shared/lib/format';
|
import { formatRelative } from '../../shared/lib/format';
|
||||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||||
@@ -163,6 +164,7 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.toolbar}>
|
<div className={s.toolbar}>
|
||||||
|
{me.role === 'admin' && <UserTransfer />}
|
||||||
<label className={s.search}>
|
<label className={s.search}>
|
||||||
<Icon src={searchIcon} size={18} className={s.searchIcon} />
|
<Icon src={searchIcon} size={18} className={s.searchIcon} />
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const file: components['schemas']['UploadFile'] = {
|
|||||||
export const mobileFixtures: Record<string, unknown> = {
|
export const mobileFixtures: Record<string, unknown> = {
|
||||||
'/api/v1/admin/sources': { items: [source] },
|
'/api/v1/admin/sources': { items: [source] },
|
||||||
[`/api/v1/admin/sources/${source.id}`]: source,
|
[`/api/v1/admin/sources/${source.id}`]: source,
|
||||||
|
[`/api/v1/admin/sources/${source.id}/cover-stats`]: { files_total: 120, files_with_cover: 87, albums_pending: 3 },
|
||||||
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
|
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
|
||||||
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
|
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
|
||||||
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
|
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
|
||||||
|
|||||||
@@ -56,6 +56,22 @@
|
|||||||
Аналогично для `must_change_password` (после сброса пароля админом / модератором): разрешён только
|
Аналогично для `must_change_password` (после сброса пароля админом / модератором): разрешён только
|
||||||
`POST /me/password` (без `current_password`, если вход был по временному паролю).
|
`POST /me/password` (без `current_password`, если вход был по временному паролю).
|
||||||
|
|
||||||
|
## Перенос пользователей через файл
|
||||||
|
|
||||||
|
Администратору на странице «Пользователи» доступны «Экспорт пользователей» и «Импорт пользователей».
|
||||||
|
JSON-файл версии 1 содержит логины, отображаемые имена, роли, дополнительные права аудита загрузок
|
||||||
|
и управления обновлениями, флаги блокировки и обязательной смены пароля, а также хэши Argon2id.
|
||||||
|
Действующие пароли сохраняются; открытые пароли в файле отсутствуют. Файл содержит секретные данные
|
||||||
|
и должен храниться в защищённом месте. Экспорт охватывает всех пользователей независимо от фильтров страницы.
|
||||||
|
|
||||||
|
Импорт добавляет отсутствующие логины с новыми ID. Существующие логины пропускаются без изменений
|
||||||
|
пароля, роли и данных пользователя; список пропущенных логинов показывается в результате.
|
||||||
|
Весь файл проверяется до записи, добавление выполняется атомарно. Ограничения: 10000 пользователей,
|
||||||
|
16 MiB; параметры импортированных хэшей ограничены для безопасной проверки пароля при следующем входе.
|
||||||
|
Рекомендации, история, лайки, плейлисты, сессии и настройки интерфейса не экспортируются и не импортируются.
|
||||||
|
Язык новых записей берётся из настроек сервера. Импорт и экспорт пишутся в журнал без хэшей паролей.
|
||||||
|
API: `GET /admin/users/export`, `POST /admin/users/import` (оба только для admin).
|
||||||
|
|
||||||
## Пароли
|
## Пароли
|
||||||
|
|
||||||
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
|
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
|
|||||||
общие файлы кэша; ошибки чтения и конвертации остаются для повтора при следующем запуске обработчика.
|
общие файлы кэша; ошибки чтения и конвертации остаются для повтора при следующем запуске обработчика.
|
||||||
Клиент получает ссылку на готовый WebP через API каталога; nginx раздаёт файл из кэша.
|
Клиент получает ссылку на готовый WebP через API каталога; nginx раздаёт файл из кэша.
|
||||||
|
|
||||||
|
В карточке источника показано «Локальные обложки: X из Y файлов» и число альбомов, ожидающих обработки.
|
||||||
|
Статистика обновляется каждые 10 секунд через `GET /admin/sources/{id}/cover-stats`.
|
||||||
|
Считаются аудиофайлы каталога без ошибок чтения, пропавших файлов и исключённых папок, включая
|
||||||
|
альтернативные форматы одного трека. Числитель — файлы, у альбома которых есть готовая обложка
|
||||||
|
из тегов или папки; внешние URL Genius не учитываются. Это покрытие файлов обложками альбомов,
|
||||||
|
а не количество отдельных `cover.jpg` на диске.
|
||||||
|
|
||||||
## Поиск
|
## Поиск
|
||||||
|
|
||||||
Meilisearch, индексы:
|
Meilisearch, индексы:
|
||||||
|
|||||||
@@ -357,6 +357,65 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/admin/sources/{id}/cover-stats": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Покрытие аудиофайлов локальными обложками (admin) */
|
||||||
|
get: operations["getSourceCoverStats"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/admin/users/export": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Экспорт учётных записей с ролями и хэшами паролей (admin)
|
||||||
|
* @description Только учётные записи и права доступа, без рекомендаций, истории, лайков и сессий. Ответ не кэшируется.
|
||||||
|
*/
|
||||||
|
get: operations["exportUsers"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/admin/users/import": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Импорт учётных записей с сохранением паролей (admin)
|
||||||
|
* @description Атомарное добавление пользователей из экспорта, до 10000 записей и 16 MiB.
|
||||||
|
* Существующие логины пропускаются без изменений. Новые записи получают новые ID.
|
||||||
|
* Рекомендации, история, лайки и сессии не импортируются.
|
||||||
|
*/
|
||||||
|
post: operations["importUsers"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/admin/users/{id}": {
|
"/api/v1/admin/users/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2010,6 +2069,38 @@ export interface components {
|
|||||||
new_username: string;
|
new_username: string;
|
||||||
new_password: string;
|
new_password: string;
|
||||||
};
|
};
|
||||||
|
SourceCoverStats: {
|
||||||
|
/** Format: int64 */
|
||||||
|
files_total: number;
|
||||||
|
/**
|
||||||
|
* Format: int64
|
||||||
|
* @description Файлы, альбом которых имеет готовую локальную обложку (встроенную или из папки)
|
||||||
|
*/
|
||||||
|
files_with_cover: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
albums_pending: number;
|
||||||
|
};
|
||||||
|
UserArchive: {
|
||||||
|
/** @enum {integer} */
|
||||||
|
version: 1;
|
||||||
|
users: components["schemas"]["UserArchiveEntry"][];
|
||||||
|
};
|
||||||
|
UserArchiveEntry: {
|
||||||
|
username: string;
|
||||||
|
display_name: string | null;
|
||||||
|
role: components["schemas"]["Role"];
|
||||||
|
/** @description Argon2id PHC. Секретные данные; файл нужно хранить в защищённом месте. */
|
||||||
|
password_hash: string;
|
||||||
|
is_blocked: boolean;
|
||||||
|
must_change_password: boolean;
|
||||||
|
must_change_credentials: boolean;
|
||||||
|
reviews_uploads: boolean;
|
||||||
|
manages_updates: boolean;
|
||||||
|
};
|
||||||
|
UserImportResult: {
|
||||||
|
created: number;
|
||||||
|
skipped: string[];
|
||||||
|
};
|
||||||
AdminUser: {
|
AdminUser: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -3259,6 +3350,75 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getSourceCoverStats: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: components["parameters"]["SourceId"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Доступные аудиофайлы без исключённых папок; внешние обложки не учитываются */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SourceCoverStats"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
exportUsers: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Файл переноса пользователей */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserArchive"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
importUsers: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserArchive"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Результат импорта */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserImportResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -37,6 +37,17 @@
|
|||||||
"grant_updates": "Grant update management",
|
"grant_updates": "Grant update management",
|
||||||
"revoke_updates": "Revoke update management"
|
"revoke_updates": "Revoke update management"
|
||||||
},
|
},
|
||||||
|
"transfer": {
|
||||||
|
"export": "Export users",
|
||||||
|
"import": "Import users",
|
||||||
|
"apply": "Import",
|
||||||
|
"description": "Transfer accounts, roles and access permissions with existing passwords. Recommendations, history, likes and sessions are excluded. Existing usernames will be skipped without changes.",
|
||||||
|
"sensitive": "The JSON file contains password hashes. Keep it in a secure location.",
|
||||||
|
"file": "Users file (.json)",
|
||||||
|
"preview": "Accounts in file: {{count}}",
|
||||||
|
"invalid": "Invalid file. Select a Pipu JSON export, version 1, up to 16 MiB and 10000 users.",
|
||||||
|
"result": "Created: {{count}}. Existing accounts skipped: {{skipped}}."
|
||||||
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "New user",
|
"title": "New user",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
@@ -64,6 +75,8 @@
|
|||||||
"empty": "No entries yet",
|
"empty": "No entries yet",
|
||||||
"more": "Show more",
|
"more": "Show more",
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"users.export": "Users exported",
|
||||||
|
"users.import": "Users imported",
|
||||||
"user.username_change": "Username changed",
|
"user.username_change": "Username changed",
|
||||||
"user.bootstrap_create": "Initial administrator created",
|
"user.bootstrap_create": "Initial administrator created",
|
||||||
"user.bootstrap_credentials": "Administrator credentials set",
|
"user.bootstrap_credentials": "Administrator credentials set",
|
||||||
@@ -79,6 +92,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sources": {
|
"sources": {
|
||||||
|
"cover_files": "Local covers: {{count}} of {{total}} files",
|
||||||
|
"cover_pending": "Albums awaiting processing: {{count}}",
|
||||||
"title": "Storage",
|
"title": "Storage",
|
||||||
"subtitle": "Where the server takes music from: a folder on the server or NFS",
|
"subtitle": "Where the server takes music from: a folder on the server or NFS",
|
||||||
"add": "Add storage",
|
"add": "Add storage",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"forbidden": "You don't have permission to do this"
|
"forbidden": "You don't have permission to do this"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
"user_archive_invalid": "Invalid user file: check the version, usernames, roles and password hashes. No changes were saved.",
|
||||||
"password_too_short": "The password must be at least {{min}} characters",
|
"password_too_short": "The password must be at least {{min}} characters",
|
||||||
"password_too_long": "The password must be at most {{max}} characters",
|
"password_too_long": "The password must be at most {{max}} characters",
|
||||||
"password_same_as_username": "The password must not match the username",
|
"password_same_as_username": "The password must not match the username",
|
||||||
|
|||||||
@@ -37,6 +37,17 @@
|
|||||||
"grant_updates": "Дать управление обновлениями",
|
"grant_updates": "Дать управление обновлениями",
|
||||||
"revoke_updates": "Забрать управление обновлениями"
|
"revoke_updates": "Забрать управление обновлениями"
|
||||||
},
|
},
|
||||||
|
"transfer": {
|
||||||
|
"export": "Экспорт пользователей",
|
||||||
|
"import": "Импорт пользователей",
|
||||||
|
"apply": "Импортировать",
|
||||||
|
"description": "Перенос учётных записей, ролей и прав доступа с сохранением паролей. Рекомендации, история, лайки и сессии не переносятся. Существующие логины будут пропущены без изменений.",
|
||||||
|
"sensitive": "JSON-файл содержит хэши паролей. Храните его в защищённом месте.",
|
||||||
|
"file": "Файл пользователей (.json)",
|
||||||
|
"preview": "Учётных записей в файле: {{count}}",
|
||||||
|
"invalid": "Некорректный файл. Выберите экспорт Pipu в формате JSON, версия 1, до 16 MiB и 10000 пользователей.",
|
||||||
|
"result": "Создано: {{count}}. Пропущено существующих: {{skipped}}."
|
||||||
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Новый пользователь",
|
"title": "Новый пользователь",
|
||||||
"username": "Логин",
|
"username": "Логин",
|
||||||
@@ -64,6 +75,8 @@
|
|||||||
"empty": "Записей пока нет",
|
"empty": "Записей пока нет",
|
||||||
"more": "Показать ещё",
|
"more": "Показать ещё",
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"users.export": "Экспортированы пользователи",
|
||||||
|
"users.import": "Импортированы пользователи",
|
||||||
"user.username_change": "Изменён логин",
|
"user.username_change": "Изменён логин",
|
||||||
"user.bootstrap_create": "Создан стартовый администратор",
|
"user.bootstrap_create": "Создан стартовый администратор",
|
||||||
"user.bootstrap_credentials": "Заданы учётные данные администратора",
|
"user.bootstrap_credentials": "Заданы учётные данные администратора",
|
||||||
@@ -79,6 +92,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sources": {
|
"sources": {
|
||||||
|
"cover_files": "Локальные обложки: {{count}} из {{total}} файлов",
|
||||||
|
"cover_pending": "Альбомов ожидают обработки: {{count}}",
|
||||||
"title": "Хранилища",
|
"title": "Хранилища",
|
||||||
"subtitle": "Откуда сервер берёт музыку: папка на диске сервера или NFS",
|
"subtitle": "Откуда сервер берёт музыку: папка на диске сервера или NFS",
|
||||||
"add": "Подключить хранилище",
|
"add": "Подключить хранилище",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"forbidden": "Недостаточно прав для этого действия"
|
"forbidden": "Недостаточно прав для этого действия"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
"user_archive_invalid": "Некорректный файл пользователей: проверьте версию, логины, роли и хэши паролей. Изменения не сохранены.",
|
||||||
"password_too_short": "Пароль должен быть не короче {{min}} символов",
|
"password_too_short": "Пароль должен быть не короче {{min}} символов",
|
||||||
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
|
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
|
||||||
"password_same_as_username": "Пароль не должен совпадать с логином",
|
"password_same_as_username": "Пароль не должен совпадать с логином",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -119,6 +119,9 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
|||||||
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
|
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if route == "POST /api/v1/admin/users/import" {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, MaxUserArchiveBytes)
|
||||||
|
}
|
||||||
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
|
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxArchivedUsers = 10000
|
||||||
|
const MaxUserArchiveBytes = 16 << 20
|
||||||
|
|
||||||
|
// ArchivedUser is deliberately limited to identity, credentials and access rights.
|
||||||
|
// IDs, sessions and personal music data never cross the transfer boundary.
|
||||||
|
type ArchivedUser struct {
|
||||||
|
Username string
|
||||||
|
DisplayName *string
|
||||||
|
Role Role
|
||||||
|
PasswordHash string
|
||||||
|
IsBlocked bool
|
||||||
|
MustChangePassword bool
|
||||||
|
MustChangeCredentials bool
|
||||||
|
ReviewsUploads bool
|
||||||
|
ManagesUpdates bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveInvalid() error {
|
||||||
|
return apperr.Validation("file", "validation.user_archive_invalid", "invalid user archive", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExportUsers(ctx context.Context, p Principal) ([]ArchivedUser, error) {
|
||||||
|
if p.Role != RoleAdmin {
|
||||||
|
return nil, forbidden()
|
||||||
|
}
|
||||||
|
out := make([]ArchivedUser, 0)
|
||||||
|
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
after := ""
|
||||||
|
for {
|
||||||
|
users, err := tx.ListUsers(ctx, UserFilter{After: after, Limit: 200})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if len(out) == MaxArchivedUsers {
|
||||||
|
return archiveInvalid()
|
||||||
|
}
|
||||||
|
out = append(out, ArchivedUser{
|
||||||
|
Username: u.Username, DisplayName: u.DisplayName, Role: u.Role, PasswordHash: u.PasswordHash,
|
||||||
|
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword,
|
||||||
|
MustChangeCredentials: u.MustChangeCredentials, ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
after = users[len(users)-1].Username
|
||||||
|
}
|
||||||
|
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||||
|
ActorID: &p.UserID, Action: "users.export", TargetType: "users", Payload: map[string]any{"count": len(out)},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportUsers creates all missing accounts in one transaction. Existing accounts
|
||||||
|
// retain their passwords, roles, sessions and personal music data.
|
||||||
|
func (s *Service) ImportUsers(ctx context.Context, p Principal, version int, entries []ArchivedUser) (int, []string, error) {
|
||||||
|
if p.Role != RoleAdmin {
|
||||||
|
return 0, nil, forbidden()
|
||||||
|
}
|
||||||
|
if version != 1 || len(entries) > MaxArchivedUsers {
|
||||||
|
return 0, nil, archiveInvalid()
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(entries))
|
||||||
|
for _, a := range entries {
|
||||||
|
username := NormalizeUsername(a.Username)
|
||||||
|
if username != a.Username || ValidateUsername("username", username) != nil || seen[username] || !a.Role.Valid() {
|
||||||
|
return 0, nil, archiveInvalid()
|
||||||
|
}
|
||||||
|
seen[username] = true
|
||||||
|
if a.DisplayName != nil {
|
||||||
|
if _, err := displayName(*a.DisplayName); err != nil {
|
||||||
|
return 0, nil, archiveInvalid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.PasswordHash) > 512 {
|
||||||
|
return 0, nil, archiveInvalid()
|
||||||
|
}
|
||||||
|
params, salt, key, err := decodePHC(a.PasswordHash)
|
||||||
|
// Imported PHC parameters are used on the next login; bound CPU and RAM
|
||||||
|
// before accepting them, without performing expensive password hashing.
|
||||||
|
if err != nil || params.Threads == 0 || params.Threads > 16 || params.Time == 0 || params.Time > 10 ||
|
||||||
|
params.MemoryKiB < 8*uint32(params.Threads) || params.MemoryKiB > 256*1024 ||
|
||||||
|
len(salt) < 8 || len(salt) > 64 || len(key) < 16 || len(key) > 64 {
|
||||||
|
return 0, nil, archiveInvalid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created := 0
|
||||||
|
skipped := make([]string, 0)
|
||||||
|
err := s.store.WithTx(ctx, func(tx Store) error {
|
||||||
|
for _, a := range entries {
|
||||||
|
_, err := tx.GetUserByUsername(ctx, a.Username)
|
||||||
|
if err == nil {
|
||||||
|
skipped = append(skipped, a.Username)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
locale := "ru"
|
||||||
|
if len(s.cfg.SupportedLocales) > 0 {
|
||||||
|
locale = s.cfg.SupportedLocales[0]
|
||||||
|
}
|
||||||
|
u := User{
|
||||||
|
ID: s.ids.New(), Username: a.Username, DisplayName: a.DisplayName, PasswordHash: a.PasswordHash,
|
||||||
|
Role: a.Role, Locale: locale, CreatedAt: s.clock.Now(), MustChangePassword: a.MustChangePassword,
|
||||||
|
MustChangeCredentials: a.MustChangeCredentials, ReviewsUploads: a.ReviewsUploads, ManagesUpdates: a.ManagesUpdates,
|
||||||
|
}
|
||||||
|
if err := tx.CreateUser(ctx, u, &p.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if a.IsBlocked {
|
||||||
|
blocked := true
|
||||||
|
if _, err := tx.UpdateUser(ctx, u.ID, UserUpdate{IsBlocked: &blocked}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||||
|
ActorID: &p.UserID, Action: "users.import", TargetType: "users",
|
||||||
|
Payload: map[string]any{"created": created, "skipped": len(skipped)},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
return created, skipped, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) ExportUsers(ctx context.Context, _ gen.ExportUsersRequestObject) (gen.ExportUsersResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users, err := h.svc.ExportUsers(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
archive := gen.UserArchive{Version: 1, Users: make([]gen.UserArchiveEntry, 0, len(users))}
|
||||||
|
for _, u := range users {
|
||||||
|
archive.Users = append(archive.Users, gen.UserArchiveEntry{
|
||||||
|
Username: u.Username, DisplayName: u.DisplayName, PasswordHash: u.PasswordHash, Role: gen.Role(u.Role),
|
||||||
|
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword, MustChangeCredentials: u.MustChangeCredentials,
|
||||||
|
ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return archiveResponse{archive}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveResponse struct{ gen.UserArchive }
|
||||||
|
|
||||||
|
func (r archiveResponse) VisitExportUsersResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="pipu-users.json"`)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
return json.NewEncoder(w).Encode(r.UserArchive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ImportUsers(ctx context.Context, req gen.ImportUsersRequestObject) (gen.ImportUsersResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Body == nil {
|
||||||
|
return nil, archiveInvalid()
|
||||||
|
}
|
||||||
|
entries := make([]ArchivedUser, 0, len(req.Body.Users))
|
||||||
|
for _, u := range req.Body.Users {
|
||||||
|
entries = append(entries, ArchivedUser{
|
||||||
|
Username: u.Username, DisplayName: u.DisplayName, PasswordHash: u.PasswordHash, Role: Role(u.Role),
|
||||||
|
IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword, MustChangeCredentials: u.MustChangeCredentials,
|
||||||
|
ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
created, skipped, err := h.svc.ImportUsers(ctx, p, int(req.Body.Version), entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.ImportUsers200JSONResponse{Created: created, Skipped: skipped}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserArchiveRoundTrip(t *testing.T) {
|
||||||
|
src := newEnv(t)
|
||||||
|
src.addUser("root", "root-original-password", auth.RoleAdmin)
|
||||||
|
src.addUser("listener", "listener-original-password", auth.RoleUser, func(u *auth.User) { u.ReviewsUploads = true })
|
||||||
|
src.addUser("staff", "staff-original-password", auth.RoleModerator, func(u *auth.User) { u.ManagesUpdates = true })
|
||||||
|
src.addUser("blocked", "blocked-original-password", auth.RoleUser, func(u *auth.User) {
|
||||||
|
u.IsBlocked = true
|
||||||
|
u.MustChangePassword = true
|
||||||
|
})
|
||||||
|
token := src.signIn("root", "root-original-password")
|
||||||
|
r := src.do(http.MethodGet, "/api/v1/admin/users/export", nil, bearer(token))
|
||||||
|
require.Equal(t, 200, r.Code, r.Body.String())
|
||||||
|
require.Equal(t, "no-store", r.Header().Get("Cache-Control"))
|
||||||
|
require.Contains(t, r.Header().Get("Content-Disposition"), "attachment")
|
||||||
|
var archive gen.UserArchive
|
||||||
|
require.NoError(t, json.Unmarshal(r.Body.Bytes(), &archive))
|
||||||
|
require.Len(t, archive.Users, 4)
|
||||||
|
for _, field := range []string{"ui_prefs", "refresh", "last_login_at", "recommendations", "history", "likes", "token_version"} {
|
||||||
|
require.NotContains(t, r.Body.String(), `"`+field+`"`)
|
||||||
|
}
|
||||||
|
for _, audit := range src.store.Audit {
|
||||||
|
require.NotContains(t, fmt.Sprint(audit.Payload), "$argon2id$")
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := newEnv(t)
|
||||||
|
root := dst.addUser("root", "destination-root-password", auth.RoleAdmin)
|
||||||
|
destToken := dst.signIn("root", "destination-root-password")
|
||||||
|
result := dst.do(http.MethodPost, "/api/v1/admin/users/import", archive, bearer(destToken))
|
||||||
|
require.Equal(t, 200, result.Code, result.Body.String())
|
||||||
|
require.EqualValues(t, 3, result.body["created"])
|
||||||
|
require.Equal(t, []any{"root"}, result.body["skipped"])
|
||||||
|
for _, username := range []string{"listener", "staff", "blocked"} {
|
||||||
|
original, err := src.store.GetUserByUsername(context.Background(), username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
imported, err := dst.store.GetUserByUsername(context.Background(), username)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, original.ID, imported.ID)
|
||||||
|
require.Equal(t, original.PasswordHash, imported.PasswordHash)
|
||||||
|
require.Equal(t, original.Role, imported.Role)
|
||||||
|
require.Equal(t, original.IsBlocked, imported.IsBlocked)
|
||||||
|
require.Equal(t, original.MustChangePassword, imported.MustChangePassword)
|
||||||
|
require.Equal(t, original.ReviewsUploads, imported.ReviewsUploads)
|
||||||
|
require.Equal(t, original.ManagesUpdates, imported.ManagesUpdates)
|
||||||
|
}
|
||||||
|
retained, err := dst.store.GetUserByUsername(context.Background(), "root")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, root.PasswordHash, retained.PasswordHash)
|
||||||
|
require.Len(t, dst.store.Tokens, 1, "sessions are not imported")
|
||||||
|
require.Empty(t, dst.store.RecResets)
|
||||||
|
dst.mustLogin("listener", "listener-original-password", "web")
|
||||||
|
dst.mustLogin("staff", "staff-original-password", "web")
|
||||||
|
result = dst.do(http.MethodPost, "/api/v1/admin/users/import", archive, bearer(destToken))
|
||||||
|
require.Equal(t, 200, result.Code)
|
||||||
|
require.EqualValues(t, 0, result.body["created"], "import is idempotent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserArchiveAdminOnly(t *testing.T) {
|
||||||
|
for _, role := range []auth.Role{auth.RoleUser, auth.RoleModerator} {
|
||||||
|
t.Run(string(role), func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("actor", "actor-password-123", role)
|
||||||
|
token := e.signIn("actor", "actor-password-123")
|
||||||
|
for _, endpoint := range []struct {
|
||||||
|
method, path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api/v1/admin/users/export", nil},
|
||||||
|
{http.MethodPost, "/api/v1/admin/users/import", gen.UserArchive{Version: 1, Users: []gen.UserArchiveEntry{}}},
|
||||||
|
} {
|
||||||
|
r := e.do(endpoint.method, endpoint.path, endpoint.body, bearer(token))
|
||||||
|
require.Equal(t, 403, r.Code)
|
||||||
|
require.NotContains(t, r.Body.String(), "$argon2id$")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserArchiveValidationBeforeWrites(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||||
|
hash, err := e.hasher.Hash("valid-password-123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
valid := auth.ArchivedUser{Username: "new-user", Role: auth.RoleUser, PasswordHash: hash}
|
||||||
|
for name, change := range map[string]func(*auth.ArchivedUser){
|
||||||
|
"hash": func(a *auth.ArchivedUser) { a.PasswordHash = "invalid" },
|
||||||
|
"memory": func(a *auth.ArchivedUser) { a.PasswordHash = strings.Replace(hash, "m=64,", "m=4294967295,", 1) },
|
||||||
|
"zero threads": func(a *auth.ArchivedUser) { a.PasswordHash = strings.Replace(hash, "p=1$", "p=0$", 1) },
|
||||||
|
"role": func(a *auth.ArchivedUser) { a.Role = "owner" },
|
||||||
|
"username": func(a *auth.ArchivedUser) { a.Username = "../invalid" },
|
||||||
|
"duplicate": func(a *auth.ArchivedUser) { a.Username = valid.Username },
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
bad := valid
|
||||||
|
bad.Username = "other-user"
|
||||||
|
change(&bad)
|
||||||
|
_, _, err := e.svc.ImportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role}, 1, []auth.ArchivedUser{valid, bad})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Len(t, e.store.Users, 1, "no partial writes")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failArchiveAudit struct{ auth.Store }
|
||||||
|
|
||||||
|
func (s failArchiveAudit) WithTx(ctx context.Context, fn func(auth.Store) error) error {
|
||||||
|
return s.Store.WithTx(ctx, func(tx auth.Store) error { return fn(failArchiveAudit{tx}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s failArchiveAudit) InsertAudit(context.Context, uuid.UUID, auth.AuditEntry) error {
|
||||||
|
return errors.New("audit unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserArchiveRollsBackOnStoreFailure(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||||
|
svc, err := auth.NewService(failArchiveAudit{e.store}, e.hasher, nil, e.limiter, e.clock, auth.UUIDv7{}, nil, auth.DefaultConfig())
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, _, err = svc.ImportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role}, 1,
|
||||||
|
[]auth.ArchivedUser{{Username: "new-user", Role: auth.RoleUser, PasswordHash: root.PasswordHash}})
|
||||||
|
require.ErrorContains(t, err, "audit unavailable")
|
||||||
|
require.Len(t, e.store.Users, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserArchiveExportsAllPages(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
root := e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||||
|
for i := range 205 {
|
||||||
|
u := root
|
||||||
|
u.ID, u.Username, u.Role = uuid.New(), fmt.Sprintf("listener-%03d", i), auth.RoleUser
|
||||||
|
require.NoError(t, e.store.CreateUser(context.Background(), u, nil))
|
||||||
|
}
|
||||||
|
users, err := e.svc.ExportUsers(context.Background(), auth.Principal{UserID: root.ID, Role: root.Role})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, users, 206)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserArchiveRejectsOversizedRequest(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.addUser("root", "root-password-123", auth.RoleAdmin)
|
||||||
|
token := e.signIn("root", "root-password-123")
|
||||||
|
r := e.do(http.MethodPost, "/api/v1/admin/users/import", map[string]any{
|
||||||
|
"version": 1, "users": []any{}, "padding": strings.Repeat("x", auth.MaxUserArchiveBytes),
|
||||||
|
}, bearer(token))
|
||||||
|
require.Equal(t, http.StatusBadRequest, r.Code)
|
||||||
|
require.Len(t, e.store.Users, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package dbtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/platform/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSourceCoverStats(t *testing.T) {
|
||||||
|
pool := connect(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, db.Migrate(ctx, pool))
|
||||||
|
store := library.NewStore(pool)
|
||||||
|
src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "cover stats", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, store.DeleteSource(ctx, src.ID))
|
||||||
|
require.NoError(t, store.CleanupOrphans(ctx))
|
||||||
|
})
|
||||||
|
root := t.TempDir()
|
||||||
|
scanner := library.NewScanner(store, fakeProber{}, library.NewProgressBoard(), slog.New(slog.DiscardHandler))
|
||||||
|
for _, rel := range []string{"covered/StatsArtist - First.flac", "covered/StatsArtist - Second.flac", "external/StatsArtist - Third.flac", "pending/StatsArtist - Fourth.flac"} {
|
||||||
|
file := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte(rel), 0o644))
|
||||||
|
_, err := scanner.ScanFile(ctx, src, root, rel)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE albums SET cover_path = 'test-key', cover_checked_at = now() WHERE id IN (
|
||||||
|
SELECT t.album_id FROM tracks t JOIN media_files f ON f.track_id=t.id WHERE f.source_id=$1 AND f.rel_path LIKE 'covered/%')`, src.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE albums SET genius_cover_url = 'https://example.org/cover.jpg', cover_checked_at = now() WHERE id IN (
|
||||||
|
SELECT t.album_id FROM tracks t JOIN media_files f ON f.track_id=t.id WHERE f.source_id=$1 AND f.rel_path LIKE 'external/%')`, src.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stats, err := store.SourceCoverStats(ctx, src.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, library.CoverStats{FilesTotal: 4, FilesWithCover: 2, AlbumsPending: 1}, stats)
|
||||||
|
// Removed and excluded files leave the denominator as well as the numerator.
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE media_files SET missing_since=$2 WHERE source_id=$1 AND rel_path LIKE '%Second.flac'`, src.ID, time.Now())
|
||||||
|
require.NoError(t, err)
|
||||||
|
src.ExcludedDirs = []string{"external"}
|
||||||
|
_, err = store.SaveSource(ctx, src)
|
||||||
|
require.NoError(t, err)
|
||||||
|
stats, err = store.SourceCoverStats(ctx, src.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, library.CoverStats{FilesTotal: 2, FilesWithCover: 1, AlbumsPending: 1}, stats)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CoverStats struct {
|
||||||
|
FilesTotal int64
|
||||||
|
FilesWithCover int64
|
||||||
|
AlbumsPending int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceCoverStats counts physical audio files, including alternate encodings.
|
||||||
|
// Only completed local covers count; external image URLs are intentionally ignored.
|
||||||
|
func (st *Store) SourceCoverStats(ctx context.Context, id uuid.UUID) (CoverStats, error) {
|
||||||
|
var stats CoverStats
|
||||||
|
err := st.pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*), count(*) FILTER (WHERE al.cover_path IS NOT NULL),
|
||||||
|
count(DISTINCT al.id) FILTER (WHERE al.cover_checked_at IS NULL)
|
||||||
|
FROM media_files mf
|
||||||
|
JOIN library_sources s ON s.id = mf.source_id
|
||||||
|
LEFT JOIN tracks t ON t.id = mf.track_id
|
||||||
|
LEFT JOIN albums al ON al.id = t.album_id
|
||||||
|
WHERE mf.source_id = $1 AND mf.missing_since IS NULL AND mf.scan_error IS NULL
|
||||||
|
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)`, id).
|
||||||
|
Scan(&stats.FilesTotal, &stats.FilesWithCover, &stats.AlbumsPending)
|
||||||
|
if err != nil {
|
||||||
|
return CoverStats{}, fmt.Errorf("library: source cover stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetSourceCoverStats(ctx context.Context, req gen.GetSourceCoverStatsRequestObject) (gen.GetSourceCoverStatsResponseObject, error) {
|
||||||
|
if _, err := h.svc.GetSource(ctx, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats, err := h.store.SourceCoverStats(ctx, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetSourceCoverStats200JSONResponse{
|
||||||
|
FilesTotal: stats.FilesTotal, FilesWithCover: stats.FilesWithCover, AlbumsPending: stats.AlbumsPending,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user