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
+28
View File
@@ -0,0 +1,28 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "server-be (local test)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@cardy/server-be", "exec", "tsx", "src/index.ts"],
"port": 10002,
"env": {
"KEY_ENCRYPTION_SECRET": "cHrUrVMk3KgaAw066+CPj7iYXuBfBXbH5lPLFwoBSLA=",
"SUPERADMIN_EMAIL": "admin@local.test",
"ADMIN_PROXY_SECRET": "local-proxy-secret",
"DATA_DIR": "C:/Users/User/AppData/Local/Temp/claude/D--Documents-ProjectsGit-Cardy/bf03f49e-0768-4ffa-b6d2-c9a06a1562aa/scratchpad/data"
}
},
{
"name": "server-fe (local test)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@cardy/server-fe", "exec", "vite"],
"port": 10001,
"env": {
"ADMIN_PROXY_SECRET": "local-proxy-secret",
"DEV_REMOTE_EMAIL": "admin@local.test",
"ALLOWED_HOSTS": "localhost"
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
data/
.env
*.log
+44
View File
@@ -0,0 +1,44 @@
# Cardy
Discount card storage. Specification: [development specification-v2.md](development%20specification-v2.md).
| Package | Port | Public URL | Proxy auth |
|---|---|---|---|
| `server-fe` — admin UI (Vite) | 10001 | `https://dev.aggtaa.com/` | Authelia |
| `server-be` — admin API | 10002 | `https://dev.aggtaa.com/api/admin/*` → `/admin/*` | Authelia, passes `Remote-Email` |
| `server-be` — client API | 10002 | `https://dev.aggtaa.com/api/*` → `/*` | none |
| `mobile-client` — PWA | 10003 | `https://dev.aggtaa.com/m/*` → `/*` | none |
The proxy strips `/api` and `/m` and passes them in `X-Forwarded-Prefix`.
## Setup
```bash
pnpm install
cp server-be/.env.example server-be/.env # fill in KEY_ENCRYPTION_SECRET, SUPERADMIN_EMAIL
cp server-fe/.env.example server-fe/.env
```
## Run
```bash
pnpm --filter @cardy/server-be dev # API on 127.0.0.1:10002 (tsx watch)
pnpm --filter @cardy/server-fe dev # admin UI on :10001
```
Production-like: `pnpm --filter @cardy/server-be build && pnpm --filter @cardy/server-be start`,
`pnpm --filter @cardy/server-fe start` (build + `vite preview` on :10001).
`server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`.
If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add
`X-Cardy-Proxy-Secret` on the `/api/admin` route.
Without the proxy (local development), `server-fe` emulates it: forwards `/api/*` to `server-be`
with `/api` stripped; `DEV_REMOTE_EMAIL` in `server-fe/.env` fakes the Authelia user.
## Tests
```bash
pnpm test
pnpm typecheck
```
+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-тестов нет.
+65
View File
@@ -0,0 +1,65 @@
# pnpm монорепо приложение для хранения скидочных карт (штрих/qr-код + тип кода, EAN-13, EAN-8, Code128, QR, PDF417, Aztec и т.д. + название + цвет + опционально лого + опционально скриншоты).
# серверное приложение (fastify+react+vite+ts):
* хранение пользователей в файле users.yaml (по email) и их групп (по названиям). для пользователя может быть храниться признак того, что это - администратор или суперадминистратор.
* пользователь является администратором только для своей группы. суперадминистратор - администратор всех групп.
* аутентификация пользователя для доступа к UI (не API) через заголовок Remote-Email
* для каждого пользователя может храниться несколько строковых ключей доступа. ключи хранятся в зашифрованном обратимым шифрованием AES-256-GCM виде, ключ расшифровки (32-байта в base64) в переменной окружении.
* административный (для суперадмина) CRUD UI для управления группами пользователей и выбора одной группы, как текущей. группу, к которой принадлежит хотя бы один суперадмин, невозможно удалить.
* административный UI CRUD пользователей текущей группы и приязанных к ним ключей доступа. ключи генерируются случайно (32 символа)
* хранилище списка карт в yaml файле в папке соответственной группы пользователей, картинки рядом с ним на диске в виде файлов. количество картинок на карту не ограничено. карты общие для всех пользователей одной группы. для каждой карты указывается числовой номер версии картинок.
* административный UI для CRUD всех хранящихся, включая удаленные, карт текущей группы пользователей, загрузки картинок к ним. изменение/добавление/удаление любой картинки увеличивает номер версии в карте. загруженные картинки хранятся как есть.
* UI для показа списка ключей текущего пользователя. Для одного выбранного ключа генерирутся QR код, содержащий адрес сервера и сам ключ.
* API для клиентского приложения:
* аутентификация пользователя по ключу (Authorization: Bearer)
* получение списка всех карт (без пагинации). удаленные карты получить невозможно
* получение картинок по выбранной карте
* редактирование, удаление и добавление карт с их лого. удалённые карты помечаются, как удаленные и не удаляются
* удаление и добавление картинок карт
* весь UI на английском
# клиентское мобильное приложение (react/vite/ts pwa + capacitor андроид):
* хранение ключа и пинкода для входа
* экран нового входа
* поле для ввода адреса серверного API
* показывает камеру, даёт отсканировать ключ из QR кода
* позволяет ввести ключ текстом в открытом виде.
обязательна аутентификация на сервере для подтверждения правильности ключа. экран можно вызвать даже если ключ уже хранится в приложении, в этом случае старый ключ удаляется. хранится всегда только один последний ключ
* экран цифрового кода (= "пинкода"). показывается для ввода пинкода (4 цифры) при смене ключа (пинкод сохраняется) и при каждом открытии приложения (пинкод сверяется с хранящимся). защиты от перебора нет.
* экран карт. список всех хранящихся локально карт (цветными названия с логотипами или, если их нет, с аббревиатурами на цветном фоне) с возможностью выбрать одну из них во весь экран. сортировка по давности использования
* экран (одной) карты: просмотр её названия, штрих/qr-кода и её хранящиеся локально картинки, если есть. яркость экрана на андроиде поднимается на максимум. открывание экрана считается использованием карты и сохраняет дату и время
* на экране карт кнопка "фильтр по названию" (по подстроке длиной от 1 символа)
* на экране карт кнопка "обновить". перекачивает заново список всех карт с сервера и обновляет сохраненный список (только если скачалось успешно). для изменившихся карт асинхронно по очереди (макс 5 запросов одновременно) скачиваются их картинки.
* экран редактирования/добавления новой карты: поле для ввода названия, выбора цвета (по умолчанию случайный), загрузки опционального лого, загрузки опциональных фото, ввода кода и выбора типа код карты текстом или распознаванием QR/штрих-кода и его типа с карты или из загруженной картинки. лого и картинки перед отправкой, пережимаются в webp до размера 1000px по наибольшей стороне, EXIF удаляется
* на экране карт кнопка "добавить", вызывает экран редактирования/добавления новой карты
* на экране карты кнопка "удалить" с подтверждением.
* на экране карты кнопка "редактировать", вызывает экран редактирования/добавления новой карты
* весь UI на английском
клиентское приложение должно работать без интернета за исключением функций аутентификации, скачивания, редактирования, удаления и добавления карт и картинок. функции, требующие наличия сети, показывают ошибки сообщениями на экране и отваливаются.
вопросы деплоя в этом задании не рассматриваются
# Дополнительная информация
Пользователи и группы
* Пользователь состоит ровно в одной группе
* Первый суперадмин создаётся из переменной окружения SUPERADMIN_EMAIL
* При удалении группы все пользователи, ключи и карты удаляются.
* Текущая группа пользователя читается из users.yaml и сохраняется в cookie. UI суперадмина позволяет записать в эту cookie другое значение.
* При ошибка аутентификации отвечать ошибкой 401.
* Новые ключи генерируются только админом или суперадмином
Сессии и ключи
* Шифрование ключей: AES-256-GCM с 32-байтным ключом в base64 в env подойдёт?
Данные
* Где генерируется ID карты? На сервере, при добавлении. Возвращается клиенту.
* Конкурентные правки одной карты с разных устройств: побеждает последняя запись? Да.
* Дата последнего использования карты хранится только локально на устройстве и сохраняется при «обновить» по ID карты? Да.
Клиент и деплой
* Как менять PIN без смены ключа? Или только через повторный вход? Да.
* PWA раздаётся с того же домена что и API.
* Нужны ли Dockerfile и docker-compose с томом под данные и пример конфига прокси с bypass для /api? Нет.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "cardy",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck",
"dev:server": "pnpm --filter @cardy/server-be dev",
"dev:admin": "pnpm --filter @cardy/server-fe dev"
}
}
+1926
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
packages:
- shared
- server-be
- server-fe
allowBuilds:
esbuild: true
+25
View File
@@ -0,0 +1,25 @@
# Copy to .env and fill in.
# 32 random bytes in base64, e.g.: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Losing it makes all stored access keys unreadable.
KEY_ENCRYPTION_SECRET=
# Created (or promoted) as superadmin on start.
SUPERADMIN_EMAIL=
# Group of that superadmin if it has to be created.
SUPERADMIN_GROUP=default
# The reverse proxy strips /api and sends X-Forwarded-Prefix: /api.
# public /api/admin/* (Authelia) -> /admin/*
# public /api/* (no auth) -> /*
# Public API URL for key QR codes. Optional: derived from X-Forwarded-Proto/Host/Prefix when empty.
# PUBLIC_API_URL=https://dev.aggtaa.com/api
# Optional extra protection: when set, /admin/* requires the header X-Cardy-Proxy-Secret with this value.
# Configure the reverse proxy to add it on the /api/admin route. Recommended if HOST is not loopback.
# ADMIN_PROXY_SECRET=
DATA_DIR=./data
# Loopback by default: /admin/* trusts Remote-Email, so only the local reverse proxy may connect.
HOST=127.0.0.1
PORT=10002
+25
View File
@@ -0,0 +1,25 @@
import { build } from 'esbuild';
import path from 'node:path';
// Bundle workspace packages (they ship TypeScript sources), keep npm dependencies external.
const externalNpmPackages = {
name: 'external-npm-packages',
setup(b) {
b.onResolve({ filter: /.*/ }, (args) => {
if (args.kind === 'entry-point' || args.path.startsWith('.') || path.isAbsolute(args.path)) return undefined;
if (args.path.startsWith('@cardy/')) return undefined;
return { path: args.path, external: true };
});
},
};
await build({
entryPoints: ['src/index.ts'],
outfile: 'dist/index.js',
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22',
sourcemap: true,
plugins: [externalNpmPackages],
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@cardy/server-be",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"build": "node build.mjs",
"start": "node --env-file-if-exists=.env dist/index.js",
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@cardy/shared": "workspace:*",
"@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0",
"@fastify/multipart": "^10.1.2",
"fastify": "^5.12.5",
"yaml": "^2.9.1",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/node": "^22.20.4",
"esbuild": "^0.28.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}
+50
View File
@@ -0,0 +1,50 @@
import cookie from '@fastify/cookie';
import multipart from '@fastify/multipart';
import Fastify, { type FastifyServerOptions } from 'fastify';
import { ZodError } from 'zod';
import { CardsService } from './cards.js';
import { ADMIN_API_PREFIX, type Config } from './config.js';
import { HttpError } from './errors.js';
import { formatZodError } from './http.js';
import { adminApi } from './routes/adminApi.js';
import { clientApi } from './routes/clientApi.js';
import { UsersService } from './users.js';
// Upload size is limited by the reverse proxy, not by the server.
const NO_LIMIT = Number.MAX_SAFE_INTEGER;
export async function buildApp(config: Config, options: FastifyServerOptions = {}) {
// trustProxy: protocol and host come from X-Forwarded-* set by the reverse proxy.
const app = Fastify({ bodyLimit: NO_LIMIT, trustProxy: true, ...options });
const users = new UsersService(config.dataDir, config.keySecret);
const cards = new CardsService(config.dataDir);
await users.ensureSuperadmin(config.superadminEmail, config.superadminGroup);
app.setErrorHandler((error, request, reply) => {
if (error instanceof HttpError) return reply.code(error.statusCode).send({ error: error.message });
if (error instanceof ZodError) return reply.code(400).send({ error: formatZodError(error) });
const statusCode = (error as { statusCode?: number }).statusCode;
if (statusCode && statusCode >= 400 && statusCode < 500) {
return reply.code(statusCode).send({ error: (error as Error).message });
}
request.log.error(error);
return reply.code(500).send({ error: 'Internal server error' });
});
app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'Not found' }));
await app.register(cookie);
await app.register(multipart, { limits: { fileSize: NO_LIMIT, files: NO_LIMIT, fieldSize: 1024 * 1024 } });
await app.register(adminApi, {
prefix: ADMIN_API_PREFIX,
users,
cards,
proxySecret: config.adminProxySecret,
publicApiUrl: config.publicApiUrl,
});
await app.register(clientApi, { users, cards });
return app;
}
+240
View File
@@ -0,0 +1,240 @@
import { randomUUID } from 'node:crypto';
import { mkdir, rm, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { CODE_TYPES, type Card, type CardFields, type ImageRef } from '@cardy/shared';
import { badRequest, notFound } from './errors.js';
import { YamlFile } from './yamlFile.js';
const safeId = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id');
const safeFileName = z.string().regex(/^[A-Za-z0-9-]+\.[A-Za-z0-9]{1,10}$/, 'Invalid file name');
const imageRefSchema = z.object({ id: safeId, file: safeFileName, mime: z.string() });
const cardSchema = z.object({
id: safeId,
name: z.string(),
color: z.string(),
code: z.coerce.string(),
codeType: z.enum(CODE_TYPES),
logo: imageRefSchema.nullable().default(null),
images: z.array(imageRefSchema).default([]),
imagesVersion: z.number().int().default(0),
deleted: z.boolean().default(false),
createdAt: z.string(),
updatedAt: z.string(),
});
const cardsFileSchema = z.object({ cards: z.array(cardSchema).default([]) });
type CardsFile = z.infer<typeof cardsFileSchema>;
export interface Upload {
data: Buffer;
mime: string;
filename?: string;
}
export interface CardAccess {
/** Admin UI sees deleted cards; the client API does not. */
includeDeleted: boolean;
}
const MIME_EXTENSIONS: Record<string, string> = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/avif': 'avif',
'image/heic': 'heic',
'image/heif': 'heif',
'image/bmp': 'bmp',
'image/svg+xml': 'svg',
};
function extensionFor(upload: Upload): string {
const byMime = MIME_EXTENSIONS[upload.mime.toLowerCase()];
if (byMime) return byMime;
const byName = path.extname(upload.filename ?? '').slice(1).toLowerCase();
return /^[a-z0-9]{1,10}$/.test(byName) ? byName : 'bin';
}
export class CardsService {
private readonly files = new Map<string, YamlFile<CardsFile>>();
constructor(private readonly dataDir: string) {}
private groupDir(groupId: string): string {
return path.join(this.dataDir, 'groups', safeId.parse(groupId));
}
private imageDir(groupId: string, cardId: string): string {
return path.join(this.groupDir(groupId), 'images', safeId.parse(cardId));
}
private file(groupId: string): YamlFile<CardsFile> {
let file = this.files.get(groupId);
if (!file) {
file = new YamlFile(
path.join(this.groupDir(groupId), 'cards.yaml'),
(raw) => cardsFileSchema.parse(raw ?? {}),
() => ({ cards: [] }),
);
this.files.set(groupId, file);
}
return file;
}
async list(groupId: string, access: CardAccess): Promise<Card[]> {
const { cards } = await this.file(groupId).read();
return access.includeDeleted ? cards : cards.filter((c) => !c.deleted);
}
async get(groupId: string, cardId: string, access: CardAccess): Promise<Card> {
const card = (await this.file(groupId).read()).cards.find((c) => c.id === cardId);
if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found');
return card;
}
/** Path of the card's logo or image file on disk. */
async imagePath(groupId: string, cardId: string, imageId: 'logo' | string, access: CardAccess) {
const card = await this.get(groupId, cardId, access);
const ref = imageId === 'logo' ? card.logo : card.images.find((i) => i.id === imageId);
if (!ref) throw notFound('Image not found');
return { path: path.join(this.imageDir(groupId, card.id), ref.file), mime: ref.mime };
}
async create(groupId: string, fields: CardFields, logo?: Upload): Promise<Card> {
const id = randomUUID();
const now = new Date().toISOString();
const logoRef = logo ? await this.saveFile(groupId, id, logo) : null;
const card: Card = {
id,
...fields,
logo: logoRef,
images: [],
imagesVersion: logoRef ? 1 : 0,
deleted: false,
createdAt: now,
updatedAt: now,
};
try {
await this.file(groupId).update((d) => {
d.cards.push(card);
});
} catch (err) {
await rm(this.imageDir(groupId, id), { recursive: true, force: true });
throw err;
}
return card;
}
/**
* Updates fields and optionally the logo (`logo: null` removes it).
* The client API cannot touch deleted cards nor change the `deleted` flag.
*/
async update(
groupId: string,
cardId: string,
patch: Partial<CardFields> & { deleted?: boolean },
access: CardAccess,
logo?: Upload | null,
): Promise<Card> {
if (patch.deleted !== undefined && !access.includeDeleted) throw badRequest('Cannot change deleted flag');
await this.get(groupId, cardId, access);
const newLogo = logo ? await this.saveFile(groupId, cardId, logo) : undefined;
let oldLogo = null as ImageRef | null;
let card: Card;
try {
card = await this.mutate(groupId, cardId, access, (c) => {
Object.assign(c, stripUndefined(patch));
if (logo !== undefined) {
oldLogo = c.logo;
c.logo = newLogo ?? null;
c.imagesVersion++;
}
});
} catch (err) {
if (newLogo) await this.removeFiles(groupId, cardId, [newLogo]);
throw err;
}
if (oldLogo) await this.removeFiles(groupId, cardId, [oldLogo]);
return card;
}
async setDeleted(groupId: string, cardId: string, deleted: boolean): Promise<Card> {
return this.mutate(groupId, cardId, { includeDeleted: true }, (c) => {
c.deleted = deleted;
});
}
/** Permanently removes the card and its files. */
async purge(groupId: string, cardId: string): Promise<void> {
await this.file(groupId).update((d) => {
const before = d.cards.length;
d.cards = d.cards.filter((c) => c.id !== cardId);
if (d.cards.length === before) throw notFound('Card not found');
});
await rm(this.imageDir(groupId, cardId), { recursive: true, force: true });
}
async addImages(groupId: string, cardId: string, uploads: Upload[], access: CardAccess): Promise<Card> {
if (uploads.length === 0) throw badRequest('No files uploaded');
await this.get(groupId, cardId, access);
const refs: ImageRef[] = [];
try {
for (const upload of uploads) refs.push(await this.saveFile(groupId, cardId, upload));
return await this.mutate(groupId, cardId, access, (c) => {
c.images.push(...refs);
c.imagesVersion++;
});
} catch (err) {
await this.removeFiles(groupId, cardId, refs);
throw err;
}
}
async removeImage(groupId: string, cardId: string, imageId: string, access: CardAccess): Promise<Card> {
let removed: ImageRef | undefined;
const card = await this.mutate(groupId, cardId, access, (c) => {
removed = c.images.find((i) => i.id === imageId);
if (!removed) throw notFound('Image not found');
c.images = c.images.filter((i) => i !== removed);
c.imagesVersion++;
});
if (removed) await this.removeFiles(groupId, cardId, [removed]);
return card;
}
async removeGroup(groupId: string): Promise<void> {
this.files.delete(groupId);
await rm(this.groupDir(groupId), { recursive: true, force: true });
}
private mutate(groupId: string, cardId: string, access: CardAccess, fn: (card: Card) => void): Promise<Card> {
return this.file(groupId).update((d) => {
const card = d.cards.find((c) => c.id === cardId);
if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found');
fn(card);
card.updatedAt = new Date().toISOString();
return card;
});
}
private async saveFile(groupId: string, cardId: string, upload: Upload): Promise<ImageRef> {
const id = randomUUID();
const file = `${id}.${extensionFor(upload)}`;
const dir = this.imageDir(groupId, cardId);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, file), upload.data);
return { id, file, mime: upload.mime || 'application/octet-stream' };
}
private async removeFiles(groupId: string, cardId: string, refs: ImageRef[]): Promise<void> {
const dir = this.imageDir(groupId, cardId);
await Promise.all(refs.map((r) => unlink(path.join(dir, r.file)).catch(() => undefined)));
}
}
function stripUndefined<T extends object>(obj: T): Partial<T> {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>;
}
+61
View File
@@ -0,0 +1,61 @@
import path from 'node:path';
export interface Config {
dataDir: string;
keySecret: Buffer;
superadminEmail: string;
superadminGroup: string;
/**
* Public base URL of the API for key QR codes, e.g. https://dev.aggtaa.com/api.
* When unset it is derived per request from X-Forwarded-Proto/Host/Prefix.
*/
publicApiUrl?: string;
/** When set, admin API requests must carry it in X-Cardy-Proxy-Secret (added by the reverse proxy). */
adminProxySecret?: string;
host: string;
port: number;
}
/**
* The reverse proxy strips "/api": public /api/admin/* (behind Authelia) arrives here as /admin/*,
* the rest of /api/* (client API, no Authelia) arrives at the root.
*/
export const ADMIN_API_PREFIX = '/admin';
export function parseKeySecret(value: string | undefined): Buffer {
if (!value) throw new Error('KEY_ENCRYPTION_SECRET is required');
const buf = Buffer.from(value, 'base64');
if (buf.length !== 32 || buf.toString('base64') !== value.trim()) {
throw new Error('KEY_ENCRYPTION_SECRET must be exactly 32 bytes encoded in base64');
}
return buf;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const superadminEmail = env.SUPERADMIN_EMAIL?.trim().toLowerCase();
if (!superadminEmail) throw new Error('SUPERADMIN_EMAIL is required');
const publicApiUrl = env.PUBLIC_API_URL?.trim().replace(/\/+$/, '') || undefined;
if (publicApiUrl) {
try {
new URL(publicApiUrl);
} catch {
throw new Error(`PUBLIC_API_URL is not a valid URL: ${publicApiUrl}`);
}
}
const port = Number(env.PORT ?? 10002);
if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid PORT: ${env.PORT}`);
return {
dataDir: path.resolve(env.DATA_DIR ?? './data'),
keySecret: parseKeySecret(env.KEY_ENCRYPTION_SECRET),
superadminEmail,
superadminGroup: env.SUPERADMIN_GROUP?.trim() || 'default',
publicApiUrl,
adminProxySecret: env.ADMIN_PROXY_SECRET?.trim() || undefined,
// Loopback by default: the admin API trusts Remote-Email, so only the local reverse proxy may connect.
host: env.HOST ?? '127.0.0.1',
port,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { createCipheriv, createDecipheriv, randomBytes, randomInt } from 'node:crypto';
const KEY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
export const ACCESS_KEY_LENGTH = 32;
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
export function generateAccessKey(): string {
let key = '';
for (let i = 0; i < ACCESS_KEY_LENGTH; i++) key += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)];
return key;
}
/** AES-256-GCM; result is base64(iv | authTag | ciphertext). */
export function encryptSecret(plain: string, secret: Buffer): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv('aes-256-gcm', secret, iv);
const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64');
}
export function decryptSecret(encrypted: string, secret: Buffer): string {
const buf = Buffer.from(encrypted, 'base64');
if (buf.length < IV_LENGTH + TAG_LENGTH) throw new Error('Encrypted value is too short');
const decipher = createDecipheriv('aes-256-gcm', secret, buf.subarray(0, IV_LENGTH));
decipher.setAuthTag(buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH));
return Buffer.concat([decipher.update(buf.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]).toString('utf8');
}
+14
View File
@@ -0,0 +1,14 @@
export class HttpError extends Error {
constructor(
readonly statusCode: number,
message: string,
) {
super(message);
}
}
export const badRequest = (message: string) => new HttpError(400, message);
export const unauthorized = (message = 'Unauthorized') => new HttpError(401, message);
export const forbidden = (message = 'Forbidden') => new HttpError(403, message);
export const notFound = (message = 'Not found') => new HttpError(404, message);
export const conflict = (message: string) => new HttpError(409, message);
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyRequest } from 'fastify';
/**
* Path prefix the reverse proxy stripped before forwarding (X-Forwarded-Prefix), e.g. "/api".
* Returns '' when absent or malformed.
*/
export function forwardedPrefix(request: FastifyRequest): string {
const raw = request.headers['x-forwarded-prefix'];
const value = (Array.isArray(raw) ? raw[0] : raw)?.split(',')[0]?.trim().replace(/\/+$/, '') ?? '';
return /^(\/[A-Za-z0-9._~-]+)*$/.test(value) ? value : '';
}
/** Public base URL of this server as seen by clients, e.g. https://dev.aggtaa.com/api */
export function publicBaseUrl(request: FastifyRequest, override?: string): string {
if (override) return override;
// With trustProxy, protocol/host come from X-Forwarded-Proto / X-Forwarded-Host.
return `${request.protocol}://${request.host}${forwardedPrefix(request)}`;
}
+56
View File
@@ -0,0 +1,56 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { ZodError, type ZodType } from 'zod';
import type { Upload } from './cards.js';
import { badRequest, notFound } from './errors.js';
export function parseBody<T>(schema: ZodType<T>, value: unknown): T {
const result = schema.safeParse(value ?? {});
if (!result.success) throw badRequest(formatZodError(result.error));
return result.data;
}
export function formatZodError(error: ZodError): string {
return error.issues.map((i) => (i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message)).join('; ');
}
export interface MultipartData {
fields: Record<string, string>;
files: Map<string, Upload[]>;
}
export async function readMultipart(request: FastifyRequest): Promise<MultipartData> {
if (!request.isMultipart()) throw badRequest('Expected multipart/form-data');
const fields: Record<string, string> = {};
const files = new Map<string, Upload[]>();
for await (const part of request.parts()) {
if (part.type === 'file') {
const data = await part.toBuffer();
if (data.length === 0 && !part.filename) continue; // empty <input type=file>
const list = files.get(part.fieldname) ?? [];
list.push({ data, mime: part.mimetype, filename: part.filename });
files.set(part.fieldname, list);
} else {
fields[part.fieldname] = String(part.value);
}
}
return { fields, files };
}
/** Sends a stored user upload; non-images are forced to download and nothing is ever rendered as active content. */
export async function sendImage(reply: FastifyReply, file: { path: string; mime: string }) {
let size: number;
try {
size = (await stat(file.path)).size;
} catch {
throw notFound('Image file is missing');
}
return reply
.header('Content-Type', file.mime.toLowerCase().startsWith('image/') ? file.mime : 'application/octet-stream')
.header('Content-Length', size)
.header('X-Content-Type-Options', 'nosniff')
.header('Content-Security-Policy', 'sandbox')
.header('Cache-Control', 'private, no-cache')
.send(createReadStream(file.path));
}
+23
View File
@@ -0,0 +1,23 @@
import { buildApp } from './app.js';
import { loadConfig } from './config.js';
const config = loadConfig();
const app = await buildApp(config, { logger: true });
app.log.info(
{ dataDir: config.dataDir, publicApiUrl: config.publicApiUrl ?? '(from X-Forwarded-* headers)' },
'Cardy server configuration',
);
if (!config.adminProxySecret && !['127.0.0.1', '::1', 'localhost'].includes(config.host)) {
app.log.warn(
`Listening on ${config.host} without ADMIN_PROXY_SECRET: anyone who can reach this port can impersonate users via Remote-Email`,
);
}
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
app.close().then(() => process.exit(0));
});
}
await app.listen({ host: config.host, port: config.port });
+241
View File
@@ -0,0 +1,241 @@
import { timingSafeEqual } from 'node:crypto';
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import {
cardFieldsSchema,
cardPatchSchema,
currentGroupSchema,
groupInputSchema,
keyCreateSchema,
userCreateSchema,
userPatchSchema,
type Group,
type MeResponse,
} from '@cardy/shared';
import type { CardsService } from '../cards.js';
import { ADMIN_API_PREFIX } from '../config.js';
import { forwardedPrefix, publicBaseUrl } from '../forwarded.js';
import { badRequest, forbidden, notFound, unauthorized } from '../errors.js';
import { parseBody, readMultipart, sendImage } from '../http.js';
import { requireGroupAdmin, requireSuperadmin, type StoredUser, type UsersService } from '../users.js';
export const GROUP_COOKIE = 'cardy_group';
export const CSRF_HEADER = 'x-cardy-admin';
export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret';
export interface AdminContext {
user: StoredUser;
ownGroup: Group;
currentGroup: Group;
}
declare module 'fastify' {
interface FastifyRequest {
admin: AdminContext;
}
}
interface Options {
users: UsersService;
cards: CardsService;
proxySecret?: string;
publicApiUrl?: string;
}
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
const ADMIN_ACCESS = { includeDeleted: true };
type IdParams = { id: string };
type ImageParams = { id: string; imageId: string };
type EmailParams = { email: string };
type KeyParams = { email: string; keyId: string };
export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards, proxySecret, publicApiUrl }) => {
app.decorateRequest('admin', null as unknown as AdminContext);
const apiUrl = (request: FastifyRequest) => publicBaseUrl(request, publicApiUrl);
app.addHook('onRequest', async (request) => {
// Only the reverse proxy route that goes through forward-auth may reach the admin API.
if (proxySecret) {
const given = request.headers[PROXY_SECRET_HEADER];
if (typeof given !== 'string' || !safeEqual(given, proxySecret)) {
throw unauthorized(`Missing or wrong ${PROXY_SECRET_HEADER} header (ADMIN_PROXY_SECRET is set on the server)`);
}
}
// Header-based CSRF guard: cross-site forms cannot set custom headers.
if (request.method !== 'GET' && request.method !== 'HEAD' && request.headers[CSRF_HEADER] !== '1') {
throw forbidden(`Missing ${CSRF_HEADER} header`);
}
const email = request.headers['remote-email'];
if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy');
const user = await users.findUser(email);
if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`);
const ownGroup = await users.getGroup(user.groupId);
if (!ownGroup) throw unauthorized('User group does not exist');
let currentGroup = ownGroup;
const cookieGroupId = request.cookies[GROUP_COOKIE];
if (user.role === 'superadmin' && cookieGroupId && cookieGroupId !== ownGroup.id) {
currentGroup = (await users.getGroup(cookieGroupId)) ?? ownGroup;
}
request.admin = { user, ownGroup, currentGroup };
});
// Cookie path as the browser sees it, e.g. /api/admin.
const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) =>
reply.setCookie(GROUP_COOKIE, groupId, {
path: forwardedPrefix(request) + ADMIN_API_PREFIX,
httpOnly: true,
sameSite: 'strict',
secure: request.protocol === 'https',
maxAge: 60 * 60 * 24 * 365,
});
const groupAdmin = (request: FastifyRequest) => {
const { user, currentGroup } = request.admin;
requireGroupAdmin(user, currentGroup.id);
return { user, groupId: currentGroup.id };
};
// ---- current user ----
app.get('/me', async (request, reply): Promise<MeResponse> => {
const { user, ownGroup, currentGroup } = request.admin;
if (request.cookies[GROUP_COOKIE] !== currentGroup.id) setGroupCookie(request, reply, currentGroup.id);
return { email: user.email, role: user.role, group: ownGroup, currentGroup };
});
app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request)));
// ---- groups ----
app.get('/groups', async (request) => {
requireSuperadmin(request.admin.user);
return users.listGroups();
});
app.post('/groups', async (request) => {
const { name } = parseBody(groupInputSchema, request.body);
return users.createGroup(request.admin.user, name);
});
app.patch<{ Params: IdParams }>('/groups/:id', async (request) => {
const { name } = parseBody(groupInputSchema, request.body);
return users.renameGroup(request.admin.user, request.params.id, name);
});
app.delete<{ Params: IdParams }>('/groups/:id', async (request, reply) => {
await users.deleteGroup(request.admin.user, request.params.id);
await cards.removeGroup(request.params.id);
return reply.code(204).send();
});
app.put('/current-group', async (request, reply) => {
requireSuperadmin(request.admin.user);
const { groupId } = parseBody(currentGroupSchema, request.body);
const group = await users.getGroup(groupId);
if (!group) throw notFound('Group not found');
setGroupCookie(request, reply, group.id);
return group;
});
// ---- users of the current group ----
app.get('/users', async (request) => {
const { user, groupId } = groupAdmin(request);
return (await users.listUsers(user, groupId)).map((u) => users.userView(u, apiUrl(request)));
});
app.post('/users', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body));
return reply.code(201).send(users.userView(created, apiUrl(request)));
});
app.patch<{ Params: EmailParams }>('/users/:email', async (request) => {
const { user, groupId } = groupAdmin(request);
const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
return users.userView(updated, apiUrl(request));
});
app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
await users.deleteUser(user, groupId, request.params.email);
return reply.code(204).send();
});
app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
const { label } = parseBody(keyCreateSchema, request.body);
const key = await users.createKey(user, groupId, request.params.email, label || undefined, apiUrl(request));
return reply.code(201).send(key);
});
app.delete<{ Params: KeyParams }>('/users/:email/keys/:keyId', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
await users.deleteKey(user, groupId, request.params.email, request.params.keyId);
return reply.code(204).send();
});
// ---- cards of the current group ----
app.get('/cards', async (request) => cards.list(groupAdmin(request).groupId, ADMIN_ACCESS));
app.post('/cards', async (request, reply) => {
const { groupId } = groupAdmin(request);
return reply.code(201).send(await cards.create(groupId, parseBody(cardFieldsSchema, request.body)));
});
app.patch<{ Params: IdParams }>('/cards/:id', async (request) => {
const { groupId } = groupAdmin(request);
return cards.update(groupId, request.params.id, parseBody(cardPatchSchema, request.body), ADMIN_ACCESS);
});
/** First delete marks the card as deleted, deleting an already deleted card removes it permanently. */
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
const { groupId } = groupAdmin(request);
const card = await cards.get(groupId, request.params.id, ADMIN_ACCESS);
if (!card.deleted) return cards.setDeleted(groupId, card.id, true);
await cards.purge(groupId, card.id);
return reply.code(204).send();
});
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) => {
const { groupId } = groupAdmin(request);
return sendImage(reply, await cards.imagePath(groupId, request.params.id, 'logo', ADMIN_ACCESS));
});
app.put<{ Params: IdParams }>('/cards/:id/logo', async (request) => {
const { groupId } = groupAdmin(request);
const { files } = await readMultipart(request);
const logo = files.get('file')?.[0];
if (!logo) throw badRequest('No file uploaded');
return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, logo);
});
app.delete<{ Params: IdParams }>('/cards/:id/logo', async (request) => {
const { groupId } = groupAdmin(request);
return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, null);
});
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
const { groupId } = groupAdmin(request);
const { id, imageId } = request.params;
return sendImage(reply, await cards.imagePath(groupId, id, imageId, ADMIN_ACCESS));
});
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
const { groupId } = groupAdmin(request);
const { files } = await readMultipart(request);
return cards.addImages(groupId, request.params.id, files.get('file') ?? [], ADMIN_ACCESS);
});
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) => {
const { groupId } = groupAdmin(request);
return cards.removeImage(groupId, request.params.id, request.params.imageId, ADMIN_ACCESS);
});
};
+95
View File
@@ -0,0 +1,95 @@
import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared';
import type { CardsService } from '../cards.js';
import { unauthorized } from '../errors.js';
import { parseBody, readMultipart, sendImage } from '../http.js';
import type { KeyOwner, UsersService } from '../users.js';
declare module 'fastify' {
interface FastifyRequest {
keyOwner: KeyOwner;
}
}
interface Options {
users: UsersService;
cards: CardsService;
}
const CLIENT_ACCESS = { includeDeleted: false };
type IdParams = { id: string };
type ImageParams = { id: string; imageId: string };
export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => {
// Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies.
// The plugin's catch-all OPTIONS route would also match admin paths, so CORS is off there.
const corsOptions: FastifyCorsOptions = {
origin: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
maxAge: 86400,
};
await app.register(cors, () => (request: FastifyRequest, callback: (err: Error | null, options: FastifyCorsOptions) => void) => {
const isAdmin = /^\/admin(\/|\?|$)/.test(request.url ?? '');
callback(null, isAdmin ? { origin: false } : corsOptions);
});
app.decorateRequest('keyOwner', null as unknown as KeyOwner);
app.addHook('preHandler', async (request) => {
if (request.method === 'OPTIONS') return;
const match = /^Bearer\s+(\S+)\s*$/i.exec(request.headers.authorization ?? '');
const owner = match?.[1] ? await users.authenticateKey(match[1]) : undefined;
if (!owner) throw unauthorized('Invalid access key');
request.keyOwner = owner;
});
const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id;
app.get('/auth', async (request): Promise<ApiAuthResponse> => {
const { user, group } = request.keyOwner;
return { email: user.email, group };
});
app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS));
app.post('/cards', async (request, reply) => {
const { fields, files } = await readMultipart(request);
const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
return reply.code(201).send(card);
});
app.put<{ Params: IdParams }>('/cards/:id', async (request) => {
const { fields, files } = await readMultipart(request);
const { removeLogo, ...cardFields } = fields;
const patch = parseBody(cardFieldsSchema.partial(), cardFields);
const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined);
return cards.update(groupId(request), request.params.id, patch, CLIENT_ACCESS, logo);
});
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS);
await cards.setDeleted(groupId(request), card.id, true);
return reply.code(204).send();
});
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) =>
sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)),
);
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
const { id, imageId } = request.params;
return sendImage(reply, await cards.imagePath(groupId(request), id, imageId, CLIENT_ACCESS));
});
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
const { files } = await readMultipart(request);
return cards.addImages(groupId(request), request.params.id, files.get('file') ?? [], CLIENT_ACCESS);
});
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) =>
cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS),
);
};
+279
View File
@@ -0,0 +1,279 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { z } from 'zod';
import { ROLES, buildLoginDeepLink, type AccessKeyView, type Group, type Role, type UserView } from '@cardy/shared';
import { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js';
import { badRequest, conflict, forbidden, notFound } from './errors.js';
import { YamlFile } from './yamlFile.js';
const idSchema = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id');
const storedKeySchema = z.object({
id: idSchema,
label: z.string().optional(),
createdAt: z.string(),
encrypted: z.string(),
});
const storedUserSchema = z.object({
email: z.string().transform((s) => s.trim().toLowerCase()),
groupId: idSchema,
role: z.enum(ROLES),
keys: z.array(storedKeySchema).default([]),
});
const usersFileSchema = z.object({
groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]),
users: z.array(storedUserSchema).default([]),
});
export type StoredKey = z.infer<typeof storedKeySchema>;
export type StoredUser = z.infer<typeof storedUserSchema>;
type UsersFile = z.infer<typeof usersFileSchema>;
export interface KeyOwner {
user: StoredUser;
group: Group;
}
export class UsersService {
private readonly file: YamlFile<UsersFile>;
/** Plaintext key → key owner email; rebuilt when the file data object changes. */
private keyIndex: { source: UsersFile; map: Map<string, string> } | undefined;
constructor(
dataDir: string,
private readonly secret: Buffer,
) {
this.file = new YamlFile(
path.join(dataDir, 'users.yaml'),
(raw) => usersFileSchema.parse(raw ?? {}),
() => ({ groups: [], users: [] }),
);
}
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */
async ensureSuperadmin(email: string, groupName: string): Promise<void> {
const data = await this.file.read();
const existing = data.users.find((u) => u.email === email);
if (existing?.role === 'superadmin') return;
await this.file.update((d) => {
const user = d.users.find((u) => u.email === email);
if (user) {
user.role = 'superadmin';
return;
}
let group = d.groups.find((g) => sameName(g.name, groupName));
if (!group) {
group = { id: randomUUID(), name: groupName };
d.groups.push(group);
}
d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] });
});
}
// ---- lookups ----
async findUser(email: string): Promise<StoredUser | undefined> {
const normalized = email.trim().toLowerCase();
return (await this.file.read()).users.find((u) => u.email === normalized);
}
async getGroup(id: string): Promise<Group | undefined> {
return (await this.file.read()).groups.find((g) => g.id === id);
}
async listGroups(): Promise<Group[]> {
return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name));
}
async authenticateKey(key: string): Promise<KeyOwner | undefined> {
const data = await this.file.read();
if (this.keyIndex?.source !== data) {
const map = new Map<string, string>();
for (const user of data.users) {
for (const k of user.keys) map.set(decryptSecret(k.encrypted, this.secret), user.email);
}
this.keyIndex = { source: data, map };
}
const email = this.keyIndex.map.get(key);
const user = email ? data.users.find((u) => u.email === email) : undefined;
const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined;
return user && group ? { user, group } : undefined;
}
// ---- views ----
/** `apiUrl` is the public API base URL that goes into login deep links. */
keyViews(user: StoredUser, apiUrl: string): AccessKeyView[] {
return user.keys.map((k) => {
const key = decryptSecret(k.encrypted, this.secret);
return { id: k.id, label: k.label, createdAt: k.createdAt, key, deepLink: buildLoginDeepLink(apiUrl, key) };
});
}
userView(user: StoredUser, apiUrl: string): UserView {
return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) };
}
// ---- groups (superadmin) ----
async createGroup(actor: StoredUser, name: string): Promise<Group> {
requireSuperadmin(actor);
return this.file.update((d) => {
if (d.groups.some((g) => sameName(g.name, name))) throw conflict(`Group "${name}" already exists`);
const group = { id: randomUUID(), name };
d.groups.push(group);
return group;
});
}
async renameGroup(actor: StoredUser, id: string, name: string): Promise<Group> {
requireSuperadmin(actor);
return this.file.update((d) => {
const group = d.groups.find((g) => g.id === id);
if (!group) throw notFound('Group not found');
if (d.groups.some((g) => g.id !== id && sameName(g.name, name))) throw conflict(`Group "${name}" already exists`);
group.name = name;
return group;
});
}
/** Removes the group with its users and keys. The caller removes the group's cards. */
async deleteGroup(actor: StoredUser, id: string): Promise<void> {
requireSuperadmin(actor);
await this.file.update((d) => {
if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found');
if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) {
throw conflict('A group that contains a superadmin cannot be deleted');
}
d.groups = d.groups.filter((g) => g.id !== id);
d.users = d.users.filter((u) => u.groupId !== id);
});
}
// ---- users (admin of the current group) ----
async listUsers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
requireGroupAdmin(actor, groupId);
const data = await this.file.read();
return data.users.filter((u) => u.groupId === groupId).sort((a, b) => a.email.localeCompare(b.email));
}
async createUser(
actor: StoredUser,
currentGroupId: string,
input: { email: string; role: Role; groupId?: string },
): Promise<StoredUser> {
const groupId = input.groupId ?? currentGroupId;
if (groupId !== currentGroupId) requireSuperadmin(actor);
requireGroupAdmin(actor, groupId);
if (input.role === 'superadmin') requireSuperadmin(actor);
return this.file.update((d) => {
if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
if (d.users.some((u) => u.email === input.email)) throw conflict(`User ${input.email} already exists`);
const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] };
d.users.push(user);
return user;
});
}
async updateUser(
actor: StoredUser,
currentGroupId: string,
email: string,
patch: { role?: Role; groupId?: string },
): Promise<StoredUser> {
if (patch.role === 'superadmin') requireSuperadmin(actor);
if (patch.groupId !== undefined) requireSuperadmin(actor);
return this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
if (patch.role !== undefined && patch.role !== user.role) {
if (user.email === actor.email) throw badRequest('You cannot change your own role');
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
user.role = patch.role;
}
if (patch.groupId !== undefined && patch.groupId !== user.groupId) {
if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found');
user.groupId = patch.groupId;
}
return user;
});
}
async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise<void> {
await this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
if (user.email === actor.email) throw badRequest('You cannot delete yourself');
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
d.users = d.users.filter((u) => u !== user);
});
}
// ---- keys ----
async createKey(
actor: StoredUser,
currentGroupId: string,
email: string,
label: string | undefined,
apiUrl: string,
): Promise<AccessKeyView> {
const plain = generateAccessKey();
const stored: StoredKey = {
id: randomUUID(),
...(label ? { label } : {}),
createdAt: new Date().toISOString(),
encrypted: encryptSecret(plain, this.secret),
};
await this.file.update((d) => {
findManagedUser(d, actor, currentGroupId, email).keys.push(stored);
});
return {
id: stored.id,
label: stored.label,
createdAt: stored.createdAt,
key: plain,
deepLink: buildLoginDeepLink(apiUrl, plain),
};
}
async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise<void> {
await this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
const before = user.keys.length;
user.keys = user.keys.filter((k) => k.id !== keyId);
if (user.keys.length === before) throw notFound('Key not found');
});
}
}
function sameName(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
function countSuperadmins(d: UsersFile): number {
return d.users.filter((u) => u.role === 'superadmin').length;
}
export function isGroupAdmin(actor: StoredUser, groupId: string): boolean {
return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId);
}
export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
if (!isGroupAdmin(actor, groupId)) throw forbidden();
}
export function requireSuperadmin(actor: StoredUser): void {
if (actor.role !== 'superadmin') throw forbidden();
}
/** A user of the current group that the actor may manage (only superadmins manage superadmins). */
function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser {
requireGroupAdmin(actor, currentGroupId);
const normalized = email.trim().toLowerCase();
const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId);
if (!user) throw notFound('User not found');
if (user.role === 'superadmin') requireSuperadmin(actor);
return user;
}
+81
View File
@@ -0,0 +1,81 @@
import { randomBytes } from 'node:crypto';
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import YAML from 'yaml';
/**
* A YAML document on disk with an in-memory cache.
* - Reads are served from cache until the file's mtime changes (manual edits are picked up).
* - Updates are serialized and written atomically (temp file + rename).
* - Callers must treat data returned by read() as immutable.
*/
export class YamlFile<T> {
private cache: { mtimeMs: number; data: T } | undefined;
private queue: Promise<unknown> = Promise.resolve();
constructor(
readonly filePath: string,
private readonly parse: (raw: unknown) => T,
private readonly empty: () => T,
) {}
async read(): Promise<T> {
let mtimeMs: number;
try {
mtimeMs = (await stat(this.filePath)).mtimeMs;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.empty();
throw err;
}
if (this.cache?.mtimeMs === mtimeMs) return this.cache.data;
const text = await readFile(this.filePath, 'utf8');
let data: T;
try {
data = this.parse(YAML.parse(text) ?? undefined);
} catch (err) {
throw new Error(`Invalid data in ${this.filePath}: ${(err as Error).message}`);
}
this.cache = { mtimeMs, data };
return data;
}
/** Runs `fn` on a copy of the data and saves the result; if `fn` throws, nothing is written. */
update<R>(fn: (data: T) => R | Promise<R>): Promise<R> {
const run = async () => {
const data = structuredClone(await this.read());
const result = await fn(data);
await this.write(data);
return result;
};
const next = this.queue.then(run, run);
this.queue = next.catch(() => undefined);
return next;
}
private async write(data: T): Promise<void> {
await mkdir(path.dirname(this.filePath), { recursive: true });
const tmp = `${this.filePath}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, YAML.stringify(data), 'utf8');
try {
await renameWithRetry(tmp, this.filePath);
} catch (err) {
await unlink(tmp).catch(() => undefined);
throw err;
}
this.cache = { mtimeMs: (await stat(this.filePath)).mtimeMs, data };
}
}
// On Windows a rename over a file that is momentarily open elsewhere fails with EPERM/EBUSY.
async function renameWithRetry(from: string, to: string): Promise<void> {
for (let attempt = 0; ; attempt++) {
try {
await rename(from, to);
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (attempt >= 5 || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')) throw err;
await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
}
}
}
+232
View File
@@ -0,0 +1,232 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView, Card, Group, MeResponse, UserView } from '@cardy/shared';
import { SUPERADMIN, multipart, testApp } from './helpers.js';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
function admin(app: FastifyInstance, email: string, cookie?: string) {
return async (method: Method, url: string, body?: unknown) => {
const res = await app.inject({
method,
url: `/admin${url}`,
headers: { 'remote-email': email, 'x-cardy-admin': '1', ...(cookie ? { cookie } : {}) },
...(body !== undefined ? { payload: body as object } : {}),
});
const isJson = String(res.headers['content-type'] ?? '').startsWith('application/json');
return { status: res.statusCode, body: isJson ? res.json() : undefined, res };
};
}
async function setup() {
const { app, config } = await testApp();
const root = admin(app, SUPERADMIN);
const me = (await root('GET', '/me')).body as MeResponse;
return { app, config, root, defaultGroup: me.group };
}
describe('admin API authentication', () => {
it('bootstraps the superadmin from config', async () => {
const { root } = await setup();
const { status, body } = await root('GET', '/me');
expect(status).toBe(200);
expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } });
});
it('returns 401 without header or for unknown users', async () => {
const { app } = await setup();
expect((await app.inject({ url: '/admin/me' })).statusCode).toBe(401);
expect((await admin(app, 'nobody@example.com')('GET', '/me')).status).toBe(401);
});
it('matches Remote-Email case-insensitively', async () => {
const { app } = await setup();
expect((await admin(app, SUPERADMIN.toUpperCase())('GET', '/me')).status).toBe(200);
});
it('requires the CSRF header on mutations', async () => {
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/admin/groups',
headers: { 'remote-email': SUPERADMIN },
payload: { name: 'x' },
});
expect(res.statusCode).toBe(403);
});
});
describe('groups and current group', () => {
it('lets the superadmin switch groups via cookie', async () => {
const { app, root, defaultGroup } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
expect((await root('POST', '/groups', { name: 'other' })).status).toBe(409);
const switched = await root('PUT', '/current-group', { groupId: other.id });
expect(switched.status).toBe(200);
const setCookie = String(switched.res.headers['set-cookie']);
expect(setCookie).toContain(`cardy_group=${other.id}`);
expect(setCookie).toMatch(/HttpOnly/i);
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
expect(((await inOther('GET', '/me')).body as MeResponse).currentGroup.id).toBe(other.id);
// Unknown group in cookie falls back to own group.
const stale = admin(app, SUPERADMIN, 'cardy_group=missing');
expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
});
it('ignores the group cookie for non-superadmins', async () => {
const { app, root, defaultGroup } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`);
expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403);
expect((await adm('GET', '/groups')).status).toBe(403);
});
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
const { app, config, root, defaultGroup } = await setup();
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
await inOther('POST', '/users', { email: 'u@example.com' });
await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' });
expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true);
expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false);
expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401);
});
});
describe('users and keys', () => {
it('enforces role rules', async () => {
const { app, root } = await setup();
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
await root('POST', '/users', { email: 'User@Example.com' });
const adm = admin(app, 'adm@example.com');
const user = admin(app, 'user@example.com');
expect((await user('GET', '/users')).status).toBe(403);
expect((await user('GET', '/cards')).status).toBe(403);
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403);
expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403);
expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403);
expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400);
expect((await adm('DELETE', '/users/adm@example.com')).status).toBe(400);
expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200);
expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409);
expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400);
expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400);
});
it('admin generates keys; users see their own keys with deep links', async () => {
const { app, root } = await setup();
await root('POST', '/users', { email: 'user@example.com' });
const created = await root('POST', '/users/user@example.com/keys', { label: 'Phone' });
expect(created.status).toBe(201);
const key = created.body as AccessKeyView;
expect(key.key).toMatch(/^[A-Za-z0-9]{32}$/);
expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://cards.example.com/api')}&key=${key.key}`);
const user = admin(app, 'user@example.com');
expect((await user('POST', '/users/user@example.com/keys', {})).status).toBe(403);
expect((await user('GET', '/my-keys')).body).toEqual([key]);
const listed = (await root('GET', '/users')).body as UserView[];
expect(listed.find((u) => u.email === 'user@example.com')?.keys).toEqual([key]);
expect((await root('DELETE', `/users/user@example.com/keys/${key.id}`)).status).toBe(204);
expect((await user('GET', '/my-keys')).body).toEqual([]);
});
it('stores keys encrypted', async () => {
const { app, config, root } = await setup();
const key = (await root('POST', `/users/${SUPERADMIN}/keys`, {})).body as AccessKeyView;
const { readFile } = await import('node:fs/promises');
const yaml = await readFile(path.join(config.dataDir, 'users.yaml'), 'utf8');
expect(yaml).not.toContain(key.key);
expect(yaml).toContain('encrypted:');
await app.close();
});
});
describe('cards', () => {
it('supports CRUD, soft delete, restore and purge', async () => {
const { root } = await setup();
const created = await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: '4600000000000', codeType: 'EAN13' });
expect(created.status).toBe(201);
const card = created.body as Card;
expect(card).toMatchObject({ name: 'Shop', imagesVersion: 0, deleted: false, logo: null, images: [] });
expect((await root('POST', '/cards', { name: 'x', color: 'red', code: '1', codeType: 'EAN13' })).status).toBe(400);
expect((await root('POST', '/cards', { name: 'x', color: '#000000', code: '1', codeType: 'NOPE' })).status).toBe(400);
const patched = await root('PATCH', `/cards/${card.id}`, { name: 'Shop 2' });
expect((patched.body as Card).name).toBe('Shop 2');
const deleted = await root('DELETE', `/cards/${card.id}`);
expect((deleted.body as Card).deleted).toBe(true);
expect(((await root('GET', '/cards')).body as Card[])[0]?.deleted).toBe(true);
const restored = await root('PATCH', `/cards/${card.id}`, { deleted: false });
expect((restored.body as Card).deleted).toBe(false);
await root('DELETE', `/cards/${card.id}`);
expect((await root('DELETE', `/cards/${card.id}`)).status).toBe(204);
expect((await root('GET', '/cards')).body).toEqual([]);
});
it('manages logo and images with version bumps', async () => {
const { app, root } = await setup();
const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card;
const png = Buffer.from('fake-png');
const upload = (url: string, method: 'PUT' | 'POST', parts: Parameters<typeof multipart>[0]) => {
const { payload, headers } = multipart(parts);
return app.inject({ method, url: `/admin${url}`, payload, headers: { ...headers, 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' } });
};
const withLogo = (await upload(`/cards/${card.id}/logo`, 'PUT', [{ name: 'file', filename: 'l.png', mime: 'image/png', data: png }])).json() as Card;
expect(withLogo.logo?.mime).toBe('image/png');
expect(withLogo.imagesVersion).toBe(1);
const logoRes = await root('GET', `/cards/${card.id}/logo`);
expect(logoRes.res.rawPayload).toEqual(png);
expect(logoRes.res.headers['content-type']).toBe('image/png');
expect(logoRes.res.headers['content-security-policy']).toBe('sandbox');
const withImages = (
await upload(`/cards/${card.id}/images`, 'POST', [
{ name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') },
{ name: 'file', filename: 'b.html', mime: 'text/html', data: Buffer.from('<script>') },
])
).json() as Card;
expect(withImages.images).toHaveLength(2);
expect(withImages.imagesVersion).toBe(2);
const html = await root('GET', `/cards/${card.id}/images/${withImages.images[1]!.id}`);
expect(html.res.headers['content-type']).toBe('application/octet-stream');
const removed = (await root('DELETE', `/cards/${card.id}/images/${withImages.images[0]!.id}`)).body as Card;
expect(removed.images).toHaveLength(1);
expect(removed.imagesVersion).toBe(3);
const noLogo = (await root('DELETE', `/cards/${card.id}/logo`)).body as Card;
expect(noLogo.logo).toBeNull();
expect(noLogo.imagesVersion).toBe(4);
expect((await root('GET', `/cards/${card.id}/logo`)).status).toBe(404);
});
it('isolates cards between groups', async () => {
const { app, root } = await setup();
const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card;
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
expect((await inOther('GET', '/cards')).body).toEqual([]);
expect((await inOther('PATCH', `/cards/${card.id}`, { name: 'hack' })).status).toBe(404);
});
});
+133
View File
@@ -0,0 +1,133 @@
import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView, Card, Group } from '@cardy/shared';
import { SUPERADMIN, multipart, testApp } from './helpers.js';
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
async function setup() {
const { app } = await testApp();
const key = (
await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: adminHeaders, payload: {} })
).json() as AccessKeyView;
return { app, key: key.key, keyId: key.id };
}
function client(app: FastifyInstance, key: string) {
return (method: 'GET' | 'POST' | 'PUT' | 'DELETE', url: string, parts?: Parameters<typeof multipart>[0]) => {
const body = parts ? multipart(parts) : undefined;
return app.inject({
method,
url,
headers: { authorization: `Bearer ${key}`, ...body?.headers },
...(body ? { payload: body.payload } : {}),
});
};
}
const cardParts = (overrides: Record<string, string> = {}) =>
Object.entries({ name: 'Shop', color: '#123456', code: '4600000000000', codeType: 'EAN13', ...overrides }).map(
([name, value]) => ({ name, value }),
);
describe('client API', () => {
it('authenticates by bearer key', async () => {
const { app, key } = await setup();
const ok = await client(app, key)('GET', '/auth');
expect(ok.statusCode).toBe(200);
expect(ok.json()).toMatchObject({ email: SUPERADMIN, group: { name: 'default' } });
expect((await client(app, 'wrong')('GET', '/auth')).statusCode).toBe(401);
expect((await app.inject({ url: '/cards' })).statusCode).toBe(401);
});
it('stops accepting a key right after it is deleted', async () => {
const { app, key, keyId } = await setup();
await app.inject({ method: 'DELETE', url: `/admin/users/${SUPERADMIN}/keys/${keyId}`, headers: adminHeaders });
expect((await client(app, key)('GET', '/auth')).statusCode).toBe(401);
});
it('answers CORS preflight without a key', async () => {
const { app } = await setup();
const res = await app.inject({
method: 'OPTIONS',
url: '/cards',
headers: { origin: 'https://localhost', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' },
});
expect(res.statusCode).toBeLessThan(300);
expect(res.headers['access-control-allow-origin']).toBe('https://localhost');
});
it('creates, edits and deletes cards with logo and images', async () => {
const { app, key } = await setup();
const api = client(app, key);
const createdRes = await api('POST', '/cards', [
...cardParts(),
{ name: 'logo', filename: 'logo.webp', mime: 'image/webp', data: Buffer.from('logo') },
]);
expect(createdRes.statusCode).toBe(201);
const created = createdRes.json() as Card;
expect(created.id).toBeTruthy();
expect(created.logo).not.toBeNull();
expect(created.imagesVersion).toBe(1);
expect((await api('POST', '/cards', cardParts({ codeType: 'BAD' }))).statusCode).toBe(400);
const edited = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Shop 2' }])).json() as Card;
expect(edited).toMatchObject({ name: 'Shop 2', code: '4600000000000', imagesVersion: 1 });
const noLogo = (await api('PUT', `/cards/${created.id}`, [{ name: 'removeLogo', value: 'true' }])).json() as Card;
expect(noLogo.logo).toBeNull();
expect(noLogo.imagesVersion).toBe(2);
const withImages = (
await api('POST', `/cards/${created.id}/images`, [
{ name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') },
{ name: 'file', filename: 'b.webp', mime: 'image/webp', data: Buffer.from('b') },
])
).json() as Card;
expect(withImages.images).toHaveLength(2);
expect(withImages.imagesVersion).toBe(3);
const image = await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`);
expect(image.body).toBe('b');
const afterRemove = (await api('DELETE', `/cards/${created.id}/images/${withImages.images[0]!.id}`)).json() as Card;
expect(afterRemove.images.map((i) => i.id)).toEqual([withImages.images[1]!.id]);
expect((await api('GET', '/cards')).json()).toHaveLength(1);
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(204);
// Deleted cards are invisible to the client API but kept for admins.
expect((await api('GET', '/cards')).json()).toEqual([]);
expect((await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`)).statusCode).toBe(404);
expect((await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'x' }])).statusCode).toBe(404);
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(404);
const adminCards = (await app.inject({ url: '/admin/cards', headers: adminHeaders })).json() as Card[];
expect(adminCards[0]).toMatchObject({ id: created.id, deleted: true });
});
it('scopes cards to the key owner group', async () => {
const { app, key } = await setup();
const other = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
).json() as Group;
const otherHeaders = { ...adminHeaders, cookie: `cardy_group=${other.id}` };
await app.inject({ method: 'POST', url: '/admin/users', headers: otherHeaders, payload: { email: 'o@example.com' } });
const otherKey = (
await app.inject({ method: 'POST', url: '/admin/users/o@example.com/keys', headers: otherHeaders, payload: {} })
).json() as AccessKeyView;
const mine = (await client(app, key)('POST', '/cards', cardParts())).json() as Card;
const theirs = client(app, otherKey.key);
expect((await theirs('GET', '/cards')).json()).toEqual([]);
expect((await theirs('DELETE', `/cards/${mine.id}`)).statusCode).toBe(404);
});
it('returns JSON 404 for unknown API routes', async () => {
const { app, key } = await setup();
const res = await client(app, key)('GET', '/nope');
expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({ error: 'Not found' });
});
});
+77
View File
@@ -0,0 +1,77 @@
import { randomBytes } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView } from '@cardy/shared';
import { loadConfig } from '../src/config.js';
import { SUPERADMIN, testApp } from './helpers.js';
const baseEnv = {
KEY_ENCRYPTION_SECRET: randomBytes(32).toString('base64'),
SUPERADMIN_EMAIL: 'Root@Example.com',
};
describe('loadConfig', () => {
it('applies defaults', () => {
const config = loadConfig(baseEnv);
expect(config).toMatchObject({ superadminEmail: 'root@example.com', superadminGroup: 'default', host: '127.0.0.1', port: 10002 });
expect(config.publicApiUrl).toBeUndefined();
expect(config.adminProxySecret).toBeUndefined();
});
it('validates values', () => {
expect(loadConfig({ ...baseEnv, PUBLIC_API_URL: 'https://dev.aggtaa.com/api/' }).publicApiUrl).toBe('https://dev.aggtaa.com/api');
expect(() => loadConfig({ ...baseEnv, PUBLIC_API_URL: 'nope' })).toThrow();
expect(() => loadConfig({ ...baseEnv, SUPERADMIN_EMAIL: '' })).toThrow();
});
});
describe('reverse proxy integration', () => {
const proxied = {
'remote-email': SUPERADMIN,
'x-cardy-admin': '1',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'dev.aggtaa.com',
'x-forwarded-prefix': '/api',
};
it('derives the deep link server and cookie path from X-Forwarded-* headers', async () => {
const { app } = await testApp({ publicApiUrl: undefined });
const key = (await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: proxied, payload: {} })).json() as AccessKeyView;
expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://dev.aggtaa.com/api')}&key=${key.key}`);
const me = await app.inject({ url: '/admin/me', headers: proxied });
const cookie = String(me.headers['set-cookie']);
expect(cookie).toMatch(/Path=\/api\/admin/);
expect(cookie).toMatch(/Secure/);
});
it('ignores a malformed X-Forwarded-Prefix', async () => {
const { app } = await testApp({ publicApiUrl: undefined });
const key = (
await app.inject({
method: 'POST',
url: `/admin/users/${SUPERADMIN}/keys`,
headers: { ...proxied, 'x-forwarded-prefix': '/api"><script>' },
payload: {},
})
).json() as AccessKeyView;
expect(key.deepLink).toContain(`server=${encodeURIComponent('https://dev.aggtaa.com')}&key=`);
});
it('rejects admin requests without the proxy secret when configured', async () => {
const { app } = await testApp({ adminProxySecret: 's3cret' });
const headers = { 'remote-email': SUPERADMIN };
expect((await app.inject({ url: '/admin/me', headers })).statusCode).toBe(401);
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 'wrong' } })).statusCode).toBe(401);
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 's3cret' } })).statusCode).toBe(200);
});
it('does not answer CORS preflight for admin paths', async () => {
const { app } = await testApp();
const res = await app.inject({
method: 'OPTIONS',
url: '/admin/me',
headers: { origin: 'https://evil.example', 'access-control-request-method': 'GET' },
});
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { randomBytes } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach } from 'vitest';
import { buildApp } from '../src/app.js';
import type { Config } from '../src/config.js';
export const SUPERADMIN = 'root@example.com';
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
export async function tempDir(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'cardy-test-'));
tempDirs.push(dir);
return dir;
}
export async function testConfig(overrides: Partial<Config> = {}): Promise<Config> {
return {
dataDir: await tempDir(),
keySecret: randomBytes(32),
superadminEmail: SUPERADMIN,
superadminGroup: 'default',
publicApiUrl: 'https://cards.example.com/api',
host: '127.0.0.1',
port: 0,
...overrides,
};
}
export async function testApp(overrides: Partial<Config> = {}) {
const config = await testConfig(overrides);
const app = await buildApp(config);
return { app, config };
}
export function multipart(parts: Array<{ name: string; value: string } | { name: string; filename: string; mime: string; data: Buffer }>) {
const boundary = `----cardy${randomBytes(8).toString('hex')}`;
const chunks: Buffer[] = [];
for (const part of parts) {
let head = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"`;
if ('filename' in part) head += `; filename="${part.filename}"\r\nContent-Type: ${part.mime}`;
chunks.push(Buffer.from(`${head}\r\n\r\n`), 'data' in part ? part.data : Buffer.from(part.value), Buffer.from('\r\n'));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return { payload: Buffer.concat(chunks), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
}
+81
View File
@@ -0,0 +1,81 @@
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseKeySecret } from '../src/config.js';
import { ACCESS_KEY_LENGTH, decryptSecret, encryptSecret, generateAccessKey } from '../src/crypto.js';
import { YamlFile } from '../src/yamlFile.js';
import { tempDir } from './helpers.js';
describe('crypto', () => {
it('generates 32-char alphanumeric keys', () => {
const keys = new Set(Array.from({ length: 100 }, generateAccessKey));
expect(keys.size).toBe(100);
for (const key of keys) expect(key).toMatch(new RegExp(`^[A-Za-z0-9]{${ACCESS_KEY_LENGTH}}$`));
});
it('round-trips with AES-256-GCM using a random IV', () => {
const secret = randomBytes(32);
const a = encryptSecret('hello', secret);
const b = encryptSecret('hello', secret);
expect(a).not.toBe(b);
expect(decryptSecret(a, secret)).toBe('hello');
});
it('rejects a wrong secret or tampered data', () => {
const secret = randomBytes(32);
const encrypted = encryptSecret('hello', secret);
expect(() => decryptSecret(encrypted, randomBytes(32))).toThrow();
const buf = Buffer.from(encrypted, 'base64');
buf[buf.length - 1]! ^= 1;
expect(() => decryptSecret(buf.toString('base64'), secret)).toThrow();
});
it('validates KEY_ENCRYPTION_SECRET', () => {
expect(parseKeySecret(randomBytes(32).toString('base64'))).toHaveLength(32);
expect(() => parseKeySecret(randomBytes(16).toString('base64'))).toThrow(/32 bytes/);
expect(() => parseKeySecret(undefined)).toThrow(/required/);
});
});
describe('YamlFile', () => {
const make = (file: string) =>
new YamlFile<{ items: number[] }>(file, (raw) => (raw as { items: number[] }) ?? { items: [] }, () => ({ items: [] }));
it('returns empty data for a missing file and persists updates', async () => {
const file = path.join(await tempDir(), 'sub', 'data.yaml');
const store = make(file);
expect(await store.read()).toEqual({ items: [] });
await store.update((d) => d.items.push(1));
expect(await make(file).read()).toEqual({ items: [1] });
});
it('serializes concurrent updates', async () => {
const store = make(path.join(await tempDir(), 'data.yaml'));
await Promise.all(Array.from({ length: 20 }, (_, i) => store.update((d) => d.items.push(i))));
expect((await store.read()).items).toHaveLength(20);
});
it('does not write when the update throws', async () => {
const file = path.join(await tempDir(), 'data.yaml');
const store = make(file);
await store.update((d) => d.items.push(1));
await expect(
store.update((d) => {
d.items.push(2);
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect((await store.read()).items).toEqual([1]);
expect(await readFile(file, 'utf8')).not.toContain('2');
});
it('picks up manual edits', async () => {
const file = path.join(await tempDir(), 'data.yaml');
const store = make(file);
await store.update((d) => d.items.push(1));
await new Promise((r) => setTimeout(r, 20));
await writeFile(file, 'items: [7, 8]\n');
expect((await store.read()).items).toEqual([7, 8]);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src", "test"]
}
+12
View File
@@ -0,0 +1,12 @@
# Copy to .env and fill in.
# Public host names the dev/preview server answers to (comma-separated).
ALLOWED_HOSTS=dev.aggtaa.com
# ---- Local development without the reverse proxy only ----
# The admin UI calls /api/admin/*. Behind the real proxy these requests never reach this server.
# Locally, Vite emulates the proxy: forwards /api/* to server-be with /api stripped.
# BACKEND_URL=http://127.0.0.1:10002
# ADMIN_PROXY_SECRET=
# Fakes the Remote-Email header set by Authelia. NEVER set it behind the real proxy.
# DEV_REMOTE_EMAIL=
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cardy Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@cardy/server-fe",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "vite build && vite preview",
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"@cardy/shared": "workspace:*",
"bwip-js": "^4.11.4",
"qrcode": "^1.5.4",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.1"
}
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import type { MeResponse } from '@cardy/shared';
import { api, ApiError } from './api.js';
import { ErrorText, useLoad } from './components.js';
import { CardEditorPage } from './pages/CardEditor.js';
import { CardsPage } from './pages/Cards.js';
import { GroupsPage } from './pages/Groups.js';
import { MyKeysPage } from './pages/MyKeys.js';
import { UsersPage } from './pages/Users.js';
import { linkClick, navigate, usePath } from './router.js';
export function App() {
const me = useLoad(api.me);
// Bumped when the superadmin switches groups so pages reload their data.
const [groupEpoch, setGroupEpoch] = useState(0);
if (me.loading && !me.data) return <div className="center muted">Loading…</div>;
if (!me.data) {
return (
<div className="center">
<h1>Cardy</h1>
<p className="error">{me.error}</p>
</div>
);
}
return <Shell me={me.data} groupEpoch={groupEpoch} onGroupChanged={async () => { await me.reload(); setGroupEpoch((e) => e + 1); }} />;
}
function Shell({ me, groupEpoch, onGroupChanged }: { me: MeResponse; groupEpoch: number; onGroupChanged: () => Promise<void> }) {
const path = usePath();
const isAdmin = me.role !== 'user';
const isSuper = me.role === 'superadmin';
const tabs = [
...(isAdmin ? [{ path: '/cards', label: 'Cards' }, { path: '/users', label: 'Users' }] : []),
...(isSuper ? [{ path: '/groups', label: 'Groups' }] : []),
{ path: '/keys', label: 'My keys' },
];
let page = null;
const cardMatch = /^\/cards\/([^/]+)$/.exec(path);
if (isAdmin && cardMatch) page = <CardEditorPage cardId={cardMatch[1]!} />;
else if (isAdmin && path === '/cards') page = <CardsPage />;
else if (isAdmin && path === '/users') page = <UsersPage me={me} />;
else if (isSuper && path === '/groups') page = <GroupsPage me={me} onGroupsChanged={onGroupChanged} />;
else if (path === '/keys') page = <MyKeysPage me={me} />;
useEffect(() => {
if (!page) navigate(isAdmin ? '/cards' : '/keys', { replace: true });
});
return (
<div className="app">
<header className="topbar">
<div className="brand">Cardy</div>
<nav>
{tabs.map((t) => (
<a key={t.path} href={t.path} onClick={linkClick} className={path.startsWith(t.path) ? 'active' : ''}>
{t.label}
</a>
))}
</nav>
<div className="topbar-right">
{isSuper ? <GroupSwitcher me={me} onChanged={onGroupChanged} epoch={groupEpoch} /> : <span className="pill">{me.currentGroup.name}</span>}
<span className="muted small" title={me.role}>
{me.email} · {me.role}
</span>
</div>
</header>
<main key={groupEpoch}>{page}</main>
</div>
);
}
function GroupSwitcher({ me, onChanged, epoch }: { me: MeResponse; onChanged: () => Promise<void>; epoch: number }) {
const groups = useLoad(api.groups, [epoch]);
const [error, setError] = useState<string>();
return (
<label className="group-switcher">
<span className="muted small">Group</span>
<select
value={me.currentGroup.id}
onChange={async (e) => {
try {
await api.setCurrentGroup(e.target.value);
setError(undefined);
await onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err));
}
}}
>
{(groups.data ?? [me.currentGroup]).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<ErrorText error={error ?? groups.error} />
</label>
);
}
+97
View File
@@ -0,0 +1,97 @@
import type { AccessKeyView, Card, CardFields, CardPatch, Group, MeResponse, Role, UserView } from '@cardy/shared';
// Reverse proxy: /api/admin/* goes through Authelia to server-be.
const BASE = '/api/admin';
const RELOAD_MARK = 'cardy:auth-reload';
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
/**
* A 401 without Cardy's JSON body comes from Authelia (session expired): reload the page so
* Authelia shows its login form. Guarded against reload loops.
*/
function reloadForLogin(): boolean {
let last = 0;
try {
last = Number(sessionStorage.getItem(RELOAD_MARK) ?? 0);
if (Date.now() - last < 15_000) return false;
sessionStorage.setItem(RELOAD_MARK, String(Date.now()));
} catch {
return false;
}
location.reload();
return true;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
// Accept/X-Requested-With make Authelia answer 401 instead of redirecting to its login page.
const headers: Record<string, string> = { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
if (method !== 'GET') headers['X-Cardy-Admin'] = '1';
let payload: BodyInit | undefined;
if (body instanceof FormData) payload = body;
else if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetch(BASE + path, { method, headers, body: payload, credentials: 'same-origin' });
if (!res.ok) {
let cardyError: string | undefined;
try {
cardyError = ((await res.json()) as { error?: string }).error;
} catch {
// not JSON: the response came from the proxy or Authelia
}
if (res.status === 401 && cardyError === undefined && reloadForLogin()) {
await new Promise(() => undefined); // page is reloading
}
throw new ApiError(res.status, cardyError ?? `${res.status} ${res.statusText}`);
}
return (res.status === 204 ? undefined : await res.json()) as T;
}
const enc = encodeURIComponent;
function files(list: File[]): FormData {
const form = new FormData();
for (const f of list) form.append('file', f, f.name);
return form;
}
export const api = {
me: () => request<MeResponse>('GET', '/me'),
myKeys: () => request<AccessKeyView[]>('GET', '/my-keys'),
groups: () => request<Group[]>('GET', '/groups'),
createGroup: (name: string) => request<Group>('POST', '/groups', { name }),
renameGroup: (id: string, name: string) => request<Group>('PATCH', `/groups/${enc(id)}`, { name }),
deleteGroup: (id: string) => request<void>('DELETE', `/groups/${enc(id)}`),
setCurrentGroup: (groupId: string) => request<Group>('PUT', '/current-group', { groupId }),
users: () => request<UserView[]>('GET', '/users'),
createUser: (email: string, role: Role) => request<UserView>('POST', '/users', { email, role }),
updateUser: (email: string, patch: { role?: Role; groupId?: string }) =>
request<UserView>('PATCH', `/users/${enc(email)}`, patch),
deleteUser: (email: string) => request<void>('DELETE', `/users/${enc(email)}`),
createKey: (email: string, label: string) => request<AccessKeyView>('POST', `/users/${enc(email)}/keys`, { label }),
deleteKey: (email: string, keyId: string) => request<void>('DELETE', `/users/${enc(email)}/keys/${enc(keyId)}`),
cards: () => request<Card[]>('GET', '/cards'),
createCard: (fields: CardFields) => request<Card>('POST', '/cards', fields),
updateCard: (id: string, patch: CardPatch) => request<Card>('PATCH', `/cards/${enc(id)}`, patch),
/** Marks as deleted; for an already deleted card removes it permanently (returns undefined). */
deleteCard: (id: string) => request<Card | undefined>('DELETE', `/cards/${enc(id)}`),
setLogo: (id: string, file: File) => request<Card>('PUT', `/cards/${enc(id)}/logo`, files([file])),
removeLogo: (id: string) => request<Card>('DELETE', `/cards/${enc(id)}/logo`),
addImages: (id: string, list: File[]) => request<Card>('POST', `/cards/${enc(id)}/images`, files(list)),
removeImage: (id: string, imageId: string) => request<Card>('DELETE', `/cards/${enc(id)}/images/${enc(imageId)}`),
logoUrl: (card: Card) => `${BASE}/cards/${enc(card.id)}/logo?v=${card.imagesVersion}`,
imageUrl: (card: Card, imageId: string) => `${BASE}/cards/${enc(card.id)}/images/${enc(imageId)}`,
};
+214
View File
@@ -0,0 +1,214 @@
import QRCode from 'qrcode';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
BWIP_BCID,
TWO_DIMENSIONAL_CODE_TYPES,
cardAbbreviation,
textColorOn,
type AccessKeyView,
type Card,
type CodeType,
} from '@cardy/shared';
import { api } from './api.js';
/** Loads data and exposes a reload function; errors are reported via `error`. */
export function useLoad<T>(load: () => Promise<T>, deps: unknown[] = []) {
const [data, setData] = useState<T>();
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
const run = useCallback(load, deps);
const reload = useCallback(async () => {
setLoading(true);
try {
setData(await run());
setError(undefined);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [run]);
useEffect(() => {
void reload();
}, [reload]);
return { data, setData, error, loading, reload };
}
/** Wraps an async action with a busy flag and error message. */
export function useAction() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
setBusy(true);
setError(undefined);
try {
return await fn();
} catch (e) {
setError((e as Error).message);
return undefined;
} finally {
setBusy(false);
}
}, []);
return { busy, error, setError, run };
}
export function ErrorText({ error }: { error?: string }) {
return error ? <p className="error">{error}</p> : null;
}
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" role="dialog" aria-modal="true" aria-label={title}>
<header className="modal-header">
<h2>{title}</h2>
<button className="ghost" onClick={onClose} aria-label="Close">
✕
</button>
</header>
{children}
</div>
</div>
);
}
export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false);
return (
<button
className="secondary"
onClick={async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? 'Copied' : label}
</button>
);
}
export function KeyQrModal({ keyView, owner, onClose }: { keyView: AccessKeyView; owner: string; onClose: () => void }) {
const [svg, setSvg] = useState('');
useEffect(() => {
QRCode.toString(keyView.deepLink, { type: 'svg', margin: 2, errorCorrectionLevel: 'M' }).then(setSvg);
}, [keyView.deepLink]);
return (
<Modal title={keyView.label ? `Key “${keyView.label}”` : 'Access key'} onClose={onClose}>
<p className="muted">
{owner} · created {formatDate(keyView.createdAt)}
</p>
<div className="qr" dangerouslySetInnerHTML={{ __html: svg }} />
<p className="muted small">Scan with the Cardy app or the phone camera.</p>
<div className="field">
<label>Key</label>
<div className="row nowrap">
<code className="mono grow break">{keyView.key}</code>
<CopyButton text={keyView.key} />
</div>
</div>
<div className="field">
<label>Login link</label>
<div className="row nowrap">
<a className="mono grow break link-box" href={keyView.deepLink} title="Open in the Cardy app">
{keyView.deepLink}
</a>
<CopyButton text={keyView.deepLink} />
</div>
</div>
</Modal>
);
}
type Bwip = typeof import('bwip-js/browser');
let bwipPromise: Promise<Bwip> | undefined;
export function CodePreview({ code, codeType }: { code: string; codeType: CodeType }) {
// bwip-js is ~1 MB, load it only when a code is shown.
const [bwipjs, setBwipjs] = useState<Bwip>();
useEffect(() => {
bwipPromise ??= import('bwip-js/browser').then((m) => (m as { default?: Bwip }).default ?? m);
void bwipPromise.then((b) => setBwipjs(() => b));
}, []);
const result = useMemo(() => {
if (!code) return { error: 'Enter a code to see the preview' };
if (!bwipjs) return { error: 'Loading…' };
try {
const svg = bwipjs.toSVG({
bcid: BWIP_BCID[codeType],
text: code,
scale: 3,
...(TWO_DIMENSIONAL_CODE_TYPES.has(codeType) ? {} : { height: 14, includetext: true, textxalign: 'center' as const }),
paddingwidth: 4,
paddingheight: 4,
backgroundcolor: 'FFFFFF',
});
return { svg };
} catch (e) {
return { error: String((e as Error).message ?? e).replace(/^Error:\s*/, '') };
}
}, [bwipjs, code, codeType]);
if ('error' in result) return <p className="code-preview-error">{result.error}</p>;
return <div className="code-preview" dangerouslySetInnerHTML={{ __html: result.svg }} />;
}
export function CardBadge({ card, size = 56 }: { card: Pick<Card, 'id' | 'name' | 'color' | 'logo' | 'imagesVersion'>; size?: number }) {
const style = { width: size, height: size, background: card.color, color: textColorOn(card.color) };
if (card.logo) {
return (
<span className="badge" style={style}>
<img src={api.logoUrl(card as Card)} alt="" />
</span>
);
}
return (
<span className="badge" style={{ ...style, fontSize: size * 0.36 }}>
{cardAbbreviation(card.name)}
</span>
);
}
export function FilePicker({
label,
multiple,
disabled,
onFiles,
}: {
label: string;
multiple?: boolean;
disabled?: boolean;
onFiles: (files: File[]) => void;
}) {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<button className="secondary" disabled={disabled} onClick={() => ref.current?.click()}>
{label}
</button>
<input
ref={ref}
type="file"
accept="image/*"
multiple={multiple}
hidden
onChange={(e) => {
const list = [...(e.target.files ?? [])];
e.target.value = '';
if (list.length) onFiles(list);
}}
/>
</>
);
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
import { CODE_TYPES, CODE_TYPE_LABELS, cardFieldsSchema, randomCardColor, type Card, type CardFields } from '@cardy/shared';
import { api } from '../api.js';
import { CardBadge, CodePreview, ErrorText, FilePicker, formatDate, useAction, useLoad } from '../components.js';
import { linkClick, navigate } from '../router.js';
export function CardEditorPage({ cardId }: { cardId: string }) {
const isNew = cardId === 'new';
const loaded = useLoad(async () => (isNew ? undefined : (await api.cards()).find((c) => c.id === cardId) ?? null), [cardId]);
if (loaded.loading && loaded.data === undefined && !isNew) return <p className="muted">Loading…</p>;
if (loaded.error) return <ErrorText error={loaded.error} />;
if (loaded.data === null) {
return (
<section>
<p className="empty">Card not found.</p>
<a href="/cards" onClick={linkClick}>
Back to cards
</a>
</section>
);
}
return <Editor key={cardId} initial={loaded.data} />;
}
function Editor({ initial }: { initial?: Card }) {
const [card, setCard] = useState<Card | undefined>(initial);
const [fields, setFields] = useState<CardFields>(
initial
? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType }
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13' },
);
const action = useAction();
const [saved, setSaved] = useState(false);
useEffect(() => setSaved(false), [fields]);
const dirty = !card || card.name !== fields.name || card.color !== fields.color || card.code !== fields.code || card.codeType !== fields.codeType;
const set = <K extends keyof CardFields>(key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value }));
const save = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = cardFieldsSchema.safeParse(fields);
if (!parsed.success) {
action.setError(parsed.error.issues.map((i) => i.message).join('; '));
return;
}
const result = await action.run(() => (card ? api.updateCard(card.id, parsed.data) : api.createCard(parsed.data)));
if (!result) return;
setCard(result);
setSaved(true);
if (!card) navigate(`/cards/${result.id}`, { replace: true });
};
return (
<section className="editor">
<div className="row page-head">
<a href="/cards" onClick={linkClick} className="ghost-link">
← Cards
</a>
<h1 className="grow">{card ? card.name : 'New card'}</h1>
{card && <CardBadge card={{ ...card, ...fields }} size={48} />}
</div>
{card?.deleted && (
<div className="banner danger row">
<span className="grow">This card is deleted. Apps don't see it.</span>
<button
className="secondary"
disabled={action.busy}
onClick={async () => {
const restored = await action.run(() => api.updateCard(card.id, { deleted: false }));
if (restored) setCard(restored);
}}
>
Restore
</button>
</div>
)}
<div className="editor-grid">
<form className="panel" onSubmit={save}>
<div className="field">
<label htmlFor="name">Name</label>
<input id="name" value={fields.name} onChange={(e) => set('name', e.target.value)} maxLength={200} required />
</div>
<div className="field">
<label htmlFor="color">Color</label>
<div className="row">
<input id="color" type="color" value={fields.color} onChange={(e) => set('color', e.target.value.toUpperCase())} />
<input
className="mono"
value={fields.color}
onChange={(e) => set('color', e.target.value)}
pattern="#[0-9a-fA-F]{6}"
size={8}
aria-label="Color hex"
/>
<button type="button" className="secondary" onClick={() => set('color', randomCardColor())}>
Random
</button>
</div>
</div>
<div className="field">
<label htmlFor="codeType">Code type</label>
<select id="codeType" value={fields.codeType} onChange={(e) => set('codeType', e.target.value as CardFields['codeType'])}>
{CODE_TYPES.map((t) => (
<option key={t} value={t}>
{CODE_TYPE_LABELS[t]}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="code">Code</label>
<textarea id="code" className="mono" rows={2} value={fields.code} onChange={(e) => set('code', e.target.value)} required />
</div>
<CodePreview code={fields.code} codeType={fields.codeType} />
<div className="row">
<button type="submit" disabled={action.busy || (!dirty && !!card)}>
{card ? 'Save' : 'Create card'}
</button>
{saved && <span className="muted small">Saved</span>}
<span className="grow" />
{card && <DeleteButtons card={card} onChange={setCard} />}
</div>
<ErrorText error={action.error} />
{card && (
<p className="muted small">
Created {formatDate(card.createdAt)} · updated {formatDate(card.updatedAt)} · images version {card.imagesVersion}
</p>
)}
</form>
{card ? <ImagesPanel card={card} onChange={setCard} /> : <div className="panel muted">Create the card to add a logo and images.</div>}
</div>
</section>
);
}
function DeleteButtons({ card, onChange }: { card: Card; onChange: (card: Card) => void }) {
const action = useAction();
return (
<>
<button
type="button"
className="danger"
disabled={action.busy}
onClick={async () => {
if (card.deleted) {
if (!confirm(`Permanently delete "${card.name}" with all its images? This cannot be undone.`)) return;
if ((await action.run(async () => (await api.deleteCard(card.id), true))) !== undefined) navigate('/cards');
} else {
if (!confirm(`Delete "${card.name}"? It can be restored later.`)) return;
const deleted = await action.run(() => api.deleteCard(card.id));
if (deleted) onChange(deleted);
}
}}
>
{card.deleted ? 'Delete permanently' : 'Delete'}
</button>
<ErrorText error={action.error} />
</>
);
}
function ImagesPanel({ card, onChange }: { card: Card; onChange: (card: Card) => void }) {
const action = useAction();
const apply = async (fn: () => Promise<Card>) => {
const updated = await action.run(fn);
if (updated) onChange(updated);
};
return (
<div className="panel">
<h2>Logo</h2>
<div className="row">
{card.logo ? <img className="logo-preview" src={api.logoUrl(card)} alt="Logo" /> : <span className="muted">No logo</span>}
<span className="grow" />
<FilePicker label={card.logo ? 'Replace' : 'Upload'} disabled={action.busy} onFiles={([f]) => f && apply(() => api.setLogo(card.id, f))} />
{card.logo && (
<button className="danger" disabled={action.busy} onClick={() => apply(() => api.removeLogo(card.id))}>
Remove
</button>
)}
</div>
<h2>Images</h2>
<div className="thumbs">
{card.images.map((img) => (
<figure key={img.id} className="thumb">
<a href={api.imageUrl(card, img.id)} target="_blank" rel="noreferrer">
<img src={api.imageUrl(card, img.id)} alt="" loading="lazy" />
</a>
<button
className="danger small"
disabled={action.busy}
onClick={() => confirm('Delete this image?') && apply(() => api.removeImage(card.id, img.id))}
>
Delete
</button>
</figure>
))}
{card.images.length === 0 && <span className="muted">No images</span>}
</div>
<FilePicker label="Add images" multiple disabled={action.busy} onFiles={(files) => apply(() => api.addImages(card.id, files))} />
{action.busy && <span className="muted small"> Uploading…</span>}
<ErrorText error={action.error} />
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useMemo, useState } from 'react';
import { CODE_TYPE_LABELS } from '@cardy/shared';
import { api } from '../api.js';
import { CardBadge, ErrorText, useLoad } from '../components.js';
import { linkClick } from '../router.js';
export function CardsPage() {
const cards = useLoad(api.cards);
const [filter, setFilter] = useState('');
const [showDeleted, setShowDeleted] = useState(false);
const visible = useMemo(() => {
const q = filter.trim().toLowerCase();
return (cards.data ?? [])
.filter((c) => showDeleted || !c.deleted)
.filter((c) => !q || c.name.toLowerCase().includes(q))
.sort((a, b) => a.name.localeCompare(b.name));
}, [cards.data, filter, showDeleted]);
const deletedCount = cards.data?.filter((c) => c.deleted).length ?? 0;
return (
<section>
<div className="row page-head">
<h1 className="grow">Cards</h1>
<a className="button" href="/cards/new" onClick={linkClick}>
New card
</a>
</div>
<div className="row">
<input className="grow" type="search" placeholder="Filter by name" value={filter} onChange={(e) => setFilter(e.target.value)} />
<label className="checkbox">
<input type="checkbox" checked={showDeleted} onChange={(e) => setShowDeleted(e.target.checked)} />
Show deleted ({deletedCount})
</label>
</div>
<ErrorText error={cards.error} />
{cards.data && visible.length === 0 && <p className="empty">{cards.data.length ? 'No matching cards.' : 'No cards yet.'}</p>}
<div className="card-grid">
{visible.map((c) => (
<a key={c.id} href={`/cards/${c.id}`} onClick={linkClick} className={`card-tile${c.deleted ? ' deleted' : ''}`}>
<CardBadge card={c} />
<div className="grow">
<div className="card-name">{c.name}</div>
<div className="muted small">
{CODE_TYPE_LABELS[c.codeType]}
{c.images.length > 0 && ` · ${c.images.length} image${c.images.length === 1 ? '' : 's'}`}
</div>
</div>
{c.deleted && <span className="pill danger">deleted</span>}
</a>
))}
</div>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react';
import type { Group, MeResponse } from '@cardy/shared';
import { api } from '../api.js';
import { ErrorText, useAction, useLoad } from '../components.js';
export function GroupsPage({ me, onGroupsChanged }: { me: MeResponse; onGroupsChanged: () => Promise<void> }) {
const groups = useLoad(api.groups);
const action = useAction();
const [name, setName] = useState('');
const changed = async () => {
await groups.reload();
await onGroupsChanged();
};
return (
<section>
<h1>Groups</h1>
<form
className="row"
onSubmit={async (e) => {
e.preventDefault();
if (await action.run(() => api.createGroup(name.trim()))) {
setName('');
await changed();
}
}}
>
<input placeholder="New group name" value={name} onChange={(e) => setName(e.target.value)} required />
<button type="submit" disabled={action.busy || !name.trim()}>
Add group
</button>
</form>
<ErrorText error={action.error ?? groups.error} />
<ul className="list">
{groups.data?.map((g) => (
<GroupRow key={g.id} group={g} isCurrent={g.id === me.currentGroup.id} isOwn={g.id === me.group.id} onChanged={changed} />
))}
</ul>
</section>
);
}
function GroupRow({ group, isCurrent, isOwn, onChanged }: { group: Group; isCurrent: boolean; isOwn: boolean; onChanged: () => Promise<void> }) {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(group.name);
const action = useAction();
return (
<li className="list-item">
{editing ? (
<form
className="row grow"
onSubmit={async (e) => {
e.preventDefault();
if (await action.run(() => api.renameGroup(group.id, name.trim()))) {
setEditing(false);
await onChanged();
}
}}
>
<input className="grow" value={name} onChange={(e) => setName(e.target.value)} autoFocus required />
<button type="submit" disabled={action.busy}>
Save
</button>
<button type="button" className="secondary" onClick={() => { setEditing(false); setName(group.name); }}>
Cancel
</button>
</form>
) : (
<>
<div className="grow">
{group.name}
{isCurrent && <span className="pill accent">current</span>}
{isOwn && <span className="pill">yours</span>}
</div>
<button className="secondary" onClick={() => api.setCurrentGroup(group.id).then(onChanged)} disabled={isCurrent}>
Select
</button>
<button className="secondary" onClick={() => setEditing(true)}>
Rename
</button>
<button
className="danger"
disabled={action.busy}
onClick={async () => {
const ok = confirm(`Delete group "${group.name}"?\n\nAll its users, their keys and all cards with images will be deleted permanently.`);
if (ok && (await action.run(async () => (await api.deleteGroup(group.id), true)))) await onChanged();
}}
>
Delete
</button>
</>
)}
<ErrorText error={action.error} />
</li>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react';
import type { AccessKeyView, MeResponse } from '@cardy/shared';
import { api } from '../api.js';
import { ErrorText, KeyQrModal, formatDate, useLoad } from '../components.js';
export function MyKeysPage({ me }: { me: MeResponse }) {
const keys = useLoad(api.myKeys);
const [shown, setShown] = useState<AccessKeyView>();
return (
<section>
<h1>My keys</h1>
<p className="muted">Scan a key's QR code with the Cardy app to sign in. Keys are issued by your group administrator.</p>
<ErrorText error={keys.error} />
{keys.data?.length === 0 && <p className="empty">You have no keys yet.</p>}
<ul className="list">
{keys.data?.map((k) => (
<li key={k.id} className="list-item">
<div className="grow">
<div>{k.label || 'Unnamed key'}</div>
<div className="muted small">
Created {formatDate(k.createdAt)} · <span className="mono">{k.key.slice(0, 4)}…{k.key.slice(-4)}</span>
</div>
</div>
<button onClick={() => setShown(k)}>Show QR</button>
</li>
))}
</ul>
{shown && <KeyQrModal keyView={shown} owner={me.email} onClose={() => setShown(undefined)} />}
</section>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { ROLES, type AccessKeyView, type Group, type MeResponse, type Role, type UserView } from '@cardy/shared';
import { api } from '../api.js';
import { ErrorText, KeyQrModal, formatDate, useAction, useLoad } from '../components.js';
export function UsersPage({ me }: { me: MeResponse }) {
const users = useLoad(api.users);
const isSuper = me.role === 'superadmin';
const groups = useLoad(() => (isSuper ? api.groups() : Promise.resolve([] as Group[])));
const assignableRoles: Role[] = isSuper ? [...ROLES] : ['user', 'admin'];
return (
<section>
<h1>Users</h1>
<p className="muted">Users of group “{me.currentGroup.name}”. Users sign in to this site through Authelia with the same email.</p>
<AddUser roles={assignableRoles} onAdded={users.reload} />
<ErrorText error={users.error} />
{users.data?.length === 0 && <p className="empty">No users in this group.</p>}
<ul className="list">
{users.data?.map((u) => (
<UserRow key={u.email} user={u} me={me} roles={assignableRoles} groups={groups.data ?? []} onChanged={users.reload} />
))}
</ul>
</section>
);
}
function AddUser({ roles, onAdded }: { roles: Role[]; onAdded: () => Promise<void> }) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<Role>('user');
const action = useAction();
return (
<>
<form
className="row"
onSubmit={async (e) => {
e.preventDefault();
if (await action.run(() => api.createUser(email.trim(), role))) {
setEmail('');
setRole('user');
await onAdded();
}
}}
>
<input type="email" placeholder="email@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
{roles.map((r) => (
<option key={r}>{r}</option>
))}
</select>
<button type="submit" disabled={action.busy}>
Add user
</button>
</form>
<ErrorText error={action.error} />
</>
);
}
function UserRow({
user,
me,
roles,
groups,
onChanged,
}: {
user: UserView;
me: MeResponse;
roles: Role[];
groups: Group[];
onChanged: () => Promise<void>;
}) {
const [expanded, setExpanded] = useState(false);
const action = useAction();
const isSelf = user.email === me.email;
const canManage = me.role === 'superadmin' || user.role !== 'superadmin';
const update = async (patch: { role?: Role; groupId?: string }) => {
if (await action.run(() => api.updateUser(user.email, patch))) await onChanged();
};
return (
<li className="list-item column">
<div className="row">
<div className="grow">
<div>
{user.email}
{isSelf && <span className="pill">you</span>}
</div>
<div className="muted small">
{user.keys.length} key{user.keys.length === 1 ? '' : 's'}
</div>
</div>
<select
value={user.role}
disabled={!canManage || isSelf || action.busy}
onChange={(e) => update({ role: e.target.value as Role })}
aria-label="Role"
>
{(roles.includes(user.role) ? roles : [...roles, user.role]).map((r) => (
<option key={r}>{r}</option>
))}
</select>
{me.role === 'superadmin' && groups.length > 0 && (
<select
value={user.groupId}
disabled={action.busy}
onChange={(e) => {
const target = groups.find((g) => g.id === e.target.value);
if (confirm(`Move ${user.email} to group "${target?.name}"?`)) void update({ groupId: e.target.value });
}}
aria-label="Group"
>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
)}
<button className="secondary" onClick={() => setExpanded((x) => !x)} disabled={!canManage}>
{expanded ? 'Hide keys' : 'Keys'}
</button>
<button
className="danger"
disabled={!canManage || isSelf || action.busy}
onClick={async () => {
if (!confirm(`Delete user ${user.email} and all their keys?`)) return;
if (await action.run(async () => (await api.deleteUser(user.email), true))) await onChanged();
}}
>
Delete
</button>
</div>
<ErrorText error={action.error} />
{expanded && <UserKeys user={user} onChanged={onChanged} />}
</li>
);
}
function UserKeys({ user, onChanged }: { user: UserView; onChanged: () => Promise<void> }) {
const [label, setLabel] = useState('');
const [shown, setShown] = useState<AccessKeyView>();
const action = useAction();
return (
<div className="subpanel">
{user.keys.length === 0 && <p className="muted small">No keys.</p>}
<ul className="list compact">
{user.keys.map((k) => (
<li key={k.id} className="list-item">
<div className="grow">
<div>{k.label || 'Unnamed key'}</div>
<div className="muted small">
Created {formatDate(k.createdAt)} · <span className="mono">{k.key.slice(0, 4)}…{k.key.slice(-4)}</span>
</div>
</div>
<button className="secondary" onClick={() => setShown(k)}>
Show QR
</button>
<button
className="danger"
disabled={action.busy}
onClick={async () => {
if (!confirm('Delete this key? Apps signed in with it stop working immediately.')) return;
if (await action.run(async () => (await api.deleteKey(user.email, k.id), true))) await onChanged();
}}
>
Delete
</button>
</li>
))}
</ul>
<form
className="row"
onSubmit={async (e) => {
e.preventDefault();
const created = await action.run(() => api.createKey(user.email, label.trim()));
if (created) {
setLabel('');
setShown(created);
await onChanged();
}
}}
>
<input placeholder="Label (optional), e.g. Pixel 8" value={label} onChange={(e) => setLabel(e.target.value)} maxLength={100} />
<button type="submit" disabled={action.busy}>
Generate key
</button>
</form>
<ErrorText error={action.error} />
{shown && <KeyQrModal keyView={shown} owner={user.email} onClose={() => setShown(undefined)} />}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
const listeners = new Set<() => void>();
export function navigate(path: string, { replace = false } = {}): void {
if (path === location.pathname) return;
if (replace) history.replaceState(null, '', path);
else history.pushState(null, '', path);
listeners.forEach((l) => l());
}
window.addEventListener('popstate', () => listeners.forEach((l) => l()));
export function usePath(): string {
const [path, setPath] = useState(location.pathname);
useEffect(() => {
const listener = () => setPath(location.pathname);
listeners.add(listener);
listener(); // catch navigations that happened before subscribing
return () => void listeners.delete(listener);
}, []);
return path;
}
/** Click handler for <a href> that navigates without a reload (keeps ctrl/middle-click working). */
export function linkClick(e: React.MouseEvent<HTMLAnchorElement>): void {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
navigate(e.currentTarget.getAttribute('href') ?? '/');
}
+585
View File
@@ -0,0 +1,585 @@
/* Dark theme only. */
:root {
color-scheme: dark;
--bg: #0f1115;
--surface: #171a21;
--surface-2: #1f232c;
--border: #2c313c;
--text: #e6e8ec;
--muted: #8b93a3;
--accent: #5b9dff;
--accent-strong: #3d86f5;
--accent-text: #0b1220;
--danger: #ff6b6b;
--danger-bg: #3a1b1f;
--radius: 10px;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.45 var(--font);
}
h1 {
font-size: 1.5rem;
margin: 0 0 12px;
}
h2 {
font-size: 1.05rem;
margin: 16px 0 8px;
}
h2:first-child {
margin-top: 0;
}
a {
color: var(--accent);
}
p {
margin: 8px 0;
}
.muted {
color: var(--muted);
}
.small {
font-size: 0.85em;
}
.mono {
font-family: var(--mono);
}
.break {
word-break: break-all;
}
.error {
color: var(--danger);
}
.empty {
color: var(--muted);
padding: 24px 0;
}
.center {
max-width: 480px;
margin: 15vh auto;
padding: 0 16px;
text-align: center;
}
/* ---- controls ---- */
input,
select,
textarea,
button,
.button {
font: inherit;
color: var(--text);
border-radius: 8px;
}
input,
select,
textarea {
background: var(--surface-2);
border: 1px solid var(--border);
padding: 7px 10px;
min-width: 0;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
input[type='color'] {
width: 44px;
height: 36px;
padding: 2px;
}
input[type='checkbox'] {
accent-color: var(--accent);
}
textarea {
width: 100%;
resize: vertical;
}
button,
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 14px;
border: 1px solid transparent;
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
button:hover:not(:disabled),
.button:hover {
background: var(--accent-strong);
}
button:disabled {
opacity: 0.45;
cursor: default;
}
button.secondary {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
button.secondary:hover:not(:disabled) {
background: var(--border);
}
button.danger {
background: transparent;
border-color: var(--danger-bg);
color: var(--danger);
}
button.danger:hover:not(:disabled) {
background: var(--danger-bg);
}
button.ghost {
background: transparent;
color: var(--muted);
padding: 4px 8px;
}
button.ghost:hover:not(:disabled) {
background: var(--surface-2);
color: var(--text);
}
button.small {
padding: 3px 8px;
font-size: 0.85em;
}
.ghost-link {
color: var(--muted);
text-decoration: none;
}
.ghost-link:hover {
color: var(--text);
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.field label {
color: var(--muted);
font-size: 0.85em;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.row.nowrap {
flex-wrap: nowrap;
}
.row.nowrap > button {
flex: none;
}
.grow {
flex: 1 1 auto;
min-width: 0;
}
.pill {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.78em;
vertical-align: middle;
}
.pill.accent {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.pill.danger {
color: var(--danger);
border-color: var(--danger-bg);
background: var(--danger-bg);
}
/* ---- layout ---- */
.topbar {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
padding: 10px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
font-weight: 700;
font-size: 1.1rem;
letter-spacing: 0.02em;
}
.topbar nav {
display: flex;
gap: 4px;
}
.topbar nav a {
padding: 6px 12px;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
}
.topbar nav a:hover {
color: var(--text);
background: var(--surface-2);
}
.topbar nav a.active {
color: var(--text);
background: var(--surface-2);
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.group-switcher {
display: flex;
align-items: center;
gap: 6px;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
section > .row {
margin-bottom: 12px;
}
.page-head h1 {
margin: 0;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.subpanel {
margin-top: 10px;
padding: 12px;
border-radius: 8px;
background: var(--bg);
border: 1px solid var(--border);
}
.banner {
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 12px;
}
.banner.danger {
background: var(--danger-bg);
color: var(--danger);
}
/* ---- lists ---- */
.list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.list.compact {
margin: 0 0 10px;
}
.list-item {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.list-item.column {
flex-direction: column;
align-items: stretch;
}
.subpanel .list-item {
background: var(--surface-2);
}
/* ---- cards ---- */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px;
margin-top: 12px;
}
.card-tile {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
text-decoration: none;
}
.card-tile:hover {
border-color: var(--accent);
}
.card-tile.deleted {
opacity: 0.55;
}
.card-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-weight: 700;
overflow: hidden;
}
.badge img {
width: 100%;
height: 100%;
object-fit: contain;
}
.editor-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@media (max-width: 800px) {
.editor-grid {
grid-template-columns: 1fr;
}
main {
padding: 16px;
}
.topbar {
padding: 10px 16px;
}
}
/* Codes stay black on white so they remain scannable. */
.code-preview {
background: #fff;
border-radius: 8px;
padding: 8px;
margin-bottom: 12px;
display: flex;
justify-content: center;
}
.code-preview svg {
max-width: 100%;
max-height: 220px;
height: auto;
}
.code-preview-error {
color: var(--muted);
font-size: 0.85em;
border: 1px dashed var(--border);
border-radius: 8px;
padding: 16px;
margin: 0 0 12px;
text-align: center;
}
.logo-preview {
width: 72px;
height: 72px;
object-fit: contain;
background: var(--surface-2);
border-radius: 8px;
}
.thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.thumb {
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.thumb img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 8px;
background: var(--surface-2);
display: block;
}
/* ---- modal ---- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 100;
}
.modal {
width: min(480px, 100%);
max-height: 100%;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px 20px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.modal-header h2 {
margin: 0;
}
.qr {
background: #fff;
border-radius: 10px;
padding: 8px;
width: min(280px, 100%);
margin: 12px auto 4px;
}
.qr svg {
display: block;
width: 100%;
height: auto;
}
.modal code,
.modal .link-box {
background: var(--surface-2);
border: 1px solid var(--border);
padding: 6px 8px;
border-radius: 6px;
}
.modal .link-box {
text-decoration: none;
}
.modal .link-box:hover {
border-color: var(--accent);
text-decoration: underline;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+43
View File
@@ -0,0 +1,43 @@
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv, type ProxyOptions } from 'vite';
// Behind the real reverse proxy /api never reaches this server. The /api proxy below only emulates
// that reverse proxy for local development without it (strips /api, sets X-Forwarded-Prefix).
//
// Env (server-fe/.env or process env):
// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com)
// BACKEND_URL local dev: server-be address (default http://127.0.0.1:10002)
// ADMIN_PROXY_SECRET local dev: sent as X-Cardy-Proxy-Secret, must match server-be
// DEV_REMOTE_EMAIL local dev: fakes the Remote-Email header normally set by Authelia
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const headers: Record<string, string> = { 'X-Forwarded-Prefix': '/api' };
if (env.ADMIN_PROXY_SECRET) headers['X-Cardy-Proxy-Secret'] = env.ADMIN_PROXY_SECRET;
if (env.DEV_REMOTE_EMAIL) {
headers['Remote-Email'] = env.DEV_REMOTE_EMAIL;
console.warn(`\n⚠ DEV_REMOTE_EMAIL is set: every request acts as ${env.DEV_REMOTE_EMAIL}. Never use it behind the real proxy.\n`);
}
const proxy: Record<string, ProxyOptions> = {
'/api': {
target: env.BACKEND_URL || 'http://127.0.0.1:10002',
rewrite: (path) => path.replace(/^\/api/, '') || '/',
headers,
},
};
const server = {
host: true,
port: 10001,
strictPort: true,
allowedHosts: (env.ALLOWED_HOSTS || 'dev.aggtaa.com').split(',').map((h) => h.trim()),
proxy,
};
return {
plugins: [react()],
// bwip-js alone is ~1 MB; it is lazy-loaded.
build: { chunkSizeWarningLimit: 1100 },
server,
preview: server,
};
});
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@cardy/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.6.5"
},
"devDependencies": {
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { CARD_PALETTE, cardAbbreviation, randomCardColor, textColorOn } from './cardDisplay.js';
describe('card display helpers', () => {
it('abbreviates names', () => {
expect(cardAbbreviation('Magnit')).toBe('MA');
expect(cardAbbreviation('Lenta Plus Card')).toBe('LP');
expect(cardAbbreviation(' ')).toBe('?');
expect(cardAbbreviation('пятёрочка')).toBe('ПЯ');
});
it('picks palette colors and readable text colors', () => {
expect(CARD_PALETTE).toContain(randomCardColor());
expect(textColorOn('#FFFFFF')).toBe('#000000');
expect(textColorOn('#123456')).toBe('#FFFFFF');
for (const c of CARD_PALETTE) expect(textColorOn(c)).toBe('#FFFFFF');
});
});
+29
View File
@@ -0,0 +1,29 @@
/** Colors that keep white text readable. */
export const CARD_PALETTE = [
'#C62828', '#AD1457', '#6A1B9A', '#4527A0', '#283593', '#1565C0', '#01579B', '#00695C',
'#2E7D32', '#33691E', '#BF360C', '#880E4F', '#4E342E', '#37474F', '#006064', '#5D4037',
] as const;
export function randomCardColor(): string {
return CARD_PALETTE[Math.floor(Math.random() * CARD_PALETTE.length)]!;
}
/** Up to two letters: initials of the first two words, or the first two characters of a single word. */
export function cardAbbreviation(name: string): string {
const words = name.trim().split(/[\s\-_.]+/u).filter(Boolean);
if (words.length === 0) return '?';
const letters = words.length === 1 ? [...words[0]!].slice(0, 2) : words.slice(0, 2).map((w) => [...w][0]!);
return letters.join('').toUpperCase();
}
/** Black or white, whichever reads better on the given #RRGGBB background. */
export function textColorOn(background: string): '#000000' | '#FFFFFF' {
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(background);
if (!m) return '#FFFFFF';
const [r, g, b] = [m[1], m[2], m[3]].map((h) => {
const c = parseInt(h!, 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}) as [number, number, number];
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.179 ? '#000000' : '#FFFFFF';
}
+49
View File
@@ -0,0 +1,49 @@
export const CODE_TYPES = [
'EAN13',
'EAN8',
'UPCA',
'UPCE',
'CODE128',
'CODE39',
'ITF',
'CODABAR',
'QR',
'DATAMATRIX',
'PDF417',
'AZTEC',
] as const;
export type CodeType = (typeof CODE_TYPES)[number];
export const CODE_TYPE_LABELS: Record<CodeType, string> = {
EAN13: 'EAN-13',
EAN8: 'EAN-8',
UPCA: 'UPC-A',
UPCE: 'UPC-E',
CODE128: 'Code 128',
CODE39: 'Code 39',
ITF: 'ITF (Interleaved 2 of 5)',
CODABAR: 'Codabar',
QR: 'QR Code',
DATAMATRIX: 'Data Matrix',
PDF417: 'PDF417',
AZTEC: 'Aztec',
};
/** bwip-js symbology ids ("bcid") for each code type. */
export const BWIP_BCID: Record<CodeType, string> = {
EAN13: 'ean13',
EAN8: 'ean8',
UPCA: 'upca',
UPCE: 'upce',
CODE128: 'code128',
CODE39: 'code39',
ITF: 'interleaved2of5',
CODABAR: 'rationalizedCodabar',
QR: 'qrcode',
DATAMATRIX: 'datamatrix',
PDF417: 'pdf417',
AZTEC: 'azteccode',
};
export const TWO_DIMENSIONAL_CODE_TYPES: ReadonlySet<CodeType> = new Set(['QR', 'DATAMATRIX', 'AZTEC']);
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { buildLoginDeepLink, parseLoginDeepLink } from './deepLink.js';
describe('login deep link', () => {
it('round-trips server and key', () => {
const link = buildLoginDeepLink('https://cards.example.com/sub?x=1&y=2', 'AbC123');
expect(link).toBe('cardy://login?server=https%3A%2F%2Fcards.example.com%2Fsub%3Fx%3D1%26y%3D2&key=AbC123');
expect(parseLoginDeepLink(link)).toEqual({ server: 'https://cards.example.com/sub?x=1&y=2', key: 'AbC123' });
});
it('rejects foreign or incomplete links', () => {
expect(parseLoginDeepLink('https://login?server=a&key=b')).toBeNull();
expect(parseLoginDeepLink('cardy://other?server=a&key=b')).toBeNull();
expect(parseLoginDeepLink('cardy://login?server=a')).toBeNull();
expect(parseLoginDeepLink('not a url')).toBeNull();
});
});
+20
View File
@@ -0,0 +1,20 @@
export const DEEP_LINK_SCHEME = 'cardy';
export function buildLoginDeepLink(server: string, key: string): string {
return `${DEEP_LINK_SCHEME}://login?server=${encodeURIComponent(server)}&key=${encodeURIComponent(key)}`;
}
export function parseLoginDeepLink(link: string): { server: string; key: string } | null {
let url: URL;
try {
url = new URL(link.trim());
} catch {
return null;
}
// Non-special schemes put "login" into host for "cardy://login?…".
if (url.protocol !== `${DEEP_LINK_SCHEME}:` || url.host !== 'login') return null;
const server = url.searchParams.get('server');
const key = url.searchParams.get('key');
if (!server || !key) return null;
return { server, key };
}
+5
View File
@@ -0,0 +1,5 @@
export * from './codeTypes.js';
export * from './models.js';
export * from './schemas.js';
export * from './deepLink.js';
export * from './cardDisplay.js';
+61
View File
@@ -0,0 +1,61 @@
import type { CodeType } from './codeTypes.js';
export const ROLES = ['user', 'admin', 'superadmin'] as const;
export type Role = (typeof ROLES)[number];
export interface Group {
id: string;
name: string;
}
export interface ImageRef {
id: string;
file: string;
mime: string;
}
export interface Card {
id: string;
name: string;
color: string;
code: string;
codeType: CodeType;
logo: ImageRef | null;
images: ImageRef[];
imagesVersion: number;
deleted: boolean;
createdAt: string;
updatedAt: string;
}
/** Access key as shown in the admin UI (decrypted). */
export interface AccessKeyView {
id: string;
label?: string;
createdAt: string;
key: string;
deepLink: string;
}
export interface UserView {
email: string;
groupId: string;
role: Role;
keys: AccessKeyView[];
}
export interface MeResponse {
email: string;
role: Role;
group: Group;
currentGroup: Group;
}
export interface ApiAuthResponse {
email: string;
group: Group;
}
export interface ErrorResponse {
error: string;
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
import { CODE_TYPES } from './codeTypes.js';
import { ROLES } from './models.js';
export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB');
export const cardFieldsSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(200),
color: colorSchema,
code: z.string().min(1, 'Code is required').max(4000),
codeType: z.enum(CODE_TYPES),
});
export type CardFields = z.infer<typeof cardFieldsSchema>;
export const cardPatchSchema = cardFieldsSchema.partial().extend({
deleted: z.boolean().optional(),
});
export type CardPatch = z.infer<typeof cardPatchSchema>;
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
export const groupInputSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(100),
});
export const userCreateSchema = z.object({
email: emailSchema,
role: z.enum(ROLES).default('user'),
groupId: z.string().optional(),
});
export const userPatchSchema = z.object({
role: z.enum(ROLES).optional(),
groupId: z.string().optional(),
});
export const keyCreateSchema = z.object({
label: z.string().trim().max(100).optional(),
});
export const currentGroupSchema = z.object({
groupId: z.string().min(1),
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
}
}