chore(ops): restore all source files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
"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
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
ListBucketsCommand,
|
||||
CreateBucketCommand,
|
||||
DeleteObjectsCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import type { StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider';
|
||||
|
||||
let storageProvider: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
} else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
||||
|
||||
// Mapa de extensão → mimetype para o proxy
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
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 implements PluginInstance, StorageProviderInterface {
|
||||
manifest: any;
|
||||
private s3: S3Client | null = null;
|
||||
private bucket: string = '';
|
||||
private region: string = 'us-east-1';
|
||||
private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback');
|
||||
private ctx!: PluginContext;
|
||||
private routesRegistered = false;
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
|
||||
if (!fs.existsSync(this.localFallbackDir)) {
|
||||
fs.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') as string;
|
||||
this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '') as string;
|
||||
|
||||
const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com') as string;
|
||||
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 S3Client({
|
||||
region: this.region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey as string, secretAccessKey: secretKey as string },
|
||||
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.join(this.localFallbackDir, filePath);
|
||||
if (fs.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.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 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 as any).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 as Record<string, string>;
|
||||
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 S3Client({
|
||||
region: region || 'us-east-1',
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const data = await s3t.send(new ListBucketsCommand({}));
|
||||
const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate }));
|
||||
return res.json({ buckets });
|
||||
} catch (err: any) {
|
||||
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 as Record<string, string>;
|
||||
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 S3Client({
|
||||
region: effectiveRegion,
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const params: any = { Bucket: safeName };
|
||||
if (effectiveRegion !== 'us-east-1') {
|
||||
params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion };
|
||||
}
|
||||
await s3t.send(new CreateBucketCommand(params));
|
||||
ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`);
|
||||
return res.status(201).json({ ok: true, name: safeName });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
this.s3 = null;
|
||||
ctx.logger.info('UnifiedStorageProvider desativado');
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
return (await this.checkDetailedHealth()).healthy;
|
||||
}
|
||||
|
||||
async checkDetailedHealth(): Promise<import('../../backend/src/core/StorageProvider').StorageHealthStatus> {
|
||||
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 HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` };
|
||||
} catch (err: any) {
|
||||
const code: string = err?.Code || err?.code || err?.name || '';
|
||||
const httpStatus: number = 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: string): Promise<{ deleted: number }> {
|
||||
let deletedCount = 0;
|
||||
|
||||
// 1. Limpa do Wasabi
|
||||
if (this.s3 && this.bucket) {
|
||||
try {
|
||||
let isTruncated = true;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
while (isTruncated) {
|
||||
const listParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
};
|
||||
if (continuationToken) {
|
||||
listParams.ContinuationToken = continuationToken;
|
||||
}
|
||||
|
||||
const listResult = await this.s3.send(new 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 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: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Limpa do fallback local
|
||||
const localPath = path.join(this.localFallbackDir, prefix);
|
||||
if (fs.existsSync(localPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(localPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`);
|
||||
} else {
|
||||
fs.unlinkSync(localPath);
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted: deletedCount };
|
||||
}
|
||||
|
||||
async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
|
||||
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 sharp(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: any) {
|
||||
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 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: any) {
|
||||
this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`);
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(relativePath: string): Promise<Buffer | null> {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, relativePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
return fs.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) return null;
|
||||
try {
|
||||
const result = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body) return null;
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
|
||||
const dest = path.join(this.localFallbackDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.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 */
|
||||
private startSyncWorker(): void {
|
||||
setInterval(async () => {
|
||||
if (!this.s3 || !this.bucket) return;
|
||||
await this.syncDir(this.localFallbackDir);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
private async syncDir(dir: string): Promise<void> {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await this.syncDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.s3!,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: fs.readFileSync(fullPath),
|
||||
},
|
||||
}).done();
|
||||
|
||||
fs.unlinkSync(fullPath);
|
||||
this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`);
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new UnifiedStorageProviderPlugin();
|
||||
export default instance;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "UnifiedStorageProvider",
|
||||
"displayName": "Wasabi Storage",
|
||||
"version": "1.1.0",
|
||||
"description": "Armazenamento de mídias do WhatsApp via Wasabi (S3 compatível)",
|
||||
"author": "NewWhats",
|
||||
"category": "core",
|
||||
"enabled": true,
|
||||
"canDisable": false,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/storage",
|
||||
"hasMigrations": false
|
||||
},
|
||||
"configSchema": [
|
||||
{ "key": "wasabiAccessKey", "label": "Access Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave de acesso gerada em Account → Access Keys no painel Wasabi." },
|
||||
{ "key": "wasabiSecretKey", "label": "Secret Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave secreta correspondente à Access Key — exibida só uma vez no momento da criação." },
|
||||
{ "key": "wasabiBucket", "label": "Bucket Ativo", "type": "text", "required": true, "group": "Bucket", "tooltip": "Nome do bucket onde as mídias do WhatsApp serão armazenadas." },
|
||||
{ "key": "wasabiRegion", "label": "Region", "type": "text", "required": false, "group": "Avançado", "tooltip": "Região do bucket. Padrão: us-east-1. Exemplos: us-east-2, eu-central-1." },
|
||||
{ "key": "wasabiEndpoint", "label": "Endpoint", "type": "text", "required": false, "group": "Avançado", "tooltip": "Endpoint S3. Padrão: s3.wasabisys.com. Só altere se usar região diferente." }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user