Files
Trilium/apps/server/src/services/utils.ts
2026-03-22 20:17:48 +02:00

444 lines
12 KiB
TypeScript

import { getCrypto,utils as coreUtils } from "@triliumnext/core";
import chardet from "chardet";
import crypto from "crypto";
import { t } from "i18next";
import { release as osRelease } from "os";
import path from "path";
import stripBom from "strip-bom";
import log from "./log.js";
import type NoteMeta from "./meta/note_meta.js";
const osVersion = osRelease().split('.').map(Number);
export const isMac = process.platform === "darwin";
export const isWindows = process.platform === "win32";
export const isWindows11 = isWindows && osVersion[0] === 10 && osVersion[2] >= 22000;
export const isElectron = !!process.versions["electron"];
export const isDev = !!(process.env.TRILIUM_ENV && process.env.TRILIUM_ENV === "dev");
/** @deprecated */
export function newEntityId() {
return coreUtils.newEntityId();
}
/** @deprecated */
export function randomString(length: number): string {
return coreUtils.randomString(length);
}
export function md5(content: crypto.BinaryLike) {
return crypto.createHash("md5").update(content).digest("hex");
}
/** @deprecated */
export function hashedBlobId(content: string | Buffer) {
return coreUtils.hashedBlobId(content);
}
export function toBase64(plainText: string | Buffer) {
const buffer = (Buffer.isBuffer(plainText) ? plainText : Buffer.from(plainText));
return buffer.toString("base64");
}
export function fromBase64(encodedText: string) {
return Buffer.from(encodedText, "base64");
}
export function hmac(secret: string | Uint8Array, value: string | Uint8Array) {
return getCrypto().hmac(secret, value);
}
/**
* Constant-time string comparison to prevent timing attacks.
* Uses crypto.timingSafeEqual to ensure comparison time is independent
* of how many characters match.
*
* @param a First string to compare
* @param b Second string to compare
* @returns true if strings are equal, false otherwise
* @note Returns false for null/undefined/non-string inputs. Empty strings are considered equal.
*/
export function constantTimeCompare(a: string | null | undefined, b: string | null | undefined): boolean {
// Handle null/undefined/non-string cases safely
if (typeof a !== "string" || typeof b !== "string") {
return false;
}
const bufA = Buffer.from(a, "utf-8");
const bufB = Buffer.from(b, "utf-8");
// If lengths differ, we still do a constant-time comparison
// to avoid leaking length information through timing
if (bufA.length !== bufB.length) {
// Compare bufA against itself to maintain constant time behavior
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
export function toObject<T, K extends string | number | symbol, V>(array: T[], fn: (item: T) => [K, V]): Record<K, V> {
const obj: Record<K, V> = {} as Record<K, V>; // TODO: unsafe?
for (const item of array) {
const ret = fn(item);
obj[ret[0]] = ret[1];
}
return obj;
}
export function stripTags(text: string) {
return text.replace(/<(?:.|\n)*?>/gm, "");
}
export async function crash(message: string) {
if (isElectron) {
const electron = await import("electron");
electron.dialog.showErrorBox(t("modals.error_title"), message);
electron.app.exit(1);
} else {
log.error(message);
process.exit(1);
}
}
/** @deprecated */
export function getContentDisposition(filename: string) {
return coreUtils.getContentDisposition(filename);
}
/** @deprecated */
export function isStringNote(type: string | undefined, mime: string) {
return coreUtils.isStringNote(type, mime);
}
/** @deprecated */
export function quoteRegex(url: string) {
return coreUtils.quoteRegex(url);
}
/** @deprecated */
export function replaceAll(string: string, replaceWhat: string, replaceWith: string) {
return coreUtils.replaceAll(string, replaceWhat, replaceWith);
}
/** @deprecated */
export function formatDownloadTitle(fileName: string, type: string | null, mime: string) {
return coreUtils.formatDownloadTitle(fileName, type, mime);
}
export function removeFileExtension(filePath: string, mime?: string) {
const extension = path.extname(filePath).toLowerCase();
if (mime?.startsWith("video/") || mime?.startsWith("audio/")) {
return filePath.substring(0, filePath.length - extension.length);
}
switch (extension) {
case ".md":
case ".mdx":
case ".markdown":
case ".html":
case ".htm":
case ".excalidraw":
case ".mermaid":
case ".mmd":
case ".pdf":
return filePath.substring(0, filePath.length - extension.length);
default:
return filePath;
}
}
export function getNoteTitle(filePath: string, replaceUnderscoresWithSpaces: boolean, noteMeta?: NoteMeta) {
const trimmedNoteMeta = noteMeta?.title?.trim();
if (trimmedNoteMeta) return trimmedNoteMeta;
const basename = path.basename(removeFileExtension(filePath, noteMeta?.mime));
return replaceUnderscoresWithSpaces ? basename.replace(/_/g, " ").trim() : basename;
}
/** @deprecated */
export function removeDiacritic(str: string) {
return coreUtils.removeDiacritic(str);
}
/** @deprecated */
export function normalize(str: string) {
return coreUtils.normalize(str);
}
/** @deprecated */
export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) {
return coreUtils.toMap(list, key);
}
// try to turn 'true' and 'false' strings from process.env variables into boolean values or undefined
export function envToBoolean(val: string | undefined) {
if (val === undefined || typeof val !== "string") return undefined;
const valLc = val.toLowerCase().trim();
if (valLc === "true") return true;
if (valLc === "false") return false;
return undefined;
}
/**
* Parses a string value to an integer. If the resulting number is NaN or undefined, the result is also undefined.
*
* @param val the value to parse.
* @returns the parsed value.
*/
export function stringToInt(val: string | undefined) {
if (!val) {
return undefined;
}
const parsed = parseInt(val, 10);
if (Number.isNaN(parsed)) {
return undefined;
}
return parsed;
}
/**
* Returns the directory for resources. On Electron builds this corresponds to the `resources` subdirectory inside the distributable package.
* On development builds, this simply refers to the src directory of the application.
*
* @returns the resource dir.
*/
export function getResourceDir() {
if (process.env.TRILIUM_RESOURCE_DIR) {
return process.env.TRILIUM_RESOURCE_DIR;
}
if (isElectron && !isDev) return __dirname;
if (!isDev) {
return path.dirname(process.argv[1]);
}
return path.join(__dirname, "..");
}
// TODO: Deduplicate with src/public/app/services/utils.ts
/**
* Compares two semantic version strings.
* Returns:
* 1 if v1 is greater than v2
* 0 if v1 is equal to v2
* -1 if v1 is less than v2
*
* @param v1 First version string
* @param v2 Second version string
* @returns
*/
function compareVersions(v1: string, v2: string): number {
// Remove 'v' prefix and everything after dash if present
v1 = v1.replace(/^v/, "").split("-")[0];
v2 = v2.replace(/^v/, "").split("-")[0];
const v1parts = v1.split(".").map(Number);
const v2parts = v2.split(".").map(Number);
// Pad shorter version with zeros
while (v1parts.length < 3) v1parts.push(0);
while (v2parts.length < 3) v2parts.push(0);
// Compare major version
if (v1parts[0] !== v2parts[0]) {
return v1parts[0] > v2parts[0] ? 1 : -1;
}
// Compare minor version
if (v1parts[1] !== v2parts[1]) {
return v1parts[1] > v2parts[1] ? 1 : -1;
}
// Compare patch version
if (v1parts[2] !== v2parts[2]) {
return v1parts[2] > v2parts[2] ? 1 : -1;
}
return 0;
}
/**
* For buffers, they are scanned for a supported encoding and decoded (UTF-8, UTF-16). In some cases, the BOM is also stripped.
*
* For strings, they are returned immediately without any transformation.
*
* For nullish values, an empty string is returned.
*
* @param data the string or buffer to process.
* @returns the string representation of the buffer, or the same string is it's a string.
*/
export function processStringOrBuffer(data: string | Buffer | null) {
if (!data) {
return "";
}
if (!Buffer.isBuffer(data)) {
return data;
}
const detectedEncoding = chardet.detect(data);
switch (detectedEncoding) {
case "UTF-16LE":
return stripBom(data.toString("utf-16le"));
case "UTF-8":
default:
return data.toString("utf-8");
}
}
/**
* Normalizes a path pattern for custom request handlers.
* Ensures both trailing slash and non-trailing slash versions are handled.
*
* @param pattern The original pattern from customRequestHandler attribute
* @returns An array of patterns to match both with and without trailing slash
*/
export function normalizeCustomHandlerPattern(pattern: string | null | undefined): (string | null | undefined)[] {
if (!pattern || typeof pattern !== 'string') {
return [pattern];
}
pattern = pattern.trim();
if (!pattern) {
return [pattern];
}
// If pattern already ends with optional trailing slash, return as-is
if (pattern.endsWith('/?$') || pattern.endsWith('/?)')) {
return [pattern];
}
// If pattern ends with $, handle it specially
if (pattern.endsWith('$')) {
const basePattern = pattern.slice(0, -1);
// If already ends with slash, create both versions
if (basePattern.endsWith('/')) {
const withoutSlash = `${basePattern.slice(0, -1) }$`;
const withSlash = pattern;
return [withoutSlash, withSlash];
}
// Add optional trailing slash
const withSlash = `${basePattern }/?$`;
return [withSlash];
}
// For patterns without $, add both versions
if (pattern.endsWith('/')) {
const withoutSlash = pattern.slice(0, -1);
return [withoutSlash, pattern];
}
const withSlash = `${pattern }/`;
return [pattern, withSlash];
}
export function formatUtcTime(time: string) {
return time.replace("T", " ").substring(0, 19);
}
// TODO: Deduplicate with client utils
export function formatSize(size: number | null | undefined) {
if (size === null || size === undefined) {
return "";
}
size = Math.max(Math.round(size / 1024), 1);
if (size < 1024) {
return `${size} KiB`;
}
return `${Math.round(size / 102.4) / 10} MiB`;
}
function slugify(text: string) {
return text
.normalize("NFC") // keep composed form, preserves accents
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, "-") // replace non-letter/number with "-"
.replace(/(^-|-$)+/g, ""); // trim dashes
}
/** @deprecated */
export const escapeHtml = coreUtils.escapeHtml;
/** @deprecated */
export const escapeRegExp = coreUtils.escapeRegExp;
/** @deprecated */
export const unescapeHtml = coreUtils.unescapeHtml;
/** @deprecated */
export const randomSecureToken = coreUtils.randomSecureToken;
/** @deprecated */
export const safeExtractMessageAndStackFromError = coreUtils.safeExtractMessageAndStackFromError;
/** @deprecated */
export const isEmptyOrWhitespace = coreUtils.isEmptyOrWhitespace;
/** @deprecated */
export const normalizeUrl = coreUtils.normalizeUrl;
export const timeLimit = coreUtils.timeLimit;
export const sanitizeSqlIdentifier = coreUtils.sanitizeSqlIdentifier;
export function waitForStreamToFinish(stream: any): Promise<void> {
return new Promise((resolve, reject) => {
stream.on("finish", () => resolve());
stream.on("error", (err) => reject(err));
});
}
export default {
compareVersions,
constantTimeCompare,
crash,
envToBoolean,
escapeHtml,
escapeRegExp,
formatDownloadTitle,
fromBase64,
getContentDisposition,
getNoteTitle,
getResourceDir,
hashedBlobId,
hmac,
isDev,
isElectron,
isEmptyOrWhitespace,
isMac,
isStringNote,
isWindows,
md5,
newEntityId,
normalize,
normalizeCustomHandlerPattern,
quoteRegex,
randomSecureToken,
randomString,
removeDiacritic,
removeFileExtension,
replaceAll,
safeExtractMessageAndStackFromError,
stripTags,
slugify,
toBase64,
toMap,
toObject,
unescapeHtml,
waitForStreamToFinish
};