- 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
379 lines
8.8 KiB
TypeScript
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');
|
|
}
|
|
|