Files
docmost/apps/server/src/common/helpers/utils.ts
T
Philip Okugbe 641ce142df feat(ee): SCIM (#1347)
* SCIM - init (EE)

* accept db transaction

* sync

* Content parser support for scim+json

* patch scimmy

* sync

* return early if userIds is empty

* sync

* SCIM db table

* fixes

* scim tokens

* backfill

* feat(audit): add scim token events

* rename scim migration

* fix

* fix translation

* cleanup
2026-05-01 14:53:30 +01:00

193 lines
5.5 KiB
TypeScript

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<boolean> {
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<string, any>,
before: Record<string, any> | undefined | null,
after: Record<string, any> | undefined | null,
): { before: Record<string, any>; after: Record<string, any> } | null {
const beforeDiff: Record<string, any> = {};
const afterDiff: Record<string, any> = {};
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 };
}