- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8 - Actualizaciones de configuracion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
349 lines
13 KiB
JavaScript
349 lines
13 KiB
JavaScript
"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
|