@mvp-factory/holy-upload
Version:
File upload processing system extracted from Holy Habit project with security validation and image optimization
461 lines • 15 kB
JavaScript
;
/**
* File Utilities
*
* File operations and utilities for upload system
* Extracted from Holy Habit file handling logic
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileUtils = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const crypto = __importStar(require("crypto"));
const uuid_1 = require("uuid");
class FileUtils {
/**
* Generate secure filename
*
* @param originalName - Original filename
* @param userId - User ID (optional)
* @param prefix - Filename prefix (optional)
* @returns Generated filename
*/
static generateSecureFilename(originalName, userId, prefix) {
const extension = path.extname(originalName).toLowerCase();
const timestamp = Date.now();
const randomBytes = crypto.randomBytes(8).toString('hex');
let filename = '';
if (prefix) {
filename += `${prefix}_`;
}
if (userId) {
filename += `${userId}_`;
}
filename += `${timestamp}_${randomBytes}${extension}`;
return filename;
}
/**
* Generate UUID-based filename
*
* @param originalName - Original filename
* @param preserveExtension - Whether to preserve original extension
* @returns UUID-based filename
*/
static generateUUIDFilename(originalName, preserveExtension = true) {
const uuid = (0, uuid_1.v4)();
if (preserveExtension) {
const extension = path.extname(originalName).toLowerCase();
return `${uuid}${extension}`;
}
return uuid;
}
/**
* Sanitize filename
*
* @param filename - Original filename
* @returns Sanitized filename
*/
static sanitizeFilename(filename) {
// Remove path separators and dangerous characters
let sanitized = filename
.replace(/[/\\:*?"<>|]/g, '_')
.replace(/\s+/g, '_')
.replace(/_{2,}/g, '_')
.replace(/^_+|_+$/g, '');
// Ensure filename is not empty
if (!sanitized) {
sanitized = 'file';
}
// Limit length
const maxLength = 100;
const extension = path.extname(sanitized);
const nameWithoutExt = path.basename(sanitized, extension);
if (sanitized.length > maxLength) {
const truncatedName = nameWithoutExt.substring(0, maxLength - extension.length);
sanitized = truncatedName + extension;
}
return sanitized;
}
/**
* Ensure directory exists
*
* @param dirPath - Directory path
* @param mode - Directory permissions (default: 0o755)
*/
static async ensureDirectory(dirPath, mode = 0o755) {
try {
await fs.promises.access(dirPath);
}
catch (error) {
await fs.promises.mkdir(dirPath, { recursive: true, mode });
}
}
/**
* Get directory size
*
* @param dirPath - Directory path
* @returns Directory size in bytes
*/
static async getDirectorySize(dirPath) {
let totalSize = 0;
try {
const files = await fs.promises.readdir(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stats = await fs.promises.stat(filePath);
if (stats.isDirectory()) {
totalSize += await this.getDirectorySize(filePath);
}
else {
totalSize += stats.size;
}
}
}
catch (error) {
// Directory doesn't exist or access denied
return 0;
}
return totalSize;
}
/**
* Count files in directory
*
* @param dirPath - Directory path
* @param recursive - Count files recursively
* @returns Number of files
*/
static async countFiles(dirPath, recursive = false) {
let fileCount = 0;
try {
const files = await fs.promises.readdir(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stats = await fs.promises.stat(filePath);
if (stats.isDirectory() && recursive) {
fileCount += await this.countFiles(filePath, recursive);
}
else if (stats.isFile()) {
fileCount++;
}
}
}
catch (error) {
return 0;
}
return fileCount;
}
/**
* Get storage information
*
* @param uploadDir - Upload directory
* @param storageLimit - Storage limit in bytes
* @returns Storage information
*/
static async getStorageInfo(uploadDir, storageLimit = 100 * 1024 * 1024) {
const totalUsed = await this.getDirectorySize(uploadDir);
const fileCount = await this.countFiles(uploadDir, true);
const usagePercentage = (totalUsed / storageLimit) * 100;
return {
totalUsed,
totalUsedFormatted: this.formatBytes(totalUsed),
fileCount,
storageLimit,
usagePercentage: Math.round(usagePercentage * 100) / 100
};
}
/**
* Clean up old files
*
* @param uploadDir - Upload directory
* @param options - Cleanup options
* @returns Cleanup result
*/
static async cleanupOldFiles(uploadDir, options = {}) {
const { olderThanDays = 7, dryRun = false } = options;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
let deletedCount = 0;
let freedSpace = 0;
const errors = [];
try {
const files = await this.getFilesRecursively(uploadDir);
for (const filePath of files) {
try {
const stats = await fs.promises.stat(filePath);
if (stats.mtime < cutoffDate) {
if (!dryRun) {
await fs.promises.unlink(filePath);
}
deletedCount++;
freedSpace += stats.size;
}
}
catch (error) {
errors.push(`Failed to process ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Clean up empty directories
if (!dryRun) {
await this.cleanupEmptyDirectories(uploadDir);
}
}
catch (error) {
errors.push(`Cleanup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
return {
success: errors.length === 0,
deletedCount,
freedSpace,
freedSpaceFormatted: this.formatBytes(freedSpace),
errors
};
}
/**
* Get files recursively
*
* @param dirPath - Directory path
* @returns Array of file paths
*/
static async getFilesRecursively(dirPath) {
const files = [];
try {
const entries = await fs.promises.readdir(dirPath);
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
const stats = await fs.promises.stat(fullPath);
if (stats.isDirectory()) {
const subFiles = await this.getFilesRecursively(fullPath);
files.push(...subFiles);
}
else {
files.push(fullPath);
}
}
}
catch (error) {
// Ignore errors (directory may not exist or be inaccessible)
}
return files;
}
/**
* Clean up empty directories
*
* @param dirPath - Directory path
*/
static async cleanupEmptyDirectories(dirPath) {
try {
const entries = await fs.promises.readdir(dirPath);
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
const stats = await fs.promises.stat(fullPath);
if (stats.isDirectory()) {
await this.cleanupEmptyDirectories(fullPath);
// Check if directory is now empty
const subEntries = await fs.promises.readdir(fullPath);
if (subEntries.length === 0) {
await fs.promises.rmdir(fullPath);
}
}
}
}
catch (error) {
// Ignore errors
}
}
/**
* Move file
*
* @param sourcePath - Source file path
* @param destinationPath - Destination file path
*/
static async moveFile(sourcePath, destinationPath) {
await fs.promises.rename(sourcePath, destinationPath);
}
/**
* Copy file
*
* @param sourcePath - Source file path
* @param destinationPath - Destination file path
*/
static async copyFile(sourcePath, destinationPath) {
await fs.promises.copyFile(sourcePath, destinationPath);
}
/**
* Delete file safely
*
* @param filePath - File path
* @returns True if deleted successfully
*/
static async deleteFile(filePath) {
try {
await fs.promises.unlink(filePath);
return true;
}
catch (error) {
return false;
}
}
/**
* Check if file exists
*
* @param filePath - File path
* @returns True if file exists
*/
static async fileExists(filePath) {
try {
await fs.promises.access(filePath);
return true;
}
catch (error) {
return false;
}
}
/**
* Get file stats
*
* @param filePath - File path
* @returns File stats or null if not found
*/
static async getFileStats(filePath) {
try {
return await fs.promises.stat(filePath);
}
catch (error) {
return null;
}
}
/**
* Format bytes to human readable string
*
* @param bytes - Number of bytes
* @param decimals - Number of decimal places
* @returns Formatted string
*/
static formatBytes(bytes, decimals = 2) {
if (bytes === 0)
return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Get MIME type from file extension
*
* @param filename - Filename
* @returns MIME type
*/
static getMimeTypeFromExtension(filename) {
const ext = path.extname(filename).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.ico': 'image/x-icon'
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* Generate hash for file
*
* @param filePath - File path
* @param algorithm - Hash algorithm (default: sha256)
* @returns File hash
*/
static async generateFileHash(filePath, algorithm = 'sha256') {
const hash = crypto.createHash(algorithm);
const stream = fs.createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', (data) => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Get duplicate files
*
* @param dirPath - Directory path
* @returns Map of file hashes to file paths
*/
static async findDuplicateFiles(dirPath) {
const files = await this.getFilesRecursively(dirPath);
const hashes = new Map();
for (const filePath of files) {
try {
const hash = await this.generateFileHash(filePath);
if (!hashes.has(hash)) {
hashes.set(hash, []);
}
hashes.get(hash).push(filePath);
}
catch (error) {
// Skip files that can't be hashed
}
}
// Return only duplicates
const duplicates = new Map();
for (const [hash, paths] of hashes) {
if (paths.length > 1) {
duplicates.set(hash, paths);
}
}
return duplicates;
}
/**
* Create file backup
*
* @param filePath - Original file path
* @param backupDir - Backup directory
* @returns Backup file path
*/
static async createBackup(filePath, backupDir) {
await this.ensureDirectory(backupDir);
const filename = path.basename(filePath);
const timestamp = Date.now();
const backupFilename = `${timestamp}_${filename}`;
const backupPath = path.join(backupDir, backupFilename);
await this.copyFile(filePath, backupPath);
return backupPath;
}
}
exports.FileUtils = FileUtils;
//# sourceMappingURL=FileUtils.js.map