Files
cardy/development specification-v2.md
T
2026-09-29 13:50:01 +03:00

211 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-тестов нет.