Files
cardy/development specification-v2.md
T

218 lines
29 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
comment: "Card of Anna\nScan twice" # многострочный, до 2000 символов, '' если пусто
tags: [продукты, семья] # [] если нет
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`, опционально `comment`, `tags` (строка через запятую) и файл `logo`); возвращает карту с ID |
| `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий, пустые `tags` — теги; опционально файл `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`). При конкурентных правках побеждает последняя запись.
**Теги карты.** Вводятся строкой через запятую; у каждого тега обрезаются ведущие и концевые пробелы, пустые теги отбрасываются, точные повторы тоже (`один,,, два` → `[один, два]`). Не больше 30 тегов, каждый до 50 символов. API принимает строку или массив строк (к каждому элементу применяется то же правило).
## 4. Клиентское мобильное приложение
React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 (`vite preview` собранной версии; `vite` — для разработки) с базовым путём `/m` (`https://dev.aggtaa.com/m`); прокси срезает `/m`, сервер восстанавливает его сам. Сборка для Capacitor (`pnpm build:android`) — с относительным базовым путём, в `dist-capacitor`. Service worker кэширует приложение целиком (включая `bwip-js` и wasm-модуль сканера) для работы офлайн.
- Хранит один ключ, адрес API и хэш PIN-кода (SHA-256 с солью), список карт, даты использования и версии скачанных картинок — в Capacitor Preferences (в PWA это localStorage). Картинки — в IndexedDB (и в PWA, и в WebView Capacitor).
- **Deep link `cardy://login?server=…&key=…`** зарегистрирован в Android-приложении (intent filter для схемы `cardy`, хост `login`). Открытие ссылки (например, QR из системной камеры) открывает экран нового входа с заполненными полями.
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически.
- **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает.
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне.
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, поиск по подстроке (от 1 символа, без учёта регистра) в названии, тегах и комментарии, кнопки «обновить» и «добавить».
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
- **Экран карты**: название, код (отрисовка `bwip-js`), теги, комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
- **Экран добавления и редактирования**: название, комментарий (многострочное поле), теги (через запятую), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся.
- **Обновления Android-приложения без переустановки APK** (`@capgo/capacitor-updater` в ручном режиме, без облака Capgo, телеметрия отключена). APK содержит веб-сборку и полностью работает офлайн. `pnpm publish:android` собирает новую версию (номер — время сборки в UTC, `ГГГГ.МДД.Ччммсс`), кладёт zip и `version.json` (версия, SHA-256, минимальный `versionCode` APK) в `mobile-client/updates/android`; сервер `mobile-client` раздаёт их по `/m/android/`. При запуске и каждом возврате в приложение (не чаще раза в минуту, только при наличии связи) приложение проверяет `version.json` и скачивает более новую совместимую версию в фоне. На экране списка карт (разблокировано, без идущей загрузки картинок) она применяется сразу — перезагрузкой без повторного PIN; на экране карты, в редакторе, на экране входа или PIN — откладывается (`next()`: применяется при уходе в фон или при возврате к списку). Версия, не подтвердившая запуск за 10 секунд, откатывается к предыдущей. Установка нового APK сбрасывает скачанные версии.
- **Обновление самого APK** (при изменениях нативной части: плагины, манифест, Java). `pnpm publish:android-apk` собирает релизный APK, подписанный ключом из `android/keystore.properties` (не в git; тем же ключом подписываются и отладочные сборки), с `versionCode` из времени сборки (`ггДДДЧЧмм`, UTC) и `versionName` = версия веб-сборки, и кладёт его вместе с `apk.json` (versionCode, versionName, SHA-256, размер) в `mobile-client/updates/android`. При запуске и возврате в приложение (не чаще раза в минуту) приложение сверяет `apk.json` со своим `versionCode` и при наличии более нового APK показывает на экране списка карт баннер «A new version of the app is available» с кнопкой Install: APK скачивается с прогрессом, проверяется SHA-256 и передаётся системному установщику Android (`PackageInstaller`). В первый раз нужно разрешить Cardy «Установку неизвестных приложений» (приложение открывает этот экран настроек), затем подтвердить системный диалог; на Android 12+ последующие обновления могут устанавливаться без подтверждения, если это разрешит система. Веб-обновления, опубликованные после выпуска APK, требуют этот APK или новее (`minNativeBuild`), старые установки получают предложение обновить APK.
- Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет).
## 5. Тесты
Vitest. Сервер: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`), интеграция с прокси. Клиент: пул загрузок, выбор карт для докачки, сортировка, PIN, распознаваемые форматы. E2E-тестов нет.