30 KiB
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:
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:
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— значение cookiecardy_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-модуль входит в сборку). После съёмки или выбора каждое изображение открывается на экране кадрирования: рамку можно двигать и менять за углы (для лого рамка квадратная, для фото свободная и по умолчанию охватывает всё изображение), изображение можно повернуть на 90°; «Cancel» пропускает изображение, несколько фото из галереи кадрируются по очереди; системная кнопка «назад» Android закрывает кадрирование (и сканер камеры), не уходя с экрана редактирования. Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла сcapture) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. - После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся.
- Обновления Android-приложения без переустановки APK (
@capgo/capacitor-updaterв ручном режиме, без облака Capgo, телеметрия отключена). APK содержит веб-сборку и полностью работает офлайн.pnpm publish:androidсобирает новую версию (номер — время сборки в UTC,ГГГГ.МДД.Ччммсс), кладёт zip иversion.json(версия, SHA-256, минимальныйversionCodeAPK) в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-тестов нет.