"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const client_s3_1 = require("@aws-sdk/client-s3"); const lib_storage_1 = require("@aws-sdk/lib-storage"); let storageProvider; if (process.env.NODE_ENV === 'production') { storageProvider = require('../../dist/core/StorageProvider').storageProvider; } else { storageProvider = require('../../backend/src/core/StorageProvider').storageProvider; } const sharp_1 = __importDefault(require("sharp")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media']; // Mapa de extensão → mimetype para o proxy const MIME_MAP = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg', mp3: 'audio/mpeg', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }; class UnifiedStorageProviderPlugin { constructor() { this.s3 = null; this.bucket = ''; this.region = 'us-east-1'; this.localFallbackDir = path_1.default.resolve(process.cwd(), 'storage', 'local_fallback'); this.routesRegistered = false; } async activate(ctx) { this.ctx = ctx; const cfg = ctx.config.get('UnifiedStorageProvider') || {}; if (!fs_1.default.existsSync(this.localFallbackDir)) { fs_1.default.mkdirSync(this.localFallbackDir, { recursive: true }); } const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY; const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY; this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1'); this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || ''); const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com'); const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`; ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug'); if (!accessKey || !secretKey || !this.bucket) { ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin'); } else { this.s3 = new client_s3_1.S3Client({ region: this.region, endpoint, credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, forcePathStyle: true, }); storageProvider.register(this); ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`); this.startSyncWorker(); } if (!this.routesRegistered) { // ── Proxy: serve arquivos do Wasabi (ou fallback local) ──────────────── ctx.app.get('/api/storage/view/*', async (req, res) => { const filePath = req.params[0]; if (!filePath) return res.status(400).send('Path missing'); try { // 1. Fallback local const localPath = path_1.default.join(this.localFallbackDir, filePath); if (fs_1.default.existsSync(localPath)) { const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('X-Storage-Source', 'local_fallback'); return res.send(fs_1.default.readFileSync(localPath)); } // 2. Wasabi if (!this.s3 || !this.bucket) { return res.status(503).send('Storage não inicializado'); } const result = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: filePath, })); const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('X-Storage-Source', 'wasabi'); if (result.Body) { const bytes = await result.Body.transformToByteArray(); return res.send(Buffer.from(bytes)); } return res.status(404).send('Body vazio'); } catch { return res.status(404).send('Arquivo não encontrado'); } }); // ── POST /api/storage/wasabi/list-buckets ────────────────────────────── // Lista buckets usando credenciais do body (sem exigir S3 pré-configurado) ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => { const { accessKey, secretKey, region, endpoint } = req.body; if (!accessKey || !secretKey) { return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' }); } try { const ep = (endpoint || 's3.wasabisys.com').startsWith('http') ? endpoint || 's3.wasabisys.com' : `https://${endpoint || 's3.wasabisys.com'}`; const s3t = new client_s3_1.S3Client({ region: region || 'us-east-1', endpoint: ep, credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, forcePathStyle: true, }); const data = await s3t.send(new client_s3_1.ListBucketsCommand({})); const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate })); return res.json({ buckets }); } catch (err) { return res.status(400).json({ error: err.message }); } }); // ── POST /api/storage/wasabi/create-bucket ───────────────────────────── ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => { const { accessKey, secretKey, region, endpoint, name } = req.body; if (!accessKey || !secretKey) return res.status(400).json({ error: 'Credenciais obrigatórias' }); if (!name) return res.status(400).json({ error: 'Campo "name" obrigatório' }); const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'); const effectiveRegion = region || 'us-east-1'; const ep = (endpoint || 's3.wasabisys.com').startsWith('http') ? endpoint || 's3.wasabisys.com' : `https://${endpoint || 's3.wasabisys.com'}`; try { const s3t = new client_s3_1.S3Client({ region: effectiveRegion, endpoint: ep, credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, forcePathStyle: true, }); const params = { Bucket: safeName }; if (effectiveRegion !== 'us-east-1') { params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion }; } await s3t.send(new client_s3_1.CreateBucketCommand(params)); ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`); return res.status(201).json({ ok: true, name: safeName }); } catch (err) { return res.status(500).json({ error: err.message }); } }); this.routesRegistered = true; } setInterval(() => { storageProvider.updateHealth(); }, 30000); } async deactivate(ctx) { this.s3 = null; ctx.logger.info('UnifiedStorageProvider desativado'); } async checkHealth() { return (await this.checkDetailedHealth()).healthy; } async checkDetailedHealth() { if (!this.s3 || !this.bucket) { return { healthy: false, configured: false, reason: 'not_configured', message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.', }; } try { await this.s3.send(new client_s3_1.HeadBucketCommand({ Bucket: this.bucket })); return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` }; } catch (err) { const code = err?.Code || err?.code || err?.name || ''; const httpStatus = err?.$metadata?.httpStatusCode ?? 0; if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') { return { healthy: false, configured: true, reason: 'auth_error', message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.', }; } if (httpStatus === 403 || code === 'AccessDenied') { // 403 pode ser pagamento atrasado ou permissão negada return { healthy: false, configured: true, reason: 'billing_error', message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.', }; } if (httpStatus === 404 || code === 'NoSuchBucket') { return { healthy: false, configured: true, reason: 'bucket_not_found', message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`, }; } if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') { return { healthy: false, configured: true, reason: 'network_error', message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.', }; } return { healthy: false, configured: true, reason: 'unknown', message: `Erro ao verificar Wasabi: ${err?.message ?? code}`, }; } } async deletePrefix(prefix) { let deletedCount = 0; // 1. Limpa do Wasabi if (this.s3 && this.bucket) { try { let isTruncated = true; let continuationToken; while (isTruncated) { const listParams = { Bucket: this.bucket, Prefix: prefix, MaxKeys: 1000, }; if (continuationToken) { listParams.ContinuationToken = continuationToken; } const listResult = await this.s3.send(new client_s3_1.ListObjectsV2Command(listParams)); const objects = listResult.Contents ?? []; if (objects.length > 0) { const deleteParams = { Bucket: this.bucket, Delete: { Objects: objects.map((obj) => ({ Key: obj.Key })), Quiet: true, }, }; await this.s3.send(new client_s3_1.DeleteObjectsCommand(deleteParams)); deletedCount += objects.length; this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`); } isTruncated = listResult.IsTruncated ?? false; continuationToken = listResult.NextContinuationToken; } } catch (err) { this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`); } } // 2. Limpa do fallback local const localPath = path_1.default.join(this.localFallbackDir, prefix); if (fs_1.default.existsSync(localPath)) { try { const stat = fs_1.default.statSync(localPath); if (stat.isDirectory()) { fs_1.default.rmSync(localPath, { recursive: true, force: true }); this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`); } else { fs_1.default.unlinkSync(localPath); this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`); } } catch (err) { this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`); } } return { deleted: deletedCount }; } async upload(payload) { const { category, file } = payload; if (!VALID_CATEGORIES.includes(category)) throw new Error(`Categoria inválida: ${category}`); let buffer = file.buffer; let originalName = file.originalname; let mimetype = file.mimetype; // Otimiza imagens grandes (>150 KB) para WebP if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) { try { buffer = await (0, sharp_1.default)(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer(); mimetype = 'image/webp'; originalName = originalName.replace(/\.[^.]+$/, '.webp'); this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`); } catch (err) { this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`); } } const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); const fileName = `${Date.now()}-${safeBase}`; // Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file} const relativePath = payload.customPath ?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID) ? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}` : `${category}/${fileName}`); if (!this.s3 || !this.bucket) { return this.saveToLocalFallback(relativePath, buffer, originalName, category); } try { await new lib_storage_1.Upload({ client: this.s3, params: { Bucket: this.bucket, Key: relativePath, Body: buffer, ContentType: mimetype, }, }).done(); this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`); return { provider: 'wasabi', path: relativePath }; } catch (err) { this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`); return this.saveToLocalFallback(relativePath, buffer, originalName, category); } } async getBuffer(relativePath) { // 1. Fallback local const localPath = path_1.default.join(this.localFallbackDir, relativePath); if (fs_1.default.existsSync(localPath)) { return fs_1.default.readFileSync(localPath); } // 2. Wasabi if (!this.s3 || !this.bucket) return null; try { const result = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: relativePath })); if (!result.Body) return null; const bytes = await result.Body.transformToByteArray(); return Buffer.from(bytes); } catch { return null; } } /** Salva buffer no diretório de fallback local, criando subpastas se necessário */ saveToLocalFallback(relativePath, buffer, originalName, _category) { const dest = path_1.default.join(this.localFallbackDir, relativePath); fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true }); fs_1.default.writeFileSync(dest, buffer); this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`); return { provider: 'local_fallback', path: relativePath }; } /** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */ startSyncWorker() { setInterval(async () => { if (!this.s3 || !this.bucket) return; await this.syncDir(this.localFallbackDir); }, 120000); } async syncDir(dir) { if (!fs_1.default.existsSync(dir)) return; for (const entry of fs_1.default.readdirSync(dir)) { const fullPath = path_1.default.join(dir, entry); const stat = fs_1.default.statSync(fullPath); if (stat.isDirectory()) { await this.syncDir(fullPath); continue; } const relativePath = path_1.default.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/'); try { await new lib_storage_1.Upload({ client: this.s3, params: { Bucket: this.bucket, Key: relativePath, Body: fs_1.default.readFileSync(fullPath), }, }).done(); fs_1.default.unlinkSync(fullPath); this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`); } catch (err) { this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`); } } } } const instance = new UnifiedStorageProviderPlugin(); exports.default = instance; //# sourceMappingURL=index.js.map