server be & fe
This commit is contained in:
@@ -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-тестов нет.
|
||||
Reference in New Issue
Block a user