Files
the-order/packages/auth/src/file-utils.ts
T
defiQUG 92cc41d26d Add Legal Office seal and complete Azure CDN deployment
- Add Legal Office of the Master seal (SVG design with Maltese Cross, scales of justice, legal scroll)
- Create legal-office-manifest-template.json for Legal Office credentials
- Update SEAL_MAPPING.md and DESIGN_GUIDE.md with Legal Office seal documentation
- Complete Azure CDN infrastructure deployment:
  - Resource group, storage account, and container created
  - 17 PNG seal files uploaded to Azure Blob Storage
  - All manifest templates updated with Azure URLs
  - Configuration files generated (azure-cdn-config.env)
- Add comprehensive Azure CDN setup scripts and documentation
- Fix manifest URL generation to prevent double slashes
- Verify all seals accessible via HTTPS
2025-11-12 22:03:42 -08:00

379 lines
8.8 KiB
TypeScript

/**
* File handling utilities for Entra VerifiedID and other integrations
* Provides base64 encoding/decoding, validation, and content type detection
*/
import { createHash } from 'crypto';
/**
* Supported MIME types for document processing
*/
export const SUPPORTED_MIME_TYPES = {
// Documents
PDF: 'application/pdf',
DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
DOC: 'application/msword',
XLSX: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
XLS: 'application/vnd.ms-excel',
// Images
PNG: 'image/png',
JPEG: 'image/jpeg',
JPG: 'image/jpg',
GIF: 'image/gif',
WEBP: 'image/webp',
// Text
TEXT: 'text/plain',
JSON: 'application/json',
XML: 'application/xml',
// Archives
ZIP: 'application/zip',
TAR: 'application/x-tar',
GZIP: 'application/gzip',
} as const;
/**
* Maximum file size limits (in bytes)
*/
export const FILE_SIZE_LIMITS = {
SMALL: 1024 * 1024, // 1 MB
MEDIUM: 10 * 1024 * 1024, // 10 MB
LARGE: 100 * 1024 * 1024, // 100 MB
XLARGE: 500 * 1024 * 1024, // 500 MB
} as const;
/**
* File validation options
*/
export interface FileValidationOptions {
maxSize?: number;
allowedMimeTypes?: string[];
requireMimeType?: boolean;
}
/**
* File encoding result
*/
export interface FileEncodingResult {
base64: string;
mimeType: string;
size: number;
hash: string;
}
/**
* File validation result
*/
export interface FileValidationResult {
valid: boolean;
errors: string[];
mimeType?: string;
size?: number;
}
/**
* Encode a file buffer or string to base64
*/
export function encodeFileToBase64(
file: Buffer | string | Uint8Array,
mimeType?: string
): string {
let buffer: Buffer;
if (typeof file === 'string') {
// If it's already base64, validate and return
if (isBase64(file)) {
return file;
}
// Otherwise, convert string to buffer
buffer = Buffer.from(file, 'utf-8');
} else if (file instanceof Uint8Array) {
buffer = Buffer.from(file);
} else {
buffer = file;
}
const base64 = buffer.toString('base64');
// If MIME type is provided, return as data URL
if (mimeType) {
return `data:${mimeType};base64,${base64}`;
}
return base64;
}
/**
* Decode base64 string to buffer
*/
export function decodeBase64ToBuffer(base64: string): Buffer {
// Remove data URL prefix if present
const base64Data = base64.includes(',')
? (base64.split(',')[1] ?? base64)
: base64;
return Buffer.from(base64Data, 'base64');
}
/**
* Check if a string is valid base64
*/
export function isBase64(str: string): boolean {
if (!str || str.length === 0) {
return false;
}
// Remove data URL prefix if present
const base64Data = str.includes(',')
? (str.split(',')[1] ?? str)
: str;
// Base64 regex pattern
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
if (!base64Regex.test(base64Data)) {
return false;
}
// Check length is multiple of 4
if (base64Data.length % 4 !== 0) {
return false;
}
// Try to decode
try {
Buffer.from(base64Data, 'base64');
return true;
} catch {
return false;
}
}
/**
* Detect MIME type from buffer or file extension
*/
export function detectMimeType(
data: Buffer | string,
filename?: string
): string {
// Try to detect from file extension first
if (filename) {
const extension = filename.split('.').pop()?.toLowerCase();
const mimeType = getMimeTypeFromExtension(extension || '');
if (mimeType) {
return mimeType;
}
}
// Try to detect from buffer magic bytes
if (data instanceof Buffer && data.length > 0) {
const mimeType = detectMimeTypeFromBuffer(data);
if (mimeType) {
return mimeType;
}
}
// Default to application/octet-stream
return 'application/octet-stream';
}
/**
* Get MIME type from file extension
*/
function getMimeTypeFromExtension(extension: string): string | null {
const mimeTypes: Record<string, string> = {
// Documents
pdf: SUPPORTED_MIME_TYPES.PDF,
docx: SUPPORTED_MIME_TYPES.DOCX,
doc: SUPPORTED_MIME_TYPES.DOC,
xlsx: SUPPORTED_MIME_TYPES.XLSX,
xls: SUPPORTED_MIME_TYPES.XLS,
// Images
png: SUPPORTED_MIME_TYPES.PNG,
jpg: SUPPORTED_MIME_TYPES.JPG,
jpeg: SUPPORTED_MIME_TYPES.JPEG,
gif: SUPPORTED_MIME_TYPES.GIF,
webp: SUPPORTED_MIME_TYPES.WEBP,
// Text
txt: SUPPORTED_MIME_TYPES.TEXT,
json: SUPPORTED_MIME_TYPES.JSON,
xml: SUPPORTED_MIME_TYPES.XML,
// Archives
zip: SUPPORTED_MIME_TYPES.ZIP,
tar: SUPPORTED_MIME_TYPES.TAR,
gz: SUPPORTED_MIME_TYPES.GZIP,
};
return mimeTypes[extension.toLowerCase()] || null;
}
/**
* Detect MIME type from buffer magic bytes
*/
function detectMimeTypeFromBuffer(buffer: Buffer): string | null {
// Check magic bytes (file signatures)
if (buffer.length < 4) {
return null;
}
const header = buffer.slice(0, 12);
// PDF
if (header.slice(0, 4).toString() === '%PDF') {
return SUPPORTED_MIME_TYPES.PDF;
}
// PNG
if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) {
return SUPPORTED_MIME_TYPES.PNG;
}
// JPEG
if (header[0] === 0xFF && header[1] === 0xD8 && header[2] === 0xFF) {
return SUPPORTED_MIME_TYPES.JPEG;
}
// GIF
if (header.slice(0, 3).toString() === 'GIF') {
return SUPPORTED_MIME_TYPES.GIF;
}
// ZIP (also detects DOCX, XLSX which are ZIP-based)
if (header[0] === 0x50 && header[1] === 0x4B) {
// Check if it's a DOCX or XLSX by checking internal structure
// For simplicity, return ZIP - caller can refine based on filename
return SUPPORTED_MIME_TYPES.ZIP;
}
// JSON (starts with { or [)
const text = buffer.slice(0, 100).toString('utf-8').trim();
if (text.startsWith('{') || text.startsWith('[')) {
try {
JSON.parse(text);
return SUPPORTED_MIME_TYPES.JSON;
} catch {
// Not valid JSON
}
}
return null;
}
/**
* Validate a base64-encoded file
*/
export function validateBase64File(
base64: string,
options: FileValidationOptions = {}
): FileValidationResult {
const errors: string[] = [];
// Check if it's valid base64
if (!isBase64(base64)) {
errors.push('Invalid base64 encoding');
return { valid: false, errors };
}
// Decode to get size
let buffer: Buffer;
try {
buffer = decodeBase64ToBuffer(base64);
} catch (error) {
errors.push(`Failed to decode base64: ${error instanceof Error ? error.message : String(error)}`);
return { valid: false, errors };
}
const size = buffer.length;
// Check size limit
if (options.maxSize && size > options.maxSize) {
errors.push(`File size (${size} bytes) exceeds maximum allowed size (${options.maxSize} bytes)`);
}
// Detect and validate MIME type
let mimeType: string | undefined;
try {
const detected = detectMimeTypeFromBuffer(buffer);
mimeType = detected ?? undefined;
} catch {
// MIME type detection failed, but not critical
}
if (options.requireMimeType && !mimeType) {
errors.push('Could not detect file MIME type');
}
if (options.allowedMimeTypes && mimeType) {
if (!options.allowedMimeTypes.includes(mimeType)) {
errors.push(`MIME type ${mimeType} is not allowed. Allowed types: ${options.allowedMimeTypes.join(', ')}`);
}
}
return {
valid: errors.length === 0,
errors,
mimeType,
size,
};
}
/**
* Encode file with full metadata
*/
export function encodeFileWithMetadata(
file: Buffer | string | Uint8Array,
filename?: string,
mimeType?: string
): FileEncodingResult {
let buffer: Buffer;
if (typeof file === 'string') {
if (isBase64(file)) {
buffer = decodeBase64ToBuffer(file);
} else {
buffer = Buffer.from(file, 'utf-8');
}
} else if (file instanceof Uint8Array) {
buffer = Buffer.from(file);
} else {
buffer = file;
}
const detectedMimeType = mimeType || detectMimeType(buffer, filename);
const base64 = encodeFileToBase64(buffer, detectedMimeType);
const hash = createHash('sha256').update(buffer).digest('hex');
return {
base64,
mimeType: detectedMimeType,
size: buffer.length,
hash,
};
}
/**
* Sanitize filename for safe storage
*/
export function sanitizeFilename(filename: string): string {
// Remove path components
const basename = filename.split(/[/\\]/).pop() || filename;
// Remove or replace unsafe characters
return basename
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/_{2,}/g, '_')
.replace(/^_+|_+$/g, '')
.substring(0, 255); // Limit length
}
/**
* Calculate file hash for integrity verification
*/
export function calculateFileHash(data: Buffer | string, algorithm: 'sha256' | 'sha512' = 'sha256'): string {
const buffer = typeof data === 'string'
? Buffer.from(data, 'utf-8')
: data;
return createHash(algorithm).update(buffer).digest('hex');
}