initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
src/utils/encryption.ts
Normal file
35
src/utils/encryption.ts
Normal 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
28
src/utils/pagination.ts
Normal 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
30
src/utils/response.ts
Normal 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
46
src/utils/sequence.ts
Normal 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
20
src/utils/totp.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user