297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
import { S3Client, PutObjectCommand, GetObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
|
import { Upload } from '@aws-sdk/lib-storage';
|
|
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
|
import { storageProvider, StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider';
|
|
import { pluginConfig } from '../../backend/src/core/plugin-config';
|
|
import sharp from 'sharp';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
const VALID_CATEGORIES = ['cdi', 'xrays', 'documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
|
|
|
class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInterface {
|
|
manifest: any;
|
|
private s3: S3Client | null = null;
|
|
private bucket: string = '';
|
|
private prefix: string = '';
|
|
private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback');
|
|
private ctx!: PluginContext;
|
|
|
|
/** Activation logic */
|
|
async activate(ctx: PluginContext): Promise<void> {
|
|
this.ctx = ctx;
|
|
const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {};
|
|
|
|
// Ensure local fallback directory exists
|
|
if (!fs.existsSync(this.localFallbackDir)) {
|
|
fs.mkdirSync(this.localFallbackDir, { recursive: true });
|
|
}
|
|
|
|
const accessKey = storedConfig.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
|
const secretKey = storedConfig.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
|
const region = storedConfig.wasabiRegion || process.env.WASABI_REGION || 'us-east-1';
|
|
this.bucket = storedConfig.wasabiBucket || process.env.WASABI_BUCKET || '';
|
|
this.prefix = storedConfig.wasabiBucketPrefix || process.env.WASABI_BUCKET_PREFIX || '';
|
|
|
|
const rawEndpoint = storedConfig.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com';
|
|
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
|
|
|
if (!storedConfig['category_bucket_whatsapp']) {
|
|
storedConfig['category_bucket_whatsapp'] = this.bucket || 'clube67-storage';
|
|
pluginConfig.set('UnifiedStorageProvider', storedConfig);
|
|
}
|
|
|
|
if (!accessKey || !secretKey || !this.bucket) {
|
|
ctx.logger.error('Wasabi credentials or bucket missing in .env or plugin config');
|
|
} else {
|
|
this.s3 = new S3Client({
|
|
region,
|
|
endpoint,
|
|
credentials: {
|
|
accessKeyId: accessKey,
|
|
secretAccessKey: secretKey,
|
|
},
|
|
forcePathStyle: true,
|
|
});
|
|
|
|
storageProvider.register(this);
|
|
ctx.logger.info(`UnifiedStorageProvider activated (Endpoint: ${endpoint})`);
|
|
|
|
// start the background worker
|
|
this.startSyncWorker();
|
|
}
|
|
|
|
// --- Proxy Route ---
|
|
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. Check local fallback
|
|
const localPath = path.join(this.localFallbackDir, filePath);
|
|
if (fs.existsSync(localPath)) {
|
|
const data = fs.readFileSync(localPath);
|
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
const mimeTypes: any = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'webp': 'image/webp', 'pdf': 'application/pdf' };
|
|
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
|
res.setHeader('X-Storage-Source', 'local_fallback');
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
|
return res.send(data);
|
|
}
|
|
|
|
// 2. Wasabi Logic
|
|
if (!this.s3 || !this.bucket) return res.status(500).send('Storage not initialized');
|
|
|
|
const category = filePath.split('/')[0];
|
|
const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {};
|
|
const mappedBucket = storedConfig[`category_bucket_${category}`] || this.bucket;
|
|
const legacyBucket = (category === 'whatsapp') ? 'clube67-whatsapp-v1' : null;
|
|
|
|
const tryFetch = async (bucket: string) => {
|
|
return this.s3!.send(new GetObjectCommand({ Bucket: bucket, Key: filePath }));
|
|
};
|
|
|
|
let data;
|
|
try {
|
|
data = await tryFetch(mappedBucket);
|
|
} catch (e: any) {
|
|
if (legacyBucket && legacyBucket !== mappedBucket) {
|
|
try { data = await tryFetch(legacyBucket); } catch (e2) { throw e; }
|
|
} else { throw e; }
|
|
}
|
|
|
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
const mimeTypes: any = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml', 'pdf': 'application/pdf' };
|
|
res.setHeader('Content-Type', data.ContentType || mimeTypes[ext] || 'application/octet-stream');
|
|
res.setHeader('X-Storage-Source', 'wasabi');
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
|
|
|
// In v3, Body has utility methods to convert the stream
|
|
if (data.Body) {
|
|
const bytes = await (data.Body as any).transformToByteArray();
|
|
res.send(Buffer.from(bytes));
|
|
} else {
|
|
res.status(404).send('Body empty');
|
|
}
|
|
} catch (err: any) {
|
|
res.status(404).send('File not found');
|
|
}
|
|
});
|
|
|
|
// --- Admin/Fetch Routes ---
|
|
ctx.app.post('/api/storage/list-files', async (req, res) => {
|
|
const { accessKey, secretKey, bucket, prefix, region, endpoint } = req.body;
|
|
if (!accessKey || !secretKey || !bucket) return res.status(400).json({ error: 'Params missing' });
|
|
try {
|
|
const s3 = new S3Client({
|
|
region: region || 'us-east-1',
|
|
endpoint: endpoint || 'https://s3.wasabisys.com',
|
|
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
|
forcePathStyle: true
|
|
});
|
|
const data = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix || '', MaxKeys: 20 }));
|
|
const files = data.Contents?.map(item => ({ key: item.Key, size: item.Size, lastModified: item.LastModified })) || [];
|
|
res.json({ files });
|
|
} catch (err: any) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
|
}
|
|
|
|
async deactivate(ctx: PluginContext): Promise<void> { ctx.logger.info('UnifiedStorageProvider deactivated'); }
|
|
|
|
async checkHealth(): Promise<boolean> {
|
|
if (!this.s3 || !this.bucket) return false;
|
|
try { await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket })); return true; }
|
|
catch (err) { return false; }
|
|
}
|
|
|
|
async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
|
|
const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {};
|
|
const { category, file } = payload;
|
|
if (!VALID_CATEGORIES.includes(category)) throw new Error(`Invalid category: ${category}`);
|
|
|
|
let buffer = file.buffer;
|
|
let originalName = file.originalname;
|
|
let mimetype = file.mimetype;
|
|
|
|
if (mimetype.startsWith('image/') && buffer.length > 150 * 1024) {
|
|
try {
|
|
this.ctx.logger.info(`[Optimizer] Processing ${originalName}`);
|
|
const optimized = await sharp(buffer)
|
|
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
|
|
.webp({ quality: 75 })
|
|
.toBuffer();
|
|
buffer = optimized;
|
|
mimetype = 'image/webp';
|
|
originalName = originalName.replace(/\.[^.]+$/, '.webp');
|
|
} catch (err: any) { this.ctx.logger.warn(`[Optimizer] Failed: ${err.message}`); }
|
|
}
|
|
|
|
const fileName = `${Date.now()}-${originalName.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
|
|
const relativePath = `${category}/${fileName}`;
|
|
|
|
try {
|
|
if (!this.s3 || !this.bucket) throw new Error('S3 not initialized');
|
|
const targetBucket = storedConfig[`category_bucket_${category}`] || this.bucket;
|
|
|
|
const parallelUploads3 = new Upload({
|
|
client: this.s3,
|
|
params: {
|
|
Bucket: targetBucket,
|
|
Key: relativePath,
|
|
Body: buffer,
|
|
ContentType: mimetype,
|
|
ACL: 'public-read'
|
|
},
|
|
});
|
|
|
|
await parallelUploads3.done();
|
|
return { provider: 'wasabi', path: relativePath };
|
|
} catch (err: any) {
|
|
this.ctx.logger.error(`[Wasabi] Fallback to LOCAL: ${err.message}`);
|
|
|
|
// Log fallback attempt
|
|
try {
|
|
await this.ctx.db('janitor_logs').insert({
|
|
filename: originalName,
|
|
category: category,
|
|
status: 'fallback',
|
|
message: `Erro Wasabi: ${err.message.substring(0, 100)}`
|
|
});
|
|
} catch (dbErr) { }
|
|
|
|
const localDest = path.join(this.localFallbackDir, category);
|
|
if (!fs.existsSync(localDest)) fs.mkdirSync(localDest, { recursive: true });
|
|
fs.writeFileSync(path.join(this.localFallbackDir, relativePath), buffer);
|
|
return { provider: 'local_fallback', path: relativePath };
|
|
}
|
|
}
|
|
|
|
private startSyncWorker() {
|
|
setInterval(async () => {
|
|
if (!this.s3 || !this.bucket) return;
|
|
const scanFolders = [
|
|
{ path: this.localFallbackDir, isCategorized: true },
|
|
{ path: path.resolve(process.cwd(), 'uploads'), isCategorized: false, category: 'media' }
|
|
];
|
|
for (const folder of scanFolders) {
|
|
try {
|
|
if (!fs.existsSync(folder.path)) continue;
|
|
if (folder.isCategorized) {
|
|
const categories = fs.readdirSync(folder.path);
|
|
for (const cat of categories) {
|
|
const catDir = path.join(folder.path, cat);
|
|
if (!fs.statSync(catDir).isDirectory()) continue;
|
|
await this.syncFolder(catDir, cat);
|
|
}
|
|
} else if (folder.category) {
|
|
await this.syncFolder(folder.path, folder.category);
|
|
}
|
|
} catch (err: any) { }
|
|
}
|
|
}, 120000);
|
|
}
|
|
|
|
private async syncFolder(dir: string, category: string) {
|
|
if (!this.s3 || !this.bucket) return;
|
|
try {
|
|
const files = fs.readdirSync(dir);
|
|
if (files.length === 0) return;
|
|
for (const file of files) {
|
|
const localPath = path.join(dir, file);
|
|
if (!fs.existsSync(localPath) || fs.statSync(localPath).isDirectory()) continue;
|
|
|
|
try {
|
|
// Log step 1: Wasabi
|
|
const logId = await this.ctx.db('janitor_logs').insert({
|
|
filename: file,
|
|
category: category,
|
|
status: 'wasabi',
|
|
message: 'Sincronizando com Wasabi...'
|
|
});
|
|
|
|
const buffer = fs.readFileSync(localPath);
|
|
const targetBucket = pluginConfig.get('UnifiedStorageProvider')?.[`category_bucket_${category}`] || this.bucket;
|
|
|
|
const parallelUploads3 = new Upload({
|
|
client: this.s3,
|
|
params: {
|
|
Bucket: targetBucket,
|
|
Key: `${category}/${file}`,
|
|
Body: buffer
|
|
},
|
|
});
|
|
|
|
await parallelUploads3.done();
|
|
|
|
// Log step 2: Deleting VPS
|
|
await this.ctx.db('janitor_logs').where('id', logId[0]).update({
|
|
status: 'vps_delete',
|
|
message: 'Wasabi OK, excluindo do servidor...'
|
|
});
|
|
|
|
fs.unlinkSync(localPath);
|
|
|
|
// Log step 3: Fim
|
|
await this.ctx.db('janitor_logs').where('id', logId[0]).update({
|
|
status: 'fim',
|
|
message: 'Limpeza concluída com sucesso.'
|
|
});
|
|
} catch (err: any) {
|
|
this.ctx.logger.error(`[Janitor] Sync failed for ${file}: ${err.message}`);
|
|
await this.ctx.db('janitor_logs').insert({
|
|
filename: file,
|
|
category: category,
|
|
status: 'erro',
|
|
message: `Falha: ${err.message.substring(0, 100)}`
|
|
});
|
|
}
|
|
}
|
|
} catch (err: any) { }
|
|
}
|
|
}
|
|
|
|
const instance = new UnifiedStorageProviderPlugin();
|
|
export default instance;
|