"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var StorageService_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.StorageService = void 0; const common_1 = require("@nestjs/common"); const typeorm_1 = require("@nestjs/typeorm"); const typeorm_2 = require("typeorm"); const uuid_1 = require("uuid"); const file_entity_1 = require("../entities/file.entity"); const pending_upload_entity_1 = require("../entities/pending-upload.entity"); const storage_usage_entity_1 = require("../entities/storage-usage.entity"); const s3_provider_1 = require("../providers/s3.provider"); const ALLOWED_MIME_TYPES = { images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'], documents: [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ], spreadsheets: [ 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ], data: ['text/csv', 'application/json', 'text/plain'], }; const BLOCKED_EXTENSIONS = ['.exe', '.bat', '.sh', '.php', '.js', '.cmd', '.com', '.scr']; let StorageService = StorageService_1 = class StorageService { constructor(fileRepository, pendingUploadRepository, usageRepository, s3Provider) { this.fileRepository = fileRepository; this.pendingUploadRepository = pendingUploadRepository; this.usageRepository = usageRepository; this.s3Provider = s3Provider; this.logger = new common_1.Logger(StorageService_1.name); } async getUploadUrl(tenantId, userId, dto) { if (!this.s3Provider.isConfigured()) { throw new common_1.BadRequestException('Storage not configured'); } if (!this.isAllowedMimeType(dto.mimeType)) { throw new common_1.BadRequestException(`File type not allowed: ${dto.mimeType}`); } if (this.hasBlockedExtension(dto.filename)) { throw new common_1.BadRequestException('File extension not allowed'); } const canUpload = await this.canUpload(tenantId, dto.sizeBytes); if (!canUpload.allowed) { throw new common_1.ForbiddenException(canUpload.reason); } const uploadId = (0, uuid_1.v4)(); const folder = dto.folder || 'files'; const path = this.s3Provider.generatePath(tenantId, folder, uploadId, dto.filename); const bucket = this.s3Provider.getBucket(); const presigned = await this.s3Provider.getUploadUrl({ bucket, key: path, contentType: dto.mimeType, contentLength: dto.sizeBytes, metadata: { 'tenant-id': tenantId, 'upload-id': uploadId, 'user-id': userId, }, }); const pendingUpload = this.pendingUploadRepository.create({ id: uploadId, tenant_id: tenantId, user_id: userId, filename: this.sanitizeFilename(dto.filename), original_name: dto.filename, mime_type: dto.mimeType, size_bytes: dto.sizeBytes, folder, bucket, path, provider: this.s3Provider.getProvider(), expires_at: presigned.expiresAt, }); await this.pendingUploadRepository.save(pendingUpload); return { uploadId, url: presigned.url, expiresAt: presigned.expiresAt, maxSize: dto.sizeBytes, }; } async confirmUpload(tenantId, userId, dto) { const pending = await this.pendingUploadRepository.findOne({ where: { id: dto.uploadId, tenant_id: tenantId, status: 'pending', }, }); if (!pending) { throw new common_1.NotFoundException('Upload not found or expired'); } if (pending.expires_at < new Date()) { await this.pendingUploadRepository.update(pending.id, { status: 'expired' }); throw new common_1.BadRequestException('Upload expired'); } const headResult = await this.s3Provider.headObject(pending.path); if (!headResult) { await this.pendingUploadRepository.update(pending.id, { status: 'failed' }); throw new common_1.BadRequestException('File not found in storage'); } const file = this.fileRepository.create({ tenant_id: tenantId, uploaded_by: userId, filename: pending.filename, original_name: pending.original_name, mime_type: pending.mime_type, size_bytes: headResult.contentLength, bucket: pending.bucket, path: pending.path, provider: pending.provider, folder: pending.folder, status: file_entity_1.FileStatus.READY, visibility: file_entity_1.FileVisibility.PRIVATE, metadata: dto.metadata || {}, }); await this.fileRepository.save(file); await this.pendingUploadRepository.update(pending.id, { status: 'completed', completed_at: new Date(), }); return this.toFileResponse(file); } async listFiles(tenantId, dto) { const { page = 1, limit = 20, folder, mimeType, search } = dto; const skip = (page - 1) * limit; const where = { tenant_id: tenantId, deleted_at: (0, typeorm_2.IsNull)(), status: file_entity_1.FileStatus.READY, }; if (folder) { where.folder = folder; } if (mimeType) { where.mime_type = (0, typeorm_2.Like)(`${mimeType}%`); } if (search) { where.original_name = (0, typeorm_2.ILike)(`%${search}%`); } const [files, total] = await this.fileRepository.findAndCount({ where, order: { created_at: 'DESC' }, skip, take: limit, }); return { data: files.map((f) => this.toFileResponse(f)), total, page, limit, totalPages: Math.ceil(total / limit), }; } async getFile(tenantId, fileId) { const file = await this.fileRepository.findOne({ where: { id: fileId, tenant_id: tenantId, deleted_at: (0, typeorm_2.IsNull)(), }, }); if (!file) { throw new common_1.NotFoundException('File not found'); } return this.toFileResponse(file); } async getDownloadUrl(tenantId, fileId) { const file = await this.fileRepository.findOne({ where: { id: fileId, tenant_id: tenantId, deleted_at: (0, typeorm_2.IsNull)(), status: file_entity_1.FileStatus.READY, }, }); if (!file) { throw new common_1.NotFoundException('File not found'); } const result = await this.s3Provider.getDownloadUrl(file.path); return { url: result.url, expiresAt: result.expiresAt, }; } async updateFile(tenantId, fileId, dto) { const file = await this.fileRepository.findOne({ where: { id: fileId, tenant_id: tenantId, deleted_at: (0, typeorm_2.IsNull)(), }, }); if (!file) { throw new common_1.NotFoundException('File not found'); } if (dto.folder) file.folder = dto.folder; if (dto.visibility) file.visibility = dto.visibility; if (dto.metadata) file.metadata = { ...file.metadata, ...dto.metadata }; await this.fileRepository.save(file); return this.toFileResponse(file); } async deleteFile(tenantId, fileId) { const file = await this.fileRepository.findOne({ where: { id: fileId, tenant_id: tenantId, deleted_at: (0, typeorm_2.IsNull)(), }, }); if (!file) { throw new common_1.NotFoundException('File not found'); } file.deleted_at = new Date(); file.status = file_entity_1.FileStatus.DELETED; await this.fileRepository.save(file); try { await this.s3Provider.deleteObject(file.path); } catch (error) { this.logger.warn(`Failed to delete file from S3: ${file.path}`, error); } } async getUsage(tenantId) { let usage = await this.usageRepository.findOne({ where: { tenant_id: tenantId }, }); if (!usage) { usage = this.usageRepository.create({ tenant_id: tenantId, total_files: 0, total_bytes: 0, }); await this.usageRepository.save(usage); } const folderStats = await this.fileRepository .createQueryBuilder('f') .select('f.folder', 'folder') .addSelect('COUNT(*)', 'count') .where('f.tenant_id = :tenantId', { tenantId }) .andWhere('f.deleted_at IS NULL') .andWhere('f.status = :status', { status: file_entity_1.FileStatus.READY }) .groupBy('f.folder') .getRawMany(); const filesByFolder = {}; for (const stat of folderStats) { filesByFolder[stat.folder] = parseInt(stat.count, 10); } const usagePercent = usage.max_bytes ? Math.round((Number(usage.total_bytes) / Number(usage.max_bytes)) * 10000) / 100 : 0; return { totalFiles: usage.total_files, totalBytes: Number(usage.total_bytes), maxBytes: usage.max_bytes ? Number(usage.max_bytes) : null, maxFileSize: usage.max_file_size ? Number(usage.max_file_size) : null, usagePercent, filesByFolder, }; } isAllowedMimeType(mimeType) { const allAllowed = Object.values(ALLOWED_MIME_TYPES).flat(); return allAllowed.includes(mimeType); } hasBlockedExtension(filename) { const lower = filename.toLowerCase(); return BLOCKED_EXTENSIONS.some((ext) => lower.endsWith(ext)); } sanitizeFilename(filename) { return filename.replace(/[^a-zA-Z0-9.-]/g, '_'); } async canUpload(tenantId, sizeBytes) { const usage = await this.usageRepository.findOne({ where: { tenant_id: tenantId }, }); if (!usage) { return { allowed: true }; } if (usage.max_file_size && sizeBytes > Number(usage.max_file_size)) { return { allowed: false, reason: `File size exceeds limit (max: ${this.formatBytes(Number(usage.max_file_size))})`, }; } if (usage.max_bytes && Number(usage.total_bytes) + sizeBytes > Number(usage.max_bytes)) { return { allowed: false, reason: `Storage limit exceeded (max: ${this.formatBytes(Number(usage.max_bytes))})`, }; } return { allowed: true }; } formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } toFileResponse(file) { return { id: file.id, filename: file.filename, originalName: file.original_name, mimeType: file.mime_type, sizeBytes: Number(file.size_bytes), folder: file.folder, visibility: file.visibility, thumbnails: file.thumbnails, metadata: file.metadata, createdAt: file.created_at, updatedAt: file.updated_at, }; } }; exports.StorageService = StorageService; exports.StorageService = StorageService = StorageService_1 = __decorate([ (0, common_1.Injectable)(), __param(0, (0, typeorm_1.InjectRepository)(file_entity_1.FileEntity)), __param(1, (0, typeorm_1.InjectRepository)(pending_upload_entity_1.PendingUploadEntity)), __param(2, (0, typeorm_1.InjectRepository)(storage_usage_entity_1.StorageUsageEntity)), __metadata("design:paramtypes", [typeorm_2.Repository, typeorm_2.Repository, typeorm_2.Repository, s3_provider_1.S3Provider]) ], StorageService); //# sourceMappingURL=storage.service.js.map