server be & fe
This commit is contained in:
@@ -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
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}` } };
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user