import * as path from 'path'; import * as bcrypt from 'bcrypt'; import sanitize = require('sanitize-filename'); import { FastifyRequest } from 'fastify'; import { Readable, Transform } from 'stream'; export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); export async function hashPassword(password: string) { const saltRounds = 12; return bcrypt.hash(password, saltRounds); } export async function comparePasswordHash( plainPassword: string, passwordHash: string, ): Promise { return bcrypt.compare(plainPassword, passwordHash); } export function generateRandomSuffixNumbers(length: number) { return Math.random() .toFixed(length) .substring(2, 2 + length); } export type RedisConfig = { host: string; port: number; db: number; password?: string; family?: number; }; export function parseRedisUrl(redisUrl: string): RedisConfig { // format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6] const url = new URL(redisUrl); const { hostname, port, password, pathname, searchParams } = url; const portInt = parseInt(port, 10); let db: number = 0; // extract db value if present if (pathname.length > 1) { const value = pathname.slice(1); if (!isNaN(parseInt(value))) { db = parseInt(value, 10); } } // extract family from query parameters let family: number | undefined; const familyParam = searchParams.get('family'); if (familyParam && !isNaN(parseInt(familyParam))) { family = parseInt(familyParam, 10); } return { host: hostname, port: portInt, password, db, family }; } export function createRetryStrategy() { return function (times: number): number { return Math.max(Math.min(Math.exp(times), 20000), 3000); }; } export function extractDateFromUuid7(uuid7: string) { //https://park.is/blog_posts/20240803_extracting_timestamp_from_uuid_v7/ const parts = uuid7.split('-'); const highBitsHex = parts[0] + parts[1].slice(0, 4); const timestamp = parseInt(highBitsHex, 16); return new Date(timestamp); } export type SanitizeFileNameOptions = { /** Keep spaces and `#` instead of replacing them with `_`. Useful for * download filenames where readability matters. Defaults to false. */ preserveSpaces?: boolean; }; export function sanitizeFileName( fileName: string, options: SanitizeFileNameOptions = {}, ): string { // Decode percent-encoded sequences so that bypasses like "..%2F" reach // sanitize() as literal "../" and get stripped. sanitize-filename only // strips literal characters and won't catch encoded path separators // on its own. const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => { try { return decodeURIComponent(m); } catch { return m; } }); const sanitized = sanitize(decoded); if (options.preserveSpaces) { return sanitized; } return sanitized.replace(/ /g, '_').replace(/#/g, '_'); } export function removeAccent(str: string): string { if (!str) return str; return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } export function extractBearerTokenFromHeader( request: FastifyRequest, ): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type?.toLowerCase() === 'bearer' ? token : undefined; } /** * Normalizes a database URL for postgres.js compatibility. * - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values * - Removes `schema` parameter (has no effect via connection string) * Note: If we don't strip them, the connection will fail */ export function normalizePostgresUrl(url: string): string { const parsed = new URL(url); const newParams = new URLSearchParams(); for (const [key, value] of parsed.searchParams) { if (key === 'sslmode' && value === 'no-verify') continue; if (key === 'schema') continue; newParams.append(key, value); } parsed.search = newParams.toString(); return parsed.toString(); } export function diffAuditTrackedFields( fields: readonly string[], dto: Record, before: Record | undefined | null, after: Record | undefined | null, ): { before: Record; after: Record } | null { const beforeDiff: Record = {}; const afterDiff: Record = {}; let hasChanges = false; for (const field of fields) { if (typeof dto[field] === 'undefined') continue; const oldVal = JSON.stringify(before?.[field] ?? null); const newVal = JSON.stringify(after?.[field] ?? null); if (oldVal !== newVal) { beforeDiff[field] = before?.[field]; afterDiff[field] = after?.[field]; hasChanges = true; } } return hasChanges ? { before: beforeDiff, after: afterDiff } : null; } export function isUserDisabled(user: { deactivatedAt?: Date | null; deletedAt?: Date | null; }): boolean { return !!(user.deactivatedAt || user.deletedAt); } const SENSITIVE_URL_PREFIXES = ['/api/sso/']; export function redactSensitiveUrl(url: string): string { if (url && SENSITIVE_URL_PREFIXES.some((prefix) => url.includes(prefix))) { const qsIndex = url.indexOf('?'); if (qsIndex !== -1) { return url.substring(0, qsIndex); } } return url; } export function createByteCountingStream(source: Readable) { let bytesRead = 0; const stream = new Transform({ transform(chunk, encoding, callback) { bytesRead += chunk.length; callback(null, chunk); }, }); source.pipe(stream); source.on('error', (err) => stream.emit('error', err)); return { stream, getBytesRead: () => bytesRead }; }