server be & fe

This commit is contained in:
azykov@mail.ru
2026-09-29 13:50:01 +03:00
commit 657009bd6c
56 changed files with 6162 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
# Cardy — техническое задание, версия 2
Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональное лого + опциональные картинки (фото/скриншоты).
Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались).
Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят.
## 1. Структура репозитория
pnpm-монорепо, TypeScript, ESM.
| Каталог | Пакет | Назначение |
|---|---|---|
| `shared/` | `@cardy/shared` | Общие типы, схемы валидации (zod), список типов кодов, соответствие типов кодов `bwip-js`, формат deep link |
| `server-be/` | `@cardy/server-be` | Бэкенд: Fastify + TS |
| `server-fe/` | `@cardy/server-fe` | Административный веб-UI: React + Vite + TS |
| `mobile-client/` | `@cardy/mobile-client` | Клиент: React + Vite + TS, PWA + Capacitor (Android) |
## 2. Процессы, порты и прокси
Три отдельных процесса за одним реверс-прокси (домен для разработки — `https://dev.aggtaa.com`):
| Приложение | Порт | Публичный адрес | Аутентификация на прокси |
|---|---|---|---|
| `server-fe` (Vite dev/preview-сервер) | 10001 | `https://dev.aggtaa.com/` | forward-auth (Authelia) |
| `server-be` (Fastify), админский API | 10002 | `https://dev.aggtaa.com/api/admin/*` | forward-auth (Authelia), прокси передаёт `Remote-Email` |
| `server-be` (Fastify), клиентский API | 10002 | `https://dev.aggtaa.com/api/*` | нет |
| `mobile-client` (PWA) | 10003 | `https://dev.aggtaa.com/m` | нет |
- Прокси срезает префиксы `/api` и `/m` и передаёт их в заголовке `X-Forwarded-Prefix`. На `server-be` админский API обслуживается по `/admin/*`, клиентский — от корня (`/auth`, `/cards`, …); на `mobile-client` запросы приходят от корня.
- Админский UI обращается к `/api/admin/*` напрямую (тот же origin, та же cookie сессии Authelia). Запросы отправляются с `Accept: application/json` и `X-Requested-With: XMLHttpRequest`, чтобы при истёкшей сессии Authelia отвечала `401`, а не редиректом; на такой `401` (ответ не от Cardy) UI перезагружает страницу, и Authelia показывает форму входа.
- `server-be` по умолчанию слушает только `127.0.0.1`: админский API доверяет заголовку `Remote-Email`. Дополнительная защита — `ADMIN_PROXY_SECRET`: прокси добавляет заголовок `X-Cardy-Proxy-Secret` на маршруте `/api/admin`, без него `/admin/*` отвечает `401`.
- PWA на `/m` и API на `/api` — один origin, CORS для PWA не нужен; для Capacitor-приложения CORS включён (кроме `/admin/*`).
- Для локальной разработки без прокси Vite (`server-fe`) эмулирует прокси: пересылает `/api/*` на `server-be` со срезанным `/api` и `X-Forwarded-Prefix: /api`.
## 3. Сервер
### 3.1. Конфигурация (переменные окружения)
Задаются в окружении или в файле `.env` в каталоге приложения (образцы — `.env.example`).
`server-be`:
| Переменная | Обязательна | Описание |
|---|---|---|
| `KEY_ENCRYPTION_SECRET` | да | 32 байта в base64 — ключ AES-256-GCM для шифрования ключей доступа |
| `SUPERADMIN_EMAIL` | да | email первого суперадмина |
| `PUBLIC_API_URL` | нет | публичный адрес API (`https://dev.aggtaa.com/api`) для QR-кода ключа; если не задан — вычисляется из `X-Forwarded-Proto`/`X-Forwarded-Host`/`X-Forwarded-Prefix` |
| `ADMIN_PROXY_SECRET` | нет | если задан, `/admin/*` требует заголовок `X-Cardy-Proxy-Secret` с этим значением |
| `SUPERADMIN_GROUP` | нет, `default` | имя группы, в которую создаётся суперадмин из `SUPERADMIN_EMAIL` |
| `DATA_DIR` | нет, `./data` | каталог данных |
| `HOST` / `PORT` | нет, `127.0.0.1` / `10002` | адрес прослушивания |
`server-fe`:
| Переменная | Описание |
|---|---|
| `ALLOWED_HOSTS` | публичные имена хоста для Vite (по умолчанию `dev.aggtaa.com`) |
| `BACKEND_URL` | только локально: адрес `server-be` (по умолчанию `http://127.0.0.1:10002`) |
| `ADMIN_PROXY_SECRET` | только локально: тот же секрет, что у `server-be` |
| `DEV_REMOTE_EMAIL` | только локально, без Authelia: подставляет `Remote-Email` |
При старте: если пользователя `SUPERADMIN_EMAIL` нет — он создаётся в группе `SUPERADMIN_GROUP` (группа создаётся, если её нет); если есть — ему гарантированно выставляется роль суперадмина.
### 3.2. Хранилище
```
DATA_DIR/
users.yaml # группы, пользователи, зашифрованные ключи
groups/<groupId>/cards.yaml # карты группы
groups/<groupId>/images/<cardId>/<imageId>.<ext>
```
- Папки групп называются по неизменяемому ID группы, а не по имени — переименование группы не трогает файлы.
- Запись файлов атомарная (временный файл + rename), записи в один файл сериализуются.
- Если yaml-файл изменён вручную, сервер перечитывает его при следующем обращении (по времени изменения).
`users.yaml`:
```yaml
groups:
- id: 3f1c… # UUID
name: Family
users:
- email: alice@example.com # уникален глобально, хранится в нижнем регистре
groupId: 3f1c…
role: superadmin # user | admin | superadmin
keys:
- id: 9a0b… # UUID
label: Alice's phone # опционально
createdAt: 2026-09-29T10:00:00.000Z
encrypted: <base64(iv | authTag | ciphertext)>
```
`cards.yaml`:
```yaml
cards:
- id: 7c9e… # UUID, генерируется сервером
name: Magnit
color: "#E53935" # #RRGGBB
code: "4600000000000"
codeType: EAN13
logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null
images: # порядок = порядок добавления
- { id: 5f6a…, file: 5f6a….webp, mime: image/webp }
imagesVersion: 3
deleted: false
createdAt: …
updatedAt: …
```
Типы кодов: `EAN13`, `EAN8`, `UPCA`, `UPCE`, `CODE128`, `CODE39`, `ITF`, `CODABAR`, `QR`, `DATAMATRIX`, `PDF417`, `AZTEC`.
`imagesVersion` начинается с 0 и увеличивается на 1 при любом добавлении, замене или удалении лого или картинки — и из админского UI, и через клиентский API.
Картинки сохраняются как есть, без валидации типа и размера. Лимит размера запроса задаёт реверс-прокси; собственный лимит сервера на размер тела запроса отключён.
При отдаче картинок сервер выставляет `X-Content-Type-Options: nosniff` и `Content-Security-Policy: sandbox`, а для типов не `image/*` — `Content-Type: application/octet-stream`.
### 3.3. Пользователи, группы, роли
- Пользователь состоит ровно в одной группе.
- `admin` — администратор только своей группы. `superadmin` — администратор всех групп.
- Карты общие для всех пользователей группы.
- Группу, в которой есть хотя бы один суперадмин, удалить нельзя. При удалении группы удаляются все её пользователи, их ключи и все карты с картинками.
- Нельзя удалить самого себя и нельзя понизить свою роль. Нельзя удалить или понизить последнего суперадмина.
- `admin` может создавать, удалять и менять роль (`user`/`admin`) пользователей своей группы. Назначать `superadmin`, изменять суперадминов и переносить пользователей между группами может только `superadmin`.
- Новые ключи генерирует только админ (для пользователей своей группы) или суперадмин.
### 3.4. Ключи доступа
- 32 случайных символа из `[A-Za-z0-9]` (криптостойкий генератор).
- Хранятся зашифрованными AES-256-GCM (случайный 12-байтный IV на каждый ключ).
- Сессий нет: клиент передаёт ключ в каждом запросе (`Authorization: Bearer <ключ>`). Удалённый ключ перестаёт работать сразу.
- QR-код ключа содержит deep link: `cardy://login?server=<публичный адрес API, URL-encoded>&key=<ключ>`. Клиент обращается к `<server>/auth`, `<server>/cards` и т.д.
### 3.5. Аутентификация административного UI
- Пользователь определяется по заголовку `Remote-Email` (без учёта регистра). Нет заголовка или пользователь не найден в `users.yaml` — `401`. Недостаточно прав — `403`.
- Текущая группа: для `user` и `admin` всегда своя группа. Для `superadmin` — значение cookie `cardy_group` (если его нет или группа не существует — своя группа); UI суперадмина позволяет выбрать другую группу, что записывает cookie.
- Если задан `ADMIN_PROXY_SECRET`, без правильного заголовка `X-Cardy-Proxy-Secret` — `401`.
- Изменяющие запросы (`POST`/`PUT`/`PATCH`/`DELETE`) к админскому API требуют заголовка `X-Cardy-Admin: 1` (защита от CSRF).
### 3.6. Административный UI (`server-fe`, `https://dev.aggtaa.com/`)
Страницы: `/cards`, `/cards/:id`, `/cards/new`, `/users`, `/groups`, `/keys`.
- Шапка: email, роль, текущая группа; у суперадмина — выбор текущей группы.
- **My keys** (все пользователи): список своих ключей; для выбранного ключа — QR-код с deep link, сам ключ текстом, кнопка копирования.
- **Users** (admin, superadmin): CRUD пользователей текущей группы (email, роль; у суперадмина — ещё группа), по каждому пользователю — список ключей, генерация ключа (с опциональной меткой), удаление ключа, показ QR.
- **Groups** (superadmin): CRUD групп (имя, уникально).
- **Cards** (admin, superadmin): список всех карт текущей группы, включая удалённые (фильтр по названию, переключатель «показывать удалённые»). Создание и редактирование: название, цвет, код, тип кода, предпросмотр кода, лого (загрузка/удаление), картинки (загрузка/удаление). Удаление помечает карту удалённой; удалённую карту можно восстановить или удалить окончательно (вместе с картинками).
### 3.7. API административного UI (публично `/api/admin`, на сервере `/admin`)
Пути ниже — относительно этого префикса.
| Метод и путь | Доступ | Описание |
|---|---|---|
| `GET /me` | все | текущий пользователь, текущая группа |
| `GET /my-keys` | все | свои ключи (расшифрованные) и deep link |
| `GET /groups` | superadmin | список групп |
| `POST /groups`, `PATCH /groups/:id`, `DELETE /groups/:id` | superadmin | CRUD групп |
| `PUT /current-group` | superadmin | выбор текущей группы (cookie) |
| `GET /users` | admin | пользователи текущей группы с ключами |
| `POST /users`, `PATCH /users/:email`, `DELETE /users/:email` | admin | CRUD пользователей |
| `POST /users/:email/keys`, `DELETE /users/:email/keys/:keyId` | admin | генерация и удаление ключей |
| `GET /cards` | admin | все карты текущей группы, включая удалённые |
| `POST /cards`, `PATCH /cards/:id` | admin | создание и изменение (JSON; `deleted: false` восстанавливает) |
| `DELETE /cards/:id` | admin | пометить удалённой; для уже удалённой — удалить окончательно |
| `PUT /cards/:id/logo`, `DELETE /cards/:id/logo` | admin | лого (multipart, поле `file`) |
| `POST /cards/:id/images`, `DELETE /cards/:id/images/:imageId` | admin | картинки (multipart, одно или несколько полей `file`) |
| `GET /cards/:id/logo`, `GET /cards/:id/images/:imageId` | admin | получить файл |
### 3.8. Клиентский API (публично `/api`, на сервере — от корня)
Все запросы с `Authorization: Bearer <ключ>`; неверный или отсутствующий ключ — `401`. Группа определяется по владельцу ключа. Удалённые карты для клиентского API не существуют (`404`). CORS разрешён для любого origin (нужно для Capacitor, origin `https://localhost`); cookie не используются.
| Метод и путь | Описание |
|---|---|
| `GET /api/auth` | проверка ключа; возвращает email и группу |
| `GET /api/cards` | все неудалённые карты группы, без пагинации |
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально файл `logo`); возвращает карту с ID |
| `PUT /api/cards/:id` | изменить карту (multipart: те же поля, опционально файл `logo`, опционально `removeLogo=true`) |
| `DELETE /api/cards/:id` | пометить карту удалённой |
| `GET /api/cards/:id/logo` | лого |
| `GET /api/cards/:id/images/:imageId` | картинка |
| `POST /api/cards/:id/images` | добавить картинки (multipart, одно или несколько полей `file`) |
| `DELETE /api/cards/:id/images/:imageId` | удалить картинку |
Все изменяющие запросы возвращают карту целиком (включая новый `imagesVersion`). При конкурентных правках побеждает последняя запись.
## 4. Клиентское мобильное приложение
React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 с базовым путём `/m` (`https://dev.aggtaa.com/m`).
- Хранит один ключ, адрес сервера и хэш PIN-кода (Capacitor Preferences / localStorage). Картинки — в Filesystem (Capacitor) или IndexedDB (PWA).
- **Deep link `cardy://login?server=…&key=…`** зарегистрирован в Android-приложении (intent filter для схемы `cardy`, хост `login`). Открытие ссылки (например, QR из системной камеры) открывает экран нового входа с заполненными полями.
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний.
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход.
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить».
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно.
- **Экран карты**: название, код (отрисовка `bwip-js`), локальные картинки. На Android яркость поднимается до максимума. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
- **Экран добавления и редактирования**: название, цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (Android — ML Kit, PWA — `zxing-wasm`). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера.
- Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет).
## 5. Тесты
Unit- и интеграционные тесты сервера на Vitest: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`). E2E-тестов нет.