Compare commits

...
14 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 976ffb574b feat(web): player bar, playable track lists and Collection (M3)
ci / ts / ts (packages + web) (push) Successful in 53s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 2m33s
- player bar with cover, like and dislike, previous / play / next,
  shuffle, repeat, seek bar and volume; PlayerProvider lives with the
  signed-in shell and restores the last track and position paused
- clicking a row (or Enter) plays the list from that track with its
  context (album, search, history, collection); the current track is
  highlighted and rows have a like button; a reaction refreshes lists
- Collection: history grouped by day with clearing, likes with "play
  all" and dislikes, all with paging; sidebar section
- events and playback state are flushed when the page is hidden;
  player timers survive StrictMode remounts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:11:12 +03:00
mrleo1nidandClaude Opus 5.5 ec3c2810c8 feat(core,web): player queue, play events and web player controller (M3, wip)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 1m5s
- core: PlayQueue with shuffle and repeat, PlayAttempt counting only
  time actually played, EventBuffer with batching and backoff, uuidv7
- web: Player over HTMLAudioElement with Media Session, preload of the
  next track, expired-link refresh, local and server playback state,
  hotkeys; PlayerProvider is not wired into the shell yet
- ru/en strings and icons for the player and Collection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:28:20 +03:00
mrleo1nidandClaude Opus 5.5 c05e523cbe feat(api): streaming, transcoding, events, history and likes (M3)
ci / api / api (push) Successful in 29s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
ci / ts / ts (packages + web) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
- tracks carry a stream_url signed for the user for 12 h and the
  user's reaction; /stream checks the signature, expiry and that the
  user is not blocked, then hands the file to nginx by X-Accel-Redirect
  (or serves it with Range support when X_ACCEL=false)
- original by default; formats browsers can't play and explicit
  high/medium/low go through Opus transcoding, never upwards: a lossy
  source under the target bitrate stays original; transcodes are
  cached per file and size, deduplicated in flight, limited by
  TRANSCODE_CONCURRENCY and trimmed hourly by LRU to the size limit
- /events takes up to 500 events with client UUIDs, drops repeats and
  events older than 30 days, counts plays (≥ half or 4 min, or
  finished), early skips and history entries (≥ 30 s)
- history with clearing, likes and dislikes with cursors and totals,
  track reactions, playback state with last-write-wins, tracks batch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:25:15 +03:00
mrleo1nidandClaude Opus 5.5 8b76aa3964 feat: album covers with palettes and signed links (M2)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
- covers are taken from the embedded picture of the first track or a
  cover/folder/front image in the album folder, converted by ffmpeg to
  WebP 64/300/1200 and named by content hash, so albums sharing a cover
  share the files; a full scan looks again for albums without one
- a 3-colour palette (dominant, second, most saturated accent) from a
  deterministic k-means feeds placeholders, page tints and the wave
- /covers/{id} is public but HMAC-signed and immutable; nginx serves
  the file via X-Accel-Redirect, X_ACCEL=false serves it from api
- the web client picks the size by display size and pixel density
- install.sh and update.sh generate STREAM_SIGNING_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:06:15 +03:00
mrleo1nidandClaude Opus 5.5 a685b1b7ca feat(web): storage admin, search and catalog pages (M2)
ci / ts / ts (packages + web) (push) Successful in 1m56s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m59s
- Storage admin: local folders with a folder picker and NFS sources,
  connection test, scan and full scan with live progress, enable,
  disable, delete
- path pattern editor with reorder, examples and a preview table that
  shows which pattern matched and what came from tags; compile errors
  land under the broken pattern
- Library page with counters, scans and unreadable files; search
  reindex for admins
- search box in the top bar: combobox with keyboard navigation, 150 ms
  debounce, cancelled stale requests, "/" and Ctrl+K, layout fix hint
- search page with tabs and paging, artist and album pages, album
  cards and track lists; unavailable tracks are dimmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:01:52 +03:00
mrleo1nidandClaude Opus 5.5 c1160cccd3 feat(api): search with Meilisearch (M2)
ci / api / api (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / ts / ts (packages + web) (push) Successful in 1m1s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 25s
- artists, albums and tracks indexes are rebuilt from PostgreSQL next
  to the live ones and swapped in, so search keeps working; a rebuild
  runs on start, after every scan and when a source is removed
- transliteration fields (Кино ↔ kino) and a retry in the other
  keyboard layout (ghbdtn → привет) reported as corrected_query
- /search/suggest (3 artists, 3 albums, 5 tracks), /search by type
  with cursor pages, /admin/search/reindex; Meilisearch is part of
  /readyz
- found ids are hydrated from the catalog, so a lagging index never
  returns deleted entries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:51:50 +03:00
mrleo1nidandClaude Opus 5.5 c583991d14 feat(api): library sources, scanner and catalog (M2)
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
- schema for sources, path patterns, media files, artists, albums,
  tracks, genres (migration 00003)
- path patterns (RE2 with named groups, skip), merge with tags by
  source priority, fallbacks from the file name and folder, name
  normalization and multi-artist split
- scanner: walk, skip unchanged files by size and mtime, ffprobe in 8
  workers with a single catalog writer, renames by content hash keep
  the track, duplicates fold into one track with the best file as
  primary, missing files are kept 30 days
- storage-agent mounts NFS with validated server, export and an option
  whitelist, no shell; test mount counts audio files
- admin API: sources CRUD, connection test, patterns with preview,
  scan, library status, file errors, host folder picker; scheduled
  scans and health checks; mounts are restored on start
- catalog API: artist, artist albums, album with tracks, track
- api image is alpine with ffmpeg for ffprobe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:48:37 +03:00
mrleo1nidandClaude Opus 5.5 ab092def54 feat(web): sign-in, first-run setup, app shell and user admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
- core: AuthSession keeps the access token in memory, refreshes it
  once for all parallel requests (single-flight) and retries them;
  refresh races between tabs are retried, reuse signs out; ApiError
  carries problem+json codes and field errors
- web: TanStack Router with guards — guests go to /login, the bootstrap
  admin to /setup/admin, temporary passwords to /setup/password, admin
  pages by role
- login, setup screens, Classic shell (top bar, role-based sidebar,
  empty player), settings: name, theme, language, password, devices
- users admin: search, role filter, create with a one-time temporary
  password, reset, block, role change, delete as the policy allows;
  audit log for admins
- ru/en strings for auth, admin and error codes; language and theme
  are saved in the profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:33:46 +03:00
mrleo1nidandClaude Opus 5.5 f075863f15 feat(api): user management and audit log for admins (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 38s
ci / api / api (push) Successful in 18s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
- /admin/users: list with search, role filter and cursor pages; create
  with a one-time temporary password; rename, change role, block;
  reset password; delete
- every change is checked against the role policy, a forbidden field
  rejects the whole patch; the last active admin is locked in the
  transaction and cannot be demoted, blocked or deleted
- role change and blocking revoke access tokens at once; blocking and
  password reset also end all sessions
- /admin/audit: newest-first journal with actor names
- path and query parameter errors are problem+json now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:18:02 +03:00
mrleo1nidandClaude Opus 5.5 ed4a492dba feat(api): sign-in, refresh rotation and bootstrap admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002)
- argon2id passwords with rehash on login, password policy, lockout
  with exponential back-off and a per-IP rate limit in Redis
- Ed25519 JWT access tokens with kid-based key rotation; rotating
  refresh tokens with reuse detection (cookie for web, body for Android)
- bootstrap admin on first start; must_change_credentials and
  must_change_password gate every other endpoint
- /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password,
  /me/bootstrap-credentials, /me/sessions; server-info reports
  bootstrap_required
- role policy from docs/auth-and-roles.md with table tests
- problem+json errors with field errors; ru/en translations of the codes
- install.sh and update.sh generate JWT_PRIVATE_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:12:25 +03:00
mrleo1nidandClaude Opus 5.5 b6def39e29 fix(deploy): probe readyz via 127.0.0.1 in update.sh
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 20s
In the alpine nginx image localhost resolves to ::1 first while nginx
listens on IPv4 only, so the readiness wait never succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:51:50 +03:00
mrleo1nidandClaude Opus 5.5 6fe76aea90 fix(deploy): mount the local music folder read-only in storage-agent
Upload import is not implemented yet, so nothing needs write access to
LIBRARY_HOST. Write access will come back together with the import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:51:50 +03:00
mrleo1nidandClaude Opus 5.5 0acdc3fbb3 fix(deploy): make install.sh and update.sh executable
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 21s
Scripts were committed without the executable bit, so ./install.sh failed
with "command not found" on a fresh clone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:42:11 +03:00
mrleo1nidandClaude Opus 5.5 a996b97694 fix(release): run the android job steps in bash
release / release (версия и заметки) (push) Successful in 4s
release / android (APK в релиз) (push) Successful in 1m55s
With a container block act runs steps through sh, which rejected the bash
array in the build step (v0.1.13: Syntax error: "(" unexpected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:38:33 +03:00
141 changed files with 30120 additions and 264 deletions

No files matched your search

+4
View File
@@ -52,6 +52,10 @@ jobs:
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK # Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома # переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново. # (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
defaults:
run:
shell: bash
container: container:
image: docker.gitea.com/runner-images:ubuntu-latest image: docker.gitea.com/runner-images:ubuntu-latest
volumes: volumes:
+1
View File
@@ -34,3 +34,4 @@ Thumbs.db
.idea/ .idea/
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.cache/
+1 -1
View File
@@ -34,7 +34,7 @@ docker compose -f deploy/compose.dev.yaml up -d
API (из `services/api`): API (из `services/api`):
```bash ```bash
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" X_ACCEL=false CACHE_DIR="$PWD/.cache" go run ./cmd/api
``` ```
Веб на http://localhost:5173 (проксирует `/api` на `:8080`): Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
+1957 -1
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -14,18 +14,22 @@
"@fontsource/jetbrains-mono": "^5.2.6", "@fontsource/jetbrains-mono": "^5.2.6",
"@fontsource/onest": "^5.2.6", "@fontsource/onest": "^5.2.6",
"@fontsource/unbounded": "^5.2.6", "@fontsource/unbounded": "^5.2.6",
"@material-symbols/svg-400": "^0.31.9",
"@pipu/api-client": "workspace:*", "@pipu/api-client": "workspace:*",
"@pipu/core": "workspace:*", "@pipu/core": "workspace:*",
"@pipu/design-tokens": "workspace:*", "@pipu/design-tokens": "workspace:*",
"@pipu/i18n": "workspace:*", "@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7", "@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^15.5.3" "react-i18next": "^15.5.3"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
+275 -29
View File
@@ -1,51 +1,297 @@
import { createApiClient } from '@pipu/api-client'; import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n'; import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { createMemoryHistory } from '@tanstack/react-router';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App'; import { App } from './App';
import { createAppRouter, safeRedirect } from './app/router';
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup); afterEach(cleanup);
const info = { async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
name: 'pipu-music', const api = fakeApi(users);
version: '0.1.0',
api_version: 'v1',
supported_locales: ['ru', 'en'],
min_android_version_code: 100,
};
async function renderApp(fetchImpl: typeof fetch) {
const i18n = await createI18n('ru'); const i18n = await createI18n('ru');
const client = createApiClient({ baseUrl: 'http://localhost', fetch: fetchImpl }); const session = new AuthSession({ baseUrl: 'http://localhost', fetch: api.fetch, clientType: 'web' });
if (opts.signedInAs) await session.login(opts.signedInAs.username, opts.signedInAs.password);
else await session.restore();
const router = createAppRouter(session, { history: createMemoryHistory({ initialEntries: [path] }) });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render( render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}> <App session={session} router={router} queryClient={queryClient} />
<App client={client} />
</QueryClientProvider>
</I18nextProvider>, </I18nextProvider>,
); );
return { api, router, session, i18n, user: userEvent.setup() };
} }
describe('App', () => { describe('sign-in', () => {
it('shows the server version when the server is online', async () => { it('sends a guest to the login page and back after signing in', async () => {
await renderApp(async () => Response.json(info)); const alice = makeUser('alice', 'user', { display_name: 'Алиса' });
expect(await screen.findByText('Сервер 0.1.0 на связи')).toBeTruthy(); const { user, router } = await renderApp([alice], '/settings');
await screen.findByRole('heading', { name: 'Вход' });
expect(router.state.location.pathname).toBe('/login');
await user.type(screen.getByLabelText('Логин'), 'alice');
await user.type(screen.getByLabelText('Пароль'), 'wrong');
await user.click(screen.getByRole('button', { name: 'Войти' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Неверный логин или пароль');
await user.type(screen.getByLabelText('Пароль'), alice.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Настройки' });
expect(router.state.location.pathname).toBe('/settings');
}); });
it('shows an error when the server is unreachable', async () => { it('shows the bootstrap hint on first start', async () => {
await renderApp(async () => { await renderApp([makeUser('admin', 'admin', { must_change_credentials: true })], '/login');
throw new TypeError('Failed to fetch'); expect(await screen.findByText(/Первый запуск/)).toBeInTheDocument();
});
it('forces the bootstrap admin to change credentials', async () => {
const admin = makeUser('admin', 'admin', { must_change_credentials: true });
const { user, router, api } = await renderApp([admin], '/admin/users', { signedInAs: admin });
await screen.findByRole('heading', { name: 'Смените логин и пароль' });
expect(router.state.location.pathname).toBe('/setup/admin');
const login = screen.getByLabelText('Новый логин');
expect(login).toHaveValue('admin');
await user.clear(login);
await user.type(login, 'owner');
await user.type(screen.getByLabelText('Новый пароль'), 'short');
await user.type(screen.getByLabelText('Повторите пароль'), 'shorter');
expect(screen.getByText('Пароли не совпадают')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Сохранить и продолжить' })).toBeDisabled();
await user.clear(screen.getByLabelText('Повторите пароль'));
await user.type(screen.getByLabelText('Повторите пароль'), 'short');
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
expect(await screen.findByText('Пароль должен быть не короче 12 символов')).toBeInTheDocument();
for (const label of ['Новый пароль', 'Повторите пароль']) {
await user.clear(screen.getByLabelText(label));
await user.type(screen.getByLabelText(label), 'a-strong-owner-pass');
}
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
await screen.findByText('Привет, owner!');
expect(router.state.location.pathname).toBe('/');
expect(api.calls.find((c) => c.path === '/api/v1/me/bootstrap-credentials' && c.body)?.body).toMatchObject({
new_username: 'owner',
}); });
expect(await screen.findByText(/Сервер не отвечает/)).toBeTruthy();
}); });
it('switches the interface language', async () => { it('forces a temporary password to be replaced', async () => {
await renderApp(async () => Response.json(info)); const bob = makeUser('bob', 'user', { must_change_password: true });
await screen.findByText('Сервер 0.1.0 на связи'); const { user } = await renderApp([bob], '/', { signedInAs: bob });
fireEvent.click(screen.getByRole('button', { name: 'EN' })); await screen.findByRole('heading', { name: 'Задайте новый пароль' });
expect(await screen.findByText('Server 0.1.0 is online')).toBeTruthy(); await user.type(screen.getByLabelText('Новый пароль'), 'bobs-own-pass');
await user.type(screen.getByLabelText('Повторите пароль'), 'bobs-own-pass');
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
await screen.findByText('Привет, bob!');
});
it('signs out from the profile menu', async () => {
const alice = makeUser('alice', 'user');
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(screen.getByRole('menuitem', { name: 'Выйти' }));
await screen.findByRole('heading', { name: 'Вход' });
expect(router.state.location.pathname).toBe('/login');
});
});
describe('navigation by role', () => {
it('hides the admin section from listeners and blocks the route', async () => {
const alice = makeUser('alice', 'user');
const { router } = await renderApp([alice], '/admin/users', { signedInAs: alice });
await screen.findByText('Привет, alice!');
expect(router.state.location.pathname).toBe('/');
expect(screen.queryByRole('link', { name: 'Пользователи' })).not.toBeInTheDocument();
});
it('shows users but not the audit log to moderators', async () => {
const mod = makeUser('mod', 'moderator');
await renderApp([mod], '/', { signedInAs: mod });
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
});
it('shows the audit log to admins', async () => {
const root = makeUser('root', 'admin');
await renderApp([root], '/admin/audit', { signedInAs: root });
expect(await screen.findByText('Записей пока нет')).toBeInTheDocument();
});
});
describe('users admin', () => {
it('lets an admin create a moderator and shows the temporary password once', async () => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user', { last_login_at: new Date().toISOString() });
const { user } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
const table = await screen.findByRole('table');
await within(table).findByText('@anna');
expect(within(table).getByText('(вы)')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
await user.type(within(dialog).getByLabelText('Логин'), 'ilya');
await user.selectOptions(within(dialog).getByRole('combobox'), 'moderator');
await user.click(within(dialog).getByRole('button', { name: 'Создать' }));
const secret = await screen.findByRole('dialog', { name: 'Временный пароль' });
expect(within(secret).getByTestId('temporary-password')).toHaveTextContent('Temp-Pass-123');
await user.click(within(secret).getByRole('button', { name: 'Готово' }));
expect(screen.queryByTestId('temporary-password')).not.toBeInTheDocument();
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
});
it('offers moderators only what they may do', async () => {
const mod = makeUser('mod', 'moderator');
const anna = makeUser('anna', 'user');
const other = makeUser('other', 'moderator');
const { user } = await renderApp([mod, anna, other], '/admin/users', { signedInAs: mod });
await screen.findByText('@anna');
expect(screen.queryByRole('button', { name: 'Действия с other' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Действия с mod' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument();
});
it('filters by role', async () => {
const root = makeUser('root', 'admin');
const { user, api } = await renderApp([root, makeUser('anna', 'user'), makeUser('mod', 'moderator')], '/admin/users', {
signedInAs: root,
});
await screen.findByText('@anna');
await user.click(screen.getByRole('button', { name: 'Модератор' }));
await waitFor(() => expect(screen.queryByText('@anna')).not.toBeInTheDocument());
expect(screen.getByText('@mod')).toBeInTheDocument();
expect(api.calls.some((c) => c.path === '/api/v1/admin/users' && c.method === 'GET')).toBe(true);
});
});
describe('language and settings', () => {
it('saves the language in the profile', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/settings', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Настройки' });
const groups = screen.getAllByRole('group', { name: 'Язык' });
await user.click(within(groups[0]!).getByRole('button', { name: 'EN' }));
await screen.findByRole('heading', { name: 'Settings' });
await waitFor(() => expect(api.users.get(alice.id)?.locale).toBe('en'));
});
it('applies the theme from the profile', async () => {
const alice = makeUser('alice', 'user');
const { user } = await renderApp([alice], '/settings', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Светлая' }));
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('light'));
await user.click(screen.getByRole('button', { name: 'Тёмная' }));
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('dark'));
});
it('switches the language on the login page without a session', async () => {
const { user } = await renderApp([], '/login');
await user.click(await screen.findByRole('button', { name: 'EN' }));
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
});
});
describe('search box', () => {
it('opens the search page on Enter and an item with the arrows', async () => {
const alice = makeUser('alice', 'user');
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
const box = await screen.findByRole('combobox', { name: 'Поиск' });
await user.type(box, 'rbyj');
const list = await screen.findByRole('listbox');
const options = within(list).getAllByRole('option');
expect(options.map((o) => o.textContent)).toEqual(['КиноИсполнитель', 'Группа кровиКино · 1988']);
expect(screen.getByText('Показаны результаты для «кино»')).toBeInTheDocument();
await user.keyboard('{Enter}');
await waitFor(() => expect(router.state.location.pathname).toBe('/search'));
expect(router.state.location.search).toEqual({ q: 'rbyj' });
await user.clear(box);
await user.type(box, 'кино');
await screen.findByRole('listbox');
await user.keyboard('{ArrowDown}{Enter}');
await waitFor(() => expect(router.state.location.pathname).toBe('/artist/00000000-0000-4000-8000-0000000000aa'));
expect(await screen.findByRole('heading', { name: 'Кино' })).toBeInTheDocument();
});
it('focuses on the slash key', async () => {
const alice = makeUser('alice', 'user');
const { user } = await renderApp([alice], '/', { signedInAs: alice });
const box = await screen.findByRole('combobox', { name: 'Поиск' });
await user.keyboard('/');
expect(box).toHaveFocus();
});
});
describe('player', () => {
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
expect(row).toHaveAttribute('aria-current', 'true');
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
await user.click(within(bar).getByRole('button', { name: 'Нравится' }));
await waitFor(() =>
expect(api.calls).toContainEqual({
method: 'PUT',
path: '/api/v1/me/reactions/tracks/00000000-0000-4000-8000-000000000101',
body: { reaction: 'like' },
}),
);
expect(within(bar).getByRole('button', { name: 'Убрать из лайков' })).toHaveAttribute('aria-pressed', 'true');
// Пробел на кнопке нажимает её саму; горячая клавиша работает, когда фокус не на элементе управления.
(document.activeElement as HTMLElement).blur();
await user.keyboard(' ');
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
// Уход со страницы досылает события и сохраняет состояние плеера.
window.dispatchEvent(new Event('pagehide'));
await waitFor(() => {
const sent = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
expect(sent.find((e) => e.type === 'play_start')).toMatchObject({ context_type: 'album' });
});
expect(api.calls.find((c) => c.method === 'PUT' && c.path === '/api/v1/me/playback-state')?.body).toMatchObject({
track_id: '00000000-0000-4000-8000-000000000101',
queue_index: 1,
});
});
});
describe('safeRedirect', () => {
it.each([
['/settings', '/settings'],
['//evil.example', '/'],
['https://evil.example', '/'],
[undefined, '/'],
])('%s → %s', (input, expected) => {
expect(safeRedirect(input)).toBe(expected);
}); });
}); });
+24 -51
View File
@@ -1,57 +1,30 @@
import { createApiClient, type ApiClient } from '@pipu/api-client'; import type { AuthSession } from '@pipu/core';
import { checkServer } from '@pipu/core'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SUPPORTED_LOCALES } from '@pipu/i18n'; import { RouterProvider } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
const defaultClient = createApiClient(); import type { createAppRouter } from './app/router';
import { SessionProvider } from './app/session';
export function App({ client = defaultClient }: { client?: ApiClient }) { export function createQueryClient() {
const { t, i18n } = useTranslation(); return new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
const server = useQuery({ queryKey: ['server-info'], queryFn: () => checkServer(client) }); }
export function App({
session,
router,
queryClient,
}: {
session: AuthSession;
router: ReturnType<typeof createAppRouter>;
queryClient?: QueryClient;
}) {
const [client] = useState(() => queryClient ?? createQueryClient());
return ( return (
<div className="shell"> <SessionProvider session={session}>
<header className="top"> <QueryClientProvider client={client}>
<div className="logo"> <RouterProvider router={router} />
<span className="logo-dot" aria-hidden /> </QueryClientProvider>
{t('app.name')} </SessionProvider>
</div>
<div className="langs" role="group" aria-label={t('language.label')}>
{SUPPORTED_LOCALES.map((l) => (
<button
key={l}
type="button"
aria-pressed={i18n.language === l}
onClick={() => {
void i18n.changeLanguage(l);
document.documentElement.lang = l;
}}
>
{l.toUpperCase()}
</button>
))}
</div>
</header>
<main className="main">
<section className="card">
<h1>{t('app.name')}</h1>
<p className="muted">{t('app.tagline')}</p>
{server.data?.status === 'ok' ? (
<div className="status" data-state="ok" role="status">
{t('server.online', { version: server.data.info.version })}
</div>
) : server.data ? (
<div className="status" data-state="error" role="status">
{t('server.offline')}
</div>
) : (
<div className="status" data-state="pending" role="status">
{t('server.checking')}
</div>
)}
</section>
</main>
</div>
); );
} }
+240
View File
@@ -0,0 +1,240 @@
import type { AuthSession, AuthUser } from '@pipu/core';
import {
createRootRouteWithContext,
createRoute,
createRouter,
Outlet,
redirect,
type ParsedLocation,
} from '@tanstack/react-router';
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
export interface RouterContext {
session: AuthSession;
}
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
export function homeFor(user: AuthUser): '/setup/admin' | '/setup/password' | '/' {
if (user.must_change_credentials) return '/setup/admin';
if (user.must_change_password) return '/setup/password';
return '/';
}
function signedInUser(session: AuthSession): AuthUser | null {
return session.state.status === 'signed_in' ? session.state.user : null;
}
/** Внутренний путь из ?redirect=; внешние адреса игнорируются. */
export function safeRedirect(target: unknown): string {
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
const user = signedInUser(session);
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
return user;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: Outlet,
errorComponent: RouteError,
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
beforeLoad: ({ context, search }) => {
const user = signedInUser(context.session);
if (!user) return;
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
throw redirect({ href: safeRedirect(search.redirect) });
},
component: LoginPage,
});
const setupAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/admin',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="admin" />,
});
const setupPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/password',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="password" />,
});
/** Каркас приложения: только для вошедших, прошедших обязательные шаги. */
const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
return { user };
},
component: AppShell,
});
const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
if (context.user.role !== 'admin') throw redirect({ to: '/' });
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
const usersRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/users',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: UsersPage,
});
const auditRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/audit',
beforeLoad: adminOnly,
component: AuditPage,
});
const historyRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/history',
component: () => <CollectionPage tab="history" />,
});
const likesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/likes',
component: () => <CollectionPage tab="likes" />,
});
const dislikesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/dislikes',
component: () => <CollectionPage tab="dislikes" />,
});
const searchRoute = createRoute({
getParentRoute: () => appRoute,
path: '/search',
validateSearch: (search: Record<string, unknown>): { q: string; type?: Exclude<SearchType, 'all'> } => {
const type = search.type;
return {
q: typeof search.q === 'string' ? search.q : '',
...(type === 'artists' || type === 'albums' || type === 'tracks' ? { type } : {}),
};
},
component: function SearchRoute() {
const { q, type } = searchRoute.useSearch();
return <SearchPage q={q} type={type ?? 'all'} />;
},
});
const artistRoute = createRoute({
getParentRoute: () => appRoute,
path: '/artist/$id',
component: function ArtistRoute() {
const { id } = artistRoute.useParams();
return <ArtistPage key={id} id={id} />;
},
});
const albumRoute = createRoute({
getParentRoute: () => appRoute,
path: '/album/$id',
component: function AlbumRoute() {
const { id } = albumRoute.useParams();
return <AlbumPage key={id} id={id} />;
},
});
const sourcesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources',
beforeLoad: adminOnly,
component: SourcesPage,
});
const patternsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources/$id/patterns',
beforeLoad: adminOnly,
component: function PatternsRoute() {
const { id } = patternsRoute.useParams();
return <PatternsPage id={id} />;
},
});
const libraryRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/library',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: LibraryPage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
settingsRoute,
searchRoute,
historyRoute,
likesRoute,
dislikesRoute,
artistRoute,
albumRoute,
usersRoute,
auditRoute,
sourcesRoute,
patternsRoute,
libraryRoute,
]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
session.subscribe(() => void router.invalidate());
return router;
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { AuthSession, AuthState, AuthUser } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
const SessionContext = createContext<AuthSession | null>(null);
export function SessionProvider({ session, children }: { session: AuthSession; children: ReactNode }) {
return (
<SessionContext.Provider value={session}>
<UserEffects session={session} />
{children}
</SessionContext.Provider>
);
}
export function useSession(): AuthSession {
const s = useContext(SessionContext);
if (!s) throw new Error('useSession outside SessionProvider');
return s;
}
export function useAuthState(): AuthState {
const session = useSession();
return useSyncExternalStore(
(fn) => session.subscribe(fn),
() => session.state,
);
}
/** Текущий пользователь; вызывать только на защищённых страницах. */
export function useUser(): AuthUser {
const state = useAuthState();
if (state.status !== 'signed_in') throw new Error('useUser: not signed in');
return state.user;
}
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
/** Применяет настройки вошедшего пользователя: язык и тему. */
function UserEffects({ session }: { session: AuthSession }) {
const { i18n } = useTranslation();
const state = useSyncExternalStore(
(fn) => session.subscribe(fn),
() => session.state,
);
const user = state.status === 'signed_in' ? state.user : null;
const locale = user?.locale;
const theme = user?.ui_prefs.theme;
useEffect(() => {
if (locale && isLocale(locale) && locale !== i18n.language) void i18n.changeLanguage(locale);
}, [locale, i18n]);
useEffect(() => {
applyTheme(theme);
}, [theme]);
return null;
}
/** Тёмная тема по умолчанию (docs/design.md); system — по ОС через prefers-color-scheme (tokens.css). */
export function applyTheme(theme: string | undefined) {
const root = document.documentElement;
if (theme === 'system') delete root.dataset.theme;
else root.dataset.theme = theme === 'light' ? 'light' : 'dark';
}
+222
View File
@@ -0,0 +1,222 @@
.cover {
position: relative;
display: inline-grid;
place-items: center;
flex: none;
overflow: hidden;
border-radius: 8px;
color: rgba(255, 255, 255, 0.85);
}
.cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.round {
border-radius: 50%;
}
.link {
color: inherit;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 20px;
}
.card,
.artistChip {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 10px;
margin: -10px;
border-radius: var(--radius-lg);
color: inherit;
text-decoration: none;
}
.card:hover,
.artistChip:hover {
background: var(--hover);
}
.artistChip {
align-items: center;
text-align: center;
width: 128px;
}
.cardCover {
aspect-ratio: 1;
border-radius: 12px;
margin-bottom: 4px;
}
.cardTitle {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.cardMeta {
font-size: 13px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.tracks {
display: flex;
flex-direction: column;
}
.disc {
margin: 14px 12px 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text3);
}
.track {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-height: 56px;
padding: 6px 12px;
border-radius: 10px;
}
.track:has(.trackAlbum) {
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
}
.track:hover {
background: var(--hover);
}
.unavailable {
opacity: 0.45;
}
.num {
position: relative;
display: grid;
place-items: center;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.playHint {
position: absolute;
inset: 0;
display: none;
place-items: center;
border-radius: 8px;
background: rgba(5, 7, 13, 0.55);
color: #fff;
}
.track:not(.unavailable) {
cursor: pointer;
}
.track:hover .playHint,
.track:focus-visible .playHint {
display: grid;
}
.current {
background: var(--accent-soft);
}
.current .trackTitle {
color: var(--accent2);
}
.end {
display: flex;
align-items: center;
gap: 10px;
}
.like {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
color: var(--text3);
opacity: 0;
}
.track:hover .like,
.track:focus-within .like,
.liked {
opacity: 1;
}
.liked {
color: var(--accent2);
}
.rowMeta {
color: var(--text3);
}
.trackMain {
display: flex;
flex-direction: column;
min-width: 0;
}
.trackTitle {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackArtists,
.trackAlbum {
font-size: 13px;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.duration {
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px) {
.track:has(.trackAlbum) {
grid-template-columns: 40px minmax(0, 1fr) auto;
}
.trackAlbum {
display: none;
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
export type Track = components['schemas']['Track'];
export type AlbumSummary = components['schemas']['AlbumSummary'];
export type ArtistRef = components['schemas']['ArtistRef'];
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
function fallbackColors(id: string): string[] {
let h = 0;
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const base = h % 360;
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
}
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string {
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
}
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
export function Cover({
album,
size = 48,
round,
className,
}: {
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
size?: number | string;
round?: boolean;
className?: string;
}) {
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
return (
<span
className={cx(s.cover, round && s.round, className)}
style={{
width: size,
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
height: typeof size === 'number' ? size : undefined,
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
}}
aria-hidden
>
{album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
</span>
);
}
/** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
return (
<>
{artists.map((a, i) => (
<Fragment key={a.id}>
{i > 0 && ', '}
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{a.name}
</Link>
</Fragment>
))}
</>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) {
return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} />
<span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
</span>
</Link>
);
}
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
return (
<div className={s.grid}>
{albums.map((a) => (
<AlbumCard key={a.id} album={a} />
))}
</div>
);
}
/**
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
*/
export function TrackList({
tracks,
showAlbum,
numbered,
context,
rowMeta,
}: {
tracks: Track[];
showAlbum?: boolean;
numbered?: boolean;
context: PlayContext;
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
rowMeta?: (t: Track, i: number) => string | undefined;
}) {
const { t } = useTranslation();
const player = usePlayer();
const current = usePlayerState().track;
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
lastDisc = disc;
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
role="row"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
<span className={s.num}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={s.trackMain}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end}>
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
</Fragment>
);
})}
</div>
);
}
export function ArtistChip({ artist }: { artist: ArtistRef }) {
const { t } = useTranslation();
return (
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
<Cover album={{ id: artist.id }} size={88} round />
<span className={s.cardTitle}>{artist.name}</span>
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
</Link>
);
}
+36 -5
View File
@@ -1,12 +1,14 @@
import './styles.css'; import './styles.css';
import { AuthSession } from '@pipu/core';
import { createI18n, pickLocale } from '@pipu/i18n'; import { createI18n, pickLocale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { App } from './App'; import { App } from './App';
import { createAppRouter } from './app/router';
import { applyTheme } from './app/session';
const LOCALE_KEY = 'pipu.locale'; const LOCALE_KEY = 'pipu.locale';
@@ -19,9 +21,37 @@ function storedLocale(): string[] {
} }
} }
/** «Chrome / Windows» — подпись сессии в списке устройств. */
function deviceName(): string {
const ua = navigator.userAgent;
const browser = /Edg\//.test(ua)
? 'Edge'
: /Firefox\//.test(ua)
? 'Firefox'
: /Chrome\//.test(ua)
? 'Chrome'
: /Safari\//.test(ua)
? 'Safari'
: 'Browser';
const os = /Windows/.test(ua)
? 'Windows'
: /Android/.test(ua)
? 'Android'
: /iPhone|iPad/.test(ua)
? 'iOS'
: /Mac OS X/.test(ua)
? 'macOS'
: /Linux/.test(ua)
? 'Linux'
: '';
return os ? `${browser} / ${os}` : browser;
}
applyTheme(undefined);
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages])); const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
document.documentElement.lang = i18n.language; document.documentElement.lang = i18n.language;
i18n.on('languageChanged', (l) => { i18n.on('languageChanged', (l) => {
document.documentElement.lang = l;
try { try {
localStorage.setItem(LOCALE_KEY, l); localStorage.setItem(LOCALE_KEY, l);
} catch { } catch {
@@ -29,14 +59,15 @@ i18n.on('languageChanged', (l) => {
} }
}); });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } }); const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
await session.restore();
const router = createAppRouter(session);
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}> <App session={session} router={router} />
<App />
</QueryClientProvider>
</I18nextProvider> </I18nextProvider>
</StrictMode>, </StrictMode>,
); );
+53
View File
@@ -0,0 +1,53 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function AlbumPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const album = useQuery({
queryKey: ['album', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
});
if (album.isError) return <NotFound error={album.error} />;
const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
return (
<div className={s.page}>
<header className={c.hero}>
{colors && (
<span
className={c.heroBg}
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
aria-hidden
/>
)}
<Cover album={a ?? { id }} size={200} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '}
{formatDuration(a.duration_ms)}
</span>
)}
</div>
</header>
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, Cover } from '../entities/catalog';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function ArtistPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const artist = useQuery({
queryKey: ['artist', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
});
const albums = useQuery({
queryKey: ['artist-albums', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data;
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return (
<div className={s.page}>
<header className={c.hero}>
<Cover album={{ id }} size={168} round />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
)}
</div>
</header>
{own.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.albums')}</h2>
<AlbumGrid albums={own} />
</section>
)}
{appears.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.appears_on')}</h2>
<AlbumGrid albums={appears} />
</section>
)}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { Logo } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './auth.module.css';
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
return (
<div className={s.screen}>
<div className={s.backdrop} aria-hidden>
<span className={s.blob1} />
<span className={s.blob2} />
<span className={s.blob3} />
</div>
<header className={s.top}>
<Logo />
<LanguageSwitch />
</header>
<main className={s.center}>
<section className={s.card}>{children}</section>
{footer && <div className={s.footer}>{footer}</div>}
</main>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { TrackList, type Track } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { playIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError } from '../shared/ui/ui';
import c from './catalog.module.css';
import s from './page.module.css';
export type CollectionTab = 'history' | 'likes' | 'dislikes';
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
export function CollectionPage({ tab }: { tab: CollectionTab }) {
const { t } = useTranslation();
return (
<div className={s.page}>
<div className={s.head}>
<h1>{t('collection.title')}</h1>
</div>
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
{TABS.map((k) => (
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}>
{t(`collection.${k}`)}
</Link>
))}
</div>
{tab === 'history' ? <History /> : <Reacted reaction={tab === 'likes' ? 'like' : 'dislike'} />}
</div>
);
}
/** Ключ дня в локальном времени: история группируется на клиенте. */
function dayKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
function dayTitle(iso: string, locale: string, t: (k: 'collection.today' | 'collection.yesterday') => string): string {
const d = new Date(iso);
const today = new Date();
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
if (dayKey(iso) === dayKey(today.toISOString())) return t('collection.today');
if (dayKey(iso) === dayKey(yesterday.toISOString())) return t('collection.yesterday');
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(d);
}
function History() {
const { t, i18n } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [confirm, setConfirm] = useState(false);
const history = useInfiniteQuery({
queryKey: ['history'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET('/api/v1/me/history', { params: { query: { cursor: pageParam, limit: 100 } } })),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const clear = useMutation({
mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/history')),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history'] }),
});
const items = history.data?.pages.flatMap((p) => p.items) ?? [];
const groups: { key: string; title: string; items: typeof items }[] = [];
for (const it of items) {
const key = dayKey(it.played_at);
const last = groups.at(-1);
if (last?.key === key) last.items.push(it);
else groups.push({ key, title: dayTitle(it.played_at, i18n.language, t), items: [it] });
}
const time = new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit' });
return (
<>
{items.length > 0 && (
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setConfirm(true)}>
{t('collection.clear_history')}
</Button>
</div>
)}
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
{groups.map((g) => (
<section key={g.key} className={c.section}>
<h2>{g.title}</h2>
<TrackList
tracks={g.items.map((i) => i.track)}
context={{ type: 'history' }}
rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))}
/>
</section>
))}
{history.hasNextPage && (
<Button className={s.more} busy={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
{t('collection.more')}
</Button>
)}
{confirm && (
<Dialog
title={t('collection.clear_history')}
onClose={() => setConfirm(false)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirm(false)}>
{t('form.cancel')}
</Button>
<Button
variant="danger"
onClick={() => {
setConfirm(false);
clear.mutate();
}}
>
{t('collection.clear_history')}
</Button>
</>
}
>
<p className={s.note}>{t('collection.clear_confirm')}</p>
</Dialog>
)}
</>
);
}
function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const errorText = useErrorText();
const path = reaction === 'like' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
const list = useInfiniteQuery({
queryKey: [reaction === 'like' ? 'likes' : 'dislikes'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET(path, { params: { query: { cursor: pageParam, limit: 100 } } })),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const tracks: Track[] = list.data?.pages.flatMap((p) => p.items.map((i) => i.track)) ?? [];
const total = list.data?.pages[0]?.total ?? 0;
const context = { type: 'collection' as const, id: reaction === 'like' ? 'likes' : 'dislikes' };
return (
<>
{reaction === 'like' && tracks.length > 0 && (
<div className={s.toolbar}>
<Button variant="primary" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
{t('collection.play_all')}
</Button>
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
</div>
)}
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
)}
{tracks.length > 0 && <TrackList tracks={tracks} showAlbum context={context} />}
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('collection.more')}
</Button>
)}
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { useUser } from '../app/session';
import { playIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import s from './home.module.css';
/** Главная. Кнопка волны — заглушка до M4 (рекомендации). */
export function HomePage() {
const { t } = useTranslation();
const user = useUser();
return (
<div className={s.home}>
<h1 className={s.greeting}>{t('home.greeting', { name: user.display_name ?? user.username })}</h1>
<div className={s.orb} aria-disabled="true">
<Icon src={playIcon} size={44} />
<span className={s.orbLabel}>{t('home.wave')}</span>
</div>
<p className={s.soon}>{t('home.wave_soon')}</p>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Field, FormError } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
export function LoginPage() {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const errorText = useErrorText();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const info = useQuery({
queryKey: ['server-info'],
queryFn: async () => (await session.client.GET('/api/v1/server-info')).data ?? null,
});
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
// Переход выполнит роутер: смена состояния сессии пересчитывает guards.
await session.login(username.trim(), password);
} catch (err) {
setError(err);
setPassword('');
setBusy(false);
}
};
return (
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
<h1>{t('login.title')}</h1>
<p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
<form onSubmit={(e) => void submit(e)} noValidate>
<Field
label={t('login.username')}
value={username}
onChange={setUsername}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
/>
<Field
label={t('login.password')}
type="password"
value={password}
onChange={setPassword}
autoComplete="current-password"
required
/>
<FormError>{error ? errorText(error) : null}</FormError>
<Button
type="submit"
variant="primary"
className={s.submit}
busy={busy}
disabled={!username.trim() || !password}
>
{t('login.submit')}
</Button>
</form>
</AuthLayout>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiError } from '@pipu/core';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../shared/lib/errors';
import s from './page.module.css';
/** Сущность каталога не найдена (удалена сканом) или не загрузилась. */
export function NotFound({ error }: { error: unknown }) {
const { t } = useTranslation();
const errorText = useErrorText();
const missing = error instanceof ApiError && error.status === 404;
return (
<div className={s.empty} role="alert">
<p>{missing ? t('catalog.not_found') : errorText(error)}</p>
<Link to="/">{t('catalog.back_home')}</Link>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui';
import s from './page.module.css';
import c from './catalog.module.css';
export type SearchType = 'all' | 'artists' | 'albums' | 'tracks';
const TYPES: SearchType[] = ['all', 'artists', 'albums', 'tracks'];
export function SearchPage({ q, type }: { q: string; type: SearchType }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const query = q.trim();
const results = useInfiniteQuery({
queryKey: ['search', query, type],
enabled: query !== '',
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) =>
unwrap(
await session.client.GET('/api/v1/search', {
params: { query: { q: query, type, cursor: pageParam, limit: type === 'all' ? undefined : 40 } },
signal,
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const pages = results.data?.pages ?? [];
const first = pages[0];
const artists = pages.flatMap((p) => p.artists);
const albums = pages.flatMap((p) => p.albums);
const tracks = pages.flatMap((p) => p.tracks);
const empty = results.isSuccess && artists.length + albums.length + tracks.length === 0;
const count = (k: Exclude<SearchType, 'all'>) => first?.totals[k];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('search.title', { query })}</h1>
{first?.corrected_query && (
<p className={s.subtitle}>{t('search.corrected', { query: first.corrected_query })}</p>
)}
</div>
</div>
<div className={s.chips} role="tablist" aria-label={t('search.label')}>
{TYPES.map((k) => (
<Link
key={k}
to="/search"
search={{ q, type: k === 'all' ? undefined : k }}
role="tab"
aria-selected={type === k}
className={c.tab}
>
{t(`search.${k}`)}
{k !== 'all' && count(k) !== undefined && type === 'all' && <span className={c.tabCount}>{count(k)}</span>}
</Link>
))}
</div>
<FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
{artists.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.artists')}</h2>}
<div className={c.artistRow}>
{artists.map((a) => (
<ArtistChip key={a.id} artist={a} />
))}
</div>
</section>
)}
{albums.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.albums')}</h2>}
<AlbumGrid albums={albums} />
</section>
)}
{tracks.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.tracks')}</h2>}
<TrackList tracks={tracks} showAlbum context={{ type: 'search', id: query, label: query }} />
</section>
)}
{results.hasNextPage && (
<Button className={s.more} busy={results.isFetchingNextPage} onClick={() => void results.fetchNextPage()}>
{t('search.more')}
</Button>
)}
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../app/session';
import { useErrorText, useFieldError } from '../shared/lib/errors';
import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
export function SettingsPage() {
const { t } = useTranslation();
return (
<div className={`${s.page} ${s.narrow}`}>
<div className={s.head}>
<h1>{t('settings.title')}</h1>
</div>
<ProfileSection />
<AppearanceSection />
<PasswordSection />
<SessionsSection />
</div>
);
}
function ProfileSection() {
const { t } = useTranslation();
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const [name, setName] = useState(user.display_name ?? '');
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/me', { body: { display_name: name.trim() } })),
onSuccess: (u) => session.setUser(u),
});
return (
<section className={s.section}>
<h2>{t('settings.account')}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
<div className={s.row}>
<Field
label={t('settings.display_name')}
value={name}
onChange={(v) => {
setName(v);
save.reset();
}}
maxLength={64}
hint={`@${user.username}`}
/>
<Button type="submit" busy={save.isPending} disabled={name.trim() === (user.display_name ?? '')}>
{t('settings.save')}
</Button>
</div>
{save.isSuccess && <span className={s.ok}>{t('settings.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</form>
</section>
);
}
function AppearanceSection() {
const { t } = useTranslation();
const session = useSession();
const user = useUser();
const theme: Theme = user.ui_prefs.theme ?? 'dark';
const setTheme = async (value: Theme) => {
// Оптимистично: тема применяется сразу, сервер подтверждает.
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, theme: value } });
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: { theme: value } } })));
} catch {
session.setUser(user);
}
};
return (
<section className={s.section}>
<h2>{t('settings.appearance')}</h2>
<div className={s.settingRow}>
<span>{t('settings.theme')}</span>
<Segmented<Theme>
label={t('settings.theme')}
value={theme}
onChange={(v) => void setTheme(v)}
options={[
{ value: 'dark', label: t('settings.theme_dark') },
{ value: 'light', label: t('settings.theme_light') },
{ value: 'system', label: t('settings.theme_system') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('language.label')}</span>
<LanguageSwitch />
</div>
</section>
);
}
function PasswordSection() {
const { t } = useTranslation(['common', 'auth']);
const session = useSession();
const user = useUser();
const queryClient = useQueryClient();
const errorText = useErrorText();
const fieldError = useFieldError();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [repeat, setRepeat] = useState('');
const mismatch = repeat !== '' && repeat !== next;
const change = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }),
),
onSuccess: async (tokens) => {
await session.apply(tokens);
setCurrent('');
setNext('');
setRepeat('');
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const err = change.error;
const currentError = fieldError(err, 'current_password');
const nextError = fieldError(err, 'new_password');
const submit = (e: FormEvent) => {
e.preventDefault();
if (!mismatch) change.mutate();
};
return (
<section className={s.section}>
<h2>{t('settings.password')}</h2>
<form onSubmit={submit} noValidate>
<Field
label={t('settings.current_password')}
type="password"
value={current}
onChange={setCurrent}
autoComplete="current-password"
error={currentError}
/>
<Field
label={t('settings.new_password')}
type="password"
value={next}
onChange={setNext}
autoComplete="new-password"
hint={t('auth:setup.min_length', { min: user.role === 'user' ? 8 : 12 })}
error={nextError}
/>
<Field
label={t('settings.repeat_password')}
type="password"
value={repeat}
onChange={setRepeat}
autoComplete="new-password"
error={mismatch ? t('form.passwords_mismatch') : undefined}
/>
{change.isSuccess && <span className={s.ok}>{t('settings.password_changed')}</span>}
<FormError>{err && !currentError && !nextError ? errorText(err) : null}</FormError>
<div className={s.actions}>
<Button type="submit" variant="primary" busy={change.isPending} disabled={!current || !next || next !== repeat}>
{t('settings.change_password')}
</Button>
</div>
</form>
</section>
);
}
function SessionsSection() {
const { t, i18n } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const sessions = useQuery({
queryKey: ['sessions'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/sessions')).items,
});
const revoke = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.DELETE('/api/v1/me/sessions/{id}', { params: { path: { id } } })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sessions'] }),
});
return (
<section className={s.section}>
<h2>{t('settings.sessions')}</h2>
<div className={s.sessions}>
{sessions.data?.map((ss) => (
<div key={ss.id} className={s.session}>
<div>
<div>{ss.device_name ?? ss.client_type}</div>
<div className={s.sessionMeta}>
{ss.current
? t('settings.session_current')
: t('settings.session_last_used', { date: formatDateTime(ss.last_used_at, i18n.language) })}
</div>
</div>
{!ss.current && (
<Button variant="ghost" busy={revoke.isPending && revoke.variables === ss.id} onClick={() => revoke.mutate(ss.id)}>
{t('settings.session_end')}
</Button>
)}
</div>
))}
</div>
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
<div>
<Button variant="danger" onClick={() => void session.logout(true)}>
{t('settings.logout_all')}
</Button>
</div>
</section>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { unwrap } from '@pipu/core';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import { useErrorText, useFieldError } from '../shared/lib/errors';
import { lockResetIcon, shieldIcon } from '../shared/ui/icons';
import { Button, Field, FormError, Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/** Обязательный шаг после входа: новые логин и пароль админа (admin) или замена временного пароля (password). */
export function SetupPage({ variant }: { variant: 'admin' | 'password' }) {
return useAuthState().status === 'signed_in' ? <SetupForm variant={variant} /> : null;
}
function SetupForm({ variant }: { variant: 'admin' | 'password' }) {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const fieldError = useFieldError();
const admin = variant === 'admin';
const minLength = user.role === 'user' ? 8 : 12;
const [username, setUsername] = useState(user.username);
const [password, setPassword] = useState('');
const [repeat, setRepeat] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const mismatch = repeat !== '' && repeat !== password;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (mismatch) return;
setBusy(true);
setError(null);
try {
const res = admin
? await session.client.POST('/api/v1/me/bootstrap-credentials', {
body: { new_username: username.trim(), new_password: password },
})
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
await session.apply(unwrap(res));
} catch (err) {
setError(err);
setBusy(false);
}
};
const usernameError = fieldError(error, 'new_username');
const passwordError = fieldError(error, 'new_password');
const generalError = error && !usernameError && !passwordError ? errorText(error) : null;
return (
<AuthLayout>
<span className={s.badge}>
<Icon src={admin ? shieldIcon : lockResetIcon} size={16} />
{t(admin ? 'setup.admin_badge' : 'setup.password_badge')}
</span>
<h1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</h1>
<p className={s.lead}>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</p>
<form onSubmit={(e) => void submit(e)} noValidate>
{admin && (
<Field
label={t('setup.new_username')}
value={username}
onChange={setUsername}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
error={usernameError}
/>
)}
<Field
label={t('setup.new_password')}
type="password"
value={password}
onChange={setPassword}
autoComplete="new-password"
hint={t('setup.min_length', { min: minLength })}
error={passwordError}
/>
<Field
label={t('setup.repeat_password')}
type="password"
value={repeat}
onChange={setRepeat}
autoComplete="new-password"
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
/>
<FormError>{generalError}</FormError>
<Button
type="submit"
variant="primary"
className={s.submit}
busy={busy}
disabled={!password || password !== repeat || (admin && !username.trim())}
>
{t('setup.submit')}
</Button>
<Button variant="ghost" className={s.link} onClick={() => void session.logout()}>
{t('setup.logout')}
</Button>
</form>
</AuthLayout>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatDateTime } from '../../shared/lib/format';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
type Entry = components['schemas']['AuditEntry'];
/** Краткое описание полезной нагрузки записи: «user → moderator», «anna (user)». */
function details(e: Entry, roleName: (r: string) => string): string {
const p = e.payload ?? {};
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
(v): v is string => typeof v === 'string' && v !== '',
);
return parts.join(' · ');
}
export function AuditPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const list = useInfiniteQuery({
queryKey: ['admin-audit'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET('/api/v1/admin/audit', { params: { query: { cursor: pageParam, limit: 50 } } })),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const entries = list.data?.pages.flatMap((p) => p.items) ?? [];
const tr = t as unknown as (key: string, opts?: Record<string, unknown>) => string;
const roleName = (r: string) => tr(`common:roles.${r}`, { defaultValue: r });
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('audit.title')}</h1>
<p className={s.subtitle}>{t('audit.subtitle')}</p>
</div>
</div>
<FormError>{list.error ? errorText(list.error) : null}</FormError>
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('audit.col_when')}</th>
<th>{t('audit.col_who')}</th>
<th>{t('audit.col_action')}</th>
<th>{t('audit.col_details')}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id}>
<td className={s.muted}>{formatDateTime(e.created_at, i18n.language)}</td>
<td>{e.actor ? `@${e.actor.username}` : <span className={s.muted}>{t('audit.system')}</span>}</td>
<td>{tr(`admin:audit.actions.${e.action}`, { defaultValue: e.action })}</td>
<td className={s.muted}>{details(e, roleName)}</td>
</tr>
))}
</tbody>
</table>
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('audit.more')}
</Button>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { refreshIcon } from '../../shared/ui/icons';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
export function LibraryPage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const admin = user.role === 'admin';
const status = useQuery({
queryKey: ['library-status'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/status')),
refetchInterval: (q) => (q.state.data?.sources.some((x) => x.scan?.running) ? 2000 : 15000),
});
const errors = useQuery({
queryKey: ['library-errors'],
enabled: admin,
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
});
const reindex = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
});
const d = status.data;
const tiles = d
? ([
['artists', d.artists],
['albums', d.albums],
['tracks', d.tracks],
['files', d.files],
['missing', d.missing_files],
['errors', d.file_errors],
] as const)
: [];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('library.title')}</h1>
<p className={s.subtitle}>{t('library.subtitle')}</p>
</div>
{admin && (
<Button icon={refreshIcon} busy={reindex.isPending} onClick={() => reindex.mutate()}>
{reindex.isSuccess ? t('library.reindex_started') : t('library.reindex')}
</Button>
)}
</div>
<FormError>{status.error || reindex.error ? errorText(status.error ?? reindex.error) : null}</FormError>
<div className={l.tiles}>
{tiles.map(([k, v]) => (
<div key={k} className={l.tile}>
<span className={l.tileValue}>{v.toLocaleString()}</span>
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
</div>
))}
</div>
{d && d.sources.length > 0 && (
<section className={s.section}>
<h2>{t('library.sources')}</h2>
{d.sources.map((src) => (
<div key={src.id} className={l.cardFoot}>
<div className={l.cardTitle}>
{src.name} <StatusBadge status={src.status} />
</div>
<ScanLine scan={src.scan} lastScanAt={src.last_scan_at} />
</div>
))}
</section>
)}
{admin && errors.data && errors.data.items.length > 0 && (
<section className={s.section}>
<h2>{t('library.error_files')}</h2>
{errors.data.items.map((e) => (
<div key={e.file_id} className={l.cardFoot}>
<span className={l.location}>{e.rel_path}</span>
<span className={l.scanError}>{e.error}</span>
</div>
))}
</section>
)}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import type { components } from '@pipu/api-client';
import { ApiError, unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui/icons';
import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
type Pattern = components['schemas']['PathPattern'];
type Preview = components['schemas']['PatternPreview'];
/** Примеры из docs/library-and-storage.md — частые раскладки папок. */
const EXAMPLES = [
String.raw`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
String.raw`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
String.raw`^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$`,
];
/** Ошибка компиляции из 422: номер шаблона и причина. */
function patternError(err: unknown): { index: number; reason: string } | null {
if (err instanceof ApiError && err.code === 'validation.pattern_invalid') {
return { index: Number(err.params.index), reason: String(err.params.reason) };
}
return null;
}
export function PatternsPage({ id }: { id: string }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const source = useQuery({
queryKey: ['source', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
});
const saved = useQuery({
queryKey: ['patterns', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } } })).patterns,
});
const [rows, setRows] = useState<Pattern[]>([]);
useEffect(() => {
if (saved.data) setRows(saved.data);
}, [saved.data]);
const preview = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/admin/sources/{id}/patterns/preview', {
params: { path: { id } },
body: { patterns: rows, sample: 50 },
}),
),
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PUT('/api/v1/admin/sources/{id}/patterns', {
params: { path: { id } },
body: { patterns: rows },
}),
),
onSuccess: (r) => setRows(r.patterns),
});
const err = save.error ?? preview.error;
const perr = patternError(err);
const update = (i: number, p: Partial<Pattern>) => {
save.reset();
setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...p } : r)));
};
const move = (i: number, d: -1 | 1) =>
setRows((rs) => {
const next = [...rs];
[next[i], next[i + d]] = [next[i + d]!, next[i]!];
return next;
});
return (
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
<div className={s.head}>
<div>
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
<p className={s.subtitle}>
<Link to="/admin/sources">{t('sources.title')}</Link>
</p>
</div>
</div>
<section className={s.section}>
<p className={s.note}>{t('patterns.intro')}</p>
<p className={l.groups}>{t('patterns.groups')}</p>
{rows.map((r, i) => (
<div key={i} className={l.patternRow}>
<span className={l.patternNum}>{i + 1}</span>
<Field
label={t('patterns.regex', { n: i + 1 })}
value={r.regex}
onChange={(v) => update(i, { regex: v })}
maxLength={1000}
spellCheck={false}
autoCapitalize="none"
mono
error={perr?.index === i ? t('patterns.error_at', { n: i + 1, reason: perr.reason }) : undefined}
/>
<div className={l.patternTools} style={{ marginTop: 22 }}>
<label className={l.check}>
<input type="checkbox" checked={r.is_enabled} onChange={(e) => update(i, { is_enabled: e.target.checked })} />
{t('patterns.enabled')}
</label>
<Button variant="ghost" aria-label={t('patterns.up')} disabled={i === 0} onClick={() => move(i, -1)}>
<Icon src={arrowUpIcon} size={18} />
</Button>
<Button
variant="ghost"
aria-label={t('patterns.down')}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
<Icon src={arrowDownIcon} size={18} />
</Button>
<Button
variant="ghost"
aria-label={t('patterns.remove')}
onClick={() => setRows((rs) => rs.filter((_, k) => k !== i))}
>
<Icon src={deleteIcon} size={18} />
</Button>
</div>
</div>
))}
<div className={s.toolbar}>
<Button icon={addIcon} onClick={() => setRows((rs) => [...rs, { regex: '', is_enabled: true }])}>
{t('patterns.add')}
</Button>
<Button
variant="ghost"
onClick={() => setRows((rs) => [...rs, ...EXAMPLES.map((regex) => ({ regex, is_enabled: true }))])}
>
{t('patterns.examples')}
</Button>
<span style={{ flex: 1 }} />
<Button busy={preview.isPending} onClick={() => preview.mutate()}>
{t('patterns.preview')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('patterns.save')}
</Button>
</div>
{save.isSuccess && <span className={s.ok}>{t('patterns.saved')}</span>}
<FormError>{err && !perr ? errorText(err) : null}</FormError>
</section>
{preview.data && <PreviewTable preview={preview.data} />}
</div>
);
}
function PreviewTable({ preview }: { preview: Preview }) {
const { t } = useTranslation('admin');
const fields = ['artist', 'album', 'title'] as const;
return (
<>
<p className={s.note}>{t('patterns.unmatched', { count: preview.unmatched, total: preview.items.length })}</p>
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
<table className={s.table}>
<thead>
<tr>
<th>{t('patterns.col_path')}</th>
<th>{t('patterns.col_pattern')}</th>
<th>{t('patterns.col_result')}</th>
</tr>
</thead>
<tbody>
{preview.items.map((it) => (
<tr key={it.rel_path}>
<td className={l.match}>{it.rel_path}</td>
<td className={cx(l.match, it.pattern_index === null && l.noMatch)}>
{it.skipped
? t('patterns.skipped')
: it.pattern_index === null
? t('patterns.no_match')
: `#${it.pattern_index + 1}`}
</td>
<td>
<div className={l.result}>
{fields.map((f) => {
const fromPath = f in it.path_fields;
const fromTags = (f === 'artist' ? 'artist' : f) in it.tags;
return (
<span key={f}>
<b>{it.result[f]}</b>{' '}
{!fromPath && fromTags && <span className={l.fromTags}>{t('patterns.from_tags')}</span>}
</span>
);
})}
<span className={l.fromTags}>
{[it.result.year, it.result.track && `#${it.result.track}`, it.result.genres.join(', ')]
.filter(Boolean)
.join(' · ')}
</span>
{it.error && <span className={l.scanError}>{it.error}</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { components } from '@pipu/api-client';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from './library.module.css';
type ScanProgress = components['schemas']['ScanProgress'];
export type SourceStatus = components['schemas']['SourceStatus'];
export function StatusBadge({ status }: { status: SourceStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.status, s[`st_${status}`])}>{t(`sources.status_${status}`)}</span>;
}
/** Прогресс идущего скана или итог последнего. */
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
const { t, i18n } = useTranslation('admin');
if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return (
<div className={s.scan}>
<div className={s.scanText}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
</div>
</div>
);
}
return (
<div className={s.scan}>
<div className={s.scanText}>
{lastScanAt ? t('sources.last_scan', { date: formatDateTime(lastScanAt, i18n.language) }) : t('sources.never_scanned')}
</div>
{scan?.error ? (
<div className={s.scanError}>{t('sources.scan_failed', { error: scan.error })}</div>
) : (
scan && <div className={s.scanSummary}>{t('sources.scan_done', { ...scan })}</div>
)}
</div>
);
}
+352
View File
@@ -0,0 +1,352 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
type Source = components['schemas']['Source'];
type TestResult = components['schemas']['SourceTestResult'];
const SCAN_POLL_MS = 2000;
export function SourcesPage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const [editing, setEditing] = useState<Source | 'new' | null>(null);
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
// Пока идёт скан — опрашиваем прогресс.
refetchInterval: (q) => (q.state.data?.some((x) => x.scan?.running) ? SCAN_POLL_MS : false),
});
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('sources.title')}</h1>
<p className={s.subtitle}>{t('sources.subtitle')}</p>
</div>
<Button variant="primary" icon={addIcon} onClick={() => setEditing('new')}>
{t('sources.add')}
</Button>
</div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
<div className={l.list}>
{sources.data?.map((src) => (
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
))}
</div>
{editing && <SourceDialog source={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />}
</div>
);
}
function location(src: Source): string {
if (src.type === 'nfs') return `${src.nfs_server ?? ''}:${src.nfs_export ?? ''}`;
return `LIBRARY_HOST/${src.local_path ?? ''}`;
}
function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const [test, setTest] = useState<TestResult | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const id = source.id;
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
const scan = useMutation({
mutationFn: async (full: boolean) =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/scan', { params: { path: { id } }, body: { full } })),
onSuccess: refresh,
});
const check = useMutation({
mutationFn: async () =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/test', { params: { path: { id } } })),
onSuccess: setTest,
});
const patch = useMutation({
mutationFn: async (enabled: boolean) =>
unwrap(await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id } }, body: { enabled } })),
onSuccess: refresh,
});
const remove = useMutation({
mutationFn: async () =>
unwrap(await session.client.DELETE('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
onSuccess: refresh,
});
const error = scan.error ?? check.error ?? patch.error ?? remove.error;
const running = source.scan?.running ?? false;
const disabled = source.status === 'disabled';
const menu: MenuItem[] = [
{ label: t('sources.full_scan'), icon: refreshIcon, onSelect: () => scan.mutate(true) },
{ label: t('sources.edit'), onSelect: onEdit },
disabled
? { label: t('sources.enable'), onSelect: () => patch.mutate(true) }
: { label: t('sources.disable'), onSelect: () => patch.mutate(false) },
{ label: t('sources.delete'), danger: true, onSelect: () => setConfirmDelete(true) },
];
return (
<article className={l.card} aria-label={source.name}>
<div className={l.cardHead}>
<div className={l.cardTitle}>
{source.name}
<span className={l.typeBadge}>{t(`sources.type_${source.type}`)}</span>
<StatusBadge status={source.status} />
</div>
<span className={l.location}>{location(source)}</span>
{source.status === 'error' && source.status_message && (
<span className={l.message}>{source.status_message}</span>
)}
</div>
<div className={l.cardActions}>
<Button
variant="primary"
busy={scan.isPending}
disabled={running || source.status !== 'mounted'}
onClick={() => scan.mutate(false)}
>
{t('sources.scan')}
</Button>
<Button busy={check.isPending} onClick={() => check.mutate()}>
{t('sources.test')}
</Button>
<Button onClick={() => void navigate({ to: '/admin/sources/$id/patterns', params: { id } })}>
{t('sources.patterns')}
</Button>
<Menu label={t('users.actions', { username: source.name })} trigger={<Icon src={moreIcon} />} items={menu} />
</div>
<div className={l.cardFoot}>
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
{test && (
<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 })}
</span>
)}
<FormError>{error ? errorText(error) : null}</FormError>
</div>
{confirmDelete && (
<Dialog
title={t('sources.delete')}
onClose={() => setConfirmDelete(false)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
{t('common:form.cancel')}
</Button>
<Button
variant="danger"
onClick={() => {
setConfirmDelete(false);
remove.mutate();
}}
>
{t('users.confirm')}
</Button>
</>
}
>
<p className={s.note}>{t('sources.delete_confirm', { name: source.name })}</p>
</Dialog>
)}
</article>
);
}
const INTERVALS = [0, 60, 360, 1440];
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const fieldError = useFieldError();
const [type, setType] = useState<'local' | 'nfs'>(source?.type ?? 'local');
const [name, setName] = useState(source?.name ?? '');
const [localPath, setLocalPath] = useState(source?.local_path ?? '');
const [server, setServer] = useState(source?.nfs_server ?? '');
const [exportPath, setExportPath] = useState(source?.nfs_export ?? '');
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
const save = useMutation({
mutationFn: async () => {
const common = {
name: name.trim(),
tag_priority: priority,
...(type === 'local'
? { local_path: localPath }
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
};
if (source)
return unwrap(
await session.client.PATCH('/api/v1/admin/sources/{id}', {
params: { path: { id: source.id } },
body: { ...common, scan_interval_minutes: interval },
}),
);
return unwrap(
await session.client.POST('/api/v1/admin/sources', {
body: { ...common, type, scan_interval_minutes: interval || null },
}),
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['sources'] });
onClose();
},
});
const err = save.error;
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
const submit = (e: FormEvent) => {
e.preventDefault();
save.mutate();
};
return (
<Dialog
title={t(source ? 'sources.form_title_edit' : 'sources.form_title_new')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button type="submit" form="source-form" variant="primary" busy={save.isPending} disabled={!name.trim()}>
{t('sources.save')}
</Button>
</>
}
>
<form id="source-form" onSubmit={submit} noValidate className={s.sessions}>
<Field label={t('sources.name')} value={name} onChange={setName} maxLength={100} error={fieldError(err, 'name')} />
{!source && (
<div className={l.field}>
<span className={l.label}>{t('sources.type')}</span>
<Segmented
label={t('sources.type')}
value={type}
onChange={setType}
options={[
{ value: 'local', label: t('sources.type_local') },
{ value: 'nfs', label: t('sources.type_nfs') },
]}
/>
</div>
)}
{type === 'local' ? (
<FolderPicker value={localPath} onChange={setLocalPath} error={fieldError(err, 'local_path')} />
) : (
<>
<Field
label={t('sources.nfs_server')}
value={server}
onChange={setServer}
hint={t('sources.nfs_server_hint')}
error={fieldError(err, 'nfs_server')}
mono
/>
<Field
label={t('sources.nfs_export')}
value={exportPath}
onChange={setExportPath}
hint={t('sources.nfs_export_hint')}
error={fieldError(err, 'nfs_export')}
mono
/>
<Field
label={t('sources.nfs_options')}
value={options}
onChange={setOptions}
hint={t('sources.nfs_options_hint')}
error={fieldError(err, 'nfs_options')}
mono
/>
</>
)}
<div className={l.field}>
<span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented
label={t('sources.tag_priority')}
value={priority}
onChange={setPriority}
options={[
{ value: 'tags_first', label: t('sources.tags_first') },
{ value: 'path_first', label: t('sources.path_first') },
]}
/>
</div>
<label className={l.field}>
<span className={l.label}>{t('sources.interval')}</span>
<select className={s.select} value={interval} onChange={(e) => setIntervalMin(Number(e.target.value))}>
{[...new Set([...INTERVALS, interval])].map((m) => (
<option key={m} value={m}>
{m === 0 ? t('sources.interval_off') : t('sources.interval_hours', { count: Math.round(m / 60) })}
</option>
))}
</select>
</label>
<FormError>{general}</FormError>
</form>
</Dialog>
);
}
/** Выбор подпапки LIBRARY_HOST: хлебные крошки и список вложенных папок. */
function FolderPicker({ value, onChange, error }: { value: string; onChange: (v: string) => void; error?: string }) {
const { t } = useTranslation('admin');
const session = useSession();
const dirs = useQuery({
queryKey: ['host-dirs', value],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/admin/library/host-dirs', { params: { query: { path: value } } })),
});
const parts = value ? value.split('/') : [];
return (
<div className={l.field}>
<span className={l.label}>{t('sources.folder')}</span>
<div className={l.picker}>
<div className={l.crumbs}>
<button type="button" className={l.dir} onClick={() => onChange('')}>
LIBRARY_HOST
</button>
{parts.map((p, i) => (
<span key={i} className={l.crumbs}>
<Icon src={chevronRightIcon} size={14} />
<button type="button" className={l.dir} onClick={() => onChange(parts.slice(0, i + 1).join('/'))}>
{p}
</button>
</span>
))}
</div>
{dirs.data?.dirs.map((d) => (
<button key={d} type="button" className={l.dir} onClick={() => onChange(value ? `${value}/${d}` : d)}>
<Icon src={folderIcon} size={18} className={l.dirIcon} />
{d}
</button>
))}
</div>
<span className={s.note}>{error ?? (value ? value : t('sources.folder_root'))}</span>
</div>
);
}
+362
View File
@@ -0,0 +1,362 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
type AdminUser = components['schemas']['AdminUser'];
const ROLES: Role[] = ['admin', 'moderator', 'user'];
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const id = setTimeout(() => setV(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return v;
}
function status(u: AdminUser): 'blocked' | 'must_change' | 'active' {
if (u.is_blocked) return 'blocked';
if (u.must_change_password || u.must_change_credentials) return 'must_change';
return 'active';
}
export function UsersPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const me = useUser();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [q, setQ] = useState('');
const [role, setRole] = useState<Role | null>(null);
const query = useDebounced(q.trim(), 200);
const [creating, setCreating] = useState(false);
const [confirm, setConfirm] = useState<Confirm | null>(null);
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const list = useInfiniteQuery({
queryKey: ['admin-users', query, role],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/users', {
params: { query: { q: query || undefined, role: role ?? undefined, cursor: pageParam, limit: 50 } },
}),
),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const users = list.data?.pages.flatMap((p) => p.items) ?? [];
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] });
const patch = useMutation({
mutationFn: async ({ id, body }: { id: string; body: components['schemas']['UpdateUserRequest'] }) =>
unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', { params: { path: { id } }, body })),
onSuccess: refresh,
onError: setActionError,
});
const reset = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(
await session.client.POST('/api/v1/admin/users/{id}/reset-password', { params: { path: { id: u.id } } }),
),
onSuccess: (r, u) => {
setSecret({ username: u.username, password: r.temporary_password });
void refresh();
},
onError: setActionError,
});
const remove = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
onSuccess: refresh,
onError: setActionError,
});
const runConfirm = () => {
if (!confirm) return;
const { kind, user } = confirm;
setConfirm(null);
setActionError(null);
if (kind === 'reset') reset.mutate(user);
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
if (kind === 'delete') remove.mutate(user);
};
const actionsFor = (u: AdminUser): MenuItem[] => {
const items: MenuItem[] = [];
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
if (can('reset_password'))
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
if (can('block'))
items.push(
u.is_blocked
? { label: t('users.unblock'), onSelect: () => patch.mutate({ id: u.id, body: { is_blocked: false } }) }
: { label: t('users.block'), onSelect: () => setConfirm({ kind: 'block', user: u }) },
);
if (can('change_role'))
for (const r of ROLES.filter((r) => r !== u.role))
items.push({
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
});
if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items;
};
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('users.title')}</h1>
<p className={s.subtitle}>{t('users.subtitle')}</p>
</div>
<Button variant="primary" icon={addIcon} onClick={() => setCreating(true)}>
{t('users.create')}
</Button>
</div>
<div className={s.toolbar}>
<label className={s.search}>
<Icon src={searchIcon} size={18} className={s.searchIcon} />
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('users.search')}
aria-label={t('users.search')}
/>
</label>
<div className={s.chips} role="group" aria-label={t('users.col_role')}>
<button type="button" className={s.chip} aria-pressed={role === null} onClick={() => setRole(null)}>
{t('users.all')}
</button>
{ROLES.map((r) => (
<button key={r} type="button" className={s.chip} aria-pressed={role === r} onClick={() => setRole(r)}>
{t(`common:roles.${r}`)}
</button>
))}
</div>
</div>
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('users.col_user')}</th>
<th>{t('users.col_role')}</th>
<th>{t('users.col_status')}</th>
<th>{t('users.col_last_login')}</th>
<th aria-hidden />
</tr>
</thead>
<tbody>
{users.map((u) => {
const items = actionsFor(u);
const st = status(u);
return (
<tr key={u.id}>
<td>
<div className={s.userCell}>
<Avatar name={u.display_name ?? u.username} />
<div>
<div className={s.userName}>
{u.display_name ?? u.username}
{u.id === me.id && <span className={s.you}>({t('users.you')})</span>}
</div>
<div className={s.userLogin}>@{u.username}</div>
</div>
</div>
</td>
<td>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
</td>
<td>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
</td>
<td className={s.muted}>
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
</td>
<td>
{items.length > 0 && (
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('users.more')}
</Button>
)}
{creating && (
<CreateUserDialog
actorRole={me.role}
onClose={() => setCreating(false)}
onCreated={(username, password) => {
setCreating(false);
setSecret({ username, password });
void refresh();
}}
/>
)}
{confirm && (
<Dialog
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
onClose={() => setConfirm(null)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirm(null)}>
{t('common:form.cancel')}
</Button>
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
{t('users.confirm')}
</Button>
</>
}
>
<p className={s.note}>{t(`users.${confirm.kind}_confirm`, { username: confirm.user.username })}</p>
</Dialog>
)}
{secret && <TemporaryPasswordDialog {...secret} onClose={() => setSecret(null)} />}
</div>
);
}
function CreateUserDialog({
actorRole,
onClose,
onCreated,
}: {
actorRole: Role;
onClose: () => void;
onCreated: (username: string, password: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const roles = assignableRoles(actorRole);
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<Role>('user');
const create = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/admin/users', {
body: { username: username.trim(), display_name: name.trim() || undefined, role },
}),
),
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
});
const usernameError = fieldError(create.error, 'username');
const nameError = fieldError(create.error, 'display_name');
const submit = (e: FormEvent) => {
e.preventDefault();
create.mutate();
};
return (
<Dialog
title={t('create.title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button type="submit" form="create-user" variant="primary" busy={create.isPending} disabled={!username.trim()}>
{t('create.submit')}
</Button>
</>
}
>
<form id="create-user" onSubmit={submit} noValidate className={s.sessions}>
<Field
label={t('create.username')}
value={username}
onChange={setUsername}
autoCapitalize="none"
spellCheck={false}
hint={t('create.username_hint')}
error={usernameError}
mono
/>
<Field label={t('create.display_name')} value={name} onChange={setName} maxLength={64} error={nameError} />
{roles.length > 1 && (
<label className={s.sessions}>
<span className={s.subtitle}>{t('create.role')}</span>
<select className={s.select} value={role} onChange={(e) => setRole(e.target.value as Role)}>
{roles.map((r) => (
<option key={r} value={r}>
{t(`common:roles.${r}`)}
</option>
))}
</select>
</label>
)}
<p className={s.note}>{t('create.password_note')}</p>
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
</form>
</Dialog>
);
}
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(password);
setCopied(true);
} catch {
// Буфер обмена недоступен (http, права) — пароль виден на экране.
}
};
return (
<Dialog
title={t('temp_password.title')}
onClose={onClose}
footer={
<Button variant="primary" onClick={onClose}>
{t('temp_password.done')}
</Button>
}
>
<p className={s.note}>{t('temp_password.text', { username })}</p>
<div className={s.secret}>
<span data-testid="temporary-password">{password}</span>
<Button variant="ghost" icon={copied ? checkIcon : copyIcon} onClick={() => void copy()}>
{copied ? t('common:form.copied') : t('common:form.copy')}
</Button>
</div>
</Dialog>
);
}
+301
View File
@@ -0,0 +1,301 @@
.list {
display: flex;
flex-direction: column;
gap: 14px;
}
.card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px 20px;
padding: 20px 22px;
border-radius: var(--radius-lg);
background: var(--surface-a);
border: 1px solid var(--line);
}
.cardHead {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.cardTitle {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 17px;
font-weight: 600;
}
.location {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text2);
overflow-wrap: anywhere;
}
.message {
font-size: 13px;
color: var(--danger);
}
.cardActions {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.cardFoot {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 6px;
}
.typeBadge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 9px;
border-radius: var(--radius-pill);
background: var(--surface3);
color: var(--text2);
font-size: 12px;
font-weight: 600;
}
.status {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
}
.status::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.st_mounted {
color: var(--ok);
}
.st_error {
color: var(--danger);
}
.st_pending {
color: var(--warn);
}
.st_disabled {
color: var(--text3);
}
.scan {
display: flex;
flex-direction: column;
gap: 6px;
}
.scanText,
.scanSummary {
font-size: 13px;
color: var(--text2);
}
.scanError {
font-size: 13px;
color: var(--danger);
}
.bar {
height: 6px;
border-radius: 3px;
background: var(--surface3);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--violet));
transition: width 0.4s;
}
.test {
font-size: 13px;
}
.testOk {
color: var(--ok);
}
.testFail {
color: var(--danger);
}
.picker {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
padding: 6px;
border-radius: var(--radius-md);
background: var(--surface2);
border: 1px solid var(--line2);
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
padding: 4px 6px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text2);
}
.dir {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 8px;
border-radius: 8px;
text-align: left;
font-size: 14px;
}
.dir:hover {
background: var(--hover);
}
.dirIcon {
color: var(--accent2);
}
.label {
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.tile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
border-radius: var(--radius-lg);
background: var(--surface-a);
border: 1px solid var(--line);
}
.tileValue {
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.tileLabel {
font-size: 13px;
color: var(--text2);
}
.patternRow {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
}
.patternNum {
height: 48px;
margin-top: 22px;
display: grid;
place-items: center;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.patternTools {
display: flex;
align-items: center;
gap: 2px;
height: 48px;
}
.check {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 6px;
font-size: 13px;
color: var(--text2);
}
.groups {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text3);
}
.match {
font-family: var(--font-mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.noMatch {
color: var(--warn);
}
.result {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.result b {
font-weight: 600;
}
.fromTags {
color: var(--text3);
font-size: 12px;
}
@media (max-width: 767px) {
.card {
grid-template-columns: 1fr;
}
.cardActions {
justify-content: flex-start;
}
}
+166
View File
@@ -0,0 +1,166 @@
.screen {
position: relative;
min-height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.backdrop {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: var(--amb-op);
}
.backdrop span {
position: absolute;
border-radius: 50%;
filter: blur(30px);
}
.blob1 {
width: 62vmax;
height: 62vmax;
left: -18vmax;
top: -24vmax;
background: radial-gradient(circle, rgba(47, 128, 255, 0.34), transparent 65%);
animation: drift1 16s ease-in-out infinite alternate;
}
.blob2 {
width: 55vmax;
height: 55vmax;
right: -20vmax;
bottom: -26vmax;
background: radial-gradient(circle, rgba(124, 92, 255, 0.3), transparent 65%);
animation: drift2 21s ease-in-out infinite alternate;
}
.blob3 {
width: 40vmax;
height: 40vmax;
left: 40%;
top: 30%;
background: radial-gradient(circle, rgba(53, 211, 255, 0.16), transparent 65%);
animation: drift1 26s ease-in-out infinite alternate-reverse;
}
@keyframes drift1 {
to {
transform: translate(6vmax, 4vmax);
}
}
@keyframes drift2 {
to {
transform: translate(-5vmax, -6vmax);
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop span {
animation: none;
}
}
.top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
}
.center {
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px 16px 48px;
}
.card {
width: 420px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
padding: 36px;
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.2s ease-out;
}
@media (max-width: 480px) {
.card {
padding: 28px 20px;
}
}
.card h1 {
margin: 0;
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
}
.card form {
display: flex;
flex-direction: column;
gap: 14px;
}
.lead {
margin: -6px 0 4px;
color: var(--text2);
line-height: 1.5;
}
.badge {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border-radius: var(--radius-pill);
background: var(--violet-soft);
color: var(--violet);
font-size: 12px;
font-weight: 700;
}
.submit {
height: 48px;
margin-top: 4px;
}
.hint {
padding: 10px 12px;
border-radius: 10px;
background: var(--accent-soft);
color: var(--text2);
font-size: 13px;
line-height: 1.45;
}
.footer {
font-size: 12px;
color: var(--text3);
}
.link {
align-self: center;
}
+116
View File
@@ -0,0 +1,116 @@
.section {
display: flex;
flex-direction: column;
gap: 14px;
}
.section h2 {
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
}
.artistRow {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.tab[aria-selected='true'] {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
.tabCount {
font-size: 12px;
color: var(--text3);
}
.tab[aria-selected='true'] .tabCount {
color: inherit;
opacity: 0.6;
}
.hero {
position: relative;
display: flex;
align-items: flex-end;
gap: 28px;
padding: 28px;
margin: -32px -36px 0;
overflow: hidden;
}
.heroBg {
position: absolute;
inset: 0;
opacity: 0.35;
filter: blur(60px);
pointer-events: none;
}
.heroBody {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.kind {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text2);
}
.heroTitle {
margin: 0;
font-family: var(--font-display);
font-size: clamp(28px, 4vw, 48px);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
overflow-wrap: anywhere;
}
.heroMeta {
color: var(--text2);
}
.heroMeta a {
color: var(--text);
font-weight: 600;
text-decoration: none;
}
.heroMeta a:hover {
text-decoration: underline;
}
@media (max-width: 767px) {
.hero {
flex-direction: column;
align-items: flex-start;
margin: -20px -16px 0;
padding: 20px 16px;
}
}
+63
View File
@@ -0,0 +1,63 @@
.home {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
text-align: center;
}
.greeting {
margin: 0;
font-family: var(--font-display);
font-size: 24px;
font-weight: 600;
letter-spacing: -0.01em;
}
.orb {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 300px;
height: 300px;
max-width: 70vw;
max-height: 70vw;
border-radius: 50%;
color: #fff;
background:
radial-gradient(circle at 35% 30%, rgba(156, 198, 255, 0.55), transparent 45%),
radial-gradient(circle at 65% 70%, var(--wave-color3), transparent 55%),
radial-gradient(circle at 50% 50%, var(--wave-color2), var(--wave-color1) 70%);
box-shadow: 0 0 80px rgba(47, 128, 255, 0.35);
opacity: 0.85;
animation: breathe 6s ease-in-out infinite;
}
.orbLabel {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
}
@keyframes breathe {
50% {
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: reduce) {
.orb {
animation: none;
}
}
.soon {
max-width: 420px;
margin: 0;
color: var(--text2);
line-height: 1.5;
}
+343
View File
@@ -0,0 +1,343 @@
.page {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.narrow {
max-width: 720px;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.head h1 {
margin: 0;
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
letter-spacing: -0.01em;
}
.subtitle {
margin: 6px 0 0;
color: var(--text2);
}
.section {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
border-radius: var(--radius-lg);
background: var(--surface-a);
border: 1px solid var(--line);
}
.section h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.section form {
display: flex;
flex-direction: column;
gap: 14px;
}
.row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.row > :first-child {
flex: 1;
min-width: 220px;
}
.settingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.ok {
color: var(--ok);
font-size: 13px;
font-weight: 500;
}
.actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1;
min-width: 220px;
max-width: 360px;
}
.search input {
width: 100%;
height: 40px;
padding: 0 12px 0 38px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
}
.search input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chip {
height: 32px;
padding: 0 14px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.chip[aria-pressed='true'] {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--text);
}
.tableWrap {
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: var(--surface-a);
}
/* Горизонтальная прокрутка только там, где таблица не помещается: она обрезала бы меню действий. */
@media (max-width: 900px) {
.tableWrap {
overflow-x: auto;
}
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
height: 44px;
padding: 0 16px;
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.table td {
height: 60px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: 0;
}
.table tbody tr:hover {
background: var(--hover);
}
.userCell {
display: flex;
align-items: center;
gap: 12px;
min-width: 200px;
}
.userName {
font-weight: 600;
}
.userLogin {
font-size: 12px;
color: var(--text3);
}
.you {
margin-left: 6px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
}
.badge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.role_admin {
background: var(--violet-soft);
color: var(--violet);
}
.role_moderator {
background: var(--accent-soft);
color: var(--accent2);
}
.role_user {
background: var(--hover);
color: var(--text2);
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.status::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.status_active {
color: var(--ok);
}
.status_blocked {
color: var(--danger);
}
.status_must_change {
color: var(--warn);
}
.muted {
color: var(--text3);
white-space: nowrap;
}
.empty {
padding: 40px 16px;
text-align: center;
color: var(--text3);
}
.more {
align-self: center;
}
.mono {
font-family: var(--font-mono);
}
.secret {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-md);
background: var(--surface2);
border: 1px dashed var(--line2);
font-family: var(--font-mono);
font-size: 18px;
letter-spacing: 0.04em;
word-break: break-all;
}
.select {
height: 48px;
padding: 0 12px;
border-radius: var(--radius-md);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font: inherit;
}
.note {
margin: 0;
font-size: 13px;
color: var(--text2);
line-height: 1.5;
}
.sessions {
display: flex;
flex-direction: column;
gap: 8px;
}
.session {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--surface2);
}
.sessionMeta {
font-size: 12px;
color: var(--text3);
}
+78
View File
@@ -0,0 +1,78 @@
import { useQueryClient } from '@tanstack/react-query';
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import { useSession } from '../app/session';
import { Player, type PlayerState } from './player';
const PlayerContext = createContext<Player | null>(null);
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
export function PlayerProvider({ children }: { children: ReactNode }) {
const session = useSession();
const queryClient = useQueryClient();
const [player] = useState(() => new Player(session));
player.onReaction = () => void queryClient.invalidateQueries();
useEffect(() => {
player.start();
void player.restore().catch(() => undefined);
const onHide = () => player.onHide();
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
window.addEventListener('pagehide', onHide);
document.addEventListener('visibilitychange', onVisibility);
const onKey = (e: KeyboardEvent) => handleHotkey(player, e);
document.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pagehide', onHide);
document.removeEventListener('visibilitychange', onVisibility);
document.removeEventListener('keydown', onKey);
player.dispose();
};
}, [player]);
return <PlayerContext.Provider value={player}>{children}</PlayerContext.Provider>;
}
/** Пробел — пауза, ←/→ — ±10 с, Shift+←/→ — трек, L — лайк (docs/web.md). */
export function handleHotkey(player: Player, e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const s = player.getState();
if (!s.track) return;
switch (e.key) {
case ' ':
if (target?.closest('button, a')) return; // пробел на кнопке нажимает её
e.preventDefault();
player.toggle();
break;
case 'ArrowLeft':
e.preventDefault();
if (e.shiftKey) player.prev();
else player.seek(s.positionMs - 10_000);
break;
case 'ArrowRight':
e.preventDefault();
if (e.shiftKey) player.next();
else player.seek(s.positionMs + 10_000);
break;
case 'l':
case 'L':
case 'д':
case 'Д':
e.preventDefault();
void player.react(s.track, s.track.reaction === 'like' ? null : 'like').catch(() => undefined);
break;
}
}
export function usePlayer(): Player {
const p = useContext(PlayerContext);
if (!p) throw new Error('usePlayer outside PlayerProvider');
return p;
}
export function usePlayerState(): PlayerState {
const p = usePlayer();
return useSyncExternalStore(p.subscribe, p.getState);
}
+535
View File
@@ -0,0 +1,535 @@
import {
EventBuffer,
PlayAttempt,
PlayQueue,
unwrap,
type AuthSession,
type PlayContext,
type RepeatMode,
type Track,
} from '@pipu/core';
export type { PlayContext, Track };
export interface PlayerState {
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
const LOCAL_KEY = 'pipu.player';
const SAVE_EVERY_MS = 10_000;
const FLUSH_EVERY_MS = 30_000;
const PRELOAD_BEFORE_MS = 20_000;
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
/**
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly queue = new PlayQueue();
private readonly events: EventBuffer;
private attempt: PlayAttempt | null = null;
private context: PlayContext | null = null;
private state: PlayerState;
private readonly listeners = new Set<() => void>();
private readonly device = deviceId();
private lastServerSave = 0;
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
constructor(
private readonly session: AuthSession,
audio?: HTMLAudioElement,
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.events = new EventBuffer({
send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
},
});
this.state = {
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
};
this.bindAudio();
this.bindMediaSession();
}
/** Запуск фоновой отправки событий (парный вызов — dispose). */
start() {
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
}
// --- подписка (useSyncExternalStore) ---
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getState = () => this.state;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
}
private syncQueue() {
this.update({
track: this.queue.current ?? null,
queue: this.queue.tracks,
index: this.queue.index,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
context: this.context,
});
}
// --- управление ---
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
playList(tracks: Track[], index: number, context: PlayContext) {
const playable = tracks.filter((t) => t.available && t.stream_url);
const target = tracks[index];
if (!target || !playable.length) return;
this.finishAttempt('skip');
this.context = context;
this.queue.set(playable, Math.max(playable.indexOf(target), 0));
this.syncQueue();
void this.load(true);
}
toggle() {
if (this.state.playing) this.pause();
else this.play();
}
play() {
if (!this.queue.current) return;
if (!this.audio.src) {
void this.load(true, this.state.positionMs);
return;
}
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
}
pause() {
this.audio.pause();
}
next() {
this.finishAttempt('skip');
if (this.queue.next()) {
this.syncQueue();
void this.load(true);
} else {
this.pause();
}
}
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
prev() {
if (this.audio.currentTime > 3) {
this.seek(0);
return;
}
this.finishAttempt('skip');
if (this.queue.prev()) {
this.syncQueue();
void this.load(true);
}
}
jump(index: number) {
this.finishAttempt('skip');
this.queue.jump(index);
this.syncQueue();
void this.load(true);
}
seek(ms: number) {
const track = this.queue.current;
if (!track) return;
const to = Math.min(Math.max(ms, 0), track.duration_ms);
const from = Math.round(this.audio.currentTime * 1000);
this.attempt?.seek(to);
this.audio.currentTime = to / 1000;
this.update({ positionMs: to });
this.events.push({ type: 'seek', track_id: track.id, from_ms: from, position_ms: to });
this.saveSoon();
}
setVolume(v: number) {
this.audio.volume = Math.min(Math.max(v, 0), 1);
this.audio.muted = false;
this.update({ volume: this.audio.volume, muted: false });
this.saveLocal();
}
toggleMute() {
this.audio.muted = !this.audio.muted;
this.update({ muted: this.audio.muted });
}
toggleShuffle() {
this.queue.setShuffle(!this.queue.shuffle);
this.syncQueue();
this.saveLocal();
}
cycleRepeat() {
const order: RepeatMode[] = ['off', 'all', 'one'];
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
this.syncQueue();
this.saveLocal();
}
/** Лайк / дизлайк / снять реакцию. Дизлайк текущего трека сразу переключает на следующий. */
async react(track: Track, reaction: 'like' | 'dislike' | null) {
const prev = track.reaction;
const apply = (r: Track['reaction']) => {
for (const t of this.queue.tracks) if (t.id === track.id) t.reaction = r;
this.syncQueue();
};
apply(reaction);
const type = reaction ?? (prev === 'like' ? 'unlike' : 'undislike');
this.events.push({ type, track_id: track.id, position_ms: this.state.positionMs });
try {
const params = { params: { path: { id: track.id } } };
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
} catch (e) {
apply(prev);
throw e;
}
this.onReaction?.();
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
}
// --- загрузка трека ---
private async load(autoplay: boolean, startMs = 0) {
const track = this.queue.current;
if (!track?.stream_url) return;
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
this.audio.src = track.stream_url;
if (startMs > 0) this.audio.currentTime = startMs / 1000;
this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null;
this.events.push({
type: 'play_start',
track_id: track.id,
position_ms: startMs,
context_type: this.context?.type,
context_id: this.context?.id,
});
this.updateMediaMetadata(track);
this.saveServer();
if (autoplay) {
try {
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
this.update({ playing: false, loading: false });
}
}
}
/** Завершает текущую попытку: событие с реально прослушанным временем. */
private finishAttempt(type: 'play_end' | 'skip') {
const a = this.attempt;
if (!a) return;
this.attempt = null;
const pos = Math.round(this.audio.currentTime * 1000);
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush();
}
private bindAudio() {
const a = this.audio;
a.addEventListener('playing', () => {
this.update({ playing: true, loading: false });
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
}
});
a.addEventListener('pause', () => {
if (a.ended) return;
this.update({ playing: false });
const t = this.queue.current;
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
this.saveServer();
});
a.addEventListener('waiting', () => this.update({ loading: true }));
a.addEventListener('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms);
this.update({ positionMs: ms });
this.updatePositionState();
this.maybePreload(ms);
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal();
});
a.addEventListener('ended', () => {
this.finishAttempt('play_end');
if (this.queue.next(true)) {
this.syncQueue();
void this.load(true);
} else {
this.update({ playing: false, positionMs: 0 });
this.saveServer();
}
});
a.addEventListener('error', () => void this.onError());
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
}
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
private async onError() {
const track = this.queue.current;
if (!track) return;
if (this.refreshedFor !== track.id) {
this.refreshedFor = track.id;
try {
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
const fresh = res.items[0];
if (fresh?.stream_url) {
Object.assign(track, fresh);
void this.load(true, this.state.positionMs);
return;
}
} catch {
// упадём в пропуск ниже
}
}
this.update({ error: 'player.error', loading: false, playing: false });
this.attempt = null;
if (this.queue.peekNext() && this.queue.peekNext()!.id !== track.id) setTimeout(() => this.next(), 1500);
}
/** За 20 с до конца запрашиваем первый байт следующего трека — на сервере прогревается транскод. */
private maybePreload(ms: number) {
const cur = this.queue.current;
const next = this.queue.peekNext();
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
this.preloaded = next.id;
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
}
// --- Media Session ---
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.play());
set('pause', () => this.pause());
set('nexttrack', () => this.next());
set('previoustrack', () => this.prev());
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
}
private updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
private updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.audio.duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
// --- сохранение состояния ---
private snapshot(): Saved {
const ids = this.queue.tracks.map((t) => t.id);
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: this.queue.index - start,
positionMs: this.state.positionMs,
context: this.context,
volume: this.state.volume,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
savedAt: new Date().toISOString(),
};
}
private saveLocal() {
if (!this.queue.current) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.queue.current;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot();
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
/** Уход со страницы: дослать события и состояние. */
onHide() {
this.saveServer(true);
void this.events.flush();
}
/**
* Восстановление после запуска: сначала локальная копия (мгновенно), затем серверная,
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
*/
async restore() {
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
if (!saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
const wanted = saved.queue[saved.index];
const tracks = res.items.filter((t) => t.available && t.stream_url);
if (!tracks.length) return;
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
this.context = saved.context;
this.queue.repeat = saved.repeat;
this.queue.set(tracks, idx);
if (saved.shuffle) this.queue.setShuffle(true);
this.audio.volume = saved.volume;
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
this.syncQueue();
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
this.updateMediaMetadata(tracks[idx]!);
}
dispose() {
this.finishAttempt('skip');
this.audio.pause();
this.audio.removeAttribute('src');
for (const t of this.timers) clearInterval(t);
this.timers = [];
void this.events.flush();
}
}
+25
View File
@@ -0,0 +1,25 @@
import { ApiError } from '@pipu/core';
import { useTranslation } from 'react-i18next';
type Translate = (key: string, options?: Record<string, unknown>) => string;
/** Текст ошибки для пользователя: перевод `errors:<code>` с параметрами, иначе общий текст. */
export function useErrorText(): (err: unknown) => string {
const { t, i18n } = useTranslation();
const tr = t as unknown as Translate;
return (err) => {
const code = err instanceof ApiError ? err.code : 'common.internal';
const params = err instanceof ApiError ? err.params : {};
const key = `errors:${code}`;
return i18n.exists(key) ? tr(key, params) : tr('errors:common.internal');
};
}
/** Ошибка конкретного поля (если сервер её указал) — для подписи под полем. */
export function useFieldError(): (err: unknown, field: string) => string | undefined {
const text = useErrorText();
return (err, field) => {
if (!(err instanceof ApiError)) return undefined;
return err.fieldCode(field) ? text(err) : undefined;
};
}
+32
View File
@@ -0,0 +1,32 @@
/** Дата и время в локали интерфейса: «20 окт. 2026 г., 14:05». */
export function formatDateTime(iso: string, locale: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
}
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 3600],
['month', 30 * 24 * 3600],
['week', 7 * 24 * 3600],
['day', 24 * 3600],
['hour', 3600],
['minute', 60],
];
/** Относительное время: «12 минут назад», «вчера», «сейчас». */
export function formatRelative(iso: string, locale: string, now: Date = new Date()): string {
const diff = (new Date(iso).getTime() - now.getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
for (const [unit, sec] of UNITS) {
if (Math.abs(diff) >= sec) return rtf.format(Math.round(diff / sec), unit);
}
return rtf.format(0, 'minute');
}
/** Длительность трека: 215000 → «3:35», 3725000 → «1:02:05». */
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
+23
View File
@@ -0,0 +1,23 @@
import type { components } from '@pipu/api-client';
export type Role = components['schemas']['Role'];
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
/**
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
* какие действия показывать. Решает всё равно сервер.
*/
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
const self = actor.id === target.id;
if (self && action !== 'reset_password') return false;
if (actor.role === 'admin') return true;
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user';
return false;
}
/** Роли, которые actor может выдать при создании. */
export function assignableRoles(actor: Role): Role[] {
if (actor === 'admin') return ['user', 'moderator', 'admin'];
if (actor === 'moderator') return ['user'];
return [];
}
+48
View File
@@ -0,0 +1,48 @@
// Иконки Material Symbols Rounded (Apache 2.0). Импортируются по одной — в сборку попадают только используемые.
export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
export { default as homeFillIcon } from '@material-symbols/svg-400/rounded/home-fill.svg';
export { default as lockResetIcon } from '@material-symbols/svg-400/rounded/lock_reset.svg';
export { default as logoutIcon } from '@material-symbols/svg-400/rounded/logout.svg';
export { default as moreIcon } from '@material-symbols/svg-400/rounded/more_horiz.svg';
export { default as musicIcon } from '@material-symbols/svg-400/rounded/music_note.svg';
export { default as playIcon } from '@material-symbols/svg-400/rounded/play_arrow-fill.svg';
export { default as auditIcon } from '@material-symbols/svg-400/rounded/receipt_long.svg';
export { default as auditFillIcon } from '@material-symbols/svg-400/rounded/receipt_long-fill.svg';
export { default as searchIcon } from '@material-symbols/svg-400/rounded/search.svg';
export { default as settingsIcon } from '@material-symbols/svg-400/rounded/settings.svg';
export { default as shieldIcon } from '@material-symbols/svg-400/rounded/shield_person.svg';
export { default as visibilityIcon } from '@material-symbols/svg-400/rounded/visibility.svg';
export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/visibility_off.svg';
export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.svg';
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
export { default as libraryIcon } from '@material-symbols/svg-400/rounded/monitoring.svg';
export { default as libraryFillIcon } from '@material-symbols/svg-400/rounded/monitoring-fill.svg';
export { default as personIcon } from '@material-symbols/svg-400/rounded/person.svg';
export { default as refreshIcon } from '@material-symbols/svg-400/rounded/refresh.svg';
export { default as storageIcon } from '@material-symbols/svg-400/rounded/hard_drive.svg';
export { default as storageFillIcon } from '@material-symbols/svg-400/rounded/hard_drive-fill.svg';
export { default as pauseIcon } from '@material-symbols/svg-400/rounded/pause-fill.svg';
export { default as nextIcon } from '@material-symbols/svg-400/rounded/skip_next-fill.svg';
export { default as prevIcon } from '@material-symbols/svg-400/rounded/skip_previous-fill.svg';
export { default as likeIcon } from '@material-symbols/svg-400/rounded/favorite.svg';
export { default as likeFillIcon } from '@material-symbols/svg-400/rounded/favorite-fill.svg';
export { default as dislikeIcon } from '@material-symbols/svg-400/rounded/heart_broken.svg';
export { default as dislikeFillIcon } from '@material-symbols/svg-400/rounded/heart_broken-fill.svg';
export { default as volumeIcon } from '@material-symbols/svg-400/rounded/volume_up.svg';
export { default as volumeOffIcon } from '@material-symbols/svg-400/rounded/volume_off.svg';
export { default as shuffleIcon } from '@material-symbols/svg-400/rounded/shuffle.svg';
export { default as repeatIcon } from '@material-symbols/svg-400/rounded/repeat.svg';
export { default as repeatOneIcon } from '@material-symbols/svg-400/rounded/repeat_one.svg';
export { default as historyIcon } from '@material-symbols/svg-400/rounded/history.svg';
export { default as historyFillIcon } from '@material-symbols/svg-400/rounded/history-fill.svg';
+316
View File
@@ -0,0 +1,316 @@
.icon {
display: inline-block;
flex: none;
background-color: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
}
.logo {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text);
}
.logoDot {
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 44px;
padding: 0 18px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 14px;
white-space: nowrap;
transition:
background 0.15s,
box-shadow 0.15s,
opacity 0.15s;
}
.button:disabled {
opacity: 0.55;
cursor: default;
}
.button[aria-busy='true'] {
cursor: progress;
}
.primary {
background: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.primary:hover:not(:disabled) {
background: var(--accent2);
}
.secondary {
background: var(--surface3);
color: var(--text);
}
.secondary:hover:not(:disabled),
.ghost:hover:not(:disabled) {
background: var(--hover);
}
.ghost {
color: var(--text2);
}
.danger {
background: var(--danger-soft);
color: var(--danger);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.inputWrap {
position: relative;
display: flex;
}
.input {
width: 100%;
height: 48px;
padding: 0 14px;
border-radius: var(--radius-md);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.inputWrap:has(.reveal) .input {
padding-right: 46px;
}
.mono {
font-family: var(--font-mono);
}
.invalid {
border-color: var(--danger);
}
.reveal {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: var(--text3);
}
.reveal:hover {
color: var(--text);
background: var(--hover);
}
.hint {
font-size: 12px;
color: var(--text3);
}
.error {
font-size: 12px;
color: var(--danger);
}
.formError {
padding: 10px 12px;
border-radius: 10px;
background: var(--danger-soft);
color: var(--danger);
font-size: 13px;
font-weight: 500;
}
.segmented {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: var(--surface2);
border: 1px solid var(--line);
}
.segmented button {
height: 32px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.segmented button[aria-pressed='true'] {
background: var(--accent);
color: #fff;
}
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 16px;
background: var(--overlay);
animation: pipuIn 0.2s ease-out;
}
.dialog {
width: 440px;
max-width: 100%;
max-height: calc(100vh - 32px);
overflow: auto;
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
outline: none;
}
.dialogHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 22px 24px 0;
}
.dialogHead h2 {
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
}
.dialogBody {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 24px;
}
.dialogFoot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 0 24px 22px;
}
.iconButton {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
color: var(--text2);
}
.iconButton:hover,
.iconButton[aria-expanded='true'] {
background: var(--hover);
color: var(--text);
}
.menuWrap {
position: relative;
}
.menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 40;
min-width: 220px;
padding: 6px;
border-radius: var(--radius-lg);
background: var(--elev);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.15s ease-out;
}
.menuStart {
left: 0;
right: auto;
}
.menuItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 38px;
padding: 0 10px;
border-radius: 8px;
font-size: 14px;
text-align: left;
color: var(--text);
outline: none;
}
.menuItem:hover,
.menuItem:focus-visible {
background: var(--hover);
}
.menuDanger {
color: var(--danger);
}
.avatar {
display: inline-grid;
place-items: center;
flex: none;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--violet));
color: #fff;
font-weight: 700;
}
+282
View File
@@ -0,0 +1,282 @@
import {
useEffect,
useId,
useRef,
useState,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
import s from './ui.module.css';
const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');
/** Иконка-маска: цвет берётся из currentColor. */
export function Icon({ src, size = 20, className }: { src: string; size?: number; className?: string }) {
return (
<span
aria-hidden
className={cx(s.icon, className)}
style={{ width: size, height: size, maskImage: `url("${src}")`, WebkitMaskImage: `url("${src}")` }}
/>
);
}
export function Logo({ size = 28 }: { size?: number }) {
const { t } = useTranslation();
return (
<span className={s.logo} style={{ fontSize: size * 0.68 }}>
<span className={s.logoDot} style={{ width: size, height: size }} aria-hidden />
{t('app.name')}
</span>
);
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
icon?: string;
busy?: boolean;
};
export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) {
return (
<button
type="button"
{...rest}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={cx(s.button, s[variant], className)}
>
{icon && <Icon src={icon} size={18} />}
{children}
</button>
);
}
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
label: string;
value: string;
onChange: (v: string) => void;
error?: string;
hint?: string;
mono?: boolean;
};
/** Поле ввода с подписью, ошибкой и (для паролей) кнопкой «показать». */
export function Field({ label, value, onChange, error, hint, mono, type = 'text', className, ...rest }: FieldProps) {
const { t } = useTranslation();
const id = useId();
const [shown, setShown] = useState(false);
const isPassword = type === 'password';
const note = error ?? hint;
return (
<div className={cx(s.field, className)}>
<label htmlFor={id} className={s.label}>
{label}
</label>
<div className={s.inputWrap}>
<input
id={id}
type={isPassword && shown ? 'text' : type}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={error ? true : undefined}
aria-describedby={note ? `${id}-note` : undefined}
className={cx(s.input, mono && s.mono, error && s.invalid)}
{...rest}
/>
{isPassword && (
<button
type="button"
className={s.reveal}
onClick={() => setShown((v) => !v)}
aria-label={shown ? t('form.hide_password') : t('form.show_password')}
>
<Icon src={shown ? visibilityOffIcon : visibilityIcon} size={18} />
</button>
)}
</div>
{note && (
<div id={`${id}-note`} className={error ? s.error : s.hint} role={error ? 'alert' : undefined}>
{note}
</div>
)}
</div>
);
}
/** Сегментированный переключатель. */
export function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className={s.segmented} role="group" aria-label={label}>
{options.map((o) => (
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
);
}
export function FormError({ children }: { children?: ReactNode }) {
if (!children) return null;
return (
<div className={s.formError} role="alert">
{children}
</div>
);
}
/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */
export function Dialog({
title,
onClose,
children,
footer,
}: {
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
(first ?? ref.current)?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
prev?.focus();
};
}, [onClose]);
return (
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}>
<h2 id={titleId}>{title}</h2>
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
<Icon src={closeIcon} />
</button>
</div>
<div className={s.dialogBody}>{children}</div>
{footer && <div className={s.dialogFoot}>{footer}</div>}
</div>
</div>
);
}
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
export function Menu({
label,
trigger,
items,
header,
align = 'end',
triggerClassName,
}: {
label: string;
trigger: ReactNode;
items: MenuItem[];
header?: ReactNode;
align?: 'start' | 'end';
triggerClassName?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const onMenuKey = (e: ReactKeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
const i = els.indexOf(document.activeElement as HTMLElement);
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
els[next]?.focus();
};
return (
<div className={s.menuWrap} ref={ref}>
<button
type="button"
className={triggerClassName ?? s.iconButton}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{trigger}
</button>
{open && (
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
key={it.label}
type="button"
role="menuitem"
className={cx(s.menuItem, it.danger && s.menuDanger)}
onClick={() => {
setOpen(false);
it.onSelect();
}}
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
)}
</div>
);
}
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
const initials = name
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]!.toUpperCase())
.join('');
return (
<span className={s.avatar} style={{ width: size, height: size, fontSize: size * 0.38 }} aria-hidden>
{initials || '?'}
</span>
);
}
export { cx };
+22 -110
View File
@@ -1,6 +1,8 @@
@import '@pipu/design-tokens/tokens.css'; @import '@pipu/design-tokens/tokens.css';
@import '@fontsource/onest/400.css'; @import '@fontsource/onest/400.css';
@import '@fontsource/onest/500.css';
@import '@fontsource/onest/600.css'; @import '@fontsource/onest/600.css';
@import '@fontsource/onest/700.css';
@import '@fontsource/unbounded/600.css'; @import '@fontsource/unbounded/600.css';
@import '@fontsource/jetbrains-mono/400.css'; @import '@fontsource/jetbrains-mono/400.css';
@@ -23,6 +25,11 @@ body {
font-family: var(--font-body); font-family: var(--font-body);
font-size: 15px; font-size: 15px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
color-scheme: dark;
}
:root[data-theme='light'] body {
color-scheme: light;
} }
button { button {
@@ -34,118 +41,23 @@ button {
cursor: pointer; cursor: pointer;
} }
.shell { :focus-visible {
min-height: 100%; outline: 2px solid var(--accent);
display: flex; outline-offset: 2px;
flex-direction: column;
} }
.top { @keyframes pipuIn {
display: flex; from {
align-items: center; opacity: 0;
justify-content: space-between; transform: translateY(6px);
height: 60px; }
padding: 0 20px;
border-bottom: 1px solid var(--line);
} }
.logo { @media (prefers-reduced-motion: reduce) {
display: flex; *,
align-items: center; *::before,
gap: 10px; *::after {
font-family: var(--font-display); animation-duration: 0.01ms !important;
font-weight: 600; transition-duration: 0.01ms !important;
font-size: 19px; }
letter-spacing: -0.02em;
}
.logo-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
}
.langs {
display: flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: var(--glass);
border: 1px solid var(--line);
}
.langs button {
height: 30px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.langs button[aria-pressed='true'] {
background: var(--accent-soft);
color: var(--text);
}
.main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.card {
width: 400px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding: 36px;
border-radius: var(--radius-xl);
background: var(--glass);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
}
.card h1 {
margin: 0;
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
}
.muted {
margin: 0;
color: var(--text2);
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
}
.status[data-state='ok'] {
background: var(--ok-soft);
color: var(--ok);
}
.status[data-state='error'] {
background: var(--danger-soft);
color: var(--danger);
}
.status[data-state='pending'] {
background: var(--surface2);
color: var(--text2);
} }
+218
View File
@@ -0,0 +1,218 @@
import type { components } from '@pipu/api-client';
type Me = components['schemas']['Me'];
type AdminUser = components['schemas']['AdminUser'];
type Role = components['schemas']['Role'];
export interface FakeUser extends AdminUser {
password: string;
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
});
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
let seq = 0;
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] };
export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({
id: `00000000-0000-4000-8000-00000000010${i}`,
title,
duration_ms: 200000,
artists: [{ id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }],
album: albumRef,
genres: [],
disc_no: 1,
track_no: i + 1,
available: true,
stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`,
reaction: null as 'like' | 'dislike' | null,
}));
const albumSummary = {
id: '00000000-0000-4000-8000-0000000000bb',
title: 'Группа крови',
year: 1988,
cover_url: null,
cover_colors: [],
artist: kino,
track_count: 11,
};
export function makeUser(username: string, role: Role, extra: Partial<FakeUser> = {}): FakeUser {
seq++;
return {
id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`,
username,
display_name: null,
role,
locale: 'ru',
is_blocked: false,
must_change_password: false,
must_change_credentials: false,
created_at: '2026-01-01T00:00:00Z',
last_login_at: null,
password: `${username}-password`,
...extra,
};
}
/**
* Мини-сервер API для компонентных тестов: вход, refresh по cookie, /me, админка пользователей.
* Проверки прав упрощены — точная матрица покрыта тестами Go.
*/
export function fakeApi(initial: FakeUser[]) {
const users = new Map(initial.map((u) => [u.id, { ...u }]));
const prefs = new Map<string, components['schemas']['UiPrefs']>();
let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0;
const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>();
const me = (u: FakeUser): Me => ({
id: u.id,
username: u.username,
display_name: u.display_name,
role: u.role,
locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password,
must_change_credentials: u.must_change_credentials,
created_at: u.created_at,
});
const tokens = (u: FakeUser) => json({ access_token: `T${++token}`, token_type: 'Bearer', expires_in: 900, user: me(u) });
const admin = (u: FakeUser): AdminUser => {
const { password, ...rest } = u;
void password;
return rest;
};
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const req = input as Request;
const url = new URL(req.url);
const path = url.pathname;
const text = await req.text();
const body = text ? (JSON.parse(text) as Record<string, unknown>) : undefined;
calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`;
const user = current ? users.get(current) : undefined;
switch (route) {
case 'GET /api/v1/server-info':
return json({
name: 'pipu-music',
version: '0.2.0',
api_version: 'v1',
supported_locales: ['ru', 'en'],
min_android_version_code: 0,
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
});
case 'POST /api/v1/auth/login': {
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
if (!u) return problem(401, 'auth.invalid_credentials');
current = u.id;
return tokens(u);
}
case 'POST /api/v1/auth/refresh':
return user ? tokens(user) : problem(401, 'auth.refresh_invalid');
}
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
switch (route) {
case 'POST /api/v1/auth/logout':
case 'POST /api/v1/auth/logout-all':
current = null;
return new Response(null, { status: 204 });
case 'GET /api/v1/me':
return json(me(user));
case 'PATCH /api/v1/me': {
if (typeof body?.locale === 'string') user.locale = body.locale;
if (typeof body?.display_name === 'string') user.display_name = body.display_name || null;
if (body?.ui_prefs) prefs.set(user.id, { ...prefs.get(user.id), ...(body.ui_prefs as object) });
return json(me(user));
}
case 'POST /api/v1/me/bootstrap-credentials': {
const pw = String(body?.new_password ?? '');
if (pw.length < 12)
return problem(422, 'validation.password_too_short', {
params: { min: 12 },
errors: [{ field: 'new_password', code: 'validation.password_too_short' }],
});
Object.assign(user, { username: body?.new_username, password: pw, must_change_credentials: false });
return tokens(user);
}
case 'POST /api/v1/me/password':
Object.assign(user, { password: body?.new_password, must_change_password: false });
return tokens(user);
case 'GET /api/v1/me/sessions':
return json({ items: [] });
case 'GET /api/v1/admin/users': {
if (user.role === 'user') return problem(403, 'auth.forbidden');
const q = (url.searchParams.get('q') ?? '').toLowerCase();
const role = url.searchParams.get('role');
const items = [...users.values()]
.filter((u) => (!q || u.username.includes(q)) && (!role || u.role === role))
.sort((a, b) => a.username.localeCompare(b.username))
.map(admin);
return json({ items, next_cursor: null });
}
case 'POST /api/v1/admin/users': {
if (user.role === 'moderator' && body?.role !== 'user') return problem(403, 'auth.forbidden');
const u = makeUser(String(body?.username), body?.role as Role, {
display_name: (body?.display_name as string) ?? null,
must_change_password: true,
password: 'Temp-Pass-123',
});
users.set(u.id, u);
return json({ user: admin(u), temporary_password: u.password }, 201);
}
case 'GET /api/v1/admin/audit':
return json({ items: [], next_cursor: null });
case 'GET /api/v1/search/suggest':
case 'GET /api/v1/search': {
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
const q = url.searchParams.get('q') ?? '';
const hit = q === 'кино' || q === 'rbyj';
return json({
artists: hit ? [kino] : [],
albums: hit ? [albumSummary] : [],
tracks: [],
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
corrected_query: q === 'rbyj' ? 'кино' : null,
next_cursor: null,
});
}
case `GET /api/v1/albums/${albumSummary.id}`:
return json({
...albumRef,
artist: kino,
duration_ms: 400000,
tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })),
});
case 'POST /api/v1/events':
return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 });
case 'GET /api/v1/me/playback-state':
return json({ track_id: null, position_ms: 0, queue: [], queue_index: 0, context: null, shuffle: false,
repeat_mode: 'off', volume: 1, device_id: null, updated_at: null });
case 'PUT /api/v1/me/playback-state':
return json({ ...body, updated_at: new Date().toISOString() });
case `GET /api/v1/artists/${kino.id}`:
return json({ ...kino, album_count: 1, track_count: 0 });
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
if (reactionPath) {
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
else reactions.delete(reactionPath[1]!);
return new Response(null, { status: 204 });
}
return problem(404, 'common.not_found');
};
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
}
+19
View File
@@ -0,0 +1,19 @@
import '@testing-library/jest-dom/vitest';
// jsdom не умеет играть звук: play/pause только переключают флаг и шлют события, как браузер.
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value(this: HTMLMediaElement) {
Object.defineProperty(this, 'paused', { configurable: true, value: false });
this.dispatchEvent(new Event('playing'));
return Promise.resolve();
},
});
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
configurable: true,
value(this: HTMLMediaElement) {
Object.defineProperty(this, 'paused', { configurable: true, value: true });
this.dispatchEvent(new Event('pause'));
},
});
Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} });
+182
View File
@@ -0,0 +1,182 @@
.shell {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 60px 1fr 88px;
grid-template-areas:
'top top'
'side content'
'player player';
height: 100%;
}
.top {
grid-area: top;
display: flex;
align-items: center;
gap: 24px;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--bar-a);
backdrop-filter: blur(20px);
}
.logoLink {
width: 196px;
flex: none;
text-decoration: none;
}
@media (max-width: 1023px) {
.logoLink {
width: auto;
}
}
.topRight {
display: flex;
align-items: center;
gap: 12px;
}
.profileButton {
border-radius: 50%;
outline-offset: 3px;
}
.profileButton[aria-expanded='true'] {
box-shadow: 0 0 0 3px var(--accent-soft);
}
.profileHead {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
align-items: center;
padding: 10px 10px 12px;
margin-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.profileHead > :last-child {
grid-column: 1 / -1;
margin-top: 10px;
justify-self: start;
}
.profileName {
font-weight: 600;
}
.profileMeta {
font-size: 12px;
color: var(--text3);
}
.side {
grid-area: side;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 12px;
border-right: 1px solid var(--line);
overflow-y: auto;
}
.navSection {
margin: 18px 12px 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.navItem {
display: flex;
align-items: center;
gap: 12px;
height: 40px;
padding: 0 12px;
border-radius: 10px;
color: var(--text2);
font-weight: 500;
text-decoration: none;
}
.navItem:hover {
background: var(--hover);
color: var(--text);
}
.navItem[data-status='active'] {
background: var(--accent-soft);
color: var(--text);
}
.navIcon {
color: var(--text3);
}
.navItem[data-status='active'] .navIcon {
color: var(--accent2);
}
.content {
grid-area: content;
overflow-y: auto;
padding: 32px 36px;
animation: pipuIn 0.2s ease-out;
}
@media (max-width: 1023px) {
.shell {
grid-template-columns: 72px 1fr;
}
.navItem {
justify-content: center;
padding: 0;
font-size: 0;
}
.navSection {
margin: 14px 0 6px;
text-align: center;
font-size: 0;
border-top: 1px solid var(--line);
}
}
@media (max-width: 767px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: 60px 1fr 72px 64px;
grid-template-areas:
'top'
'content'
'player'
'side';
}
.side {
flex-direction: row;
justify-content: space-around;
padding: 6px 8px;
border-right: 0;
border-top: 1px solid var(--line);
}
.navSection {
display: none;
}
.navItem {
flex: 1;
}
.content {
padding: 20px 16px;
}
}
+132
View File
@@ -0,0 +1,132 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import {
auditFillIcon,
auditIcon,
groupFillIcon,
groupIcon,
homeFillIcon,
homeIcon,
libraryFillIcon,
libraryIcon,
logoutIcon,
historyFillIcon,
historyIcon,
likeFillIcon,
likeIcon,
dislikeFillIcon,
dislikeIcon,
settingsIcon,
storageFillIcon,
storageIcon,
} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider } from '../player/context';
import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
type NavPath =
| '/'
| '/admin/users'
| '/admin/audit'
| '/admin/sources'
| '/admin/library'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => (
<>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
{label}
</>
)}
</Link>
);
}
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
<PlayerProvider>
<Shell />
</PlayerProvider>
) : null;
}
function Shell() {
const { t } = useTranslation();
const user = useUser();
const session = useSession();
const navigate = useNavigate();
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
return (
<div className={s.shell}>
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
<SearchBox />
<div className={s.topRight}>
<Menu
label={t('profile.menu')}
triggerClassName={s.profileButton}
trigger={<Avatar name={name} />}
header={
<div className={s.profileHead}>
<Avatar name={name} size={40} />
<div>
<div className={s.profileName}>{name}</div>
<div className={s.profileMeta}>
@{user.username} · {t(`roles.${user.role}`)}
</div>
</div>
<LanguageSwitch />
</div>
}
items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]}
/>
</div>
</header>
<nav className={s.side} aria-label={t('nav.main')}>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<div className={s.navSection}>{t('nav.collection')}</div>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
{staff && (
<>
<div className={s.navSection}>{t('nav.admin')}</div>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
)}
</>
)}
</nav>
<main className={s.content}>
<Outlet />
</main>
<PlayerBar />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { unwrap } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../app/session';
import { Segmented } from '../shared/ui/ui';
/** Переключатель языка. Для вошедшего пользователя язык сохраняется в профиле (PATCH /me). */
export function LanguageSwitch() {
const { t, i18n } = useTranslation();
const session = useSession();
const auth = useAuthState();
const change = async (l: Locale) => {
document.documentElement.lang = l;
await i18n.changeLanguage(l);
if (auth.status !== 'signed_in') return;
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// Язык уже переключён локально; сохранится при следующей смене.
}
};
return (
<Segmented
label={t('language.label')}
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
onChange={(l) => void change(l)}
/>
);
}
+194
View File
@@ -0,0 +1,194 @@
.bar {
grid-area: player;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
align-items: center;
gap: 20px;
padding: 0 20px;
border-top: 1px solid var(--line);
background: var(--bar-a);
backdrop-filter: blur(20px);
}
.empty {
display: flex;
align-items: center;
gap: 14px;
color: var(--text3);
font-size: 14px;
}
.placeholder {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 10px;
background: var(--surface2);
}
.now {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artists {
font-size: 13px;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
display: grid;
place-items: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: var(--text2);
}
.icon:hover {
color: var(--text);
background: var(--hover);
}
.accent {
color: var(--accent2);
}
.danger {
color: var(--danger);
}
.center {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 0;
}
.controls {
display: flex;
align-items: center;
gap: 10px;
}
.play {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: #fff;
color: #05070d;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
transition: transform 0.12s;
}
.play:hover {
transform: scale(1.06);
}
.play[aria-busy='true'] {
opacity: 0.7;
}
.progress {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
max-width: 560px;
}
.time {
font-size: 12px;
color: var(--text3);
font-variant-numeric: tabular-nums;
min-width: 38px;
text-align: center;
}
.range {
flex: 1;
height: 4px;
appearance: none;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent2) var(--pct, 0%), var(--surface3) var(--pct, 0%));
cursor: pointer;
}
.range::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
opacity: 0;
transition: opacity 0.15s;
}
.range:hover::-webkit-slider-thumb,
.range:focus-visible::-webkit-slider-thumb {
opacity: 1;
}
.range::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: #fff;
}
.error {
font-size: 12px;
color: var(--danger);
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.volume {
flex: 0 1 120px;
}
@media (max-width: 767px) {
.bar {
grid-template-columns: minmax(0, 1fr) auto;
padding: 0 12px;
gap: 10px;
}
.progress,
.right,
.controls > :first-child,
.controls > :last-child,
.now > button {
display: none;
}
}
+162
View File
@@ -0,0 +1,162 @@
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import {
dislikeFillIcon,
dislikeIcon,
likeFillIcon,
likeIcon,
musicIcon,
nextIcon,
pauseIcon,
playIcon,
prevIcon,
repeatIcon,
repeatOneIcon,
shuffleIcon,
volumeIcon,
volumeOffIcon,
} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './PlayerBar.module.css';
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
export function PlayerBar() {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const track = st.track;
if (!track) {
return (
<footer className={s.bar} aria-label={t('player.label')}>
<span className={s.empty}>
<span className={s.placeholder}>
<Icon src={musicIcon} size={22} />
</span>
{t('player.empty')}
</span>
</footer>
);
}
const react = (r: 'like' | 'dislike') =>
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
return (
<footer className={s.bar} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div className={s.meta}>
<span className={s.title}>{track.title}</span>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
</div>
<button
type="button"
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
aria-pressed={track.reaction === 'dislike'}
onClick={() => react('dislike')}
>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button
type="button"
className={cx(s.icon, track.reaction === 'like' && s.accent)}
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
aria-pressed={track.reaction === 'like'}
onClick={() => react('like')}
>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.center}>
<div className={s.controls}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={26} />
</button>
<button
type="button"
className={s.play}
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
aria-busy={st.loading || undefined}
onClick={() => player.toggle()}
>
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<Icon src={nextIcon} size={26} />
</button>
<button
type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
</div>
<div className={s.progress}>
<span className={s.time}>{formatDuration(st.positionMs)}</span>
<input
type="range"
className={s.range}
min={0}
max={st.durationMs || track.duration_ms}
step={1000}
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
aria-valuetext={formatDuration(st.positionMs)}
onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
</div>
{st.error && (
<span className={s.error} role="status">
{t(st.error as 'player.error')}
</span>
)}
</div>
<div className={s.right}>
<button
type="button"
className={s.icon}
aria-label={t(st.muted ? 'player.unmute' : 'player.mute')}
onClick={() => player.toggleMute()}
>
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={20} />
</button>
<input
type="range"
className={cx(s.range, s.volume)}
min={0}
max={1}
step={0.01}
value={st.muted ? 0 : st.volume}
aria-label={t('player.volume')}
onChange={(e) => player.setVolume(Number(e.target.value))}
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
</footer>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next';
import { Button } from '../shared/ui/ui';
import s from '../pages/page.module.css';
/** Непредвиденная ошибка рендера страницы: короткое сообщение и перезагрузка. */
export function RouteError() {
const { t } = useTranslation(['common', 'errors']);
return (
<div className={s.empty} role="alert">
<p>{t('errors:common.internal')}</p>
<Button onClick={() => window.location.reload()}>{t('route_error.reload')}</Button>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
.box {
position: relative;
flex: 1;
max-width: 520px;
}
.icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
pointer-events: none;
}
.input {
width: 100%;
height: 40px;
padding: 0 40px 0 42px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::-webkit-search-cancel-button {
display: none;
}
.kbd {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
min-width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--line2);
color: var(--text3);
font: 12px var(--font-mono);
pointer-events: none;
}
.input:focus ~ .kbd {
display: none;
}
.popover {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 45;
max-height: min(70vh, 560px);
overflow-y: auto;
padding: 8px;
border-radius: var(--radius-lg);
background: var(--elev);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.15s ease-out;
}
.corrected {
margin: 4px 10px 8px;
font-size: 13px;
color: var(--warn);
}
.groupTitle {
margin: 8px 10px 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.option {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 10px;
border-radius: 10px;
cursor: pointer;
}
.active {
background: var(--hover);
}
.optionText {
display: flex;
flex-direction: column;
min-width: 0;
}
.optionTitle {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionSub {
font-size: 12px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nothing {
padding: 16px 10px;
color: var(--text3);
text-align: center;
}
.all {
width: 100%;
height: 38px;
margin-top: 6px;
border-radius: 10px;
color: var(--accent2);
font-weight: 600;
font-size: 14px;
}
.all:hover {
background: var(--hover);
}
@media (max-width: 767px) {
.kbd {
display: none;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { unwrap } from '@pipu/core';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { searchIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './SearchBox.module.css';
const DEBOUNCE_MS = 150;
type Item = {
key: string;
group: 'artists' | 'albums' | 'tracks';
title: string;
subtitle: string;
cover: { id: string; cover_url?: string | null; cover_colors?: string[] };
round?: boolean;
go: () => void;
};
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
export function SearchBox() {
const { t } = useTranslation();
const session = useSession();
const navigate = useNavigate();
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
return () => clearTimeout(id);
}, [q]);
// «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле).
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = target.closest('input, textarea, select, [contenteditable="true"]');
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const suggest = useQuery({
queryKey: ['suggest', debounced],
enabled: debounced !== '',
placeholderData: keepPreviousData,
// signal отменяет устаревший запрос, когда пользователь печатает дальше.
queryFn: async ({ signal }) =>
unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })),
staleTime: 30_000,
});
const data = debounced ? suggest.data : undefined;
const close = () => {
setOpen(false);
setActive(-1);
};
const goSearch = () => {
const query = q.trim();
if (!query) return;
close();
inputRef.current?.blur();
void navigate({ to: '/search', search: { q: query } });
};
const items: Item[] = [];
if (data) {
for (const a of data.artists)
items.push({
key: `ar-${a.id}`,
group: 'artists',
title: a.name,
subtitle: t('search.artist_kind'),
cover: { id: a.id },
round: true,
go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }),
});
for (const al of data.albums)
items.push({
key: `al-${al.id}`,
group: 'albums',
title: al.title,
subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '),
cover: al,
go: () => void navigate({ to: '/album/$id', params: { id: al.id } }),
});
for (const tr of data.tracks)
items.push({
key: `tr-${tr.id}`,
group: 'tracks',
title: tr.title,
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
cover: tr.album,
// Воспроизведение — M3; пока трек открывает свой альбом.
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
});
}
const choose = (i: number) => {
const it = items[i];
if (!it) return goSearch();
close();
setQ('');
inputRef.current?.blur();
it.go();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setActive((i) => Math.min(items.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => Math.max(-1, i - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
choose(active);
} else if (e.key === 'Escape') {
close();
inputRef.current?.blur();
}
};
const showList = open && debounced !== '' && data !== undefined;
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
const optionId = (i: number) => `${listId}-${i}`;
return (
<div className={s.box}>
<Icon src={searchIcon} size={18} className={s.icon} />
<input
ref={inputRef}
type="search"
className={s.input}
value={q}
placeholder={t('search.placeholder')}
aria-label={t('search.label')}
role="combobox"
aria-expanded={showList}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
setActive(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(close, 120)}
onKeyDown={onKeyDown}
/>
<kbd className={s.kbd} aria-hidden>
/
</kbd>
{showList && (
<div className={s.popover}>
{data.corrected_query && (
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
)}
<div id={listId} role="listbox" aria-label={t('search.label')}>
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
{groups.map((g) => {
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
if (groupItems.length === 0) return null;
return (
<div key={g} role="group" aria-label={t(`search.${g}`)}>
<div className={s.groupTitle}>{t(`search.${g}`)}</div>
{groupItems.map(({ it, i }) => (
<div
key={it.key}
id={optionId(i)}
role="option"
aria-selected={i === active}
className={cx(s.option, i === active && s.active)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => choose(i)}
>
<Cover album={it.cover} size={36} round={it.round} />
<span className={s.optionText}>
<span className={s.optionTitle}>{it.title}</span>
<span className={s.optionSub}>{it.subtitle}</span>
</span>
</div>
))}
</div>
);
})}
</div>
<button
type="button"
className={s.all}
onMouseDown={(e) => e.preventDefault()}
onClick={goSearch}
>
{t('search.show_all')}
</button>
</div>
)}
</div>
);
}
+1
View File
@@ -19,5 +19,6 @@ export default defineConfig({
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/test/setup.ts'],
}, },
}); });
+7
View File
@@ -20,6 +20,13 @@ HTTP_PORT=80
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
MEILI_MASTER_KEY= MEILI_MASTER_KEY=
INTERNAL_TOKEN= INTERNAL_TOKEN=
# Ключ подписи access-токенов (Ed25519, base64 от 32 случайных байт). install.sh и update.sh
# создают его, если строки нет. Смена ключа разлогинит всех на ≤15 мин — старый можно оставить
# в JWT_PREVIOUS_KEYS (через запятую), пока не истекут выданные им токены.
JWT_PRIVATE_KEY=
# Подпись ссылок на обложки и аудио (base64 от 32 байт); создают install.sh / update.sh.
STREAM_SIGNING_KEY=
#JWT_PREVIOUS_KEYS=
# --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) --- # --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) ---
#BOOTSTRAP_ADMIN_USERNAME=admin #BOOTSTRAP_ADMIN_USERNAME=admin
+6
View File
@@ -45,6 +45,12 @@ services:
environment: environment:
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
MEILI_URL: http://meilisearch:7700
MEILI_MASTER_KEY: dev-master-key-change-me
INTERNAL_TOKEN: dev-internal-token
COOKIE_SECURE: "false"
X_ACCEL: "false"
CACHE_DIR: /tmp/pipu-cache
LOG_LEVEL: debug LOG_LEVEL: debug
ports: ["127.0.0.1:8080:8080"] ports: ["127.0.0.1:8080:8080"]
depends_on: depends_on:
+3 -4
View File
@@ -18,7 +18,8 @@ x-library-ro: &library-ro
bind: { propagation: rslave } bind: { propagation: rslave }
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music. # Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Пишет в него только storage-agent (импорт одобренных загрузок), остальным — только чтение. # Всем сервисам, включая storage-agent, — только чтение: импорт загрузок ещё не реализован,
# запись откроем вместе с ним (docs/library-and-storage.md).
x-library-host-ro: &library-host-ro x-library-host-ro: &library-host-ro
type: bind type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host} source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
@@ -85,9 +86,7 @@ services:
source: ${PIPU_DATA:-/srv/pipu}/library source: ${PIPU_DATA:-/srv/pipu}/library
target: /library target: /library
bind: { propagation: rshared } bind: { propagation: rshared }
- type: bind - *library-host-ro
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro - ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender: recommender:
Regular → Executable
+10
View File
@@ -66,6 +66,16 @@ if [ ! -f .env ]; then
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)" echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
fi fi
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
# в том числе в .env, сделанном до их появления.
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
if ! grep -q "^$key=." .env; then
sed -i "/^$key=\$/d" .env
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен $key"
fi
done
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env # LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then if [ -n "$LIBRARY_HOST" ]; then
if grep -q '^LIBRARY_HOST=' .env; then if grep -q '^LIBRARY_HOST=' .env; then
Regular → Executable
+12 -1
View File
@@ -16,6 +16,16 @@ cd "$PIPU_DEPLOY_DIR"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; } [ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
# в том числе в .env, сделанном до их появления.
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
if ! grep -q "^$key=." .env; then
sed -i "/^$key=\$/d" .env
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен $key"
fi
done
REF="${1:-}" REF="${1:-}"
git fetch --tags --prune --quiet origin git fetch --tags --prune --quiet origin
@@ -62,7 +72,8 @@ docker image prune -f >/dev/null
echo "→ ждём готовности api" echo "→ ждём готовности api"
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
if docker compose exec -T nginx wget -qO- http://localhost/readyz >/dev/null 2>&1; then # 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
echo "pipu $VERSION запущен" echo "pipu $VERSION запущен"
exit 0 exit 0
fi fi
+9 -3
View File
@@ -55,7 +55,7 @@
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе. - argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
- Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по - Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по
встроенному списку 10k самых частых паролей. встроенному списку частых паролей (`internal/auth/common_passwords.txt`; пока ~150 записей, цель — 10k).
- Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**; - Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**;
у пользователя ставится `must_change_password = true`. у пользователя ставится `must_change_password = true`.
- Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч), - Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч),
@@ -73,10 +73,16 @@
| Android | в памяти | `expo-secure-store` | | Android | в памяти | `expo-secure-store` |
- **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`. - **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`.
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи. Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи
(`401 auth.refresh_reused`). Исключение — первые 10 с после ротации: так бывает, когда две вкладки
обновляют токен одновременно; ответ `401 auth.refresh_race` без отзыва, клиент повторяет запрос
с уже новой cookie.
- **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version` - **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version`
(в Redis-кэше), middleware сравнивает с `ver` в access-токене. (в Redis-кэше), middleware сравнивает с `ver` в access-токене.
- Ключи подписи: `JWT_PRIVATE_KEY` из env / файла; поддержка `kid` для ротации ключей. - Ключи подписи: `JWT_PRIVATE_KEY` (base64 seed Ed25519) из env; `kid` = первые 8 байт SHA-256 публичного ключа,
прежние ключи для проверки — `JWT_PREVIOUS_KEYS`.
- Смена пароля и учётных данных отзывает все сессии и сразу выдаёт новую пару текущему устройству —
пользователь остаётся в системе, остальные устройства разлогиниваются.
- CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок, - CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок,
поэтому CSRF к ним неприменим. поэтому CSRF к ним неприменим.
+4 -2
View File
@@ -38,6 +38,7 @@ CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family_id uuid NOT NULL, -- цепочка ротаций одного входа family_id uuid NOT NULL, -- цепочка ротаций одного входа
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
device_name text, -- "Chrome / Windows", "Pixel 8" device_name text, -- "Chrome / Windows", "Pixel 8"
client_type text NOT NULL, -- 'web' | 'android' client_type text NOT NULL, -- 'web' | 'android'
@@ -196,7 +197,7 @@ CREATE TABLE albums (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
title text NOT NULL, title text NOT NULL,
title_norm text NOT NULL, title_norm text NOT NULL,
album_artist_id uuid REFERENCES artists(id), album_artist_id uuid NOT NULL REFERENCES artists(id), -- без тегов — исполнитель трека или «Unknown artist»
year int, year int,
cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp) cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp)
cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона, cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона,
@@ -207,7 +208,8 @@ CREATE TABLE albums (
CREATE TABLE tracks ( CREATE TABLE tracks (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
title text NOT NULL, title text NOT NULL,
album_id uuid REFERENCES albums(id), title_norm text NOT NULL, -- ключ поиска дублей: (album_id, title_norm, duration ±2 с)
album_id uuid NOT NULL REFERENCES albums(id),
disc_no int, track_no int, disc_no int, track_no int,
year int, year int,
duration_ms int NOT NULL, duration_ms int NOT NULL,
+6 -3
View File
@@ -30,8 +30,8 @@
``` ```
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`): Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
каталог хоста монтируется в `api`, `nginx` и `analyzer` только на чтение, в `storage-agent` — на запись каталог хоста монтируется во все сервисы, включая `storage-agent`, только на чтение (запись появится вместе
(импорт одобренных загрузок), внутри контейнеров он виден как `/library-host`. с импортом одобренных загрузок), внутри контейнеров он виден как `/library-host`.
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library` Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки). (делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
@@ -67,7 +67,10 @@
| `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | | | `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | |
| `REDIS_URL` | `redis://redis:6379/0` | | | `REDIS_URL` | `redis://redis:6379/0` | |
| `MEILI_URL`, `MEILI_MASTER_KEY` | | | | `MEILI_URL`, `MEILI_MASTER_KEY` | | |
| `JWT_PRIVATE_KEY_FILE` | `/run/secrets/jwt_ed25519.pem` | Генерируется `install.sh` | | `JWT_PRIVATE_KEY` | base64 от 32 байт (seed Ed25519) | Подпись access-токенов. Создают `install.sh` / `update.sh`, если его нет; пусто — случайный ключ до перезапуска |
| `JWT_PREVIOUS_KEYS` | пусто | Прежние ключи через запятую — токены, подписанные ими, ещё принимаются (ротация) |
| `ACCESS_TOKEN_TTL` | `15m` | Срок access-токена |
| `COOKIE_SECURE` | `true` | Флаг `Secure` у refresh-cookie; `false` — только для разработки по http не на localhost |
| `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок | | `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок |
| `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация | | `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация |
| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | | | `BOOTSTRAP_ADMIN_USERNAME` | `admin` | |
+3 -3
View File
@@ -10,10 +10,10 @@
| Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) | | Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) |
| Клиентское состояние | Zustand (плеер, очередь, UI) | | Клиентское состояние | Zustand (плеер, очередь, UI) |
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) | | Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono, сабсет Material Symbols Rounded — всё встроено в сборку | | Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска) | | Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) | | Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
| Формы | React Hook Form + Zod | | Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
| i18n | i18next + react-i18next, словари из `packages/i18n` | | i18n | i18next + react-i18next, словари из `packages/i18n` |
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) | | API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from 'vitest';
import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth';
const user = {
id: '00000000-0000-0000-0000-000000000001',
username: 'alice',
display_name: null,
role: 'user' as const,
locale: 'ru',
ui_prefs: {},
must_change_password: false,
must_change_credentials: false,
created_at: '2026-01-01T00:00:00Z',
};
const tokens = (access: string, refresh?: string): AuthTokens => ({
access_token: access,
token_type: 'Bearer',
expires_in: 900,
refresh_token: refresh,
user,
});
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
});
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
type Handler = (req: Request) => Response | Promise<Response>;
/** Фейковый сервер: маршруты по «METHOD path», журнал запросов. */
function server(routes: Record<string, Handler>) {
const log: { route: string; auth: string | null; body: string }[] = [];
const fetchImpl = async (input: RequestInfo | URL) => {
const req = input as Request;
const route = `${req.method} ${new URL(req.url).pathname}`;
log.push({ route, auth: req.headers.get('Authorization'), body: await req.clone().text() });
const h = routes[route];
if (!h) return problem(404, 'common.not_found');
return h(req);
};
return { fetch: fetchImpl as typeof fetch, log };
}
const memoryStore = (initial: string | null = null): RefreshStore & { value: string | null } => ({
value: initial,
async get() {
return this.value;
},
async set(v) {
this.value = v;
},
async clear() {
this.value = null;
},
});
describe('AuthSession', () => {
it('logs in and sends the bearer token', async () => {
const s = server({
'POST /api/v1/auth/login': () => json(tokens('A1')),
'GET /api/v1/me': (req) =>
req.headers.get('Authorization') === 'Bearer A1' ? json(user) : problem(401, 'auth.unauthorized'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web', deviceName: 'Test' });
const states: string[] = [];
auth.subscribe((st) => states.push(st.status));
await auth.login('alice', 'secret');
expect(JSON.parse(s.log[0]!.body)).toEqual({
username: 'alice',
password: 'secret',
client_type: 'web',
device_name: 'Test',
});
const me = await auth.client.GET('/api/v1/me');
expect(me.data?.username).toBe('alice');
expect(states).toEqual(['signed_in']);
});
it('turns problem+json into ApiError with field errors', async () => {
const s = server({
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const err = await auth.login('alice', 'bad').catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).code).toBe('auth.invalid_credentials');
expect(auth.state.status).toBe('unknown');
const e2 = new ApiError(422, 'validation.password_too_short', { min: 8 }, [
{ field: 'new_password', code: 'validation.password_too_short' },
]);
expect(e2.fieldCode('new_password')).toBe('validation.password_too_short');
expect(e2.fieldCode('other')).toBeUndefined();
});
it('refreshes once for parallel expired requests and retries them', async () => {
let refreshes = 0;
const s = server({
'POST /api/v1/auth/login': () => json(tokens('old')),
'POST /api/v1/auth/refresh': async () => {
refreshes++;
await new Promise((r) => setTimeout(r, 10));
return json(tokens('new'));
},
'GET /api/v1/me': (req) =>
req.headers.get('Authorization') === 'Bearer new' ? json(user) : problem(401, 'auth.token_expired'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
await auth.login('alice', 'secret');
const results = await Promise.all([1, 2, 3].map(() => auth.client.GET('/api/v1/me')));
expect(results.every((r) => r.data?.username === 'alice')).toBe(true);
expect(refreshes).toBe(1);
});
it('does not refresh on other 401 codes and signs out a blocked user', async () => {
const s = server({
'POST /api/v1/auth/login': () => json(tokens('A')),
'GET /api/v1/me': () => problem(401, 'auth.user_blocked'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
await auth.login('alice', 'secret');
const res = await auth.client.GET('/api/v1/me');
expect(res.response.status).toBe(401);
expect(s.log.some((l) => l.route.includes('refresh'))).toBe(false);
expect(auth.state).toEqual({ status: 'signed_out', reason: 'auth.user_blocked' });
});
it('signs out when refresh is rejected', async () => {
const s = server({
'POST /api/v1/auth/refresh': () => problem(401, 'auth.refresh_reused'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
expect(await auth.restore()).toEqual({ status: 'signed_out', reason: 'auth.refresh_reused' });
});
it('retries a refresh race once', async () => {
let n = 0;
const s = server({
'POST /api/v1/auth/refresh': () => (++n === 1 ? problem(401, 'auth.refresh_race') : json(tokens('A'))),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
expect((await auth.restore()).status).toBe('signed_in');
expect(n).toBe(2);
});
it('keeps the session on network errors during refresh', async () => {
const auth = new AuthSession({
baseUrl: 'http://x',
fetch: async () => {
throw new TypeError('Failed to fetch');
},
clientType: 'web',
});
expect(await auth.refresh()).toBe(false);
expect(auth.state.status).toBe('unknown');
});
it('android keeps the refresh token in the store', async () => {
const store = memoryStore('R0');
const s = server({
'POST /api/v1/auth/refresh': async (req) => {
const body = (await req.json()) as { refresh_token: string };
return body.refresh_token === 'R0' ? json(tokens('A1', 'R1')) : problem(401, 'auth.refresh_invalid');
},
'POST /api/v1/auth/logout': () => new Response(null, { status: 204 }),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'android', refreshStore: store });
expect((await auth.restore()).status).toBe('signed_in');
expect(store.value).toBe('R1');
await auth.logout();
expect(store.value).toBeNull();
expect(auth.state.status).toBe('signed_out');
expect(s.log.at(-1)).toMatchObject({ route: 'POST /api/v1/auth/logout', auth: 'Bearer A1' });
expect(await auth.refresh()).toBe(false);
});
it('requires a refresh store for android', () => {
expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/);
});
it('notifies subscribers about profile updates', async () => {
const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')) });
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const fn = vi.fn();
auth.subscribe(fn);
await auth.login('alice', 'x');
auth.setUser({ ...user, display_name: 'Alice' });
expect(fn).toHaveBeenLastCalledWith({ status: 'signed_in', user: { ...user, display_name: 'Alice' } });
});
});
+219
View File
@@ -0,0 +1,219 @@
import { createApiClient, type ApiClient, type components } from '@pipu/api-client';
export type AuthUser = components['schemas']['Me'];
export type AuthTokens = components['schemas']['AuthTokens'];
export type ClientType = components['schemas']['ClientType'];
/** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:<code>`. */
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly params: Record<string, unknown>;
readonly fields: { field: string; code: string }[];
constructor(status: number, code: string, params: Record<string, unknown> = {}, fields: ApiError['fields'] = []) {
super(code);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.params = params;
this.fields = fields;
}
/** Код ошибки конкретного поля формы, если сервер его указал. */
fieldCode(field: string): string | undefined {
return this.fields.find((f) => f.field === field)?.code;
}
}
/** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */
export function toApiError(error: unknown, response?: Response): ApiError {
if (error instanceof ApiError) return error;
const p = (error ?? {}) as Partial<components['schemas']['Problem']>;
if (typeof p.code === 'string') {
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
}
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request');
return new ApiError(0, 'common.network');
}
/** Достаёт данные из результата openapi-fetch или бросает ApiError. */
export function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): T {
if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response);
return res.data as T;
}
/** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */
export interface RefreshStore {
get(): Promise<string | null>;
set(token: string): Promise<void>;
clear(): Promise<void>;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'signed_out'; reason?: string }
| { status: 'signed_in'; user: AuthUser };
export interface AuthSessionOptions {
/** Адрес сервера; пусто — тот же origin (веб). */
baseUrl?: string;
fetch?: typeof globalThis.fetch;
clientType: ClientType;
deviceName?: string;
/** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */
refreshStore?: RefreshStore;
}
/** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */
const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']);
const RACE_RETRY_MS = 300;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android).
* `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все
* параллельные запросы (single-flight), после чего повторяет их.
*/
export class AuthSession {
readonly client: ApiClient;
private readonly raw: ApiClient;
private readonly opts: AuthSessionOptions;
private readonly baseFetch: typeof globalThis.fetch;
private access: string | null = null;
private refreshing: Promise<boolean> | null = null;
private current: AuthState = { status: 'unknown' };
private readonly listeners = new Set<(s: AuthState) => void>();
constructor(opts: AuthSessionOptions) {
if (opts.clientType === 'android' && !opts.refreshStore) {
throw new Error('AuthSession: android client needs a refreshStore');
}
this.opts = opts;
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch });
this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) });
}
get state(): AuthState {
return this.current;
}
/** Подписка на смену состояния; возвращает функцию отписки. */
subscribe(fn: (s: AuthState) => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private setState(s: AuthState) {
this.current = s;
for (const fn of this.listeners) fn(s);
}
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
async restore(): Promise<AuthState> {
if (!(await this.refresh())) {
if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
}
return this.current;
}
async login(username: string, password: string): Promise<AuthUser> {
const res = await this.raw.POST('/api/v1/auth/login', {
body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName },
});
return this.apply(unwrap(res));
}
/** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */
async apply(tokens: AuthTokens): Promise<AuthUser> {
this.access = tokens.access_token;
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
this.setState({ status: 'signed_in', user: tokens.user });
return tokens.user;
}
/** Обновляет профиль в состоянии (после PATCH /me). */
setUser(user: AuthUser) {
if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user });
}
async logout(all = false): Promise<void> {
try {
if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout');
} finally {
await this.signOut();
}
}
private async signOut(reason?: string) {
this.access = null;
await this.opts.refreshStore?.clear();
this.setState({ status: 'signed_out', reason });
}
/** Single-flight: параллельные вызовы ждут один и тот же запрос. */
refresh(): Promise<boolean> {
this.refreshing ??= this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(attempt = 0): Promise<boolean> {
let body: { refresh_token?: string } | undefined;
if (this.opts.refreshStore) {
const token = await this.opts.refreshStore.get();
if (!token) return false;
body = { refresh_token: token };
}
let res;
try {
res = await this.raw.POST('/api/v1/auth/refresh', { body });
} catch {
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
}
if (res.data) {
await this.apply(res.data);
return true;
}
const err = toApiError(res.error, res.response);
if (err.code === 'auth.refresh_race' && attempt === 0) {
// Другая вкладка только что обновила cookie — повторяем уже с новой.
await sleep(RACE_RETRY_MS);
return this.doRefresh(attempt + 1);
}
if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code);
return false;
}
private async authFetch(request: Request): Promise<Response> {
const retry = request.clone();
const sent = this.access;
const res = await this.baseFetch(this.withAuth(request));
if (res.status !== 401) return res;
const code = await res
.clone()
.json()
.then((p: { code?: string }) => p.code)
.catch(() => undefined);
if (!code || !RETRYABLE.has(code)) {
if (code === 'auth.user_blocked') await this.signOut(code);
return res;
}
// Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации.
const fresh = this.access !== null && this.access !== sent;
if (!fresh && !(await this.refresh())) return res;
return this.baseFetch(this.withAuth(retry));
}
private withAuth(request: Request): Request {
if (!this.access) return request;
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${this.access}`);
return new Request(request, { headers });
}
}
+23
View File
@@ -1 +1,24 @@
export {
ApiError,
AuthSession,
toApiError,
unwrap,
type AuthSessionOptions,
type AuthState,
type AuthTokens,
type AuthUser,
type ClientType,
type RefreshStore,
} from './auth';
export {
EventBuffer,
PlayAttempt,
PlayQueue,
uuidv7,
type EventSink,
type PlayContext,
type PlayEvent,
type RepeatMode,
type Track,
} from './player';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server'; export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest';
import { EventBuffer, PlayAttempt, PlayQueue, uuidv7, type Track } from './player';
const track = (id: string, duration_ms = 200_000): Track => ({
id,
title: id,
duration_ms,
artists: [],
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
genres: [],
disc_no: null,
track_no: null,
available: true,
stream_url: `/s/${id}`,
reaction: null,
});
const ids = (q: PlayQueue) => q.order.map((i) => q.tracks[i]!.id);
describe('PlayQueue', () => {
it('walks forward and back, stops at the end', () => {
const q = new PlayQueue();
q.set([track('a'), track('b'), track('c')], 1);
expect(q.current?.id).toBe('b');
expect(q.next()?.id).toBe('c');
expect(q.peekNext()).toBeNull();
expect(q.next()).toBeNull();
expect(q.prev()?.id).toBe('b');
});
it('repeats one track only when it ends by itself', () => {
const q = new PlayQueue();
q.set([track('a'), track('b')]);
q.repeat = 'one';
expect(q.next(true)?.id).toBe('a');
expect(q.next()?.id).toBe('b');
});
it('wraps around with repeat all', () => {
const q = new PlayQueue();
q.set([track('a'), track('b')], 1);
q.repeat = 'all';
expect(q.peekNext()?.id).toBe('a');
expect(q.next()?.id).toBe('a');
expect(q.prev()?.id).toBe('b');
});
it('shuffles keeping the current track first and restores order', () => {
let seed = 0.3;
const q = new PlayQueue(() => (seed = (seed * 9301 + 0.49297) % 1));
q.set(['a', 'b', 'c', 'd', 'e'].map((id) => track(id)), 2);
q.setShuffle(true);
expect(q.current?.id).toBe('c');
expect(ids(q)[0]).toBe('c');
expect([...ids(q)].sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
q.next();
const cur = q.current!.id;
q.setShuffle(false);
expect(q.current?.id).toBe(cur);
expect(ids(q)).toEqual(['a', 'b', 'c', 'd', 'e']);
});
});
describe('PlayAttempt', () => {
it('counts only time actually played', () => {
const a = new PlayAttempt(track('a'));
for (let t = 250; t <= 10_000; t += 250) a.tick(t);
expect(a.playedMs).toBe(10_000);
expect(a.tick(60_000)).toBe(true); // перемотка вперёд
a.tick(60_250);
expect(a.playedMs).toBe(10_250);
a.seek(5000);
a.tick(5250);
expect(a.playedMs).toBe(10_500);
});
it('treats the last seconds as finished', () => {
const a = new PlayAttempt(track('a', 200_000));
expect(a.outcome(196_000)).toBe('play_end');
expect(a.outcome(120_000)).toBe('skip');
});
});
describe('EventBuffer', () => {
it('sends in batches and retries with backoff', async () => {
vi.useFakeTimers();
const send = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
const buf = new EventBuffer({ send }, () => Date.UTC(2026, 0, 1));
buf.push({ type: 'play_start', track_id: 't1' });
buf.push({ type: 'skip', track_id: 't1', played_ms: 1000 });
expect(await buf.flush()).toBe(false);
expect(buf.pending).toHaveLength(2);
await vi.advanceTimersByTimeAsync(2000);
expect(send).toHaveBeenCalledTimes(2);
expect(buf.pending).toHaveLength(0);
const sent = send.mock.calls[1]![0];
expect(sent[0].occurred_at).toBe('2026-01-01T00:00:00.000Z');
expect(sent[0].id).not.toBe(sent[1].id);
vi.useRealTimers();
});
});
describe('uuidv7', () => {
it('encodes time and version', () => {
const id = uuidv7(0x0190_0000_0000, () => new Uint8Array(16).fill(0xff));
expect(id).toMatch(/^01900000-0000-7fff-bfff-ffffffffffff$/);
});
});
+200
View File
@@ -0,0 +1,200 @@
import type { components } from '@pipu/api-client';
export type Track = components['schemas']['Track'];
export type PlayEvent = components['schemas']['PlayEvent'];
export type RepeatMode = 'off' | 'one' | 'all';
export type PlayContext = { type: NonNullable<PlayEvent['context_type']>; id?: string; label?: string };
/** UUIDv7: время в первых 48 битах — сервер дедуплицирует события по id, сортировка по времени. */
export function uuidv7(now = Date.now(), rnd: (n: number) => Uint8Array = randomBytes): string {
const b = rnd(16);
const ts = BigInt(now);
for (let i = 0; i < 6; i++) b[i] = Number((ts >> BigInt(8 * (5 - i))) & 0xffn);
b[6] = (b[6]! & 0x0f) | 0x70;
b[8] = (b[8]! & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
function randomBytes(n: number): Uint8Array {
const b = new Uint8Array(n);
globalThis.crypto.getRandomValues(b);
return b;
}
/**
* Очередь воспроизведения. Перемешивание хранится порядком индексов: текущий трек остаётся текущим,
* а выключение возвращает исходный порядок.
*/
export class PlayQueue {
tracks: Track[] = [];
/** Позиция в order. */
pos = 0;
order: number[] = [];
shuffle = false;
repeat: RepeatMode = 'off';
constructor(private readonly random: () => number = Math.random) {}
get current(): Track | undefined {
return this.tracks[this.order[this.pos] ?? -1];
}
/** Индекс текущего трека в исходном списке. */
get index(): number {
return this.order[this.pos] ?? 0;
}
set(tracks: Track[], index = 0) {
this.tracks = tracks;
this.order = tracks.map((_, i) => i);
this.pos = Math.min(Math.max(index, 0), Math.max(tracks.length - 1, 0));
if (this.shuffle) this.reshuffle();
}
setShuffle(on: boolean) {
const cur = this.index;
this.shuffle = on;
this.order = this.tracks.map((_, i) => i);
this.pos = cur;
if (on) this.reshuffle();
}
private reshuffle() {
const cur = this.order[this.pos] ?? 0;
const rest = this.order.filter((i) => i !== cur);
for (let i = rest.length - 1; i > 0; i--) {
const j = Math.floor(this.random() * (i + 1));
[rest[i], rest[j]] = [rest[j]!, rest[i]!];
}
this.order = this.tracks.length ? [cur, ...rest] : [];
this.pos = 0;
}
/** Следующий трек. auto — трек доиграл сам (repeat one повторяет его). null — очередь кончилась. */
next(auto = false): Track | null {
if (!this.tracks.length) return null;
if (auto && this.repeat === 'one') return this.current ?? null;
if (this.pos + 1 < this.order.length) {
this.pos++;
return this.current ?? null;
}
if (this.repeat === 'all') {
this.pos = 0;
return this.current ?? null;
}
return null;
}
/** Предыдущий трек (с начала очереди при repeat all — последний). */
prev(): Track | null {
if (!this.tracks.length) return null;
if (this.pos > 0) this.pos--;
else if (this.repeat === 'all') this.pos = this.order.length - 1;
return this.current ?? null;
}
/** Что сыграет после текущего — для предзагрузки. */
peekNext(): Track | null {
if (this.repeat === 'one') return this.current ?? null;
const i = this.pos + 1 < this.order.length ? this.pos + 1 : this.repeat === 'all' ? 0 : -1;
return i < 0 ? null : (this.tracks[this.order[i]!] ?? null);
}
jump(index: number) {
const p = this.order.indexOf(index);
if (p >= 0) this.pos = p;
}
}
/** Скачок позиции больше этого — перемотка, а не воспроизведение. */
const SEEK_GAP_MS = 2000;
/** Осталось меньше этого — трек доигран (play_end, а не skip). */
const END_SLACK_MS = 5000;
/**
* Учёт попытки прослушивания одного трека: played_ms — реально прослушанное время,
* перемотки вперёд не засчитываются.
*/
export class PlayAttempt {
playedMs = 0;
private last: number;
constructor(
readonly track: Track,
startMs = 0,
) {
this.last = startMs;
}
/** Новая позиция воспроизведения (timeupdate). Возвращает true, если это была перемотка. */
tick(positionMs: number): boolean {
const delta = positionMs - this.last;
this.last = positionMs;
if (delta > 0 && delta <= SEEK_GAP_MS) {
this.playedMs += delta;
return false;
}
return delta !== 0;
}
/** Явная перемотка: позиция меняется без засчитывания. */
seek(positionMs: number) {
this.last = positionMs;
}
/** Тип завершения попытки по позиции. */
outcome(positionMs: number): 'play_end' | 'skip' {
return this.track.duration_ms - positionMs <= END_SLACK_MS ? 'play_end' : 'skip';
}
}
export interface EventSink {
send(events: PlayEvent[]): Promise<void>;
}
/**
* Буфер событий: отправка пачками (по таймеру, при смене трека, при уходе в фон),
* при ошибке — повтор с экспоненциальной задержкой. Сервер дедуплицирует по id.
*/
export class EventBuffer {
private queue: PlayEvent[] = [];
private sending = false;
private retryMs = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly sink: EventSink,
private readonly now: () => number = Date.now,
private readonly maxRetryMs = 5 * 60_000,
) {}
get pending(): readonly PlayEvent[] {
return this.queue;
}
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
const t = this.now();
this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
if (this.queue.length >= 500) void this.flush();
}
async flush(): Promise<boolean> {
if (this.sending || this.queue.length === 0) return true;
this.sending = true;
const batch = this.queue.slice(0, 500);
try {
await this.sink.send(batch);
this.queue = this.queue.slice(batch.length);
this.retryMs = 0;
return true;
} catch {
this.retryMs = Math.min(this.retryMs ? this.retryMs * 2 : 2000, this.maxRetryMs);
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => void this.flush(), this.retryMs);
return false;
} finally {
this.sending = false;
}
}
}
+153
View File
@@ -0,0 +1,153 @@
{
"users": {
"title": "Users",
"subtitle": "Create users and moderators, manage roles, reset passwords, block accounts",
"create": "Create user",
"search": "Name or username",
"all": "All",
"col_user": "User",
"col_role": "Role",
"col_status": "Status",
"col_last_login": "Last seen",
"status_active": "Active",
"status_blocked": "Blocked",
"status_must_change": "Must change password",
"never": "Never signed in",
"empty": "Nobody found",
"more": "Show more",
"actions": "Actions for {{username}}",
"reset_password": "Reset password",
"block": "Block",
"unblock": "Unblock",
"make_role": "Make {{role}}",
"delete": "Delete",
"delete_confirm": "Delete {{username}}? Their history and likes will be lost.",
"block_confirm": "Block {{username}}? All their sessions will end.",
"reset_confirm": "Reset {{username}}'s password? All their sessions will end.",
"confirm": "Confirm",
"you": "you"
},
"create": {
"title": "New user",
"username": "Username",
"username_hint": "Latin letters, digits, \".\", \"_\", \"-\"",
"display_name": "Name",
"role": "Role",
"password_note": "The server will generate a password and show it once.",
"submit": "Create"
},
"temp_password": {
"title": "Temporary password",
"text": "Give this password to {{username}}. It is shown only now; they will be asked to change it on sign-in.",
"done": "Done"
},
"audit": {
"title": "Audit log",
"subtitle": "Actions of administrators and moderators",
"col_when": "When",
"col_who": "Who",
"col_action": "Action",
"col_details": "Details",
"system": "System",
"empty": "No entries yet",
"more": "Show more",
"actions": {
"user.bootstrap_create": "Initial administrator created",
"user.bootstrap_credentials": "Administrator credentials set",
"user.create": "User created",
"user.rename": "Name changed",
"user.role_change": "Role changed",
"user.block": "User blocked",
"user.unblock": "User unblocked",
"user.password_reset": "Password reset",
"user.delete": "User deleted"
}
},
"sources": {
"title": "Storage",
"subtitle": "Where the server takes music from: a folder on the server or NFS",
"add": "Add storage",
"empty": "No storage yet. Connect a music folder to get started.",
"type_local": "Server folder",
"type_nfs": "NFS",
"status_pending": "Connecting",
"status_mounted": "Connected",
"status_error": "Error",
"status_disabled": "Disabled",
"last_scan": "Last scan: {{date}}",
"never_scanned": "Not scanned yet",
"scan": "Scan",
"full_scan": "Full scan",
"test": "Test",
"patterns": "Path patterns",
"edit": "Edit",
"delete": "Delete",
"disable": "Disable",
"enable": "Enable",
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
"scanning": "Scanning: {{processed}} of {{total}}",
"scan_walking": "Walking folders…",
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors {{errors}}",
"scan_failed": "Scan stopped: {{error}}",
"test_ok": "Reachable, audio files found: {{count}}",
"test_fail": "Not reachable: {{message}}",
"form_title_new": "New storage",
"form_title_edit": "Storage",
"name": "Name",
"type": "Type",
"folder": "Folder",
"folder_root": "Whole folder",
"folder_hint": "A subfolder of LIBRARY_HOST on the server",
"up": "Up",
"nfs_server": "Server",
"nfs_server_hint": "Host name or IP, e.g. nas.lan",
"nfs_export": "Export",
"nfs_export_hint": "Path on the server, e.g. /export/music",
"nfs_options": "Mount options",
"nfs_options_hint": "Read-only is added automatically",
"tag_priority": "Prefer",
"tags_first": "File tags",
"path_first": "Path patterns",
"interval": "Auto scan",
"interval_off": "Manual",
"interval_hours": "Every {{count}} h",
"save": "Save"
},
"patterns": {
"title": "Path patterns: {{name}}",
"intro": "RE2 regular expressions with named groups are matched against the file path relative to the storage root. The first match wins.",
"groups": "Groups: artist, albumartist, album, year, disc, track, title, genre; skip ignores the file.",
"add": "Add pattern",
"regex": "Pattern {{n}}",
"enabled": "Enabled",
"up": "Up",
"down": "Down",
"remove": "Remove",
"preview": "Preview",
"save": "Save",
"saved": "Saved. Run a full scan to apply it to files already found.",
"col_path": "Path",
"col_pattern": "Pattern",
"col_result": "Result",
"no_match": "no match",
"skipped": "skipped",
"unmatched": "No pattern matched: {{count}} of {{total}}",
"from_tags": "from tags",
"error_at": "Error in pattern {{n}}: {{reason}}",
"examples": "Examples"
},
"library": {
"title": "Library",
"subtitle": "Catalog and scan status",
"artists": "Artists",
"albums": "Albums",
"tracks": "Tracks",
"files": "Files",
"missing": "Missing files",
"errors": "Unreadable",
"sources": "Storage",
"error_files": "Files with errors",
"reindex": "Rebuild search index",
"reindex_started": "Rebuild started"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"login": {
"title": "Sign in",
"subtitle": "Your music from your own server",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"bootstrap_hint": "First start: sign in as the administrator. The password is set in BOOTSTRAP_ADMIN_PASSWORD or printed in the api service log."
},
"setup": {
"admin_badge": "First administrator sign-in",
"admin_title": "Change the username and password",
"admin_text": "The server stays locked until you do: the initial administrator credentials are visible in logs and settings.",
"password_badge": "Password reset",
"password_title": "Set a new password",
"password_text": "You signed in with a temporary password. Choose your own — the temporary one will stop working.",
"new_username": "New username",
"new_password": "New password",
"repeat_password": "Repeat the password",
"min_length": "At least {{min}} characters",
"submit": "Save and continue",
"logout": "Sign out"
}
}
+138
View File
@@ -17,5 +17,143 @@
"label": "Language", "label": "Language",
"ru": "Русский", "ru": "Русский",
"en": "English" "en": "English"
},
"nav": {
"main": "Navigation",
"home": "Home",
"admin": "Admin",
"users": "Users",
"audit": "Audit log",
"library": "Library",
"sources": "Storage",
"collection": "Collection",
"history": "History",
"likes": "Likes",
"dislikes": "Dislikes"
},
"profile": {
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out"
},
"roles": {
"admin": "Administrator",
"moderator": "Moderator",
"user": "User"
},
"home": {
"greeting": "Hi, {{name}}!",
"wave": "My Wave",
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
},
"player": {
"empty": "Nothing is playing",
"label": "Player",
"play": "Play",
"pause": "Pause",
"next": "Next",
"prev": "Previous",
"like": "Like",
"unlike": "Remove from likes",
"dislike": "Dislike",
"undislike": "Remove dislike",
"volume": "Volume",
"mute": "Mute",
"unmute": "Unmute",
"shuffle": "Shuffle",
"repeat_off": "Repeat off",
"repeat_all": "Repeat queue",
"repeat_one": "Repeat track",
"progress": "Position",
"unavailable": "Track is unavailable — skipping",
"error": "Could not play the track"
},
"settings": {
"title": "Settings",
"account": "Account",
"display_name": "Display name",
"save": "Save",
"saved": "Saved",
"appearance": "Appearance",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"theme_system": "System",
"password": "Password",
"current_password": "Current password",
"new_password": "New password",
"repeat_password": "Repeat the password",
"change_password": "Change password",
"password_changed": "Password changed. Other devices have been signed out.",
"sessions": "Devices",
"session_current": "This device",
"session_last_used": "Active: {{date}}",
"session_end": "Sign out",
"logout_all": "Sign out everywhere"
},
"form": {
"show_password": "Show password",
"hide_password": "Hide password",
"passwords_mismatch": "Passwords don't match",
"required": "Fill in this field",
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
"copied": "Copied"
},
"loading": "Loading…",
"route_error": {
"reload": "Reload the page"
},
"search": {
"placeholder": "Artists, albums, tracks",
"label": "Search",
"artists": "Artists",
"albums": "Albums",
"tracks": "Tracks",
"all": "All",
"nothing": "Nothing found",
"corrected": "Showing results for “{{query}}”",
"show_all": "All results",
"title": "Search: {{query}}",
"more": "Show more",
"artist_kind": "Artist",
"hint": "Press / to search"
},
"catalog": {
"albums": "Albums",
"appears_on": "Appears on",
"album_count_one": "{{count}} album",
"album_count_other": "{{count}} albums",
"album_count_few": "{{count}} albums",
"album_count_many": "{{count}} albums",
"track_count_one": "{{count}} track",
"track_count_other": "{{count}} tracks",
"track_count_few": "{{count}} tracks",
"track_count_many": "{{count}} tracks",
"unavailable": "Unavailable: storage is offline or the file is gone",
"not_found": "Nothing here — it may have been removed by the last scan",
"disc": "Disc {{n}}",
"col_title": "Title",
"col_album": "Album",
"col_duration": "Time",
"back_home": "Back home",
"artist": "Artist",
"album": "Album"
},
"collection": {
"title": "Collection",
"history": "History",
"likes": "Likes",
"dislikes": "Dislikes",
"clear_history": "Clear history",
"clear_confirm": "Clear your listening history? Recommendations will still take it into account.",
"today": "Today",
"yesterday": "Yesterday",
"empty_history": "What you listen to will show up here",
"empty_likes": "Tap the heart on tracks — they will gather here",
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
"play_all": "Play all",
"more": "Show more"
} }
} }
+70
View File
@@ -0,0 +1,70 @@
{
"common": {
"internal": "Something went wrong on the server. Please try again",
"bad_request": "Invalid request",
"not_found": "Not found",
"method_not_allowed": "This action is not supported",
"network": "Cannot reach the server. Check your network"
},
"auth": {
"invalid_credentials": "Wrong username or password",
"too_many_attempts": "Too many sign-in attempts. Please wait a bit",
"user_blocked": "This account is blocked. Contact your administrator",
"unauthorized": "Sign in to continue",
"token_expired": "Your session has expired, please sign in again",
"token_invalid": "Your session is invalid, please sign in again",
"token_revoked": "Your session has ended, please sign in again",
"refresh_invalid": "Your session has ended, please sign in again",
"refresh_reused": "Your session was ended for security reasons, please sign in again",
"refresh_race": "Your session is being refreshed, please retry",
"credentials_change_required": "Change the administrator username and password first",
"password_change_required": "Change your temporary password first",
"current_password_required": "Enter your current password",
"current_password_invalid": "The current password is wrong",
"bootstrap_not_required": "The administrator credentials have already been changed",
"session_not_found": "Session not found",
"forbidden": "You don't have permission to do this"
},
"validation": {
"password_too_short": "The password must be at least {{min}} characters",
"password_too_long": "The password must be at most {{max}} characters",
"password_same_as_username": "The password must not match the username",
"password_too_common": "This password is too common — choose another one",
"password_unchanged": "The new password must differ from the current one",
"username_invalid": "Username: {{min}} to {{max}} characters — Latin letters, digits, \".\", \"_\", \"-\"",
"username_taken": "This username is already taken",
"locale_unsupported": "This language is not supported",
"display_name_too_long": "The name must be at most {{max}} characters",
"ui_prefs_invalid": "Invalid setting value",
"client_type_invalid": "Unknown app type",
"role_invalid": "Unknown role",
"cursor_invalid": "The list is outdated, please reload",
"pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value",
"local_path_invalid": "Folder not found",
"name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type",
"query_empty": "Enter a query"
},
"users": {
"not_found": "User not found",
"last_admin": "The last administrator cannot be demoted, blocked or deleted"
},
"library": {
"source_not_found": "Storage not found",
"scan_running": "A scan is already running",
"source_offline": "The storage is not connected"
},
"catalog": {
"not_found": "Not found"
},
"search": {
"unavailable": "Search is temporarily unavailable"
},
"playback": {
"link_invalid": "The link has expired — reload the page",
"track_unavailable": "Track is unavailable",
"transcode_failed": "Could not prepare the track"
}
}
+153
View File
@@ -0,0 +1,153 @@
{
"users": {
"title": "Пользователи",
"subtitle": "Создание пользователей и модераторов, роли, сброс пароля, блокировка",
"create": "Создать пользователя",
"search": "Имя или логин",
"all": "Все",
"col_user": "Пользователь",
"col_role": "Роль",
"col_status": "Статус",
"col_last_login": "Был в сети",
"status_active": "Активен",
"status_blocked": "Заблокирован",
"status_must_change": "Ждёт смены пароля",
"never": "Ещё не входил",
"empty": "Никого не нашли",
"more": "Показать ещё",
"actions": "Действия с {{username}}",
"reset_password": "Сбросить пароль",
"block": "Заблокировать",
"unblock": "Разблокировать",
"make_role": "Сделать: {{role}}",
"delete": "Удалить",
"delete_confirm": "Удалить {{username}}? Его история и лайки пропадут.",
"block_confirm": "Заблокировать {{username}}? Все его сессии завершатся.",
"reset_confirm": "Сбросить пароль {{username}}? Все его сессии завершатся.",
"confirm": "Подтвердить",
"you": "вы"
},
"create": {
"title": "Новый пользователь",
"username": "Логин",
"username_hint": "Латинские буквы, цифры, «.», «_», «-»",
"display_name": "Имя",
"role": "Роль",
"password_note": "Пароль сгенерирует сервер и покажет один раз.",
"submit": "Создать"
},
"temp_password": {
"title": "Временный пароль",
"text": "Передайте пароль пользователю {{username}}. Он виден только сейчас; при входе его попросят сменить.",
"done": "Готово"
},
"audit": {
"title": "Журнал",
"subtitle": "Действия администраторов и модераторов",
"col_when": "Когда",
"col_who": "Кто",
"col_action": "Действие",
"col_details": "Подробности",
"system": "Система",
"empty": "Записей пока нет",
"more": "Показать ещё",
"actions": {
"user.bootstrap_create": "Создан стартовый администратор",
"user.bootstrap_credentials": "Заданы учётные данные администратора",
"user.create": "Создан пользователь",
"user.rename": "Изменено имя",
"user.role_change": "Изменена роль",
"user.block": "Пользователь заблокирован",
"user.unblock": "Пользователь разблокирован",
"user.password_reset": "Сброшен пароль",
"user.delete": "Пользователь удалён"
}
},
"sources": {
"title": "Хранилища",
"subtitle": "Откуда сервер берёт музыку: папка на диске сервера или NFS",
"add": "Подключить хранилище",
"empty": "Хранилищ пока нет. Подключите папку с музыкой, чтобы начать.",
"type_local": "Папка на сервере",
"type_nfs": "NFS",
"status_pending": "Подключается",
"status_mounted": "Подключено",
"status_error": "Ошибка",
"status_disabled": "Отключено",
"last_scan": "Последний скан: {{date}}",
"never_scanned": "Ещё не сканировалось",
"scan": "Сканировать",
"full_scan": "Полный скан",
"test": "Проверить",
"patterns": "Шаблоны путей",
"edit": "Изменить",
"delete": "Удалить",
"disable": "Отключить",
"enable": "Подключить",
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…",
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок {{errors}}",
"scan_failed": "Скан прервался: {{error}}",
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
"test_fail": "Нет доступа: {{message}}",
"form_title_new": "Новое хранилище",
"form_title_edit": "Хранилище",
"name": "Название",
"type": "Тип",
"folder": "Папка",
"folder_root": "Весь каталог",
"folder_hint": "Подпапка каталога LIBRARY_HOST на сервере",
"up": "Вверх",
"nfs_server": "Сервер",
"nfs_server_hint": "Имя или IP, например nas.lan",
"nfs_export": "Экспорт",
"nfs_export_hint": "Путь на сервере, например /export/music",
"nfs_options": "Опции монтирования",
"nfs_options_hint": "Только чтение добавляется автоматически",
"tag_priority": "Что важнее",
"tags_first": "Теги файлов",
"path_first": "Шаблоны путей",
"interval": "Автоскан",
"interval_off": "Вручную",
"interval_hours": "Каждые {{count}} ч",
"save": "Сохранить"
},
"patterns": {
"title": "Шаблоны путей: {{name}}",
"intro": "Регулярные выражения RE2 с именованными группами применяются к пути файла относительно корня хранилища. Побеждает первое совпавшее.",
"groups": "Группы: artist, albumartist, album, year, disc, track, title, genre; skip — игнорировать файл.",
"add": "Добавить шаблон",
"regex": "Шаблон {{n}}",
"enabled": "Включён",
"up": "Выше",
"down": "Ниже",
"remove": "Удалить",
"preview": "Предпросмотр",
"save": "Сохранить",
"saved": "Сохранено. Запустите полный скан, чтобы применить к уже найденным файлам.",
"col_path": "Путь",
"col_pattern": "Шаблон",
"col_result": "Результат",
"no_match": "не совпал",
"skipped": "пропускается",
"unmatched": "Не совпал ни один шаблон: {{count}} из {{total}}",
"from_tags": "из тегов",
"error_at": "Ошибка в шаблоне {{n}}: {{reason}}",
"examples": "Примеры"
},
"library": {
"title": "Библиотека",
"subtitle": "Состояние каталога и сканирования",
"artists": "Исполнителей",
"albums": "Альбомов",
"tracks": "Треков",
"files": "Файлов",
"missing": "Пропавших файлов",
"errors": "Не прочитано",
"sources": "Хранилища",
"error_files": "Файлы с ошибками",
"reindex": "Переиндексировать поиск",
"reindex_started": "Переиндексация запущена"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"login": {
"title": "Вход",
"subtitle": "Ваша музыка с вашего сервера",
"username": "Логин",
"password": "Пароль",
"submit": "Войти",
"bootstrap_hint": "Первый запуск: войдите как администратор. Пароль задан в BOOTSTRAP_ADMIN_PASSWORD или напечатан в логе сервиса api."
},
"setup": {
"admin_badge": "Первый вход администратора",
"admin_title": "Смените логин и пароль",
"admin_text": "Без этого шага доступ к серверу закрыт: стартовые данные администратора видны в логах и настройках.",
"password_badge": "Пароль сброшен",
"password_title": "Задайте новый пароль",
"password_text": "Вы вошли по временному паролю. Придумайте свой — временный больше не подойдёт.",
"new_username": "Новый логин",
"new_password": "Новый пароль",
"repeat_password": "Повторите пароль",
"min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить",
"logout": "Выйти"
}
}
+138
View File
@@ -17,5 +17,143 @@
"label": "Язык", "label": "Язык",
"ru": "Русский", "ru": "Русский",
"en": "English" "en": "English"
},
"nav": {
"main": "Навигация",
"home": "Главная",
"admin": "Админка",
"users": "Пользователи",
"audit": "Журнал",
"library": "Библиотека",
"sources": "Хранилища",
"collection": "Коллекция",
"history": "История",
"likes": "Лайки",
"dislikes": "Дизлайки"
},
"profile": {
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти"
},
"roles": {
"admin": "Администратор",
"moderator": "Модератор",
"user": "Пользователь"
},
"home": {
"greeting": "Привет, {{name}}!",
"wave": "Моя волна",
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
},
"player": {
"empty": "Ничего не играет",
"label": "Плеер",
"play": "Слушать",
"pause": "Пауза",
"next": "Следующий",
"prev": "Предыдущий",
"like": "Нравится",
"unlike": "Убрать из лайков",
"dislike": "Не нравится",
"undislike": "Убрать дизлайк",
"volume": "Громкость",
"mute": "Выключить звук",
"unmute": "Включить звук",
"shuffle": "Перемешать",
"repeat_off": "Повтор выключен",
"repeat_all": "Повторять очередь",
"repeat_one": "Повторять трек",
"progress": "Позиция",
"unavailable": "Трек недоступен — пропускаем",
"error": "Не удалось воспроизвести трек"
},
"settings": {
"title": "Настройки",
"account": "Аккаунт",
"display_name": "Отображаемое имя",
"save": "Сохранить",
"saved": "Сохранено",
"appearance": "Внешний вид",
"theme": "Тема",
"theme_dark": "Тёмная",
"theme_light": "Светлая",
"theme_system": "Как в системе",
"password": "Пароль",
"current_password": "Текущий пароль",
"new_password": "Новый пароль",
"repeat_password": "Повторите пароль",
"change_password": "Сменить пароль",
"password_changed": "Пароль изменён. Другие устройства вышли из аккаунта.",
"sessions": "Устройства",
"session_current": "Это устройство",
"session_last_used": "Активность: {{date}}",
"session_end": "Завершить",
"logout_all": "Выйти на всех устройствах"
},
"form": {
"show_password": "Показать пароль",
"hide_password": "Скрыть пароль",
"passwords_mismatch": "Пароли не совпадают",
"required": "Заполните поле",
"cancel": "Отмена",
"close": "Закрыть",
"copy": "Копировать",
"copied": "Скопировано"
},
"loading": "Загрузка…",
"route_error": {
"reload": "Обновить страницу"
},
"search": {
"placeholder": "Исполнители, альбомы, треки",
"label": "Поиск",
"artists": "Исполнители",
"albums": "Альбомы",
"tracks": "Треки",
"all": "Всё",
"nothing": "Ничего не нашли",
"corrected": "Показаны результаты для «{{query}}»",
"show_all": "Все результаты",
"title": "Поиск: {{query}}",
"more": "Показать ещё",
"artist_kind": "Исполнитель",
"hint": "Нажмите / для поиска"
},
"catalog": {
"albums": "Альбомы",
"appears_on": "Встречается на",
"album_count_one": "{{count}} альбом",
"album_count_few": "{{count}} альбома",
"album_count_many": "{{count}} альбомов",
"album_count_other": "{{count}} альбома",
"track_count_one": "{{count}} трек",
"track_count_few": "{{count}} трека",
"track_count_many": "{{count}} треков",
"track_count_other": "{{count}} трека",
"unavailable": "Недоступен: хранилище отключено или файл пропал",
"not_found": "Ничего не найдено — возможно, удалено при последнем скане",
"disc": "Диск {{n}}",
"col_title": "Название",
"col_album": "Альбом",
"col_duration": "Время",
"back_home": "На главную",
"artist": "Исполнитель",
"album": "Альбом"
},
"collection": {
"title": "Коллекция",
"history": "История",
"likes": "Лайки",
"dislikes": "Дизлайки",
"clear_history": "Очистить историю",
"clear_confirm": "Очистить историю прослушиваний? Рекомендации её всё равно учтут.",
"today": "Сегодня",
"yesterday": "Вчера",
"empty_history": "Здесь появится то, что вы слушали",
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
"play_all": "Слушать всё",
"more": "Показать ещё"
} }
} }
+70
View File
@@ -0,0 +1,70 @@
{
"common": {
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
"bad_request": "Некорректный запрос",
"not_found": "Не найдено",
"method_not_allowed": "Действие не поддерживается",
"network": "Нет связи с сервером. Проверьте сеть"
},
"auth": {
"invalid_credentials": "Неверный логин или пароль",
"too_many_attempts": "Слишком много попыток входа. Подождите немного",
"user_blocked": "Учётная запись заблокирована. Обратитесь к администратору",
"unauthorized": "Войдите, чтобы продолжить",
"token_expired": "Сессия истекла, войдите снова",
"token_invalid": "Сессия недействительна, войдите снова",
"token_revoked": "Сессия завершена, войдите снова",
"refresh_invalid": "Сессия завершена, войдите снова",
"refresh_reused": "Сессия завершена из соображений безопасности, войдите снова",
"refresh_race": "Сессия обновляется, повторите действие",
"credentials_change_required": "Сначала смените логин и пароль администратора",
"password_change_required": "Сначала смените временный пароль",
"current_password_required": "Введите текущий пароль",
"current_password_invalid": "Текущий пароль указан неверно",
"bootstrap_not_required": "Учётные данные администратора уже изменены",
"session_not_found": "Сессия не найдена",
"forbidden": "Недостаточно прав для этого действия"
},
"validation": {
"password_too_short": "Пароль должен быть не короче {{min}} символов",
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
"password_same_as_username": "Пароль не должен совпадать с логином",
"password_too_common": "Этот пароль слишком распространён — придумайте другой",
"password_unchanged": "Новый пароль должен отличаться от текущего",
"username_invalid": "Логин: от {{min}} до {{max}} символов — латинские буквы, цифры, «.», «_», «-»",
"username_taken": "Этот логин уже занят",
"locale_unsupported": "Этот язык не поддерживается",
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
"ui_prefs_invalid": "Недопустимое значение настройки",
"client_type_invalid": "Неизвестный тип приложения",
"role_invalid": "Неизвестная роль",
"cursor_invalid": "Список устарел, обновите страницу",
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена",
"name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища",
"query_empty": "Введите запрос"
},
"users": {
"not_found": "Пользователь не найден",
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
},
"library": {
"source_not_found": "Хранилище не найдено",
"scan_running": "Скан уже идёт",
"source_offline": "Хранилище не подключено"
},
"catalog": {
"not_found": "Не найдено"
},
"search": {
"unavailable": "Поиск временно недоступен"
},
"playback": {
"link_invalid": "Ссылка устарела — обновите страницу",
"track_unavailable": "Трек недоступен",
"transcode_failed": "Не удалось подготовить трек"
}
}
+9 -3
View File
@@ -1,7 +1,13 @@
import i18next, { type i18n as I18n } from 'i18next'; import i18next, { type i18n as I18n } from 'i18next';
import enAdmin from '../locales/en/admin.json';
import enAuth from '../locales/en/auth.json';
import enCommon from '../locales/en/common.json'; import enCommon from '../locales/en/common.json';
import enErrors from '../locales/en/errors.json';
import ruAdmin from '../locales/ru/admin.json';
import ruAuth from '../locales/ru/auth.json';
import ruCommon from '../locales/ru/common.json'; import ruCommon from '../locales/ru/common.json';
import ruErrors from '../locales/ru/errors.json';
/** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */ /** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */
export const SUPPORTED_LOCALES = ['ru', 'en'] as const; export const SUPPORTED_LOCALES = ['ru', 'en'] as const;
@@ -9,8 +15,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en'; export const DEFAULT_LOCALE: Locale = 'en';
export const resources = { export const resources = {
ru: { common: ruCommon }, ru: { common: ruCommon, auth: ruAuth, admin: ruAdmin, errors: ruErrors },
en: { common: enCommon }, en: { common: enCommon, auth: enAuth, admin: enAdmin, errors: enErrors },
} as const; } as const;
declare module 'i18next' { declare module 'i18next' {
@@ -38,7 +44,7 @@ export async function createI18n(locale: Locale): Promise<I18n> {
fallbackLng: DEFAULT_LOCALE, fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...SUPPORTED_LOCALES], supportedLngs: [...SUPPORTED_LOCALES],
defaultNS: 'common', defaultNS: 'common',
ns: ['common'], ns: ['common', 'auth', 'admin', 'errors'],
resources, resources,
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
returnNull: false, returnNull: false,
+155
View File
@@ -69,6 +69,9 @@ importers:
'@fontsource/unbounded': '@fontsource/unbounded':
specifier: ^5.2.6 specifier: ^5.2.6
version: 5.3.0 version: 5.3.0
'@material-symbols/svg-400':
specifier: ^0.31.9
version: 0.31.9
'@pipu/api-client': '@pipu/api-client':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/api-client version: link:../../packages/api-client
@@ -84,6 +87,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.80.7 specifier: ^5.80.7
version: 5.104.4(@types/react@19.3.0)(react@19.3.1) version: 5.104.4(@types/react@19.3.0)(react@19.3.1)
'@tanstack/react-router':
specifier: ^1.120.20
version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.3.1 version: 19.3.1
@@ -97,9 +103,15 @@ importers:
'@testing-library/dom': '@testing-library/dom':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.2 version: 10.4.2
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
'@testing-library/react': '@testing-library/react':
specifier: ^16.3.0 specifier: ^16.3.0
version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.2)
'@types/react': '@types/react':
specifier: ^19.1.8 specifier: ^19.1.8
version: 19.3.0 version: 19.3.0
@@ -157,6 +169,9 @@ packages:
graphql: graphql:
optional: true optional: true
'@adobe/css-tools@4.5.0':
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
'@asamuzakjp/css-color@3.2.0': '@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@@ -985,6 +1000,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@material-symbols/svg-400@0.31.9':
resolution: {integrity: sha512-LeQrY/qYdRdIlV6ZR+hUQDXjtkDYgP1LGnMQh/Grm6FsXz+6GR2Q+DrotEJjzDnmao/oHygHFGLnkKAR/PM1hQ==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25} engines: {node: ^22.20 || ^24.12 || >=25}
@@ -1215,6 +1233,10 @@ packages:
'@sinonjs/fake-timers@10.3.0': '@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
'@tanstack/history@1.120.17':
resolution: {integrity: sha512-k07LFI4Qo074IIaWzT/XjD0KlkGx2w1V3fnNtclKx0oAl8z4O9kCh6za+FPEIRe98xLgNFEiddDbJeAYGSlPtw==}
engines: {node: '>=12'}
'@tanstack/query-core@5.104.4': '@tanstack/query-core@5.104.4':
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==} resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
@@ -1227,10 +1249,34 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@tanstack/react-router@1.120.20':
resolution: {integrity: sha512-+zNruUE9NsfGm9cHd22Xs7FRtBrBhDZe94pB69BEIjqjrEPZct6f5VhTV9WQ+bDZ6fRz8tUuxNFAgm/3Lm4AIg==}
engines: {node: '>=12'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/react-store@0.7.7':
resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.120.19':
resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==}
engines: {node: '>=12'}
'@tanstack/store@0.7.7':
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
'@testing-library/dom@10.4.2': '@testing-library/dom@10.4.2':
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@testing-library/jest-dom@6.9.1':
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/react@16.3.3': '@testing-library/react@16.3.3':
resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1246,6 +1292,12 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@types/aria-query@5.0.4': '@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1705,6 +1757,9 @@ packages:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'} engines: {node: '>=8'}
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
cssstyle@4.6.0: cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1783,6 +1838,9 @@ packages:
dom-accessibility-api@0.5.16: dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dotenv-expand@11.0.7: dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2121,6 +2179,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
index-to-position@1.2.0: index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2487,6 +2549,10 @@ packages:
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
engines: {node: '>=4'} engines: {node: '>=4'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minimatch@3.1.5: minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
@@ -2838,6 +2904,10 @@ packages:
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
regenerate-unicode-properties@10.3.0: regenerate-unicode-properties@10.3.0:
resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==} resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3058,6 +3128,10 @@ packages:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'} engines: {node: '>=12'}
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
strip-json-comments@2.0.1: strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3131,6 +3205,12 @@ packages:
throat@5.0.0: throat@5.0.0:
resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -3277,6 +3357,11 @@ packages:
uri-js-replace@1.0.1: uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
use-sync-external-store@1.7.0:
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utils-merge@1.0.1: utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
@@ -3528,6 +3613,8 @@ snapshots:
'@0no-co/graphql.web@1.3.4': {} '@0no-co/graphql.web@1.3.4': {}
'@adobe/css-tools@4.5.0': {}
'@asamuzakjp/css-color@3.2.0': '@asamuzakjp/css-color@3.2.0':
dependencies: dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -4590,6 +4677,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
'@material-symbols/svg-400@0.31.9': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true optional: true
@@ -4826,6 +4915,8 @@ snapshots:
dependencies: dependencies:
'@sinonjs/commons': 3.0.1 '@sinonjs/commons': 3.0.1
'@tanstack/history@1.120.17': {}
'@tanstack/query-core@5.104.4': {} '@tanstack/query-core@5.104.4': {}
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)': '@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
@@ -4835,6 +4926,32 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.3.0 '@types/react': 19.3.0
'@tanstack/react-router@1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@tanstack/history': 1.120.17
'@tanstack/react-store': 0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@tanstack/router-core': 1.120.19
jsesc: 3.1.0
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
'@tanstack/react-store@0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@tanstack/store': 0.7.7
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
use-sync-external-store: 1.7.0(react@19.3.1)
'@tanstack/router-core@1.120.19':
dependencies:
'@tanstack/history': 1.120.17
'@tanstack/store': 0.7.7
tiny-invariant: 1.3.3
'@tanstack/store@0.7.7': {}
'@testing-library/dom@10.4.2': '@testing-library/dom@10.4.2':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
@@ -4846,6 +4963,15 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
pretty-format: 27.5.1 pretty-format: 27.5.1
'@testing-library/jest-dom@6.9.1':
dependencies:
'@adobe/css-tools': 4.5.0
aria-query: 5.3.0
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies: dependencies:
'@babel/runtime': 7.29.10 '@babel/runtime': 7.29.10
@@ -4856,6 +4982,10 @@ snapshots:
'@types/react': 19.3.0 '@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0) '@types/react-dom': 19.3.0(@types/react@19.3.0)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.2)':
dependencies:
'@testing-library/dom': 10.4.2
'@types/aria-query@5.0.4': {} '@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
@@ -5395,6 +5525,8 @@ snapshots:
crypto-random-string@2.0.0: {} crypto-random-string@2.0.0: {}
css.escape@1.5.1: {}
cssstyle@4.6.0: cssstyle@4.6.0:
dependencies: dependencies:
'@asamuzakjp/css-color': 3.2.0 '@asamuzakjp/css-color': 3.2.0
@@ -5445,6 +5577,8 @@ snapshots:
dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
dotenv-expand@11.0.7: dotenv-expand@11.0.7:
dependencies: dependencies:
dotenv: 16.4.7 dotenv: 16.4.7
@@ -5792,6 +5926,8 @@ snapshots:
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
indent-string@4.0.0: {}
index-to-position@1.2.0: {} index-to-position@1.2.0: {}
inflight@1.0.6: inflight@1.0.6:
@@ -6269,6 +6405,8 @@ snapshots:
mimic-fn@1.2.0: {} mimic-fn@1.2.0: {}
min-indent@1.0.1: {}
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
brace-expansion: 1.1.21 brace-expansion: 1.1.21
@@ -6625,6 +6763,11 @@ snapshots:
react@19.3.1: {} react@19.3.1: {}
redent@3.0.0:
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
regenerate-unicode-properties@10.3.0: regenerate-unicode-properties@10.3.0:
dependencies: dependencies:
regenerate: 1.5.0 regenerate: 1.5.0
@@ -6852,6 +6995,10 @@ snapshots:
dependencies: dependencies:
ansi-regex: 6.4.0 ansi-regex: 6.4.0
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
strip-json-comments@2.0.1: {} strip-json-comments@2.0.1: {}
strip-literal@3.1.0: strip-literal@3.1.0:
@@ -6931,6 +7078,10 @@ snapshots:
throat@5.0.0: {} throat@5.0.0: {}
tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
@@ -7036,6 +7187,10 @@ snapshots:
uri-js-replace@1.0.1: {} uri-js-replace@1.0.1: {}
use-sync-external-store@1.7.0(react@19.3.1):
dependencies:
react: 19.3.1
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
uuid@7.0.3: {} uuid@7.0.3: {}
+4 -1
View File
@@ -14,8 +14,11 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \ go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent
FROM gcr.io/distroless/static-debian12:nonroot AS api # api вызывает ffprobe (сканер) и ffmpeg (транскодинг, M3) — нужен не distroless, а alpine с ffmpeg.
FROM alpine:3.22 AS api
RUN apk add --no-cache ffmpeg ca-certificates tzdata
COPY --from=build /out/api /usr/local/bin/api COPY --from=build /out/api /usr/local/bin/api
USER 65532:65532
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/api"] ENTRYPOINT ["/usr/local/bin/api"]
+115 -1
View File
@@ -3,17 +3,29 @@ package main
import ( import (
"context" "context"
"crypto/ed25519"
"fmt"
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"runtime"
"syscall" "syscall"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo" "pipu/api/internal/buildinfo"
"pipu/api/internal/library"
"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"
"pipu/api/internal/platform/sign"
"pipu/api/internal/playback"
"pipu/api/internal/search"
"pipu/api/internal/server" "pipu/api/internal/server"
"pipu/api/internal/system" "pipu/api/internal/system"
) )
@@ -56,16 +68,118 @@ func run() error {
rdb := redis.NewClient(redisOpts) rdb := redis.NewClient(redisOpts)
defer func() { _ = rdb.Close() }() defer func() { _ = rdb.Close() }()
authSvc, err := newAuthService(cfg, pool, rdb, log)
if err != nil {
return err
}
if err := authSvc.Bootstrap(ctx); err != nil {
return err
}
sys := system.NewHandler(system.Info{ sys := system.NewHandler(system.Info{
Version: buildinfo.Version, Version: buildinfo.Version,
SupportedLocales: cfg.SupportedLocales, SupportedLocales: cfg.SupportedLocales,
MinAndroidVersionCode: cfg.MinAndroidVersionCode, MinAndroidVersionCode: cfg.MinAndroidVersionCode,
BootstrapRequired: authSvc.BootstrapRequired,
}, log, map[string]system.Checker{ }, log, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping), "postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }), "redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
"search": system.CheckerFunc(search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey).Healthy),
}) })
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys})) libStore := library.NewStore(pool)
prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute}
board := library.NewProgressBoard()
scanner := library.NewScanner(libStore, prober, board, log)
libSvc := library.NewService(libStore, scanner, board, library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken),
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
indexer := search.NewIndexer(pool, meili, log)
signer, err := sign.New(cfg.SigningKey)
if err != nil {
return err
}
if cfg.SigningKey == "" {
log.Warn("STREAM_SIGNING_KEY is not set: cover and stream links will break on restart")
}
covers := library.NewCovers(libStore, libSvc, cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "covers"), log)
scanner.AfterScan = func(ctx context.Context) {
covers.Run(ctx)
indexer.Trigger()
}
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
concurrency := cfg.TranscodeConcurrency
if concurrency <= 0 {
concurrency = max(1, runtime.NumCPU()/2)
}
transcoder := playback.NewTranscoder(cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "transcode"), concurrency,
cfg.TranscodeCacheLimitGB, log)
go transcoder.RunCleanup(ctx)
rootFor := func(ctx context.Context, sourceID uuid.UUID) (string, error) {
src, err := libStore.GetSource(ctx, sourceID)
if err != nil {
return "", err
}
return libSvc.Root(src)
}
var dto library.DTO
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
rootFor, cfg.XAccel, log)
dto = library.DTO{Signer: signer, Personal: pb}
libSvc.OnCatalogChanged = indexer.Trigger
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
go libSvc.Restore(ctx)
go libSvc.RunSchedule(ctx)
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
Log: log,
System: sys,
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc,
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, dto, log),
Playback: pb,
}))
log.Info("starting", "version", buildinfo.Version) log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout) return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
} }
func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) {
var (
key ed25519.PrivateKey
err error
)
if cfg.JWTPrivateKey == "" {
log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart")
key, err = auth.GenerateKey()
} else {
key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey)
}
if err != nil {
return nil, err
}
var previous []ed25519.PrivateKey
for _, s := range cfg.JWTPreviousKeys {
if s == "" {
continue
}
k, err := auth.ParsePrivateKey(s)
if err != nil {
return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err)
}
previous = append(previous, k)
}
clock := auth.SystemClock{}
acfg := auth.DefaultConfig()
acfg.BootstrapUsername = cfg.BootstrapAdminUsername
acfg.BootstrapPassword = cfg.BootstrapAdminPassword
acfg.SupportedLocales = cfg.SupportedLocales
return auth.NewService(
auth.NewPgStore(pool),
auth.NewHasher(auth.DefaultArgon2Params),
auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now),
auth.NewRedisLimiter(rdb),
clock, auth.UUIDv7{}, log, acfg,
)
}
+5 -1
View File
@@ -8,14 +8,18 @@ require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/getkin/kin-openapi v0.127.0 github.com/getkin/kin-openapi v0.127.0
github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/chi/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.5 github.com/jackc/pgx/v5 v5.7.5
github.com/oapi-codegen/runtime v1.1.1 github.com/oapi-codegen/runtime v1.1.1
github.com/pressly/goose/v3 v3.24.3 github.com/pressly/goose/v3 v3.24.3
github.com/redis/go-redis/v9 v9.10.0 github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
) )
require ( require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -37,9 +41,9 @@ require (
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
+8
View File
@@ -1,3 +1,7 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -65,6 +71,7 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -120,6 +127,7 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc= github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
File diff suppressed because it is too large. Load diff
+341
View File
@@ -0,0 +1,341 @@
package auth
import (
"context"
"encoding/base64"
"errors"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr"
)
// Коды ошибок админки пользователей.
const (
CodeForbidden = "auth.forbidden"
CodeUserNotFound = "users.not_found"
CodeLastAdmin = "users.last_admin"
CodeRoleInvalid = "validation.role_invalid"
CodeCursorBad = "validation.cursor_invalid"
)
// Длина временного пароля (создание пользователя, сброс).
const tempPasswordLen = 12
// Пределы размера страницы.
const (
defaultPageSize = 50
maxPageSize = 200
)
func forbidden() error { return apperr.Forbidden(CodeForbidden, "not allowed") }
func pageSize(limit *int) int {
if limit == nil || *limit <= 0 {
return defaultPageSize
}
return min(*limit, maxPageSize)
}
func actorOf(p Principal) Actor { return Actor{ID: p.UserID, Role: p.Role} }
func actorOfUser(u User) Actor { return Actor{ID: u.ID, Role: u.Role} }
// ListUsers — страница пользователей для админки (admin, moderator).
func (s *Service) ListUsers(ctx context.Context, p Principal, query string, role *Role, cursor string, limit *int,
) ([]User, string, error) {
if !Can(p.Role, PermListUsers) {
return nil, "", forbidden()
}
if role != nil && !role.Valid() {
return nil, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
}
after := ""
if cursor != "" {
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil)
}
after = string(raw)
}
n := pageSize(limit)
users, err := s.store.ListUsers(ctx, UserFilter{Query: strings.TrimSpace(query), Role: role, After: after, Limit: n + 1})
if err != nil {
return nil, "", err
}
next := ""
if len(users) > n {
users = users[:n]
next = base64.RawURLEncoding.EncodeToString([]byte(users[n-1].Username))
}
return users, next, nil
}
// CreateUserInput — форма создания пользователя.
type CreateUserInput struct {
Username string
DisplayName string
Role Role
}
// CreateUser создаёт пользователя с временным паролем, который возвращается один раз.
func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInput) (User, string, error) {
if !in.Role.Valid() {
return User{}, "", apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
}
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
return User{}, "", forbidden()
}
username := NormalizeUsername(in.Username)
if err := ValidateUsername("username", username); err != nil {
return User{}, "", err
}
name, err := displayName(in.DisplayName)
if err != nil {
return User{}, "", err
}
password, err := GeneratePassword(tempPasswordLen)
if err != nil {
return User{}, "", err
}
hash, err := s.hasher.Hash(password)
if err != nil {
return User{}, "", err
}
locale := "ru"
if len(s.cfg.SupportedLocales) > 0 {
locale = s.cfg.SupportedLocales[0]
}
u := User{
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role, Locale: locale,
MustChangePassword: true, CreatedAt: s.clock.Now(),
}
err = s.store.WithTx(ctx, func(tx Store) error {
if err := tx.CreateUser(ctx, u, &p.UserID); err != nil {
return err
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username, "role": string(in.Role)},
})
})
if errors.Is(err, ErrUsernameTaken) {
return User{}, "", apperr.Validation("username", CodeUsernameTaken, "username is taken", nil)
}
if err != nil {
return User{}, "", err
}
return u, password, nil
}
// displayName обрезает пробелы и проверяет длину; пустое имя — nil.
func displayName(s string) (*string, error) {
s = strings.TrimSpace(s)
if utf8.RuneCountInString(s) > MaxDisplayName {
return nil, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long",
map[string]any{"max": MaxDisplayName})
}
if s == "" {
return nil, nil
}
return &s, nil
}
// UserPatch — изменения пользователя из админки. nil — не менять.
type UserPatch struct {
DisplayName *string
Role *Role
IsBlocked *bool
}
// UpdateUser меняет имя, роль и блокировку с проверкой прав по каждому изменению.
func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, patch UserPatch) (User, error) {
if patch.Role != nil && !patch.Role.Valid() {
return User{}, apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
}
var out User
err := s.store.WithTx(ctx, func(tx Store) error {
target, err := s.target(ctx, tx, id)
if err != nil {
return err
}
actor, tgt := actorOf(p), actorOfUser(target)
var (
upd UserUpdate
audits []AuditEntry
)
audit := func(action string, payload map[string]any) {
audits = append(audits, AuditEntry{
ActorID: &p.UserID, Action: action, TargetType: "user", TargetID: &target.ID, Payload: payload,
})
}
if patch.DisplayName != nil {
if !CanManageUser(actor, tgt, UserEdit) {
return forbidden()
}
name, err := displayName(*patch.DisplayName)
if err != nil {
return err
}
upd.DisplayName = &name
audit("user.rename", map[string]any{"display_name": name})
}
roleChange := patch.Role != nil && *patch.Role != target.Role
if roleChange {
if !CanManageUser(actor, tgt, UserChangeRole) || !CanAssignRole(p.Role, *patch.Role) {
return forbidden()
}
upd.Role, upd.RevokeTokens = patch.Role, true
audit("user.role_change", map[string]any{"from": string(target.Role), "to": string(*patch.Role)})
}
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
if blockChange {
if !CanManageUser(actor, tgt, UserBlock) {
return forbidden()
}
upd.IsBlocked, upd.RevokeTokens = patch.IsBlocked, true
if *patch.IsBlocked {
audit("user.block", nil)
} else {
audit("user.unblock", nil)
}
}
if len(audits) == 0 {
// Пустое изменение: отвечаем текущим состоянием, но только тем, кому оно доступно.
if !CanManageUser(actor, tgt, UserEdit) {
return forbidden()
}
out = target
return nil
}
losesAdmin := target.Role == RoleAdmin && !target.IsBlocked &&
((roleChange && *patch.Role != RoleAdmin) || (blockChange && *patch.IsBlocked))
if losesAdmin {
if err := s.ensureNotLastAdmin(ctx, tx); err != nil {
return err
}
}
if out, err = tx.UpdateUser(ctx, target.ID, upd); err != nil {
return err
}
if blockChange && *patch.IsBlocked {
if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil {
return err
}
}
for _, a := range audits {
if err := tx.InsertAudit(ctx, s.ids.New(), a); err != nil {
return err
}
}
return nil
})
return out, err
}
func (s *Service) target(ctx context.Context, st Store, id uuid.UUID) (User, error) {
u, err := st.GetUserByID(ctx, id)
if errors.Is(err, ErrNotFound) {
return User{}, apperr.NotFound(CodeUserNotFound, "user not found")
}
return u, err
}
func (s *Service) ensureNotLastAdmin(ctx context.Context, tx Store) error {
n, err := tx.LockActiveAdmins(ctx)
if err != nil {
return err
}
if n <= 1 {
return apperr.Conflict(CodeLastAdmin, "the last active admin cannot be demoted, blocked or deleted")
}
return nil
}
// ResetPassword выдаёт новый временный пароль и завершает все сессии пользователя.
func (s *Service) ResetPassword(ctx context.Context, p Principal, id uuid.UUID) (string, error) {
password, err := GeneratePassword(tempPasswordLen)
if err != nil {
return "", err
}
hash, err := s.hasher.Hash(password)
if err != nil {
return "", err
}
err = s.store.WithTx(ctx, func(tx Store) error {
target, err := s.target(ctx, tx, id)
if err != nil {
return err
}
if !CanManageUser(actorOf(p), actorOfUser(target), UserResetPassword) {
return forbidden()
}
if err := tx.ResetPassword(ctx, target.ID, hash); err != nil {
return err
}
if err := tx.RevokeUserSessions(ctx, target.ID, s.clock.Now()); err != nil {
return err
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "user.password_reset", TargetType: "user", TargetID: &target.ID,
})
})
if err != nil {
return "", err
}
return password, nil
}
// DeleteUser удаляет пользователя (только админ, не себя и не последнего админа).
func (s *Service) DeleteUser(ctx context.Context, p Principal, id uuid.UUID) error {
return s.store.WithTx(ctx, func(tx Store) error {
target, err := s.target(ctx, tx, id)
if err != nil {
return err
}
if !CanManageUser(actorOf(p), actorOfUser(target), UserDelete) {
return forbidden()
}
if target.Role == RoleAdmin && !target.IsBlocked {
if err := s.ensureNotLastAdmin(ctx, tx); err != nil {
return err
}
}
if err := tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "user.delete", TargetType: "user", TargetID: &target.ID,
Payload: map[string]any{"username": target.Username, "role": string(target.Role)},
}); err != nil {
return err
}
return tx.DeleteUser(ctx, target.ID)
})
}
// ListAudit — страница журнала действий (только админ).
func (s *Service) ListAudit(ctx context.Context, p Principal, cursor string, limit *int) ([]AuditRecord, string, error) {
if !Can(p.Role, PermSystemStatus) {
return nil, "", forbidden()
}
before := uuid.Nil
if cursor != "" {
var err error
if before, err = uuid.Parse(cursor); err != nil {
return nil, "", apperr.Validation("cursor", CodeCursorBad, "malformed cursor", nil)
}
}
n := pageSize(limit)
records, err := s.store.ListAudit(ctx, before, n+1)
if err != nil {
return nil, "", err
}
next := ""
if len(records) > n {
records = records[:n]
next = records[n-1].ID.String()
}
return records, next, nil
}
+152
View File
@@ -0,0 +1,152 @@
package auth
import (
"context"
"pipu/api/internal/api/gen"
)
// ListUsers реализует gen.StrictServerInterface.
func (h *Handler) ListUsers(ctx context.Context, req gen.ListUsersRequestObject) (gen.ListUsersResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
var (
q, cursor string
role *Role
)
if req.Params.Q != nil {
q = *req.Params.Q
}
if req.Params.Cursor != nil {
cursor = *req.Params.Cursor
}
if req.Params.Role != nil {
r := Role(*req.Params.Role)
role = &r
}
users, next, err := h.svc.ListUsers(ctx, p, q, role, cursor, req.Params.Limit)
if err != nil {
return nil, err
}
items := make([]gen.AdminUser, 0, len(users))
for _, u := range users {
items = append(items, AdminUserDTO(u))
}
return gen.ListUsers200JSONResponse{Items: items, NextCursor: optional(next)}, nil
}
// CreateUser реализует gen.StrictServerInterface.
func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObject) (gen.CreateUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
if req.Body.DisplayName != nil {
in.DisplayName = *req.Body.DisplayName
}
u, password, err := h.svc.CreateUser(ctx, p, in)
if err != nil {
return nil, err
}
return gen.CreateUser201JSONResponse{User: AdminUserDTO(u), TemporaryPassword: password}, nil
}
// UpdateUser реализует gen.StrictServerInterface.
func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObject) (gen.UpdateUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
patch := UserPatch{DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked}
if req.Body.Role != nil {
r := Role(*req.Body.Role)
patch.Role = &r
}
u, err := h.svc.UpdateUser(ctx, p, req.Id, patch)
if err != nil {
return nil, err
}
return gen.UpdateUser200JSONResponse(AdminUserDTO(u)), nil
}
// DeleteUser реализует gen.StrictServerInterface.
func (h *Handler) DeleteUser(ctx context.Context, req gen.DeleteUserRequestObject) (gen.DeleteUserResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.DeleteUser(ctx, p, req.Id); err != nil {
return nil, err
}
return gen.DeleteUser204Response{}, nil
}
// ResetUserPassword реализует gen.StrictServerInterface.
func (h *Handler) ResetUserPassword(ctx context.Context, req gen.ResetUserPasswordRequestObject) (gen.ResetUserPasswordResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
password, err := h.svc.ResetPassword(ctx, p, req.Id)
if err != nil {
return nil, err
}
return gen.ResetUserPassword200JSONResponse{TemporaryPassword: password}, nil
}
// ListAudit реализует gen.StrictServerInterface.
func (h *Handler) ListAudit(ctx context.Context, req gen.ListAuditRequestObject) (gen.ListAuditResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
cursor := ""
if req.Params.Cursor != nil {
cursor = *req.Params.Cursor
}
records, next, err := h.svc.ListAudit(ctx, p, cursor, req.Params.Limit)
if err != nil {
return nil, err
}
items := make([]gen.AuditEntry, 0, len(records))
for _, r := range records {
e := gen.AuditEntry{
Id: r.ID, Action: r.Action, TargetType: r.TargetType, TargetId: r.TargetID, CreatedAt: r.CreatedAt,
}
if r.ActorID != nil && r.ActorUsername != nil {
e.Actor = &gen.AuditActor{Id: *r.ActorID, Username: *r.ActorUsername}
}
if r.Payload != nil {
payload := r.Payload
e.Payload = &payload
}
items = append(items, e)
}
return gen.ListAudit200JSONResponse{Items: items, NextCursor: optional(next)}, nil
}
// AdminUserDTO переводит пользователя в схему AdminUser.
func AdminUserDTO(u User) gen.AdminUser {
return gen.AdminUser{
Id: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
Locale: u.Locale,
IsBlocked: u.IsBlocked,
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
CreatedAt: u.CreatedAt,
LastLoginAt: u.LastLoginAt,
}
}
func optional(s string) *string {
if s == "" {
return nil
}
return &s
}
+379
View File
@@ -0,0 +1,379 @@
package auth_test
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
// signIn входит под пользователем, у которого может стоять must_change_password, и сразу меняет пароль.
func (e *env) signIn(username, password string) string {
e.t.Helper()
r := e.mustLogin(username, password, "web")
if user(r)["must_change_password"] == true {
r = e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"new_password": username + "-own-password"}, bearer(access(r)))
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
}
return access(r)
}
func (e *env) createUser(token, username, role string) resp {
e.t.Helper()
return e.do(http.MethodPost, "/api/v1/admin/users",
map[string]any{"username": username, "role": role, "display_name": " " + username + " "}, bearer(token))
}
// Сценарий 2 из docs/testing.md: админ создаёт модератора, модератор — пользователя,
// но не модератора.
func TestAdminCreatesModeratorWhoCreatesUser(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
created := e.createUser(admin, "Mod.One", "moderator")
require.Equal(t, http.StatusCreated, created.Code, created.Body.String())
modUser := created.body["user"].(map[string]any)
assert.Equal(t, "mod.one", modUser["username"])
assert.Equal(t, "Mod.One", modUser["display_name"])
assert.Equal(t, true, modUser["must_change_password"])
temp := created.body["temporary_password"].(string)
assert.Len(t, temp, 12)
first := e.mustLogin("mod.one", temp, "web")
assert.Equal(t, auth.CodePasswordChangeRequired,
e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(access(first))).code())
mod := e.signIn("mod.one", temp)
listenerUser := e.createUser(mod, "listener", "user")
require.Equal(t, http.StatusCreated, listenerUser.Code)
for _, role := range []string{"moderator", "admin"} {
r := e.createUser(mod, "sneaky-"+role, role)
assert.Equal(t, http.StatusForbidden, r.Code, role)
assert.Equal(t, auth.CodeForbidden, r.code())
}
list := e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(mod))
require.Equal(t, http.StatusOK, list.Code)
var names []string
for _, it := range list.body["items"].([]any) {
names = append(names, it.(map[string]any)["username"].(string))
}
assert.Equal(t, []string{"listener", "mod.one", "root"}, names)
assert.Nil(t, list.body["next_cursor"])
listener := e.signIn("listener", listenerUser.body["temporary_password"].(string))
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(listener)).code())
assert.Equal(t, auth.CodeForbidden, e.createUser(listener, "another", "user").code())
}
func TestCreateUserValidation(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
assert.Equal(t, auth.CodeUsernameTaken, e.createUser(admin, "ROOT", "user").code())
assert.Equal(t, auth.CodeUsernameInvalid, e.createUser(admin, "no spaces", "user").code())
assert.Equal(t, auth.CodeRoleInvalid, e.createUser(admin, "valid", "root").code())
}
// Матрица «кто кого может» через API: сброс пароля, блокировка, смена роли, удаление.
func TestManageUsersMatrix(t *testing.T) {
type actorCase struct {
role auth.Role
// разрешено ли действие над целью с ролью user / moderator / admin
reset, block, role2, del [3]bool
}
cases := []actorCase{
{auth.RoleModerator, [3]bool{true, false, false}, [3]bool{true, false, false}, [3]bool{false, false, false}, [3]bool{false, false, false}},
{auth.RoleAdmin, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}},
}
targets := [3]auth.Role{auth.RoleUser, auth.RoleModerator, auth.RoleAdmin}
for _, ac := range cases {
for ti, tr := range targets {
actions := []struct {
name string
allowed bool
call func(e *env, tok, id string) resp
}{
{"reset", ac.reset[ti], func(e *env, tok, id string) resp {
return e.do(http.MethodPost, "/api/v1/admin/users/"+id+"/reset-password", nil, bearer(tok))
}},
{"block", ac.block[ti], func(e *env, tok, id string) resp {
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok))
}},
{"role", ac.role2[ti], func(e *env, tok, id string) resp {
next := "user"
if tr == auth.RoleUser {
next = "moderator"
}
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": next}, bearer(tok))
}},
{"delete", ac.del[ti], func(e *env, tok, id string) resp {
return e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok))
}},
}
for _, a := range actions {
t.Run(fmt.Sprintf("%s %s %s", ac.role, a.name, tr), func(t *testing.T) {
e := newEnv(t)
e.addUser("actor", "actor-password-1", ac.role)
e.addUser("spare-admin", "spare-password-1", auth.RoleAdmin) // чтобы не упереться в «последнего админа»
target := e.addUser("target", "target-password", tr)
tok := e.signIn("actor", "actor-password-1")
r := a.call(e, tok, target.ID.String())
if a.allowed {
assert.Less(t, r.Code, 300, r.Body.String())
} else {
assert.Equal(t, http.StatusForbidden, r.Code, r.Body.String())
assert.Equal(t, auth.CodeForbidden, r.code())
}
})
}
}
}
}
func TestModeratorCannotTouchSelfOrRoles(t *testing.T) {
e := newEnv(t)
mod := e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
u := e.addUser("listener", "listener-pass", auth.RoleUser)
tok := e.signIn("mod", "moderator-pass-1")
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+mod.ID.String(),
map[string]any{"display_name": "Boss"}, bearer(tok)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
map[string]any{"display_name": "ok", "role": "moderator"}, bearer(tok)).code(), "one forbidden change rejects the patch")
assert.Nil(t, e.store.Users[u.ID].DisplayName, "rejected patch changes nothing")
ok := e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
map[string]any{"display_name": "Listener", "role": "user"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.Equal(t, "Listener", ok.body["display_name"])
}
func TestAdminCannotDemoteBlockOrDeleteSelf(t *testing.T) {
e := newEnv(t)
root := e.addUser("root", "root-password-1", auth.RoleAdmin)
e.addUser("other", "other-password-1", auth.RoleAdmin)
tok := e.signIn("root", "root-password-1")
id := root.ID.String()
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": "user"}, bearer(tok)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok)).code())
rename := e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"display_name": "Root"}, bearer(tok))
assert.Equal(t, http.StatusOK, rename.Code)
}
func TestLastAdminIsProtected(t *testing.T) {
// Через API последнего админа не затронуть: себя менять нельзя, а действующий админ — второй.
// Остаётся гонка (админ удалён, пока его запрос в пути) — её и закрывает проверка в сервисе.
e := newEnv(t)
only := e.addUser("root", "root-password-1", auth.RoleAdmin)
ghost := auth.Principal{UserID: uuid.New(), Role: auth.RoleAdmin}
ctx := context.Background()
role := auth.RoleUser
blocked := true
for name, call := range map[string]func() error{
"demote": func() error { _, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role}); return err },
"block": func() error {
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{IsBlocked: &blocked})
return err
},
"delete": func() error { return e.svc.DeleteUser(ctx, ghost, only.ID) },
} {
err := call()
var ae *apperr.Error
require.ErrorAs(t, err, &ae, name)
assert.Equal(t, auth.CodeLastAdmin, ae.Code, name)
assert.Equal(t, http.StatusConflict, ae.Status, name)
}
assert.Equal(t, auth.RoleAdmin, e.store.Users[only.ID].Role)
assert.False(t, e.store.Users[only.ID].IsBlocked)
assert.Empty(t, e.store.Audit, "rolled back changes leave no audit")
// Заблокированный админ не считается: второго активного нет.
e.addUser("sleeping", "sleeping-pass-1", auth.RoleAdmin, func(u *auth.User) { u.IsBlocked = true })
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role})
assert.ErrorIs(t, err, apperr.Conflict(auth.CodeLastAdmin, ""))
}
func TestBlockAndRoleChangeRevokeAccess(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
target := e.addUser("listener", "listener-pass", auth.RoleUser)
admin := e.signIn("root", "root-password-1")
web := e.mustLogin("listener", "listener-pass", "web")
cookie := web.cookie(auth.RefreshCookie)
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"role": "moderator"}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, "moderator", r.body["role"])
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(web))).code())
refreshed := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(cookie))
require.Equal(t, http.StatusOK, refreshed.Code, "role change keeps sessions")
assert.Equal(t, "moderator", user(refreshed)["role"])
tok := access(refreshed)
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": true}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, true, r.body["is_blocked"])
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(refreshed.cookie(auth.RefreshCookie))).code(),
"blocking ends sessions")
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": false}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
e.mustLogin("listener", "listener-pass", "web")
var actions []string
for _, a := range e.store.Audit {
actions = append(actions, a.Action)
}
assert.Equal(t, []string{"user.role_change", "user.block", "user.unblock"}, actions)
assert.Equal(t, map[string]any{"from": "user", "to": "moderator"}, e.store.Audit[0].Payload)
}
func TestNoOpPatch(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
target := e.addUser("listener", "listener-pass", auth.RoleUser)
admin := e.signIn("root", "root-password-1")
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(),
map[string]any{"role": "user", "is_blocked": false}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
assert.Empty(t, e.store.Audit)
assert.Zero(t, e.store.Users[target.ID].TokenVersion)
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodPatch, "/api/v1/admin/users/"+uuid.NewString(),
map[string]any{"display_name": "x"}, bearer(admin)).code())
}
// Сценарий 10 из docs/testing.md: сброс пароля → обязательная смена при входе.
func TestResetPassword(t *testing.T) {
e := newEnv(t)
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
target := e.addUser("listener", "listener-pass", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 9 })
mod := e.signIn("mod", "moderator-pass-1")
phone := e.mustLogin("listener", "listener-pass", "android")
r := e.do(http.MethodPost, "/api/v1/admin/users/"+target.ID.String()+"/reset-password", nil, bearer(mod))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
temp := r.body["temporary_password"].(string)
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(phone))).code())
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
assert.Equal(t, auth.CodeInvalidCredentials, e.login("listener", "listener-pass", "web").code())
again := e.mustLogin("listener", temp, "web")
assert.Equal(t, true, user(again)["must_change_password"])
assert.Equal(t, auth.CodePasswordChangeRequired, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(again))).code())
assert.Equal(t, "user.password_reset", e.store.Audit[len(e.store.Audit)-1].Action)
}
func TestDeleteUser(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
target := e.addUser("listener", "listener-pass", auth.RoleUser)
admin := e.signIn("root", "root-password-1")
tok := access(e.mustLogin("listener", "listener-pass", "web"))
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).Code)
assert.NotContains(t, e.store.Users, target.ID)
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).code())
last := e.store.Audit[len(e.store.Audit)-1]
assert.Equal(t, "user.delete", last.Action)
assert.Equal(t, "listener", last.Payload["username"])
}
func TestListUsersFiltersAndPages(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
for _, n := range []string{"anna", "boris", "vera", "gleb", "dina"} {
e.addUser(n, n+"-password", auth.RoleUser)
}
name := "Анна Каренина"
e.addUser("kate", "kate-password", auth.RoleModerator, func(u *auth.User) { u.DisplayName = &name })
admin := e.signIn("root", "root-password-1")
list := func(q url.Values) resp {
return e.do(http.MethodGet, "/api/v1/admin/users?"+q.Encode(), nil, bearer(admin))
}
usernames := func(r resp) []string {
var out []string
for _, it := range r.body["items"].([]any) {
out = append(out, it.(map[string]any)["username"].(string))
}
return out
}
var (
all []string
cursor string
)
for page := 0; ; page++ {
q := url.Values{"limit": {"3"}}
if cursor != "" {
q.Set("cursor", cursor)
}
r := list(q)
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
all = append(all, usernames(r)...)
next, _ := r.body["next_cursor"].(string)
if next == "" {
break
}
cursor = next
require.Less(t, page, 5)
}
assert.Equal(t, []string{"anna", "boris", "dina", "gleb", "kate", "root", "vera"}, all)
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"q": {"анна"}})), "matches display name, case-insensitive")
assert.Equal(t, []string{"anna"}, usernames(list(url.Values{"q": {"AN"}})))
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"role": {"moderator"}})))
assert.Equal(t, auth.CodeCursorBad, list(url.Values{"cursor": {"%%%"}}).code())
assert.Equal(t, http.StatusBadRequest, list(url.Values{"limit": {"lots"}}).Code)
}
func TestAudit(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
admin := e.signIn("root", "root-password-1")
for _, n := range []string{"u1", "u2", "u3"} {
require.Equal(t, http.StatusCreated, e.createUser(admin, n+"-user", "user").Code)
}
r := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2", nil, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
items := r.body["items"].([]any)
require.Len(t, items, 2)
newest := items[0].(map[string]any)
assert.Equal(t, "user.create", newest["action"])
assert.Equal(t, "u3-user", newest["payload"].(map[string]any)["username"])
assert.Equal(t, "root", newest["actor"].(map[string]any)["username"])
assert.Equal(t, "user", newest["target_type"])
next := r.body["next_cursor"].(string)
r2 := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2&cursor="+next, nil, bearer(admin))
rest := r2.body["items"].([]any)
require.Len(t, rest, 1)
assert.Equal(t, "u1-user", rest[0].(map[string]any)["payload"].(map[string]any)["username"])
assert.Nil(t, r2.body["next_cursor"])
mod := e.signIn("mod", "moderator-pass-1")
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/audit", nil, bearer(mod)).code())
}
+569
View File
@@ -0,0 +1,569 @@
package auth_test
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/auth/authtest"
"pipu/api/internal/server"
"pipu/api/internal/system"
)
// Быстрые параметры argon2: тестам важна логика, а не стойкость.
var testArgon = auth.Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
type env struct {
t *testing.T
store *authtest.MemStore
clock *authtest.Clock
limiter *authtest.Limiter
hasher *auth.Hasher
svc *auth.Service
h http.Handler
}
func newEnv(t *testing.T, mutate ...func(*auth.Config)) *env {
t.Helper()
e := &env{
t: t,
store: authtest.NewMemStore(),
clock: &authtest.Clock{T: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)},
limiter: &authtest.Limiter{},
hasher: auth.NewHasher(testArgon),
}
key, err := auth.GenerateKey()
require.NoError(t, err)
cfg := auth.DefaultConfig()
cfg.BootstrapPassword = ""
for _, m := range mutate {
m(&cfg)
}
e.svc, err = auth.NewService(e.store, e.hasher, auth.NewTokenIssuer(key, nil, 15*time.Minute, e.clock.Now),
e.limiter, e.clock, auth.UUIDv7{}, nil, cfg)
require.NoError(t, err)
sys := system.NewHandler(system.Info{Version: "test", BootstrapRequired: e.svc.BootstrapRequired}, nil, nil)
e.h = server.NewRouter(server.Deps{System: sys, Auth: auth.NewHandler(e.svc, true), AuthService: e.svc})
return e
}
// addUser создаёт пользователя напрямую в хранилище.
func (e *env) addUser(username, password string, role auth.Role, mutate ...func(*auth.User)) auth.User {
e.t.Helper()
hash, err := e.hasher.Hash(password)
require.NoError(e.t, err)
u := auth.User{ID: uuid.New(), Username: username, PasswordHash: hash, Role: role, Locale: "ru", CreatedAt: e.clock.Now()}
for _, m := range mutate {
m(&u)
}
require.NoError(e.t, e.store.CreateUser(context.Background(), u, nil))
return u
}
type resp struct {
*httptest.ResponseRecorder
body map[string]any
}
func (r resp) code() string { s, _ := r.body["code"].(string); return s }
func (r resp) cookie(name string) *http.Cookie {
for _, c := range r.Result().Cookies() {
if c.Name == name {
return c
}
}
return nil
}
type reqOpt func(*http.Request)
func bearer(token string) reqOpt {
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }
}
func withCookie(c *http.Cookie) reqOpt { return func(r *http.Request) { r.AddCookie(c) } }
func (e *env) do(method, path string, body any, opts ...reqOpt) resp {
e.t.Helper()
var rd *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
require.NoError(e.t, err)
rd = bytes.NewReader(raw)
} else {
rd = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rd)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.RemoteAddr = "192.0.2.10:5555"
for _, o := range opts {
o(req)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
out := resp{ResponseRecorder: rec}
if rec.Body.Len() > 0 {
require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &out.body), rec.Body.String())
}
return out
}
func (e *env) login(username, password, clientType string) resp {
e.t.Helper()
return e.do(http.MethodPost, "/api/v1/auth/login", map[string]any{
"username": username, "password": password, "client_type": clientType, "device_name": "Test device",
})
}
func (e *env) mustLogin(username, password, clientType string) resp {
e.t.Helper()
r := e.login(username, password, clientType)
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
return r
}
func access(r resp) string { return r.body["access_token"].(string) }
func user(r resp) map[string]any { return r.body["user"].(map[string]any) }
func TestBootstrapFlow(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
require.NoError(t, e.svc.Bootstrap(ctx))
require.NoError(t, e.svc.Bootstrap(ctx), "second bootstrap is a no-op")
require.Len(t, e.store.Users, 1)
info := e.do(http.MethodGet, "/api/v1/server-info", nil)
assert.Equal(t, true, info.body["bootstrap_required"])
// Сгенерированный пароль неизвестен тесту — подменяем хэш известным паролем.
var admin auth.User
for _, u := range e.store.Users {
admin = u
}
assert.Equal(t, "admin", admin.Username)
assert.Equal(t, auth.RoleAdmin, admin.Role)
assert.True(t, admin.MustChangeCredentials)
admin.PasswordHash, _ = e.hasher.Hash("initial-secret-pass")
e.store.Users[admin.ID] = admin
r := e.mustLogin("admin", "initial-secret-pass", "web")
assert.Equal(t, true, user(r)["must_change_credentials"])
tok := access(r)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
blocked := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
assert.Equal(t, http.StatusForbidden, blocked.Code)
assert.Equal(t, auth.CodeCredentialsChangeRequired, blocked.code())
assert.Equal(t, auth.CodeCredentialsChangeRequired,
e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "whatever-long-pass"}, bearer(tok)).code())
short := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "owner", "new_password": "short"}, bearer(tok))
assert.Equal(t, http.StatusUnprocessableEntity, short.Code)
assert.Equal(t, auth.CodePasswordTooShort, short.code())
assert.EqualValues(t, 12, short.body["params"].(map[string]any)["min"])
same := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "owner", "new_password": "initial-secret-pass"}, bearer(tok))
assert.Equal(t, auth.CodePasswordUnchanged, same.code())
badName := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "x", "new_password": "a-much-better-secret"}, bearer(tok))
assert.Equal(t, auth.CodeUsernameInvalid, badName.code())
ok := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": " Owner ", "new_password": "a-much-better-secret"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.Equal(t, "owner", user(ok)["username"])
assert.Equal(t, false, user(ok)["must_change_credentials"])
assert.NotNil(t, ok.cookie(auth.RefreshCookie))
old := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
assert.Equal(t, http.StatusUnauthorized, old.Code)
assert.Equal(t, auth.CodeTokenRevoked, old.code())
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/server-info", nil).body["bootstrap_required"])
again := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "owner", "new_password": "another-better-secret"}, bearer(access(ok)))
assert.Equal(t, http.StatusConflict, again.Code)
actions := []string{}
for _, a := range e.store.Audit {
actions = append(actions, a.Action)
}
assert.Equal(t, []string{"user.bootstrap_create", "user.bootstrap_credentials"}, actions)
// После завершения первого запуска удаление всех админов не создаёт нового автоматически.
delete(e.store.Users, admin.ID)
require.NoError(t, e.svc.Bootstrap(ctx))
assert.Empty(t, e.store.Users)
}
func TestBootstrapWithConfiguredPassword(t *testing.T) {
e := newEnv(t, func(c *auth.Config) {
c.BootstrapUsername = "Root"
c.BootstrapPassword = "configured-admin-pass"
})
require.NoError(t, e.svc.Bootstrap(context.Background()))
r := e.mustLogin("root", "configured-admin-pass", "android")
assert.Equal(t, "root", user(r)["username"])
}
func TestBootstrapSkippedWhenAdminExists(t *testing.T) {
e := newEnv(t)
e.addUser("boss", "boss-password-123", auth.RoleAdmin)
require.NoError(t, e.svc.Bootstrap(context.Background()))
assert.Len(t, e.store.Users, 1)
}
func TestLoginErrorsAreIndistinguishable(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
wrong := e.login("alice", "nope-nope-nope", "web")
unknown := e.login("bob", "nope-nope-nope", "web")
for _, r := range []resp{wrong, unknown} {
assert.Equal(t, http.StatusUnauthorized, r.Code)
assert.Equal(t, auth.CodeInvalidCredentials, r.code())
}
assert.Equal(t, http.StatusOK, e.login("ALICE", "alice-password", "web").Code, "username is case-insensitive")
bad := e.do(http.MethodPost, "/api/v1/auth/login",
map[string]any{"username": "alice", "password": "alice-password", "client_type": "ios"})
assert.Equal(t, http.StatusUnprocessableEntity, bad.Code)
}
func TestLoginLockout(t *testing.T) {
e := newEnv(t)
u := e.addUser("alice", "alice-password", auth.RoleUser)
for range 5 {
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
e.limiter.Reset()
}
locked := e.store.Users[u.ID]
require.NotNil(t, locked.LockedUntil)
assert.Equal(t, e.clock.Now().Add(5*time.Minute), *locked.LockedUntil)
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code(),
"correct password is rejected while locked")
assert.Equal(t, 5, e.store.Users[u.ID].FailedLoginCount, "attempts while locked are not counted")
e.clock.Advance(5 * time.Minute)
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
assert.Equal(t, e.clock.Now().Add(10*time.Minute), *e.store.Users[u.ID].LockedUntil, "lock doubles")
e.clock.Advance(10 * time.Minute)
e.limiter.Reset()
e.mustLogin("alice", "alice-password", "web")
assert.Zero(t, e.store.Users[u.ID].FailedLoginCount)
assert.Nil(t, e.store.Users[u.ID].LockedUntil)
}
func TestLockDurationIsCapped(t *testing.T) {
e := newEnv(t)
u := e.addUser("alice", "alice-password", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 40 })
e.login("alice", "wrong-password", "web")
assert.Equal(t, e.clock.Now().Add(time.Hour), *e.store.Users[u.ID].LockedUntil)
}
func TestLoginRateLimit(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
for range 5 {
e.login("alice", "wrong-password", "web")
}
r := e.login("alice", "alice-password", "web")
assert.Equal(t, http.StatusTooManyRequests, r.Code)
assert.Equal(t, auth.CodeTooManyAttempts, r.code())
assert.Equal(t, "60", r.Header().Get("Retry-After"))
}
func TestBlockedUserCannotLoginOrUseTokens(t *testing.T) {
e := newEnv(t)
u := e.addUser("alice", "alice-password", auth.RoleUser)
tok := access(e.mustLogin("alice", "alice-password", "web"))
blocked := e.store.Users[u.ID]
blocked.IsBlocked = true
e.store.Users[u.ID] = blocked
assert.Equal(t, auth.CodeUserBlocked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
r := e.login("alice", "alice-password", "web")
assert.Equal(t, http.StatusForbidden, r.Code)
assert.Equal(t, auth.CodeUserBlocked, r.code())
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code(),
"blocked status is not revealed without the password")
}
func TestWebRefreshRotation(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
r := e.mustLogin("alice", "alice-password", "web")
assert.Nil(t, r.body["refresh_token"], "web never gets refresh token in the body")
c1 := r.cookie(auth.RefreshCookie)
require.NotNil(t, c1)
assert.True(t, c1.HttpOnly)
assert.True(t, c1.Secure)
assert.Equal(t, http.SameSiteStrictMode, c1.SameSite)
assert.Equal(t, "/api/v1/auth", c1.Path)
e.clock.Advance(time.Minute)
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
c2 := r2.cookie(auth.RefreshCookie)
require.NotNil(t, c2)
assert.NotEqual(t, c1.Value, c2.Value)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(r2))).Code)
// Вторая вкладка прислала старую cookie сразу после ротации — это гонка, а не кража.
race := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
assert.Equal(t, auth.CodeRefreshRace, race.code())
assert.Nil(t, race.cookie(auth.RefreshCookie), "race keeps the fresh cookie")
// Позже старый токен — признак кражи: отзывается вся семья, в том числе новый токен.
e.clock.Advance(time.Minute)
reuse := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
assert.Equal(t, http.StatusUnauthorized, reuse.Code)
assert.Equal(t, auth.CodeRefreshReused, reuse.code())
cleared := reuse.cookie(auth.RefreshCookie)
require.NotNil(t, cleared)
assert.Equal(t, -1, cleared.MaxAge)
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c2)).code())
}
func TestAndroidRefreshInBody(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
r := e.mustLogin("alice", "alice-password", "android")
assert.Nil(t, r.cookie(auth.RefreshCookie))
rt, ok := r.body["refresh_token"].(string)
require.True(t, ok)
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
assert.NotEqual(t, rt, r2.body["refresh_token"])
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": "garbage"}).code())
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil).code())
}
func TestRefreshExpiry(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
rt := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
// Скользящий срок: каждые 29 дней ротация продлевает, но не дальше 180 дней от входа.
for range 6 {
e.clock.Advance(29 * 24 * time.Hour)
r := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
rt = r.body["refresh_token"].(string)
}
e.clock.Advance(7 * 24 * time.Hour) // 181 день от входа
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}).code())
rt2 := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
e.clock.Advance(31 * 24 * time.Hour)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt2}).code())
}
func TestAccessTokenErrors(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
tok := access(e.mustLogin("alice", "alice-password", "web"))
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil).code())
assert.Equal(t, auth.CodeTokenInvalid, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok+"x")).code())
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil,
func(r *http.Request) { r.Header.Set("Authorization", "Basic abc") }).code())
e.clock.Advance(16 * time.Minute)
expired := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
assert.Equal(t, http.StatusUnauthorized, expired.Code)
assert.Equal(t, auth.CodeTokenExpired, expired.code())
}
func TestTokenSignedByPreviousKey(t *testing.T) {
clock := &authtest.Clock{T: time.Now()}
oldKey, _ := auth.GenerateKey()
newKey, _ := auth.GenerateKey()
old := auth.NewTokenIssuer(oldKey, nil, time.Minute, clock.Now)
rotated := auth.NewTokenIssuer(newKey, []ed25519.PrivateKey{oldKey}, time.Minute, clock.Now)
other := auth.NewTokenIssuer(newKey, nil, time.Minute, clock.Now)
tok, err := old.Issue(uuid.New(), auth.RoleUser, uuid.New(), 3)
require.NoError(t, err)
c, err := rotated.Parse(tok)
require.NoError(t, err)
assert.Equal(t, 3, c.TokenVersion)
_, err = other.Parse(tok)
assert.ErrorIs(t, err, auth.ErrTokenInvalid)
}
func TestChangePassword(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
web := e.mustLogin("alice", "alice-password", "web")
phone := e.mustLogin("alice", "alice-password", "android")
tok := access(web)
missing := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "brand-new-pass"}, bearer(tok))
assert.Equal(t, auth.CodeCurrentPasswordRequired, missing.code())
wrong := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "nope", "new_password": "brand-new-pass"}, bearer(tok))
assert.Equal(t, auth.CodeCurrentPasswordInvalid, wrong.code())
common := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "alice-password", "new_password": "Password123"}, bearer(tok))
assert.Equal(t, auth.CodePasswordTooCommon, common.code())
ok := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "alice-password", "new_password": "brand-new-pass"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.NotNil(t, ok.cookie(auth.RefreshCookie), "current web device keeps a session")
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
map[string]any{"refresh_token": phone.body["refresh_token"]}).code(), "other devices are signed out")
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(ok))).Code)
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code())
e.mustLogin("alice", "brand-new-pass", "web")
}
func TestTemporaryPasswordMustBeChanged(t *testing.T) {
e := newEnv(t)
e.addUser("bob", "temp-pass-123", auth.RoleUser, func(u *auth.User) { u.MustChangePassword = true })
r := e.mustLogin("bob", "temp-pass-123", "android")
tok := access(r)
assert.Equal(t, true, user(r)["must_change_password"])
denied := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
assert.Equal(t, http.StatusForbidden, denied.Code)
assert.Equal(t, auth.CodePasswordChangeRequired, denied.code())
ok := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "bobs-own-pass"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.Equal(t, false, user(ok)["must_change_password"])
assert.NotEmpty(t, ok.body["refresh_token"], "android device gets refresh token in the body")
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
}
func TestPrivilegedPasswordLength(t *testing.T) {
e := newEnv(t)
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
tok := access(e.mustLogin("mod", "moderator-pass-1", "web"))
r := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "moderator-pass-1", "new_password": "eleven-char"}, bearer(tok))
assert.Equal(t, auth.CodePasswordTooShort, r.code())
assert.EqualValues(t, 12, r.body["params"].(map[string]any)["min"])
assert.Equal(t, []any{map[string]any{"field": "new_password", "code": auth.CodePasswordTooShort}}, r.body["errors"])
}
func TestUpdateMe(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
tok := access(e.mustLogin("alice", "alice-password", "web"))
r := e.do(http.MethodPatch, "/api/v1/me", map[string]any{
"display_name": " Alice ", "locale": "en", "ui_prefs": map[string]any{"theme": "light", "crossfade_s": 3},
}, bearer(tok))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, "Alice", r.body["display_name"])
assert.Equal(t, "en", r.body["locale"])
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"wave_variant": "liquid"}}, bearer(tok))
assert.Equal(t, map[string]any{"theme": "light", "crossfade_s": float64(3), "wave_variant": "liquid"}, r.body["ui_prefs"],
"ui_prefs are merged")
assert.Equal(t, "Alice", r.body["display_name"], "absent fields are unchanged")
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": ""}, bearer(tok))
assert.Nil(t, r.body["display_name"])
assert.Equal(t, auth.CodeLocaleUnsupported,
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"locale": "de"}, bearer(tok)).code())
assert.Equal(t, auth.CodeUIPrefsInvalid,
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"crossfade_s": 9}}, bearer(tok)).code())
assert.Equal(t, auth.CodeDisplayNameTooLong,
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": strings.Repeat("я", 65)}, bearer(tok)).code())
}
func TestSessions(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
web := e.mustLogin("alice", "alice-password", "web")
e.clock.Advance(time.Second)
phone := e.mustLogin("alice", "alice-password", "android")
tok := access(web)
list := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
items := list.body["items"].([]any)
require.Len(t, items, 2)
first, second := items[0].(map[string]any), items[1].(map[string]any)
assert.Equal(t, "android", first["client_type"], "newest first")
assert.Equal(t, false, first["current"])
assert.Equal(t, true, second["current"])
assert.Equal(t, "Test device", second["device_name"])
assert.Equal(t, auth.CodeSessionNotFound,
e.do(http.MethodDelete, "/api/v1/me/sessions/"+uuid.NewString(), nil, bearer(tok)).code())
del := e.do(http.MethodDelete, "/api/v1/me/sessions/"+first["id"].(string), nil, bearer(tok))
assert.Equal(t, http.StatusNoContent, del.Code)
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
assert.Len(t, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)).body["items"], 1)
// Чужую сессию завершить нельзя.
e.addUser("bob", "bob-password", auth.RoleUser)
bob := access(e.mustLogin("bob", "bob-password", "web"))
assert.Equal(t, auth.CodeSessionNotFound,
e.do(http.MethodDelete, "/api/v1/me/sessions/"+second["id"].(string), nil, bearer(bob)).code())
}
func TestLogout(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
web := e.mustLogin("alice", "alice-password", "web")
phone := e.mustLogin("alice", "alice-password", "android")
out := e.do(http.MethodPost, "/api/v1/auth/logout", nil, bearer(access(web)))
assert.Equal(t, http.StatusNoContent, out.Code)
require.NotNil(t, out.cookie(auth.RefreshCookie))
assert.Equal(t, -1, out.cookie(auth.RefreshCookie).MaxAge)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(web.cookie(auth.RefreshCookie))).code())
rt := phone.body["refresh_token"]
ok := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
require.Equal(t, http.StatusOK, ok.Code, "logout touches only the current session")
assert.Equal(t, http.StatusNoContent, e.do(http.MethodPost, "/api/v1/auth/logout-all", nil, bearer(access(ok))).Code)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": ok.body["refresh_token"]}).code())
}
func TestPublicRoutes(t *testing.T) {
e := newEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
}
@@ -0,0 +1,433 @@
// Package authtest — тестовые реализации зависимостей auth: хранилище в памяти, часы, лимитер.
package authtest
import (
"bytes"
"context"
"encoding/json"
"maps"
"slices"
"strings"
"sync"
"time"
"github.com/google/uuid"
"pipu/api/internal/auth"
)
// MemStore — auth.Store в памяти. WithTx откатывает изменения, если fn вернула ошибку.
type MemStore struct {
mu *sync.Mutex
inTx bool
Users map[uuid.UUID]auth.User
Tokens map[uuid.UUID]auth.RefreshToken
Audit []auth.AuditEntry
AuditIDs []uuid.UUID
Settings map[string]any
}
// NewMemStore создаёт пустое хранилище.
func NewMemStore() *MemStore {
return &MemStore{
mu: &sync.Mutex{},
Users: map[uuid.UUID]auth.User{},
Tokens: map[uuid.UUID]auth.RefreshToken{},
Settings: map[string]any{},
}
}
func (s *MemStore) lock() func() {
if s.inTx {
return func() {}
}
s.mu.Lock()
return s.mu.Unlock
}
// WithTx реализует auth.Store.
func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
unlock := s.lock()
defer unlock()
users, tokens, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), maps.Clone(s.Settings)
audit, auditIDs := slices.Clone(s.Audit), slices.Clone(s.AuditIDs)
tx := &MemStore{
mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, AuditIDs: s.AuditIDs, Settings: s.Settings,
}
if err := fn(tx); err != nil {
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = users, tokens, audit, auditIDs, settings
return err
}
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.AuditIDs, tx.Settings
return nil
}
// CountAdmins реализует auth.Store.
func (s *MemStore) CountAdmins(context.Context) (int, error) {
defer s.lock()()
n := 0
for _, u := range s.Users {
if u.Role == auth.RoleAdmin && !u.IsBlocked {
n++
}
}
return n, nil
}
func (s *MemStore) usernameTaken(username string, except uuid.UUID) bool {
for _, u := range s.Users {
if u.ID != except && strings.EqualFold(u.Username, username) {
return true
}
}
return false
}
// CreateUser реализует auth.Store.
func (s *MemStore) CreateUser(_ context.Context, u auth.User, _ *uuid.UUID) error {
defer s.lock()()
if s.usernameTaken(u.Username, uuid.Nil) {
return auth.ErrUsernameTaken
}
s.Users[u.ID] = cloneUser(u)
return nil
}
// cloneUser копирует ui_prefs через JSON, чтобы тест не делил указатели с хранилищем.
func cloneUser(u auth.User) auth.User {
raw, _ := json.Marshal(u.UIPrefs)
u.UIPrefs = auth.UIPrefs{}
_ = json.Unmarshal(raw, &u.UIPrefs)
return u
}
// GetUserByID реализует auth.Store.
func (s *MemStore) GetUserByID(_ context.Context, id uuid.UUID) (auth.User, error) {
defer s.lock()()
u, ok := s.Users[id]
if !ok {
return auth.User{}, auth.ErrNotFound
}
return cloneUser(u), nil
}
// GetUserByUsername реализует auth.Store.
func (s *MemStore) GetUserByUsername(_ context.Context, username string) (auth.User, error) {
defer s.lock()()
for _, u := range s.Users {
if strings.EqualFold(u.Username, username) {
return cloneUser(u), nil
}
}
return auth.User{}, auth.ErrNotFound
}
func (s *MemStore) update(id uuid.UUID, fn func(*auth.User)) error {
defer s.lock()()
u, ok := s.Users[id]
if !ok {
return auth.ErrNotFound
}
fn(&u)
s.Users[id] = u
return nil
}
// RecordLoginFailure реализует auth.Store.
func (s *MemStore) RecordLoginFailure(_ context.Context, id uuid.UUID, failed int, lockedUntil *time.Time) error {
return s.update(id, func(u *auth.User) { u.FailedLoginCount, u.LockedUntil = failed, lockedUntil })
}
// RecordLoginSuccess реализует auth.Store.
func (s *MemStore) RecordLoginSuccess(_ context.Context, id uuid.UUID, at time.Time, newHash *string) error {
return s.update(id, func(u *auth.User) {
u.FailedLoginCount, u.LockedUntil, u.LastLoginAt = 0, nil, &at
if newHash != nil {
u.PasswordHash = *newHash
}
})
}
// SetPassword реализует auth.Store.
func (s *MemStore) SetPassword(_ context.Context, id uuid.UUID, hash string) error {
return s.update(id, func(u *auth.User) {
u.PasswordHash, u.MustChangePassword = hash, false
u.TokenVersion++
})
}
// SetCredentials реализует auth.Store.
func (s *MemStore) SetCredentials(_ context.Context, id uuid.UUID, username, hash string) error {
unlock := s.lock()
taken := s.usernameTaken(username, id)
unlock()
if taken {
return auth.ErrUsernameTaken
}
return s.update(id, func(u *auth.User) {
u.Username, u.PasswordHash = username, hash
u.MustChangeCredentials, u.MustChangePassword = false, false
u.TokenVersion++
})
}
// UpdateProfile реализует auth.Store.
func (s *MemStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd auth.ProfileUpdate) (auth.User, error) {
err := s.update(id, func(u *auth.User) {
if upd.DisplayName != nil {
u.DisplayName = *upd.DisplayName
}
if upd.Locale != nil {
u.Locale = *upd.Locale
}
if upd.UIPrefs != nil {
u.UIPrefs = *upd.UIPrefs
}
})
if err != nil {
return auth.User{}, err
}
return s.GetUserByID(ctx, id)
}
// InsertRefreshToken реализует auth.Store.
func (s *MemStore) InsertRefreshToken(_ context.Context, t auth.RefreshToken) error {
defer s.lock()()
s.Tokens[t.ID] = t
return nil
}
// GetRefreshTokenForUpdate реализует auth.Store.
func (s *MemStore) GetRefreshTokenForUpdate(_ context.Context, hash []byte) (auth.RefreshToken, error) {
defer s.lock()()
for _, t := range s.Tokens {
if bytes.Equal(t.TokenHash, hash) {
return t, nil
}
}
return auth.RefreshToken{}, auth.ErrNotFound
}
// MarkRefreshTokenUsed реализует auth.Store.
func (s *MemStore) MarkRefreshTokenUsed(_ context.Context, id uuid.UUID, at time.Time) error {
defer s.lock()()
t := s.Tokens[id]
t.UsedAt = &at
s.Tokens[id] = t
return nil
}
func (s *MemStore) revoke(at time.Time, match func(auth.RefreshToken) bool) {
defer s.lock()()
for id, t := range s.Tokens {
if t.RevokedAt == nil && match(t) {
t.RevokedAt = &at
s.Tokens[id] = t
}
}
}
// RevokeFamily реализует auth.Store.
func (s *MemStore) RevokeFamily(_ context.Context, familyID uuid.UUID, at time.Time) error {
s.revoke(at, func(t auth.RefreshToken) bool { return t.FamilyID == familyID })
return nil
}
// RevokeUserSessions реализует auth.Store.
func (s *MemStore) RevokeUserSessions(_ context.Context, userID uuid.UUID, at time.Time) error {
s.revoke(at, func(t auth.RefreshToken) bool { return t.UserID == userID })
return nil
}
// ListSessions реализует auth.Store.
func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Time) ([]auth.Session, error) {
defer s.lock()()
var out []auth.Session
for _, t := range s.Tokens {
if t.UserID == userID && t.UsedAt == nil && t.RevokedAt == nil && t.ExpiresAt.After(now) {
out = append(out, auth.Session{
ID: t.FamilyID, DeviceName: t.DeviceName, ClientType: t.ClientType,
CreatedAt: t.FamilyStartedAt, LastUsedAt: t.CreatedAt,
})
}
}
slices.SortFunc(out, func(a, b auth.Session) int { return b.LastUsedAt.Compare(a.LastUsedAt) })
return out, nil
}
// ListUsers реализует auth.Store.
func (s *MemStore) ListUsers(_ context.Context, f auth.UserFilter) ([]auth.User, error) {
defer s.lock()()
q := strings.ToLower(f.Query)
var out []auth.User
for _, u := range s.Users {
name := ""
if u.DisplayName != nil {
name = strings.ToLower(*u.DisplayName)
}
if q != "" && !strings.Contains(strings.ToLower(u.Username), q) && !strings.Contains(name, q) {
continue
}
if f.Role != nil && u.Role != *f.Role {
continue
}
if f.After != "" && strings.ToLower(u.Username) <= strings.ToLower(f.After) {
continue
}
out = append(out, cloneUser(u))
}
slices.SortFunc(out, func(a, b auth.User) int {
return strings.Compare(strings.ToLower(a.Username), strings.ToLower(b.Username))
})
if len(out) > f.Limit {
out = out[:f.Limit]
}
return out, nil
}
// LockActiveAdmins реализует auth.Store.
func (s *MemStore) LockActiveAdmins(ctx context.Context) (int, error) { return s.CountAdmins(ctx) }
// UpdateUser реализует auth.Store.
func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUpdate) (auth.User, error) {
err := s.update(id, func(u *auth.User) {
if upd.DisplayName != nil {
u.DisplayName = *upd.DisplayName
}
if upd.Role != nil {
u.Role = *upd.Role
}
if upd.IsBlocked != nil {
u.IsBlocked = *upd.IsBlocked
}
if upd.RevokeTokens {
u.TokenVersion++
}
})
if err != nil {
return auth.User{}, err
}
return s.GetUserByID(ctx, id)
}
// ResetPassword реализует auth.Store.
func (s *MemStore) ResetPassword(_ context.Context, id uuid.UUID, hash string) error {
return s.update(id, func(u *auth.User) {
u.PasswordHash, u.MustChangePassword = hash, true
u.FailedLoginCount, u.LockedUntil = 0, nil
u.TokenVersion++
})
}
// DeleteUser реализует auth.Store: токены удаляются, ссылки в журнале обнуляются, как в PostgreSQL.
func (s *MemStore) DeleteUser(_ context.Context, id uuid.UUID) error {
defer s.lock()()
delete(s.Users, id)
for tid, t := range s.Tokens {
if t.UserID == id {
delete(s.Tokens, tid)
}
}
for i := range s.Audit {
if s.Audit[i].ActorID != nil && *s.Audit[i].ActorID == id {
s.Audit[i].ActorID = nil
}
}
return nil
}
// InsertAudit реализует auth.Store. ID сохраняется в AuditIDs параллельно Audit.
func (s *MemStore) InsertAudit(_ context.Context, id uuid.UUID, e auth.AuditEntry) error {
defer s.lock()()
s.Audit = append(s.Audit, e)
s.AuditIDs = append(s.AuditIDs, id)
return nil
}
// ListAudit реализует auth.Store.
func (s *MemStore) ListAudit(_ context.Context, before uuid.UUID, limit int) ([]auth.AuditRecord, error) {
defer s.lock()()
var out []auth.AuditRecord
for i := len(s.Audit) - 1; i >= 0 && len(out) < limit; i-- {
id, e := s.AuditIDs[i], s.Audit[i]
if before != uuid.Nil && id.String() >= before.String() {
continue
}
r := auth.AuditRecord{ID: id, ActorID: e.ActorID, Action: e.Action, TargetID: e.TargetID, Payload: e.Payload}
if e.TargetType != "" {
tt := e.TargetType
r.TargetType = &tt
}
if e.ActorID != nil {
if u, ok := s.Users[*e.ActorID]; ok {
name := u.Username
r.ActorUsername = &name
}
}
out = append(out, r)
}
return out, nil
}
// GetSettingBool реализует auth.Store.
func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) {
defer s.lock()()
v, _ := s.Settings[key].(bool)
return v, nil
}
// SetSetting реализует auth.Store.
func (s *MemStore) SetSetting(_ context.Context, key string, value any) error {
defer s.lock()()
s.Settings[key] = value
return nil
}
var _ auth.Store = (*MemStore)(nil)
// Clock — управляемые часы.
type Clock struct {
mu sync.Mutex
T time.Time
}
// Now реализует auth.Clock.
func (c *Clock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.T
}
// Advance сдвигает часы вперёд.
func (c *Clock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.T = c.T.Add(d)
}
// Limiter — счётчик попыток в памяти без учёта окна (окно сбрасывает Reset).
type Limiter struct {
mu sync.Mutex
counts map[string]int
}
// Allow реализует auth.Limiter.
func (l *Limiter) Allow(_ context.Context, key string, limit int, window time.Duration) (bool, int, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.counts == nil {
l.counts = map[string]int{}
}
l.counts[key]++
if l.counts[key] > limit {
return false, int(window / time.Second), nil
}
return true, 0, nil
}
// Reset обнуляет счётчики (как истечение окна).
func (l *Limiter) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.counts = nil
}
@@ -0,0 +1,152 @@
# Частые пароли длиной от 8 символов (короче отсекает правило длины). Сравнение без учёта регистра.
# Неполный список: расширяется добавлением строк, по одной на строку.
12345678
123456789
1234567890
12345678910
123123123
1234512345
11111111
111111111
1111111111
00000000
0000000000
22222222
55555555
66666666
77777777
88888888
99999999
87654321
987654321
0987654321
11223344
12341234
12121212
13131313
147258369
123456789a
123456789q
1q2w3e4r
1q2w3e4r5t
1q2w3e4r5t6y
1qaz2wsx
1qaz2wsx3edc
zaq12wsx
qwertyui
qwertyuiop
qwerty123
qwerty1234
qwerty12345
qwerty123456
qwertyqwerty
asdfghjkl
asdfasdf
zxcvbnm123
zxcvbnmm
password
password1
password12
password123
password1234
password!
passw0rd
p@ssw0rd
p@ssword
pa55word
iloveyou
iloveyou1
iloveyou2
sunshine
princess
football
baseball
basketball
superman
batman123
starwars
whatever
trustno1
letmein1
welcome1
welcome123
changeme
changeme123
administrator
admin123
admin1234
admin12345
adminadmin
administrator1
rootroot
root1234
master123
dragon123
monkey123
shadow123
michael1
jennifer
computer
internet
abcd1234
abc12345
abc123456
abcdefgh
aaaaaaaa
aa123456
a1234567
a12345678
q1w2e3r4
q1w2e3r4t5
asdf1234
1234qwer
qwer1234
q1234567
myspace1
charlie1
freedom1
mustang1
liverpool
chelsea1
arsenal1
fuckyou1
secret123
samsung1
pokemon1
minecraft
loveyou1
hello123
helloworld
yankees1
jordan23
access14
cheese12
michelle
nicole12
jessica1
ashley12
killer12
soccer12
hunter12
ranger12
buster12
thomas12
tigger12
summer12
spring12
winter12
autumn12
qazwsxedc
1234abcd
pipu1234
pipumusic
music123
music1234
musiclover
parol123
parol1234
privet123
qwertyuiop123
йцукенгш
пароль123
пароль1234
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"errors"
"net/http"
"time"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// RefreshCookie — имя cookie с refresh-токеном веб-клиента.
const RefreshCookie = "pipu_rt"
const refreshCookiePath = "/api/v1/auth"
// Handler реализует auth- и me-части gen.StrictServerInterface.
type Handler struct {
svc *Service
cookieSecure bool
}
// NewHandler создаёт обработчик. cookieSecure = false допустим только для разработки по http.
func NewHandler(svc *Service, cookieSecure bool) *Handler {
return &Handler{svc: svc, cookieSecure: cookieSecure}
}
func principal(ctx context.Context) (Principal, error) {
p, ok := PrincipalFrom(ctx)
if !ok {
return Principal{}, apperr.Unauthorized(CodeUnauthorized, "not authenticated")
}
return p, nil
}
// Login реализует gen.StrictServerInterface.
func (h *Handler) Login(ctx context.Context, req gen.LoginRequestObject) (gen.LoginResponseObject, error) {
in := LoginInput{
Username: req.Body.Username,
Password: req.Body.Password,
ClientType: ClientType(req.Body.ClientType),
IP: clientIP(ctx),
}
if req.Body.DeviceName != nil {
in.DeviceName = *req.Body.DeviceName
}
t, err := h.svc.Login(ctx, in)
if err != nil {
return nil, err
}
return gen.Login200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// RefreshTokens реализует gen.StrictServerInterface.
func (h *Handler) RefreshTokens(ctx context.Context, req gen.RefreshTokensRequestObject) (gen.RefreshTokensResponseObject, error) {
var token string
if req.Body != nil && req.Body.RefreshToken != nil {
token = *req.Body.RefreshToken
} else if req.Params.PipuRt != nil {
token = *req.Params.PipuRt
}
t, err := h.svc.Refresh(ctx, token)
if err != nil {
var ae *apperr.Error
// Токен больше не годится — убираем cookie, чтобы браузер не присылал его снова.
// При гонке двух вкладок cookie уже новая, её не трогаем.
if req.Params.PipuRt != nil && errors.As(err, &ae) && ae.Code != CodeRefreshRace {
h.clearCookie(ctx)
}
return nil, err
}
return gen.RefreshTokens200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// Logout реализует gen.StrictServerInterface.
func (h *Handler) Logout(ctx context.Context, _ gen.LogoutRequestObject) (gen.LogoutResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.Logout(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.Logout204Response{}, nil
}
// LogoutAll реализует gen.StrictServerInterface.
func (h *Handler) LogoutAll(ctx context.Context, _ gen.LogoutAllRequestObject) (gen.LogoutAllResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.LogoutAll(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.LogoutAll204Response{}, nil
}
// GetMe реализует gen.StrictServerInterface.
func (h *Handler) GetMe(ctx context.Context, _ gen.GetMeRequestObject) (gen.GetMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
u, err := h.svc.Me(ctx, p)
if err != nil {
return nil, err
}
return gen.GetMe200JSONResponse(MeDTO(u)), nil
}
// UpdateMe реализует gen.StrictServerInterface.
func (h *Handler) UpdateMe(ctx context.Context, req gen.UpdateMeRequestObject) (gen.UpdateMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
patch := MePatch{DisplayName: req.Body.DisplayName, Locale: req.Body.Locale}
if req.Body.UiPrefs != nil {
prefs := uiPrefsFromDTO(*req.Body.UiPrefs)
patch.UIPrefs = &prefs
}
u, err := h.svc.UpdateMe(ctx, p, patch)
if err != nil {
return nil, err
}
return gen.UpdateMe200JSONResponse(MeDTO(u)), nil
}
// ChangePassword реализует gen.StrictServerInterface.
func (h *Handler) ChangePassword(ctx context.Context, req gen.ChangePasswordRequestObject) (gen.ChangePasswordResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.ChangePassword(ctx, p, req.Body.CurrentPassword, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.ChangePassword200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// SetBootstrapCredentials реализует gen.StrictServerInterface.
func (h *Handler) SetBootstrapCredentials(ctx context.Context, req gen.SetBootstrapCredentialsRequestObject) (gen.SetBootstrapCredentialsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.SetBootstrapCredentials(ctx, p, req.Body.NewUsername, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.SetBootstrapCredentials200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// ListMySessions реализует gen.StrictServerInterface.
func (h *Handler) ListMySessions(ctx context.Context, _ gen.ListMySessionsRequestObject) (gen.ListMySessionsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
sessions, err := h.svc.ListSessions(ctx, p)
if err != nil {
return nil, err
}
items := make([]gen.Session, 0, len(sessions))
for _, s := range sessions {
items = append(items, gen.Session{
Id: s.ID,
DeviceName: s.DeviceName,
ClientType: gen.ClientType(s.ClientType),
CreatedAt: s.CreatedAt,
LastUsedAt: s.LastUsedAt,
Current: s.ID == p.SessionID,
})
}
return gen.ListMySessions200JSONResponse{Items: items}, nil
}
// RevokeMySession реализует gen.StrictServerInterface.
func (h *Handler) RevokeMySession(ctx context.Context, req gen.RevokeMySessionRequestObject) (gen.RevokeMySessionResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.RevokeSession(ctx, p, req.Id); err != nil {
return nil, err
}
return gen.RevokeMySession204Response{}, nil
}
// tokensDTO собирает ответ с токенами: веб получает refresh в cookie, Android — в теле.
func (h *Handler) tokensDTO(ctx context.Context, t Tokens) gen.AuthTokens {
out := gen.AuthTokens{
AccessToken: t.Access,
TokenType: gen.Bearer,
ExpiresIn: int(t.ExpiresIn / time.Second),
User: MeDTO(t.User),
}
if t.ClientType == ClientWeb {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie,
Value: t.Refresh,
Path: refreshCookiePath,
MaxAge: int(h.svc.cfg.RefreshTTL / time.Second),
HttpOnly: true,
Secure: h.cookieSecure,
SameSite: http.SameSiteStrictMode,
})
} else {
refresh := t.Refresh
out.RefreshToken = &refresh
}
return out
}
func (h *Handler) clearCookie(ctx context.Context) {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie, Path: refreshCookiePath, MaxAge: -1,
HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode,
})
}
// MeDTO переводит пользователя в схему Me.
func MeDTO(u User) gen.Me {
return gen.Me{
Id: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
Locale: u.Locale,
UiPrefs: uiPrefsDTO(u.UIPrefs),
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
CreatedAt: u.CreatedAt,
}
}
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
return UIPrefs{
Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer),
MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// RedisLimiter — счётчик попыток с фиксированным окном в Redis.
type RedisLimiter struct{ rdb redis.Cmdable }
// NewRedisLimiter создаёт лимитер.
func NewRedisLimiter(rdb redis.Cmdable) *RedisLimiter { return &RedisLimiter{rdb: rdb} }
// Первый INCR ставит срок окна; ответ — {счётчик, оставшийся TTL в мс}.
var incrWindow = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return {n, redis.call('PTTL', KEYS[1])}
`)
// Allow реализует Limiter.
func (l *RedisLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) {
res, err := incrWindow.Run(ctx, l.rdb, []string{"rl:" + key}, window.Milliseconds()).Int64Slice()
if err != nil {
return false, 0, fmt.Errorf("auth: rate limit: %w", err)
}
if len(res) != 2 {
return false, 0, fmt.Errorf("auth: rate limit: unexpected reply %v", res)
}
if res[0] <= int64(limit) {
return true, 0, nil
}
retry := int((res[1] + 999) / 1000)
if retry < 1 {
retry = 1
}
return false, retry, nil
}
+166
View File
@@ -0,0 +1,166 @@
package auth
import (
"context"
"io"
"log/slog"
"net"
"net/http"
"strings"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
type ctxKey int
const (
principalKey ctxKey = iota
requestKey
)
// requestInfo — то, что strict-обработчикам нужно от HTTP-запроса, но не входит в контракт:
// адрес клиента и cookie, которые нужно выставить в ответе.
type requestInfo struct {
ip string
cookies []*http.Cookie
}
// PrincipalFrom возвращает пользователя запроса (ok = false для публичных эндпоинтов).
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// WithPrincipal кладёт пользователя в контекст (для тестов других модулей).
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey, p)
}
func clientIP(ctx context.Context) string {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
return ri.ip
}
return ""
}
func setCookie(ctx context.Context, c *http.Cookie) {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
ri.cookies = append(ri.cookies, c)
}
}
// Публичные эндпоинты /api/v1 — без access-токена.
var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
var (
credentialsChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/bootstrap-credentials": true,
"POST /api/v1/auth/logout": true,
}
passwordChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/password": true,
"POST /api/v1/auth/logout": true,
}
)
// Middleware аутентифицирует запросы к /api/v1 по Bearer-токену и ограничивает пользователей,
// обязанных сменить пароль или учётные данные (docs/auth-and-roles.md).
func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ri := &requestInfo{ip: hostOnly(r.RemoteAddr)}
ctx := context.WithValue(r.Context(), requestKey, ri)
w = &cookieWriter{ResponseWriter: w, ri: ri}
route := r.Method + " " + strings.TrimSuffix(r.URL.Path, "/")
if route == "POST /api/v1/auth/refresh" && r.ContentLength == 0 {
// Веб шлёт refresh без тела (токен в cookie), а strict-сервер oapi-codegen
// декодирует необязательное тело всё равно и отвечает 400 на пустое.
r.Body = io.NopCloser(strings.NewReader("{}"))
r.ContentLength = 2
}
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
token, ok := bearer(r.Header.Get("Authorization"))
if !ok {
httpx.WriteError(log, w, r, apperr.Unauthorized(CodeUnauthorized, "bearer token required"))
return
}
p, err := svc.Authenticate(ctx, token)
if err != nil {
httpx.WriteError(log, w, r, err)
return
}
switch {
case p.MustChangeCredentials && !credentialsChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodeCredentialsChangeRequired, "change bootstrap credentials first"))
return
case p.MustChangePassword && !passwordChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
})
}
}
func bearer(h string) (string, bool) {
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
t := strings.TrimSpace(h[len(prefix):])
return t, t != ""
}
func hostOnly(addr string) string {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// cookieWriter добавляет накопленные обработчиком cookie перед записью заголовков ответа.
type cookieWriter struct {
http.ResponseWriter
ri *requestInfo
wrote bool
}
func (w *cookieWriter) flush() {
if w.wrote {
return
}
w.wrote = true
for _, c := range w.ri.cookies {
http.SetCookie(w.ResponseWriter, c)
}
}
func (w *cookieWriter) WriteHeader(code int) {
w.flush()
w.ResponseWriter.WriteHeader(code)
}
func (w *cookieWriter) Write(b []byte) (int, error) {
w.flush()
return w.ResponseWriter.Write(b)
}
// Unwrap нужен http.ResponseController (flush, дедлайны) для обёрнутого writer.
func (w *cookieWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
+117
View File
@@ -0,0 +1,117 @@
// Package auth — пользователи, вход, токены, политика прав (docs/auth-and-roles.md).
package auth
import (
"time"
"github.com/google/uuid"
)
// Role — роль пользователя.
type Role string
// Роли по возрастанию прав.
const (
RoleUser Role = "user"
RoleModerator Role = "moderator"
RoleAdmin Role = "admin"
)
// Valid сообщает, известна ли роль.
func (r Role) Valid() bool { return r == RoleUser || r == RoleModerator || r == RoleAdmin }
// ClientType — тип клиента, от него зависит доставка refresh-токена.
type ClientType string
// Типы клиентов.
const (
ClientWeb ClientType = "web"
ClientAndroid ClientType = "android"
)
// Valid сообщает, известен ли тип клиента.
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid }
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
type UIPrefs struct {
Theme *string `json:"theme,omitempty"`
WaveVariant *string `json:"wave_variant,omitempty"`
WebPlayer *string `json:"web_player,omitempty"`
MobilePlayer *string `json:"mobile_player,omitempty"`
Ambient *bool `json:"ambient,omitempty"`
CrossfadeS *int `json:"crossfade_s,omitempty"`
Normalize *bool `json:"normalize,omitempty"`
}
// User — учётная запись.
type User struct {
ID uuid.UUID
Username string
DisplayName *string
PasswordHash string
Role Role
Locale string
UIPrefs UIPrefs
MustChangePassword bool
MustChangeCredentials bool
IsBlocked bool
TokenVersion int
FailedLoginCount int
LockedUntil *time.Time
CreatedAt time.Time
LastLoginAt *time.Time
}
// RefreshToken — строка refresh_tokens. Сам токен в БД не хранится, только SHA-256.
type RefreshToken struct {
ID uuid.UUID
UserID uuid.UUID
FamilyID uuid.UUID
FamilyStartedAt time.Time
TokenHash []byte
DeviceName *string
ClientType ClientType
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
RevokedAt *time.Time
}
// Session — активная семья refresh-токенов (одно устройство).
type Session struct {
ID uuid.UUID // family_id
DeviceName *string
ClientType ClientType
CreatedAt time.Time // начало семьи (вход)
LastUsedAt time.Time // последняя ротация
}
// Principal — аутентифицированный пользователь запроса (из access-токена и актуальной строки users).
type Principal struct {
UserID uuid.UUID
Role Role
SessionID uuid.UUID
MustChangePassword bool
MustChangeCredentials bool
}
// AuditRecord — прочитанная запись audit_log с логином автора.
type AuditRecord struct {
ID uuid.UUID
ActorID *uuid.UUID
ActorUsername *string
Action string
TargetType *string
TargetID *uuid.UUID
Payload map[string]any
CreatedAt time.Time
}
// AuditEntry — запись audit_log.
type AuditEntry struct {
ActorID *uuid.UUID
Action string
TargetType string
TargetID *uuid.UUID
Payload map[string]any
}
+111
View File
@@ -0,0 +1,111 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2Params — параметры argon2id. Хранятся в самом хэше (формат PHC), поэтому их можно менять:
// старые хэши проверяются со своими параметрами и пересчитываются при следующем входе.
type Argon2Params struct {
MemoryKiB uint32
Time uint32
Threads uint8
SaltLen int
KeyLen uint32
}
// DefaultArgon2Params — m=64 MiB, t=3, p=2 (docs/auth-and-roles.md).
var DefaultArgon2Params = Argon2Params{MemoryKiB: 64 * 1024, Time: 3, Threads: 2, SaltLen: 16, KeyLen: 32}
// Hasher хэширует и проверяет пароли.
type Hasher struct{ p Argon2Params }
// NewHasher создаёт Hasher с заданными параметрами.
func NewHasher(p Argon2Params) *Hasher { return &Hasher{p: p} }
// Hash возвращает PHC-строку `$argon2id$v=19$m=…,t=…,p=…$salt$key`.
func (h *Hasher) Hash(password string) (string, error) {
salt := make([]byte, h.p.SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("auth: salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, h.p.Time, h.p.MemoryKiB, h.p.Threads, h.p.KeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, h.p.MemoryKiB, h.p.Time, h.p.Threads,
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}
// Verify проверяет пароль. needsRehash = true, если хэш посчитан с устаревшими параметрами.
func (h *Hasher) Verify(password, encoded string) (ok, needsRehash bool, err error) {
p, salt, key, err := decodePHC(encoded)
if err != nil {
return false, false, err
}
got := argon2.IDKey([]byte(password), salt, p.Time, p.MemoryKiB, p.Threads, uint32(len(key)))
if subtle.ConstantTimeCompare(got, key) != 1 {
return false, false, nil
}
needsRehash = p.MemoryKiB != h.p.MemoryKiB || p.Time != h.p.Time || p.Threads != h.p.Threads ||
len(salt) != h.p.SaltLen || uint32(len(key)) != h.p.KeyLen
return true, needsRehash, nil
}
var b64 = base64.RawStdEncoding
var errBadHash = errors.New("auth: malformed password hash")
func decodePHC(s string) (Argon2Params, []byte, []byte, error) {
var p Argon2Params
parts := strings.Split(s, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return p, nil, nil, errBadHash
}
var v int
if _, err := fmt.Sscanf(parts[2], "v=%d", &v); err != nil || v != argon2.Version {
return p, nil, nil, errBadHash
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.MemoryKiB, &p.Time, &p.Threads); err != nil {
return p, nil, nil, errBadHash
}
salt, err := b64.DecodeString(parts[4])
if err != nil {
return p, nil, nil, errBadHash
}
key, err := b64.DecodeString(parts[5])
if err != nil || len(key) == 0 {
return p, nil, nil, errBadHash
}
p.SaltLen, p.KeyLen = len(salt), uint32(len(key))
return p, salt, key, nil
}
// Алфавит временных паролей: без похожих символов (0/O, 1/l/I), чтобы пароль легко продиктовать.
const tempPasswordAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// GeneratePassword возвращает случайный пароль длины n из tempPasswordAlphabet.
func GeneratePassword(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: random password: %w", err)
}
// 256 % 56 != 0: отбрасываем байты из «хвоста», чтобы распределение было равномерным.
const limit = 256 - 256%len(tempPasswordAlphabet)
out := make([]byte, 0, n)
for len(out) < n {
for _, b := range buf {
if int(b) < limit && len(out) < n {
out = append(out, tempPasswordAlphabet[int(b)%len(tempPasswordAlphabet)])
}
}
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: random password: %w", err)
}
}
return string(out), nil
}
+121
View File
@@ -0,0 +1,121 @@
package auth
import (
"encoding/base64"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/platform/apperr"
)
var fastArgon = Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
func TestHasher(t *testing.T) {
h := NewHasher(fastArgon)
enc, err := h.Hash("correct horse")
require.NoError(t, err)
assert.True(t, strings.HasPrefix(enc, "$argon2id$v=19$m=64,t=1,p=1$"), enc)
ok, rehash, err := h.Verify("correct horse", enc)
require.NoError(t, err)
assert.True(t, ok)
assert.False(t, rehash)
ok, _, err = h.Verify("wrong horse", enc)
require.NoError(t, err)
assert.False(t, ok)
other, _ := h.Hash("correct horse")
assert.NotEqual(t, enc, other, "salt is random")
stronger := NewHasher(Argon2Params{MemoryKiB: 128, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32})
ok, rehash, err = stronger.Verify("correct horse", enc)
require.NoError(t, err)
assert.True(t, ok, "old params are read from the hash")
assert.True(t, rehash)
}
func TestHasherRejectsMalformed(t *testing.T) {
h := NewHasher(fastArgon)
for _, s := range []string{"", "plain", "$argon2i$v=19$m=64,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=18$m=64,t=1,p=1$c2FsdA$a2V5",
"$argon2id$v=19$m=x,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=19$m=64,t=1,p=1$!!$a2V5", "$argon2id$v=19$m=64,t=1,p=1$c2FsdA$"} {
_, _, err := h.Verify("x", s)
assert.Error(t, err, s)
}
}
func TestGeneratePassword(t *testing.T) {
seen := map[string]bool{}
for range 50 {
p, err := GeneratePassword(12)
require.NoError(t, err)
assert.Len(t, p, 12)
for _, r := range p {
assert.Contains(t, tempPasswordAlphabet, string(r))
}
seen[p] = true
}
assert.Len(t, seen, 50)
}
func TestValidatePassword(t *testing.T) {
cases := []struct {
name, password, username string
role Role
code string
}{
{"user ok", "eightchr", "alice", RoleUser, ""},
{"user short", "sevench", "alice", RoleUser, CodePasswordTooShort},
{"moderator needs 12", "elevenchars", "mod", RoleModerator, CodePasswordTooShort},
{"admin ok", "twelve-chars", "root", RoleAdmin, ""},
{"runes not bytes", "пароль-ок", "alice", RoleUser, ""},
{"same as username", "Alexander", "alexander", RoleUser, CodePasswordSameAsUsername},
{"common", "Password123", "alice", RoleUser, CodePasswordTooCommon},
{"common cyrillic", "пароль123", "alice", RoleUser, CodePasswordTooCommon},
{"too long", strings.Repeat("a", MaxPassword+1), "alice", RoleUser, CodePasswordTooLong},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidatePassword("new_password", tc.password, tc.username, tc.role)
if tc.code == "" {
assert.NoError(t, err)
return
}
var ae *apperr.Error
require.ErrorAs(t, err, &ae)
assert.Equal(t, tc.code, ae.Code)
assert.Equal(t, "new_password", ae.Fields[0].Field)
})
}
}
func TestValidateUsername(t *testing.T) {
for _, ok := range []string{"abc", "john.doe", "a_b-c", strings.Repeat("a", 32)} {
assert.NoError(t, ValidateUsername("username", ok), ok)
}
for _, bad := range []string{"ab", "John", "имя", "a b", "a@b", strings.Repeat("a", 33)} {
assert.Error(t, ValidateUsername("username", bad), bad)
}
assert.Equal(t, "john", NormalizeUsername(" John "))
}
func TestParsePrivateKey(t *testing.T) {
seed := "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
k1, err := ParsePrivateKey(seed)
require.NoError(t, err)
k2, err := ParsePrivateKey(strings.TrimRight(strings.NewReplacer("+", "-", "/", "_").Replace(seed), "="))
require.NoError(t, err)
assert.Equal(t, k1, k2)
k3, err := ParsePrivateKey(b64std(k1))
require.NoError(t, err)
assert.Equal(t, k1, k3, "64-byte key form")
_, err = ParsePrivateKey("c2hvcnQ=")
assert.Error(t, err)
_, err = ParsePrivateKey("not base64 !!")
assert.Error(t, err)
}
func b64std(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
+452
View File
@@ -0,0 +1,452 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// querier — общее у пула и транзакции.
type querier interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// PgStore — Store поверх PostgreSQL.
type PgStore struct {
pool *pgxpool.Pool
q querier
}
// NewPgStore создаёт Store на пуле соединений.
func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool, q: pool} }
// WithTx реализует Store. Вложенный вызов выполняется в уже открытой транзакции.
func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error {
if s.pool == nil {
return fn(s)
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { return fn(&PgStore{q: tx}) })
}
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`
func scanUser(row pgx.Row) (User, error) {
var (
u User
role string
prefs []byte
)
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.LockedUntil, &u.CreatedAt, &u.LastLoginAt)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("auth: scan user: %w", err)
}
u.Role = Role(role)
if err := json.Unmarshal(prefs, &u.UIPrefs); err != nil {
return User{}, fmt.Errorf("auth: decode ui_prefs: %w", err)
}
return u, nil
}
// CountAdmins реализует Store. Заблокированные админы не считаются: войти они не могут.
func (s *PgStore) CountAdmins(ctx context.Context) (int, error) {
var n int
err := s.q.QueryRow(ctx, `SELECT count(*) FROM users WHERE role = 'admin' AND NOT is_blocked`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("auth: count admins: %w", err)
}
return n, nil
}
// CreateUser реализует Store.
func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error {
prefs, err := json.Marshal(u.UIPrefs)
if err != nil {
return err
}
_, err = s.q.Exec(ctx, `
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)`,
u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs,
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt)
if isUniqueViolation(err) {
return ErrUsernameTaken
}
if err != nil {
return fmt.Errorf("auth: create user: %w", err)
}
return nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// GetUserByID реализует Store.
func (s *PgStore) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) {
return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE id = $1`, id))
}
// GetUserByUsername реализует Store (username — citext, регистр не важен).
func (s *PgStore) GetUserByUsername(ctx context.Context, username string) (User, error) {
return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE username = $1`, username))
}
// RecordLoginFailure реализует Store.
func (s *PgStore) RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE users SET failed_login_count = $2, locked_until = $3 WHERE id = $1`,
id, failedCount, lockedUntil)
if err != nil {
return fmt.Errorf("auth: record login failure: %w", err)
}
return nil
}
// RecordLoginSuccess реализует Store.
func (s *PgStore) RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET failed_login_count = 0, locked_until = NULL, last_login_at = $2,
password_hash = COALESCE($3, password_hash)
WHERE id = $1`, id, at, newHash)
if err != nil {
return fmt.Errorf("auth: record login success: %w", err)
}
return nil
}
// SetPassword реализует Store.
func (s *PgStore) SetPassword(ctx context.Context, id uuid.UUID, hash string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET password_hash = $2, must_change_password = false,
token_version = token_version + 1, updated_at = now()
WHERE id = $1`, id, hash)
if err != nil {
return fmt.Errorf("auth: set password: %w", err)
}
return nil
}
// SetCredentials реализует Store.
func (s *PgStore) SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET username = $2, password_hash = $3, must_change_credentials = false,
must_change_password = false, token_version = token_version + 1, updated_at = now()
WHERE id = $1`, id, username, hash)
if isUniqueViolation(err) {
return ErrUsernameTaken
}
if err != nil {
return fmt.Errorf("auth: set credentials: %w", err)
}
return nil
}
// UpdateProfile реализует Store.
func (s *PgStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error) {
var (
setName bool
name *string
prefs []byte
err error
)
if upd.DisplayName != nil {
setName, name = true, *upd.DisplayName
}
if upd.UIPrefs != nil {
if prefs, err = json.Marshal(upd.UIPrefs); err != nil {
return User{}, err
}
}
return scanUser(s.q.QueryRow(ctx, `
UPDATE users SET
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
locale = COALESCE($4, locale),
ui_prefs = COALESCE($5::jsonb, ui_prefs),
updated_at = now()
WHERE id = $1
RETURNING `+userColumns, id, setName, name, upd.Locale, prefs))
}
// InsertRefreshToken реализует Store.
func (s *PgStore) InsertRefreshToken(ctx context.Context, t RefreshToken) error {
_, err := s.q.Exec(ctx, `
INSERT INTO refresh_tokens (id, user_id, family_id, family_started_at, token_hash, device_name,
client_type, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
t.ID, t.UserID, t.FamilyID, t.FamilyStartedAt, t.TokenHash, t.DeviceName, string(t.ClientType),
t.CreatedAt, t.ExpiresAt)
if err != nil {
return fmt.Errorf("auth: insert refresh token: %w", err)
}
return nil
}
// GetRefreshTokenForUpdate реализует Store.
func (s *PgStore) GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error) {
var (
t RefreshToken
ct string
)
err := s.q.QueryRow(ctx, `
SELECT id, user_id, family_id, family_started_at, token_hash, device_name, client_type,
created_at, expires_at, used_at, revoked_at
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, hash).
Scan(&t.ID, &t.UserID, &t.FamilyID, &t.FamilyStartedAt, &t.TokenHash, &t.DeviceName, &ct,
&t.CreatedAt, &t.ExpiresAt, &t.UsedAt, &t.RevokedAt)
if errors.Is(err, pgx.ErrNoRows) {
return RefreshToken{}, ErrNotFound
}
if err != nil {
return RefreshToken{}, fmt.Errorf("auth: get refresh token: %w", err)
}
t.ClientType = ClientType(ct)
return t, nil
}
// MarkRefreshTokenUsed реализует Store.
func (s *PgStore) MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET used_at = $2 WHERE id = $1`, id, at)
if err != nil {
return fmt.Errorf("auth: mark refresh token used: %w", err)
}
return nil
}
// RevokeFamily реализует Store.
func (s *PgStore) RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE family_id = $1 AND revoked_at IS NULL`,
familyID, at)
if err != nil {
return fmt.Errorf("auth: revoke family: %w", err)
}
return nil
}
// RevokeUserSessions реализует Store.
func (s *PgStore) RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error {
_, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`,
userID, at)
if err != nil {
return fmt.Errorf("auth: revoke user sessions: %w", err)
}
return nil
}
// ListSessions реализует Store.
func (s *PgStore) ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error) {
rows, err := s.q.Query(ctx, `
SELECT family_id, device_name, client_type, family_started_at, created_at
FROM refresh_tokens
WHERE user_id = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > $2
ORDER BY created_at DESC`, userID, now)
if err != nil {
return nil, fmt.Errorf("auth: list sessions: %w", err)
}
out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (Session, error) {
var (
ss Session
ct string
)
err := row.Scan(&ss.ID, &ss.DeviceName, &ct, &ss.CreatedAt, &ss.LastUsedAt)
ss.ClientType = ClientType(ct)
return ss, err
})
if err != nil {
return nil, fmt.Errorf("auth: list sessions: %w", err)
}
return out, nil
}
// ListUsers реализует Store.
func (s *PgStore) ListUsers(ctx context.Context, f UserFilter) ([]User, error) {
var role *string
if f.Role != nil {
r := string(*f.Role)
role = &r
}
// Спецсимволы LIKE в запросе экранируются: ищем подстроку буквально.
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
rows, err := s.q.Query(ctx, `
SELECT `+userColumns+` FROM users
WHERE ($1 = '%%' OR username ILIKE $1 OR display_name ILIKE $1)
AND ($2::user_role IS NULL OR role = $2::user_role)
AND ($3 = '' OR username > $3::citext)
ORDER BY username
LIMIT $4`, pattern, role, f.After, f.Limit)
if err != nil {
return nil, fmt.Errorf("auth: list users: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("auth: list users: %w", err)
}
return out, nil
}
// LockActiveAdmins реализует Store.
func (s *PgStore) LockActiveAdmins(ctx context.Context) (int, error) {
rows, err := s.q.Query(ctx, `SELECT id FROM users WHERE role = 'admin' AND NOT is_blocked FOR UPDATE`)
if err != nil {
return 0, fmt.Errorf("auth: lock admins: %w", err)
}
defer rows.Close()
n := 0
for rows.Next() {
n++
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("auth: lock admins: %w", err)
}
return n, nil
}
// UpdateUser реализует Store.
func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error) {
var (
setName bool
name *string
role *string
)
if upd.DisplayName != nil {
setName, name = true, *upd.DisplayName
}
if upd.Role != nil {
r := string(*upd.Role)
role = &r
}
return scanUser(s.q.QueryRow(ctx, `
UPDATE users SET
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
role = COALESCE($4::user_role, role),
is_blocked = COALESCE($5, is_blocked),
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
updated_at = now()
WHERE id = $1
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens))
}
// ResetPassword реализует Store.
func (s *PgStore) ResetPassword(ctx context.Context, id uuid.UUID, hash string) error {
_, err := s.q.Exec(ctx, `
UPDATE users SET password_hash = $2, must_change_password = true, failed_login_count = 0,
locked_until = NULL, token_version = token_version + 1, updated_at = now()
WHERE id = $1`, id, hash)
if err != nil {
return fmt.Errorf("auth: reset password: %w", err)
}
return nil
}
// DeleteUser реализует Store. Токены удаляются каскадно, ссылки в audit_log и created_by обнуляются.
func (s *PgStore) DeleteUser(ctx context.Context, id uuid.UUID) error {
if _, err := s.q.Exec(ctx, `DELETE FROM users WHERE id = $1`, id); err != nil {
return fmt.Errorf("auth: delete user: %w", err)
}
return nil
}
// ListAudit реализует Store.
func (s *PgStore) ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error) {
rows, err := s.q.Query(ctx, `
SELECT a.id, a.actor_id, u.username::text, a.action, a.target_type, a.target_id, a.payload, a.created_at
FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id
WHERE $1 = '00000000-0000-0000-0000-000000000000'::uuid OR a.id < $1
ORDER BY a.id DESC
LIMIT $2`, before, limit)
if err != nil {
return nil, fmt.Errorf("auth: list audit: %w", err)
}
out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (AuditRecord, error) {
var (
r AuditRecord
payload []byte
)
if err := row.Scan(&r.ID, &r.ActorID, &r.ActorUsername, &r.Action, &r.TargetType, &r.TargetID, &payload,
&r.CreatedAt); err != nil {
return r, err
}
if payload != nil {
if err := json.Unmarshal(payload, &r.Payload); err != nil {
return r, err
}
}
return r, nil
})
if err != nil {
return nil, fmt.Errorf("auth: list audit: %w", err)
}
return out, nil
}
// InsertAudit реализует Store.
func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error {
var payload []byte
if e.Payload != nil {
var err error
if payload, err = json.Marshal(e.Payload); err != nil {
return err
}
}
_, err := s.q.Exec(ctx, `
INSERT INTO audit_log (id, actor_id, action, target_type, target_id, payload)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6)`, id, e.ActorID, e.Action, e.TargetType, e.TargetID, payload)
if err != nil {
return fmt.Errorf("auth: insert audit: %w", err)
}
return nil
}
// GetSettingBool реализует Store: отсутствующий ключ — false.
func (s *PgStore) GetSettingBool(ctx context.Context, key string) (bool, error) {
var v bool
err := s.q.QueryRow(ctx, `SELECT value::boolean FROM app_settings WHERE key = $1`, key).Scan(&v)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("auth: get setting %s: %w", key, err)
}
return v, nil
}
// SetSetting реализует Store.
func (s *PgStore) SetSetting(ctx context.Context, key string, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
_, err = s.q.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, raw)
if err != nil {
return fmt.Errorf("auth: set setting %s: %w", key, err)
}
return nil
}
var _ Store = (*PgStore)(nil)
+94
View File
@@ -0,0 +1,94 @@
package auth
import "github.com/google/uuid"
// Матрица прав из docs/auth-and-roles.md. Проверяется в сервисном слое, а не только в middleware.
// Permission — право, не зависящее от целевого пользователя.
type Permission int
// Глобальные права.
const (
PermAdminPanel Permission = iota // открыть админку
PermListUsers // видеть список пользователей
PermManageLibrary // хранилища, сканирование, переиндексация
PermViewLibraryStatus // статус библиотеки (просмотр)
PermSuggestTracks // предлагать треки, видеть свои предложения
PermReviewUploads // очередь, одобрение, правка метаданных, лимиты
PermSystemStatus // статус рекомендательной системы, аудит
)
var permissions = map[Permission][]Role{
PermAdminPanel: {RoleModerator, RoleAdmin},
PermListUsers: {RoleModerator, RoleAdmin},
PermManageLibrary: {RoleAdmin},
PermViewLibraryStatus: {RoleModerator, RoleAdmin},
PermSuggestTracks: {RoleModerator, RoleAdmin},
PermReviewUploads: {RoleAdmin},
PermSystemStatus: {RoleAdmin},
}
// Can сообщает, есть ли у роли глобальное право.
func Can(r Role, p Permission) bool {
for _, allowed := range permissions[p] {
if allowed == r {
return true
}
}
return false
}
// UserAction — действие над учётной записью другого пользователя.
type UserAction int
// Действия над пользователями.
const (
UserCreate UserAction = iota // создать пользователя с ролью target
UserEdit // изменить отображаемое имя
UserResetPassword // сбросить пароль
UserBlock // заблокировать / разблокировать
UserChangeRole // сменить роль (target — текущая роль)
UserDelete // удалить
)
// Actor — кто действует (или над кем). ID нужен, чтобы запретить действия над собой.
type Actor struct {
ID uuid.UUID
Role Role
}
// CanManageUser — может ли actor выполнить action над target.
// Модератор управляет только обычными пользователями; роли и удаление — только админ.
// Блокировать, удалять себя и менять себе роль нельзя никому: так не потерять доступ случайно.
// Правило «последний админ» зависит от данных и проверяется в сервисе.
func CanManageUser(actor, target Actor, action UserAction) bool {
self := actor.ID != uuid.Nil && actor.ID == target.ID
if self && (action == UserBlock || action == UserDelete || action == UserChangeRole) {
return false
}
switch actor.Role {
case RoleAdmin:
return true
case RoleModerator:
switch action {
case UserCreate, UserEdit, UserResetPassword, UserBlock:
return target.Role == RoleUser
default:
return false
}
default:
return false
}
}
// CanAssignRole — может ли actor выдать роль newRole (при создании или смене роли).
func CanAssignRole(actor Role, newRole Role) bool {
switch actor {
case RoleAdmin:
return newRole.Valid()
case RoleModerator:
return newRole == RoleUser
default:
return false
}
}
+91
View File
@@ -0,0 +1,91 @@
package auth
import (
"fmt"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestCan(t *testing.T) {
// Строки матрицы прав из docs/auth-and-roles.md: user, moderator, admin.
matrix := []struct {
perm Permission
name string
want [3]bool
}{
{PermAdminPanel, "admin panel", [3]bool{false, true, true}},
{PermListUsers, "list users", [3]bool{false, true, true}},
{PermManageLibrary, "manage library", [3]bool{false, false, true}},
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
{PermReviewUploads, "review uploads", [3]bool{false, false, true}},
{PermSystemStatus, "system status", [3]bool{false, false, true}},
}
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
for _, row := range matrix {
for i, r := range roles {
t.Run(fmt.Sprintf("%s/%s", row.name, r), func(t *testing.T) {
assert.Equal(t, row.want[i], Can(r, row.perm))
})
}
}
assert.False(t, Can(Role("guest"), PermAdminPanel))
}
func TestCanManageUser(t *testing.T) {
actions := map[UserAction]string{
UserCreate: "create", UserEdit: "edit", UserResetPassword: "reset", UserBlock: "block",
UserChangeRole: "role", UserDelete: "delete",
}
// want[actor][target] для каждого действия; порядок ролей — user, moderator, admin.
cases := map[UserAction][3][3]bool{
UserCreate: {{false, false, false}, {true, false, false}, {true, true, true}},
UserEdit: {{false, false, false}, {true, false, false}, {true, true, true}},
UserResetPassword: {{false, false, false}, {true, false, false}, {true, true, true}},
UserBlock: {{false, false, false}, {true, false, false}, {true, true, true}},
UserChangeRole: {{false, false, false}, {false, false, false}, {true, true, true}},
UserDelete: {{false, false, false}, {false, false, false}, {true, true, true}},
}
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
for action, want := range cases {
for ai, ar := range roles {
for ti, tr := range roles {
t.Run(fmt.Sprintf("%s/%s->%s", actions[action], ar, tr), func(t *testing.T) {
actor := Actor{ID: uuid.New(), Role: ar}
target := Actor{ID: uuid.New(), Role: tr}
assert.Equal(t, want[ai][ti], CanManageUser(actor, target, action))
})
}
}
}
}
func TestCanManageUserSelf(t *testing.T) {
admin := Actor{ID: uuid.New(), Role: RoleAdmin}
for _, a := range []UserAction{UserBlock, UserDelete, UserChangeRole} {
assert.False(t, CanManageUser(admin, admin, a), "action %d on self", a)
}
assert.True(t, CanManageUser(admin, admin, UserEdit))
assert.True(t, CanManageUser(admin, admin, UserResetPassword))
}
func TestCanAssignRole(t *testing.T) {
cases := []struct {
actor, role Role
want bool
}{
{RoleAdmin, RoleAdmin, true},
{RoleAdmin, RoleModerator, true},
{RoleAdmin, RoleUser, true},
{RoleAdmin, Role("root"), false},
{RoleModerator, RoleUser, true},
{RoleModerator, RoleModerator, false},
{RoleModerator, RoleAdmin, false},
{RoleUser, RoleUser, false},
}
for _, tc := range cases {
assert.Equal(t, tc.want, CanAssignRole(tc.actor, tc.role), "%s assigns %s", tc.actor, tc.role)
}
}
+647
View File
@@ -0,0 +1,647 @@
package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr"
)
// Коды ошибок аутентификации.
const (
CodeInvalidCredentials = "auth.invalid_credentials"
CodeTooManyAttempts = "auth.too_many_attempts"
CodeUserBlocked = "auth.user_blocked"
CodeUnauthorized = "auth.unauthorized"
CodeTokenExpired = "auth.token_expired"
CodeTokenInvalid = "auth.token_invalid"
CodeTokenRevoked = "auth.token_revoked"
CodeRefreshInvalid = "auth.refresh_invalid"
CodeRefreshReused = "auth.refresh_reused"
CodeRefreshRace = "auth.refresh_race"
CodeCredentialsChangeRequired = "auth.credentials_change_required"
CodePasswordChangeRequired = "auth.password_change_required"
CodeCurrentPasswordRequired = "auth.current_password_required"
CodeCurrentPasswordInvalid = "auth.current_password_invalid"
CodeBootstrapNotRequired = "auth.bootstrap_not_required"
CodeSessionNotFound = "auth.session_not_found"
CodeUsernameTaken = "validation.username_taken"
)
// Ключ app_settings: первый вход администратора завершён.
const settingBootstrapCompleted = "bootstrap.completed"
// Config — параметры сервиса.
type Config struct {
RefreshTTL time.Duration // скользящий срок refresh-токена
RefreshMaxAge time.Duration // абсолютный срок семьи токенов
// RefreshRaceWindow — сколько после ротации повтор старого токена считается гонкой
// (две вкладки обновили токен одновременно), а не кражей.
RefreshRaceWindow time.Duration
LockThreshold int // неудачных входов до блокировки
LockBase time.Duration // первая блокировка, дальше удваивается
LockMax time.Duration
LoginLimitPerUser int // попыток в минуту на IP + логин
LoginLimitPerIP int // попыток в минуту на IP
BootstrapUsername string
BootstrapPassword string // пусто — сгенерировать
SupportedLocales []string
}
// DefaultConfig — значения из docs/auth-and-roles.md.
func DefaultConfig() Config {
return Config{
RefreshTTL: 30 * 24 * time.Hour,
RefreshMaxAge: 180 * 24 * time.Hour,
RefreshRaceWindow: 10 * time.Second,
LockThreshold: 5,
LockBase: 5 * time.Minute,
LockMax: time.Hour,
LoginLimitPerUser: 5,
LoginLimitPerIP: 30,
BootstrapUsername: "admin",
SupportedLocales: []string{"ru", "en"},
}
}
// Service — бизнес-логика входа, токенов и профиля.
type Service struct {
store Store
hasher *Hasher
tokens *TokenIssuer
limiter Limiter
clock Clock
ids IDGen
log *slog.Logger
cfg Config
// dummyHash проверяется для несуществующих логинов, чтобы время ответа не выдавало их отсутствие.
dummyHash string
}
// NewService создаёт сервис.
func NewService(store Store, hasher *Hasher, tokens *TokenIssuer, limiter Limiter, clock Clock, ids IDGen,
log *slog.Logger, cfg Config,
) (*Service, error) {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
dummy, err := hasher.Hash("pipu-dummy-password")
if err != nil {
return nil, err
}
return &Service{
store: store, hasher: hasher, tokens: tokens, limiter: limiter, clock: clock, ids: ids, log: log, cfg: cfg,
dummyHash: dummy,
}, nil
}
// Tokens — результат входа или ротации.
type Tokens struct {
Access string
ExpiresIn time.Duration
Refresh string
ClientType ClientType
User User
}
// LoginInput — данные формы входа.
type LoginInput struct {
Username string
Password string
ClientType ClientType
DeviceName string
IP string
}
func invalidCredentials() error {
return apperr.Unauthorized(CodeInvalidCredentials, "invalid username or password")
}
// Login проверяет логин и пароль и открывает новую сессию.
func (s *Service) Login(ctx context.Context, in LoginInput) (Tokens, error) {
if !in.ClientType.Valid() {
return Tokens{}, apperr.Validation("client_type", "validation.client_type_invalid", "unknown client type", nil)
}
username := NormalizeUsername(in.Username)
if err := s.checkRateLimit(ctx, in.IP, username); err != nil {
return Tokens{}, err
}
if len(in.Password) > MaxPassword*4 {
return Tokens{}, invalidCredentials()
}
u, err := s.store.GetUserByUsername(ctx, username)
if errors.Is(err, ErrNotFound) {
_, _, _ = s.hasher.Verify(in.Password, s.dummyHash)
return Tokens{}, invalidCredentials()
}
if err != nil {
return Tokens{}, err
}
now := s.clock.Now()
if u.LockedUntil != nil && now.Before(*u.LockedUntil) {
_, _, _ = s.hasher.Verify(in.Password, s.dummyHash)
return Tokens{}, invalidCredentials()
}
ok, rehash, err := s.hasher.Verify(in.Password, u.PasswordHash)
if err != nil {
return Tokens{}, err
}
if !ok {
failed := u.FailedLoginCount + 1
var lockedUntil *time.Time
if failed >= s.cfg.LockThreshold {
until := now.Add(s.lockDuration(failed))
lockedUntil = &until
}
if err := s.store.RecordLoginFailure(ctx, u.ID, failed, lockedUntil); err != nil {
return Tokens{}, err
}
return Tokens{}, invalidCredentials()
}
if u.IsBlocked {
return Tokens{}, apperr.Forbidden(CodeUserBlocked, "user is blocked")
}
var newHash *string
if rehash {
h, err := s.hasher.Hash(in.Password)
if err != nil {
return Tokens{}, err
}
newHash = &h
}
var out Tokens
err = s.store.WithTx(ctx, func(tx Store) error {
if err := tx.RecordLoginSuccess(ctx, u.ID, now, newHash); err != nil {
return err
}
out, err = s.openSession(ctx, tx, u, in.ClientType, in.DeviceName)
return err
})
return out, err
}
// lockDuration — блокировка после failed неудачных попыток: LockBase, затем вдвое больше за каждую
// следующую неудачу, но не более LockMax.
func (s *Service) lockDuration(failed int) time.Duration {
d := s.cfg.LockBase
for i := s.cfg.LockThreshold; i < failed && d < s.cfg.LockMax; i++ {
d *= 2
}
return min(d, s.cfg.LockMax)
}
func (s *Service) checkRateLimit(ctx context.Context, ip, username string) error {
if s.limiter == nil {
return nil
}
checks := []struct {
key string
limit int
}{
{"login:ip:" + ip, s.cfg.LoginLimitPerIP},
{"login:user:" + ip + ":" + username, s.cfg.LoginLimitPerUser},
}
for _, c := range checks {
ok, retry, err := s.limiter.Allow(ctx, c.key, c.limit, time.Minute)
if err != nil {
// Redis недоступен — не запираем всех снаружи: остаётся блокировка учётки по счётчику в БД.
s.log.WarnContext(ctx, "login rate limiter unavailable", "err", err)
return nil
}
if !ok {
return apperr.TooManyRequests(CodeTooManyAttempts, retry)
}
}
return nil
}
// openSession создаёт новую семью refresh-токенов и выдаёт пару.
func (s *Service) openSession(ctx context.Context, st Store, u User, ct ClientType, device string) (Tokens, error) {
now := s.clock.Now()
return s.issue(ctx, st, u, RefreshToken{
FamilyID: s.ids.New(),
FamilyStartedAt: now,
DeviceName: deviceName(device),
ClientType: ct,
})
}
// issue записывает новый refresh-токен семьи prev и подписывает access-токен.
func (s *Service) issue(ctx context.Context, st Store, u User, prev RefreshToken) (Tokens, error) {
now := s.clock.Now()
refresh, hash, err := NewRefreshToken()
if err != nil {
return Tokens{}, err
}
expires := now.Add(s.cfg.RefreshTTL)
if hardLimit := prev.FamilyStartedAt.Add(s.cfg.RefreshMaxAge); expires.After(hardLimit) {
expires = hardLimit
}
rt := RefreshToken{
ID: s.ids.New(),
UserID: u.ID,
FamilyID: prev.FamilyID,
FamilyStartedAt: prev.FamilyStartedAt,
TokenHash: hash,
DeviceName: prev.DeviceName,
ClientType: prev.ClientType,
CreatedAt: now,
ExpiresAt: expires,
}
if err := st.InsertRefreshToken(ctx, rt); err != nil {
return Tokens{}, err
}
access, err := s.tokens.Issue(u.ID, u.Role, rt.FamilyID, u.TokenVersion)
if err != nil {
return Tokens{}, err
}
return Tokens{Access: access, ExpiresIn: s.tokens.TTL(), Refresh: refresh, ClientType: rt.ClientType, User: u}, nil
}
func deviceName(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if utf8.RuneCountInString(s) > 100 {
s = string([]rune(s)[:100])
}
return &s
}
// Refresh ротирует refresh-токен: старый помечается использованным, выдаётся новый в той же семье.
func (s *Service) Refresh(ctx context.Context, token string) (Tokens, error) {
if token == "" {
return Tokens{}, apperr.Unauthorized(CodeRefreshInvalid, "refresh token missing")
}
now := s.clock.Now()
var (
out Tokens
reused bool
familyID uuid.UUID
)
err := s.store.WithTx(ctx, func(tx Store) error {
rt, err := tx.GetRefreshTokenForUpdate(ctx, HashRefreshToken(token))
if errors.Is(err, ErrNotFound) {
return apperr.Unauthorized(CodeRefreshInvalid, "unknown refresh token")
}
if err != nil {
return err
}
if rt.RevokedAt != nil {
return apperr.Unauthorized(CodeRefreshInvalid, "refresh token revoked")
}
if rt.UsedAt != nil {
if now.Sub(*rt.UsedAt) <= s.cfg.RefreshRaceWindow {
return apperr.Unauthorized(CodeRefreshRace, "refresh token was just rotated")
}
reused, familyID = true, rt.FamilyID
return nil
}
if !now.Before(rt.ExpiresAt) {
return apperr.Unauthorized(CodeRefreshInvalid, "refresh token expired")
}
u, err := tx.GetUserByID(ctx, rt.UserID)
if err != nil {
return err
}
if u.IsBlocked {
return apperr.Forbidden(CodeUserBlocked, "user is blocked")
}
if err := tx.MarkRefreshTokenUsed(ctx, rt.ID, now); err != nil {
return err
}
out, err = s.issue(ctx, tx, u, rt)
return err
})
if err != nil {
return Tokens{}, err
}
if reused {
// Токен уже обменяли — его украли или скопировали. Отзываем всю семью вне транзакции чтения.
s.log.WarnContext(ctx, "refresh token reuse detected, family revoked", "family_id", familyID)
if err := s.store.RevokeFamily(ctx, familyID, now); err != nil {
return Tokens{}, err
}
return Tokens{}, apperr.Unauthorized(CodeRefreshReused, "refresh token reuse")
}
return out, nil
}
// Authenticate проверяет access-токен и актуальное состояние пользователя.
func (s *Service) Authenticate(ctx context.Context, access string) (Principal, error) {
claims, err := s.tokens.Parse(access)
switch {
case errors.Is(err, ErrTokenExpired):
return Principal{}, apperr.Unauthorized(CodeTokenExpired, "access token expired")
case err != nil:
return Principal{}, apperr.Unauthorized(CodeTokenInvalid, "access token invalid")
}
u, err := s.store.GetUserByID(ctx, claims.UserID)
if errors.Is(err, ErrNotFound) {
return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "user not found")
}
if err != nil {
return Principal{}, err
}
if u.IsBlocked {
return Principal{}, apperr.Unauthorized(CodeUserBlocked, "user is blocked")
}
if u.TokenVersion != claims.TokenVersion {
return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "token version changed")
}
return Principal{
UserID: u.ID,
Role: u.Role,
SessionID: claims.SessionID,
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
}, nil
}
// Logout отзывает текущую сессию.
func (s *Service) Logout(ctx context.Context, p Principal) error {
return s.store.RevokeFamily(ctx, p.SessionID, s.clock.Now())
}
// LogoutAll отзывает все сессии пользователя.
func (s *Service) LogoutAll(ctx context.Context, p Principal) error {
return s.store.RevokeUserSessions(ctx, p.UserID, s.clock.Now())
}
// Me возвращает профиль текущего пользователя.
func (s *Service) Me(ctx context.Context, p Principal) (User, error) {
return s.store.GetUserByID(ctx, p.UserID)
}
// MePatch — изменения профиля из PATCH /me. nil — не менять; DisplayName "" — сбросить.
type MePatch struct {
DisplayName *string
Locale *string
UIPrefs *UIPrefs
}
// UpdateMe меняет имя, язык и настройки интерфейса.
func (s *Service) UpdateMe(ctx context.Context, p Principal, patch MePatch) (User, error) {
var upd ProfileUpdate
if patch.DisplayName != nil {
name := strings.TrimSpace(*patch.DisplayName)
if utf8.RuneCountInString(name) > MaxDisplayName {
return User{}, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long",
map[string]any{"max": MaxDisplayName})
}
var v *string
if name != "" {
v = &name
}
upd.DisplayName = &v
}
if patch.Locale != nil {
if !contains(s.cfg.SupportedLocales, *patch.Locale) {
return User{}, apperr.Validation("locale", CodeLocaleUnsupported, "unsupported locale",
map[string]any{"supported": s.cfg.SupportedLocales})
}
upd.Locale = patch.Locale
}
if patch.UIPrefs != nil {
if err := ValidateUIPrefs(*patch.UIPrefs); err != nil {
return User{}, err
}
u, err := s.store.GetUserByID(ctx, p.UserID)
if err != nil {
return User{}, err
}
merged := MergeUIPrefs(u.UIPrefs, *patch.UIPrefs)
upd.UIPrefs = &merged
}
return s.store.UpdateProfile(ctx, p.UserID, upd)
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// ChangePassword меняет пароль, отзывает все сессии и открывает новую на текущем устройстве.
// current не нужен, если пароль временный (must_change_password).
func (s *Service) ChangePassword(ctx context.Context, p Principal, current *string, newPassword string) (Tokens, error) {
u, err := s.store.GetUserByID(ctx, p.UserID)
if err != nil {
return Tokens{}, err
}
if u.MustChangeCredentials {
return Tokens{}, apperr.Forbidden(CodeCredentialsChangeRequired, "use bootstrap credentials endpoint")
}
if !u.MustChangePassword {
if current == nil || *current == "" {
return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordRequired, "current password required", nil)
}
ok, _, err := s.hasher.Verify(*current, u.PasswordHash)
if err != nil {
return Tokens{}, err
}
if !ok {
return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordInvalid, "current password is wrong", nil)
}
}
if err := s.checkNewPassword(u, u.Username, newPassword); err != nil {
return Tokens{}, err
}
hash, err := s.hasher.Hash(newPassword)
if err != nil {
return Tokens{}, err
}
return s.replaceSessions(ctx, p, u, func(tx Store) error { return tx.SetPassword(ctx, u.ID, hash) }, nil)
}
// checkNewPassword — политика паролей плюс запрет оставить прежний пароль.
func (s *Service) checkNewPassword(u User, username, newPassword string) error {
if err := ValidatePassword("new_password", newPassword, username, u.Role); err != nil {
return err
}
same, _, err := s.hasher.Verify(newPassword, u.PasswordHash)
if err != nil {
return err
}
if same {
return apperr.Validation("new_password", CodePasswordUnchanged, "new password equals current", nil)
}
return nil
}
// replaceSessions в одной транзакции применяет change, отзывает все сессии пользователя
// и открывает новую с тем же устройством, что у текущей.
func (s *Service) replaceSessions(ctx context.Context, p Principal, u User, change func(Store) error,
audit *AuditEntry,
) (Tokens, error) {
now := s.clock.Now()
var out Tokens
err := s.store.WithTx(ctx, func(tx Store) error {
sessions, err := tx.ListSessions(ctx, u.ID, now)
if err != nil {
return err
}
ct, device := ClientWeb, ""
for _, ss := range sessions {
if ss.ID == p.SessionID {
ct = ss.ClientType
if ss.DeviceName != nil {
device = *ss.DeviceName
}
}
}
if err := change(tx); err != nil {
return err
}
if err := tx.RevokeUserSessions(ctx, u.ID, now); err != nil {
return err
}
if audit != nil {
if err := tx.InsertAudit(ctx, s.ids.New(), *audit); err != nil {
return err
}
}
// Перечитываем: change увеличил token_version, новый access-токен должен нести его.
fresh, err := tx.GetUserByID(ctx, u.ID)
if err != nil {
return err
}
out, err = s.openSession(ctx, tx, fresh, ct, device)
return err
})
return out, err
}
// SetBootstrapCredentials — первый вход администратора: новые логин и пароль.
func (s *Service) SetBootstrapCredentials(ctx context.Context, p Principal, newUsername, newPassword string) (Tokens, error) {
u, err := s.store.GetUserByID(ctx, p.UserID)
if err != nil {
return Tokens{}, err
}
if !u.MustChangeCredentials {
return Tokens{}, apperr.Conflict(CodeBootstrapNotRequired, "credentials already changed")
}
username := NormalizeUsername(newUsername)
if err := ValidateUsername("new_username", username); err != nil {
return Tokens{}, err
}
if err := s.checkNewPassword(u, username, newPassword); err != nil {
return Tokens{}, err
}
hash, err := s.hasher.Hash(newPassword)
if err != nil {
return Tokens{}, err
}
audit := &AuditEntry{
ActorID: &u.ID, Action: "user.bootstrap_credentials", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username},
}
out, err := s.replaceSessions(ctx, p, u, func(tx Store) error {
if err := tx.SetCredentials(ctx, u.ID, username, hash); err != nil {
return err
}
return tx.SetSetting(ctx, settingBootstrapCompleted, true)
}, audit)
if errors.Is(err, ErrUsernameTaken) {
return Tokens{}, apperr.Validation("new_username", CodeUsernameTaken, "username is taken", nil)
}
return out, err
}
// ListSessions — активные сессии пользователя, новые сверху.
func (s *Service) ListSessions(ctx context.Context, p Principal) ([]Session, error) {
return s.store.ListSessions(ctx, p.UserID, s.clock.Now())
}
// RevokeSession завершает одну свою сессию.
func (s *Service) RevokeSession(ctx context.Context, p Principal, id uuid.UUID) error {
now := s.clock.Now()
sessions, err := s.store.ListSessions(ctx, p.UserID, now)
if err != nil {
return err
}
for _, ss := range sessions {
if ss.ID == id {
return s.store.RevokeFamily(ctx, id, now)
}
}
return apperr.NotFound(CodeSessionNotFound, "session not found")
}
// BootstrapRequired — первый вход администратора ещё не завершён.
func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) {
done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted)
return !done, err
}
// Bootstrap создаёт первого администратора, если его нет и первый запуск не завершён.
// Сгенерированный пароль печатается в лог один раз.
func (s *Service) Bootstrap(ctx context.Context) error {
done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted)
if err != nil || done {
return err
}
admins, err := s.store.CountAdmins(ctx)
if err != nil || admins > 0 {
return err
}
username := NormalizeUsername(s.cfg.BootstrapUsername)
if err := ValidateUsername("BOOTSTRAP_ADMIN_USERNAME", username); err != nil {
return fmt.Errorf("auth: bootstrap: %w", err)
}
password, generated := s.cfg.BootstrapPassword, false
if password == "" {
if password, err = GeneratePassword(20); err != nil {
return err
}
generated = true
}
hash, err := s.hasher.Hash(password)
if err != nil {
return err
}
u := User{
ID: s.ids.New(), Username: username, PasswordHash: hash, Role: RoleAdmin, Locale: "ru",
MustChangeCredentials: true, CreatedAt: s.clock.Now(),
}
err = s.store.WithTx(ctx, func(tx Store) error {
if err := tx.CreateUser(ctx, u, nil); err != nil {
return err
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
Action: "user.bootstrap_create", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username},
})
})
if err != nil {
return fmt.Errorf("auth: bootstrap: %w", err)
}
if generated {
// Единственное место, где пароль попадает в лог: админ увидит его в `docker compose logs api`.
s.log.Warn("bootstrap admin created: sign in and change the credentials",
"username", username, "password", password)
} else {
s.log.Warn("bootstrap admin created with BOOTSTRAP_ADMIN_PASSWORD: sign in and change the credentials",
"username", username)
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
package auth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
)
// Ошибки хранилища.
var (
ErrNotFound = errors.New("auth: not found")
ErrUsernameTaken = errors.New("auth: username taken")
)
// ProfileUpdate — изменяемые пользователем поля профиля. nil — не менять.
type ProfileUpdate struct {
DisplayName **string // *nil — сбросить имя
Locale *string
UIPrefs *UIPrefs
}
// UserFilter — параметры списка пользователей.
type UserFilter struct {
Query string // подстрока логина или имени
Role *Role
After string // логин последнего элемента предыдущей страницы
Limit int
}
// UserUpdate — изменения из админки. nil — не менять.
type UserUpdate struct {
DisplayName **string
Role *Role
IsBlocked *bool
RevokeTokens bool
}
// Store — доступ к users, refresh_tokens, audit_log и app_settings.
type Store interface {
// WithTx выполняет fn в транзакции; Store внутри fn работает в ней же.
WithTx(ctx context.Context, fn func(Store) error) error
CountAdmins(ctx context.Context) (int, error)
CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error
// RecordLoginSuccess сбрасывает счётчик неудач; newHash != nil — пересчитанный хэш пароля.
RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error
// SetPassword меняет хэш, снимает must_change_password и увеличивает token_version.
SetPassword(ctx context.Context, id uuid.UUID, hash string) error
// SetCredentials меняет логин и хэш, снимает must_change_credentials и увеличивает token_version.
SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error
UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error)
InsertRefreshToken(ctx context.Context, t RefreshToken) error
// GetRefreshTokenForUpdate находит токен по хэшу и блокирует строку до конца транзакции.
GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error)
MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error
RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error
// RevokeUserSessions отзывает все семьи пользователя.
RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error
// ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен.
ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error)
// ListUsers — страница пользователей по возрастанию логина, после логина f.After.
ListUsers(ctx context.Context, f UserFilter) ([]User, error)
// LockActiveAdmins блокирует строки активных админов до конца транзакции и возвращает их число:
// два параллельных понижения не оставят систему без админа.
LockActiveAdmins(ctx context.Context) (int, error)
// UpdateUser применяет изменения админки; RevokeTokens увеличивает token_version.
UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate) (User, error)
// ResetPassword ставит временный пароль: must_change_password, сброс блокировки входа, token_version + 1.
ResetPassword(ctx context.Context, id uuid.UUID, hash string) error
DeleteUser(ctx context.Context, id uuid.UUID) error
InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error
// ListAudit — записи журнала новее-первыми, с id меньше before (uuid.Nil — с начала).
ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error)
GetSettingBool(ctx context.Context, key string) (bool, error)
SetSetting(ctx context.Context, key string, value any) error
}
// Limiter ограничивает частоту попыток входа.
type Limiter interface {
// Allow учитывает попытку по ключу. retryAfter — секунды до сброса окна, если попытка отклонена.
Allow(ctx context.Context, key string, limit int, window time.Duration) (ok bool, retryAfter int, err error)
}
// Clock — источник времени (подменяется в тестах).
type Clock interface{ Now() time.Time }
// IDGen — генератор UUIDv7.
type IDGen interface{ New() uuid.UUID }
// SystemClock — реальное время в UTC.
type SystemClock struct{}
// Now возвращает текущее время.
func (SystemClock) Now() time.Time { return time.Now().UTC() }
// UUIDv7 — генератор сортируемых по времени UUID.
type UUIDv7 struct{}
// New возвращает новый UUIDv7.
func (UUIDv7) New() uuid.UUID { return uuid.Must(uuid.NewV7()) }
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more