initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

35
src/utils/encryption.ts Normal file
View File

@@ -0,0 +1,35 @@
import crypto from 'crypto';
import { config } from '../config/env';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
export function encrypt(plaintext: string): string {
const key = Buffer.from(config.totp.encryptionKey, 'hex');
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
return iv.toString('hex') + ':' + encrypted + ':' + tag.toString('hex');
}
export function decrypt(ciphertext: string): string {
const key = Buffer.from(config.totp.encryptionKey, 'hex');
const parts = ciphertext.split(':');
if (parts.length !== 3) throw new Error('Invalid ciphertext format');
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const tag = Buffer.from(parts[2], 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}

28
src/utils/pagination.ts Normal file
View File

@@ -0,0 +1,28 @@
import { PaginationQuery, PaginationMeta } from '../types';
export function parsePagination(query: Record<string, unknown>): {
page: number;
limit: number;
skip: number;
sort: string;
order: 'asc' | 'desc';
search: string;
} {
const page = Math.max(1, parseInt(String(query.page || '1'), 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(String(query.limit || query.per_page || '25'), 10) || 25));
const sort = String(query.sort || 'id');
const order = String(query.order || '').toLowerCase() === 'asc' ? 'asc' : 'desc';
const search = String(query.search || '');
return { page, limit, skip: (page - 1) * limit, sort, order, search };
}
export function buildPaginationMeta(total: number, page: number, limit: number): PaginationMeta {
return {
page,
limit,
per_page: limit,
total,
total_pages: Math.ceil(total / limit),
};
}

30
src/utils/response.ts Normal file
View File

@@ -0,0 +1,30 @@
import { FastifyReply } from 'fastify';
import { ApiResponse, PaginationMeta } from '../types';
export function success<T>(reply: FastifyReply, data: T, statusCode = 200, message?: string): void {
const response: ApiResponse<T> & { message?: string } = { success: true, data };
if (message) response.message = message;
reply.status(statusCode).send(response);
}
export function paginated<T>(
reply: FastifyReply,
data: T,
pagination: PaginationMeta,
): void {
reply.status(200).send({ success: true, data, pagination } satisfies ApiResponse<T>);
}
export function error(reply: FastifyReply, message: string, statusCode = 400): void {
reply.status(statusCode).send({ success: false, error: message } satisfies ApiResponse);
}
/** Parse and validate a numeric ID from route params. Returns NaN-safe number or sends 400 error. */
export function parseId(raw: string, reply: FastifyReply): number | null {
const id = parseInt(raw, 10);
if (isNaN(id) || id <= 0) {
error(reply, 'Neplatné ID', 400);
return null;
}
return id;
}

46
src/utils/sequence.ts Normal file
View File

@@ -0,0 +1,46 @@
import prisma from '../config/database';
/**
* Atomically get and increment the next sequence number for a document type and year.
* Uses the `number_sequences` table with upsert to avoid race conditions.
*/
export async function nextSequenceNumber(type: string, year: number): Promise<number> {
// Use a transaction with a raw query to atomically increment
const result = await prisma.$queryRaw<Array<{ last_number: number }>>`
INSERT INTO number_sequences (type, year, last_number)
VALUES (${type}, ${year}, 1)
ON DUPLICATE KEY UPDATE last_number = last_number + 1;
SELECT last_number FROM number_sequences WHERE type = ${type} AND year = ${year};
`;
// $queryRaw with multiple statements may not work on all drivers, use a transaction fallback
return result[0]?.last_number ?? 1;
}
/**
* Atomically get and increment the next sequence number using Prisma transaction.
* Compatible with all Prisma drivers.
*/
export async function getNextNumber(type: string, year: number): Promise<number> {
return prisma.$transaction(async (tx) => {
// Try to find existing sequence
const existing = await tx.number_sequences.findFirst({
where: { type, year },
});
if (existing) {
const nextNum = (existing.last_number ?? 0) + 1;
await tx.number_sequences.update({
where: { id: existing.id },
data: { last_number: nextNum },
});
return nextNum;
}
// Create new sequence
await tx.number_sequences.create({
data: { type, year, last_number: 1 },
});
return 1;
});
}

20
src/utils/totp.ts Normal file
View File

@@ -0,0 +1,20 @@
import * as OTPAuthLib from 'otpauth';
import { decrypt } from './encryption';
export const OTPAuth = {
verify(encryptedSecret: string, code: string): boolean {
try {
const secret = decrypt(encryptedSecret);
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(secret),
algorithm: 'SHA1',
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code, window: 1 });
return delta !== null;
} catch {
return false;
}
},
};