341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
import Hashids from 'hashids';
|
|
|
|
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
|
? import.meta.env
|
|
: {}) as Record<string, string | undefined>;
|
|
|
|
export const DEFAULT_HASHER_SALT =
|
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
|
|
export const DEFAULT_MIN_HASH_LENGTH = 11;
|
|
|
|
export const HASHER_SETTINGS = {
|
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
|
|
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
|
|
};
|
|
|
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
|
|
|
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
|
let updated = false;
|
|
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
|
HASHER_SETTINGS.salt = salt.trim();
|
|
updated = true;
|
|
}
|
|
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
|
const parsed = Number(minLength);
|
|
if (!isNaN(parsed) && parsed > 0) {
|
|
HASHER_SETTINGS.minHashLength = parsed;
|
|
updated = true;
|
|
}
|
|
}
|
|
if (updated) {
|
|
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates if candidate is already a HashID generated by this hasher.
|
|
*/
|
|
export function isHashId(candidate: string | null | undefined): boolean {
|
|
if (
|
|
!candidate ||
|
|
typeof candidate !== 'string' ||
|
|
candidate.length < HASHER_SETTINGS.minHashLength ||
|
|
/^\d+$/.test(candidate)
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
const decodedNumbers = hasher.decode(candidate);
|
|
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
|
|
if (hasher.encode(decodedNumbers as number[]) === candidate) {
|
|
return true;
|
|
}
|
|
}
|
|
const decodedHex = hasher.decodeHex(candidate);
|
|
if (decodedHex && decodedHex.length > 0) {
|
|
if (hasher.encodeHex(decodedHex) === candidate) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Encodes a user idNumber using Hashids Hex encoding.
|
|
* Supports South African national IDs (13 digits), passports, and alphanumeric keys.
|
|
*/
|
|
export function encodeIdNumberHex(idNumber: string | number | null | undefined): string {
|
|
if (idNumber === undefined || idNumber === null) return '';
|
|
const str = String(idNumber).trim();
|
|
if (!str) return '';
|
|
|
|
// Prevent double-encoding if already a Hashid
|
|
if (isHashId(str)) {
|
|
return str;
|
|
}
|
|
|
|
// If already pure hex digits (e.g. standard decimal SA ID 9501015800085 or hex hash)
|
|
if (/^[0-9a-fA-F]+$/.test(str)) {
|
|
try {
|
|
const encoded = hasher.encodeHex(str.toLowerCase());
|
|
if (encoded) return encoded;
|
|
} catch {
|
|
// Fallback below
|
|
}
|
|
}
|
|
|
|
// For arbitrary alphanumeric ID strings, encode character bytes to hex
|
|
try {
|
|
const hex = Array.from(str)
|
|
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
|
|
.join('');
|
|
const encoded = hasher.encodeHex(hex);
|
|
return encoded || str;
|
|
} catch {
|
|
return str;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decodes a Hashids Hex encoded string back to original idNumber.
|
|
*/
|
|
export function decodeIdNumberHex(hash: string | null | undefined): string {
|
|
if (!hash || typeof hash !== 'string') return '';
|
|
const trimmed = hash.trim();
|
|
if (!trimmed) return '';
|
|
|
|
try {
|
|
const decodedHex = hasher.decodeHex(trimmed);
|
|
if (!decodedHex) return trimmed;
|
|
|
|
// Check if decoded hex represents ASCII text with 2-char hex pairs
|
|
if (/^[0-9a-fA-F]{4,}$/.test(decodedHex) && decodedHex.length % 2 === 0) {
|
|
let isText = true;
|
|
let text = '';
|
|
for (let i = 0; i < decodedHex.length; i += 2) {
|
|
const code = parseInt(decodedHex.slice(i, i + 2), 16);
|
|
if (code < 32 || code > 126) {
|
|
isText = false;
|
|
break;
|
|
}
|
|
text += String.fromCharCode(code);
|
|
}
|
|
if (isText && text.length > 0) return text;
|
|
}
|
|
|
|
return decodedHex;
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts any numeric or string ID to a safe 64-bit integer string.
|
|
* Preserves precision beyond 2^53 - 1 by avoiding standard float coercion.
|
|
*/
|
|
export function toSafeInt64String(val: string | number | bigint | null | undefined): string {
|
|
if (val === undefined || val === null || val === '') return '';
|
|
if (typeof val === 'bigint') return val.toString();
|
|
if (typeof val === 'number') {
|
|
if (isNaN(val) || !isFinite(val)) return '';
|
|
return Math.trunc(val).toString();
|
|
}
|
|
const str = String(val).trim();
|
|
if (/^-?\d+$/.test(str)) {
|
|
return str;
|
|
}
|
|
return str;
|
|
}
|
|
|
|
/**
|
|
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
|
|
*/
|
|
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
|
|
if (id === undefined || id === null || id === '') return '';
|
|
|
|
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
|
|
if (!str) return '';
|
|
|
|
if (isHashId(str)) return str;
|
|
|
|
// If safe integer range
|
|
if (/^\d+$/.test(str)) {
|
|
try {
|
|
const num = Number(str);
|
|
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
|
|
return hasher.encode(num);
|
|
}
|
|
// For integers above 2^53 - 1, encode as hex
|
|
const bigHex = BigInt(str).toString(16);
|
|
return hasher.encodeHex(bigHex);
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
return str;
|
|
}
|
|
|
|
/**
|
|
* Deterministically decodes a standard HashID string to its canonical ID representation as string.
|
|
*/
|
|
export function decodeEntityId(hashId: string | number | null | undefined): string {
|
|
if (hashId === undefined || hashId === null || hashId === '') return '';
|
|
const str = String(hashId).trim();
|
|
if (!str) return '';
|
|
|
|
if (/^-?\d+$/.test(str)) return str;
|
|
|
|
try {
|
|
const decoded = hasher.decode(str);
|
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
|
return decoded[0].toString();
|
|
}
|
|
const decodedHex = hasher.decodeHex(str);
|
|
if (decodedHex) {
|
|
try {
|
|
return BigInt('0x' + decodedHex).toString();
|
|
} catch {
|
|
return decodedHex;
|
|
}
|
|
}
|
|
} catch {
|
|
// Return raw
|
|
}
|
|
|
|
return str;
|
|
}
|
|
|
|
/**
|
|
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
|
|
* Prevents double-encoding already-hashed route parameters or IDs.
|
|
*/
|
|
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
|
|
return isHashId(candidate);
|
|
}
|
|
|
|
/**
|
|
* Deterministically encodes a numeric ID or numeric string into a HashID string.
|
|
* Synchronous and does not perform HTTP network round trips.
|
|
*/
|
|
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
|
|
return encodeEntityId(id);
|
|
}
|
|
|
|
/**
|
|
* Deterministically decodes a HashID string back to original numeric ID or string.
|
|
* Synchronous and does not perform HTTP network round trips.
|
|
*/
|
|
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
|
|
if (hashId === undefined || hashId === null || hashId === '') return '';
|
|
if (typeof hashId === 'number') return hashId;
|
|
|
|
const str = String(hashId).trim();
|
|
if (!str) return '';
|
|
|
|
if (/^-?\d+$/.test(str)) {
|
|
const parsed = parseInt(str, 10);
|
|
return isNaN(parsed) ? str : parsed;
|
|
}
|
|
|
|
try {
|
|
const decoded = hasher.decode(str);
|
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
|
return Number(decoded[0]);
|
|
}
|
|
const decodedHex = hasher.decodeHex(str);
|
|
if (decodedHex) {
|
|
const num = Number(BigInt('0x' + decodedHex));
|
|
if (!isNaN(num)) return Math.trunc(num);
|
|
}
|
|
} catch {}
|
|
|
|
return str;
|
|
}
|
|
|
|
/**
|
|
* Decodes a HashID or numeric value strictly to a canonical integer.
|
|
* Useful for request payloads expecting int64 integers.
|
|
*/
|
|
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
|
|
if (hashId === null || hashId === undefined || hashId === '') return 0;
|
|
if (typeof hashId === 'number') {
|
|
return isNaN(hashId) ? 0 : Math.trunc(hashId);
|
|
}
|
|
const str = String(hashId).trim();
|
|
if (/^-?\d+$/.test(str)) {
|
|
const parsed = parseInt(str, 10);
|
|
return isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
try {
|
|
const decoded = hasher.decode(str);
|
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
|
return Number(decoded[0]);
|
|
}
|
|
const decodedHex = hasher.decodeHex(str);
|
|
if (decodedHex) {
|
|
const num = Number(BigInt('0x' + decodedHex));
|
|
if (!isNaN(num)) return Math.trunc(num);
|
|
}
|
|
} catch {}
|
|
const fallback = Number(str);
|
|
return isNaN(fallback) ? 0 : Math.trunc(fallback);
|
|
}
|
|
|
|
/**
|
|
* Legacy service resolver facade for compatibility with API endpoints and monitoring tables.
|
|
*/
|
|
export const toolsService = {
|
|
encodeIdSync(id: number | string | bigint | null | undefined): string {
|
|
return encodeHashIdSync(id);
|
|
},
|
|
decodeIdSync(hashId: string | number | null | undefined): number | string {
|
|
return decodeHashIdSync(hashId);
|
|
},
|
|
decodeToNumberSync(hashId: number | string | null | undefined): number {
|
|
return decodeToNumberSync(hashId);
|
|
},
|
|
async encodeId(id: number | string | bigint | null | undefined): Promise<string> {
|
|
return encodeHashIdSync(id);
|
|
},
|
|
async decodeId(hashId: string | number | null | undefined): Promise<number | string> {
|
|
return decodeHashIdSync(hashId);
|
|
},
|
|
async resolveProjectId(projectId: number | string): Promise<string> {
|
|
return encodeHashIdSync(projectId);
|
|
},
|
|
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
|
return encodeHashIdSync(organisationId);
|
|
},
|
|
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
|
return encodeHashIdSync(programmeId);
|
|
},
|
|
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
|
return encodeHashIdSync(portfolioId);
|
|
},
|
|
async resolveAwardId(awardId: number | string): Promise<string> {
|
|
return encodeHashIdSync(awardId);
|
|
},
|
|
async resolveContractId(contractId: number | string): Promise<string> {
|
|
return encodeHashIdSync(contractId);
|
|
},
|
|
async resolveHashedParams<T extends Record<string, any>>(
|
|
params: T,
|
|
keysToHash: (keyof T)[]
|
|
): Promise<T> {
|
|
const resolved: any = { ...params };
|
|
for (const key of keysToHash) {
|
|
const val = params[key];
|
|
if (val !== undefined && val !== null) {
|
|
resolved[key] = encodeHashIdSync(val);
|
|
}
|
|
}
|
|
return resolved;
|
|
},
|
|
async primeCache(): Promise<void> {},
|
|
};
|