Importação inicial do projeto Shivão (Diário de Bordo do veleiro) em estado pronto pra produção, já incluindo as 5 mudanças de hardening implementadas pela squad shivao-melhoria em 2026-04-27. Mudanças de hardening (HANDOFF.md seção "✅ Pronto"): 1. **Rate limiting nos 3 endpoints públicos de share** - express-rate-limit ^8.4.1, 60 req/min/IP - server/src/index.js linhas 38, 262, 271, 279 2. **Tamanho de upload reduzido pra 50MB** (era 200MB) - multer linha 84 3. **Validação Zod nos endpoints autenticados** (POST /api/data e /zones) - novo arquivo server/src/schemas/index.js - middleware validate() retorna 400 com top 5 issues 4. **Audit log de ações sensíveis** - nova tabela audit_log + funções db.audit() e db.recentAudit() - 6 endpoints instrumentados (state_set, media_insert/delete, share_create/revoke/zones_update) - novo endpoint GET /api/audit (autenticado) 5. **Catch silencioso de webhook em zona PROIBIDA tratado** - app/diario-bordo.html + server/public/index.html linha 3280 - agora loga erro + exibe toast ao usuário Status final do HANDOFF: - 🔴 Críticos restantes: 1 (CORS — decisão consciente single-tenant, não-acionável) - 🟡 Importantes: 4 itens (testes, vigia reconnect, refator frontend, demais catches) - 🟢 Bom-ter: 5 itens Stack: Node 20 ESM + Express + better-sqlite3 + Docker (Coolify) Single-tenant pessoal · single-file frontend HTML · offline-first Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
7.3 KiB
JavaScript
219 lines
7.3 KiB
JavaScript
import nodemailer from 'nodemailer';
|
|
|
|
const env = process.env;
|
|
|
|
// ---- Telegram ----
|
|
async function sendTelegram(text) {
|
|
const token = env.TELEGRAM_BOT_TOKEN;
|
|
const chats = (env.TELEGRAM_CHAT_IDS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (!token || chats.length === 0) return null;
|
|
|
|
const results = [];
|
|
for (const chatId of chats) {
|
|
try {
|
|
const r = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML', disable_notification: false })
|
|
});
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
|
|
results.push({ chat: chatId, ok: true });
|
|
} catch (e) {
|
|
results.push({ chat: chatId, ok: false, error: e.message });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// ---- ntfy.sh push ----
|
|
async function sendNtfy(text, title, priority) {
|
|
const topic = env.NTFY_TOPIC;
|
|
if (!topic) return null;
|
|
const server = env.NTFY_SERVER || 'https://ntfy.sh';
|
|
try {
|
|
const r = await fetch(`${server}/${topic}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Title': title || 'Alerta Shivao',
|
|
'Priority': priority || 'urgent',
|
|
'Tags': 'rotating_light,anchor,warning',
|
|
'Click': text.match(/https:\/\/maps[^\s]+/)?.[0] || ''
|
|
},
|
|
body: text
|
|
});
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
// ---- Email ----
|
|
let transporter = null;
|
|
function getEmailTransporter() {
|
|
if (transporter) return transporter;
|
|
if (!env.SMTP_HOST || !env.SMTP_USER) return null;
|
|
transporter = nodemailer.createTransport({
|
|
host: env.SMTP_HOST,
|
|
port: parseInt(env.SMTP_PORT || '587'),
|
|
secure: env.SMTP_SECURE === 'true',
|
|
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS }
|
|
});
|
|
return transporter;
|
|
}
|
|
|
|
async function sendEmail(text, subject) {
|
|
const t = getEmailTransporter();
|
|
const recipients = (env.SMTP_TO || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (!t || recipients.length === 0) return null;
|
|
try {
|
|
await t.sendMail({
|
|
from: env.SMTP_FROM || env.SMTP_USER,
|
|
to: recipients.join(','),
|
|
subject: subject || 'Alerta Shivao',
|
|
text,
|
|
html: `<pre style="font-family:system-ui;font-size:14px">${text.replace(/</g, '<')}</pre>`
|
|
});
|
|
return { ok: true, recipients: recipients.length };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
// ---- Twilio SMS / WhatsApp ----
|
|
async function twilioRequest(messages) {
|
|
const sid = env.TWILIO_ACCOUNT_SID;
|
|
const token = env.TWILIO_AUTH_TOKEN;
|
|
if (!sid || !token) return null;
|
|
const auth = 'Basic ' + Buffer.from(`${sid}:${token}`).toString('base64');
|
|
const url = `https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`;
|
|
const results = [];
|
|
for (const msg of messages) {
|
|
try {
|
|
const body = new URLSearchParams({ From: msg.from, To: msg.to, Body: msg.body });
|
|
const r = await fetch(url, { method: 'POST', headers: { 'Authorization': auth, 'Content-Type': 'application/x-www-form-urlencoded' }, body });
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
|
|
results.push({ to: msg.to, ok: true });
|
|
} catch (e) {
|
|
results.push({ to: msg.to, ok: false, error: e.message });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function sendSMS(text) {
|
|
const from = env.TWILIO_FROM_NUMBER;
|
|
const tos = (env.TWILIO_SMS_TO || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (!from || tos.length === 0) return null;
|
|
const messages = tos.map(to => ({ from, to, body: text }));
|
|
return twilioRequest(messages);
|
|
}
|
|
|
|
async function sendWhatsApp(text) {
|
|
const from = env.TWILIO_WHATSAPP_FROM;
|
|
const tos = (env.TWILIO_WHATSAPP_TO || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (!from || tos.length === 0) return null;
|
|
const messages = tos.map(to => ({
|
|
from: from.startsWith('whatsapp:') ? from : `whatsapp:${from}`,
|
|
to: to.startsWith('whatsapp:') ? to : `whatsapp:${to}`,
|
|
body: text
|
|
}));
|
|
return twilioRequest(messages);
|
|
}
|
|
|
|
// ---- Generic webhook ----
|
|
async function sendWebhook(payload) {
|
|
const url = env.WEBHOOK_URL;
|
|
if (!url) return null;
|
|
try {
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return { ok: true };
|
|
} catch (e) {
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
// ---- Build message ----
|
|
export function buildAlarmMessage(p) {
|
|
const lat = p.lat?.toFixed(6) ?? '?';
|
|
const lng = p.lng?.toFixed(6) ?? '?';
|
|
const mapsUrl = (p.lat && p.lng) ? `https://maps.google.com/?q=${lat},${lng}` : '';
|
|
const dist = Math.round(p.distance || 0);
|
|
const time = new Date(p.ts || Date.now()).toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' });
|
|
const boat = p.boat || 'Veleiro';
|
|
const reason = p.reason === 'heartbeat_lost' ?
|
|
'Servidor perdeu contato com o celular do barco' :
|
|
`Saiu ${dist}m da posição de fundeio (raio ${p.radius || '?'}m)`;
|
|
|
|
return `🚨 ALERTA — ${boat}
|
|
|
|
${reason}.
|
|
|
|
📍 Posição: ${lat}, ${lng}
|
|
${mapsUrl ? `🗺️ ${mapsUrl}\n` : ''}🕐 ${time}
|
|
|
|
Esta é uma mensagem automática do sistema de vigia de fundeio.`;
|
|
}
|
|
|
|
// ---- Main dispatcher ----
|
|
export async function dispatchAlarm(payload) {
|
|
const text = buildAlarmMessage(payload);
|
|
const subject = `🚨 ${payload.boat || 'Veleiro'} — ALERTA de fundeio`;
|
|
|
|
const tasks = [
|
|
['telegram', sendTelegram(text)],
|
|
['ntfy', sendNtfy(text, subject, 'urgent')],
|
|
['email', sendEmail(text, subject)],
|
|
['sms', sendSMS(text)],
|
|
['whatsapp', sendWhatsApp(text)],
|
|
['webhook', sendWebhook({ ...payload, message: text })]
|
|
];
|
|
|
|
const sent = [];
|
|
const failed = [];
|
|
|
|
for (const [name, promise] of tasks) {
|
|
const result = await promise;
|
|
if (result === null) continue; // not configured
|
|
if (Array.isArray(result)) {
|
|
for (const r of result) {
|
|
if (r.ok) sent.push(name);
|
|
else failed.push({ channel: name, ...r });
|
|
}
|
|
} else {
|
|
if (result.ok) sent.push(name);
|
|
else failed.push({ channel: name, ...result });
|
|
}
|
|
}
|
|
return { sent, failed };
|
|
}
|
|
|
|
// Quick test ping (no urgency)
|
|
export async function dispatchTest() {
|
|
const text = `✅ Teste — Sistema Shivao Cloud operacional.\nHora: ${new Date().toLocaleString('pt-BR')}`;
|
|
const sent = [];
|
|
const failed = [];
|
|
const r1 = await sendTelegram(text);
|
|
if (r1) r1.forEach(r => r.ok ? sent.push('telegram') : failed.push({ channel: 'telegram', ...r }));
|
|
const r2 = await sendNtfy(text, 'Teste Shivao', 'default');
|
|
if (r2) r2.ok ? sent.push('ntfy') : failed.push({ channel: 'ntfy', ...r2 });
|
|
const r3 = await sendEmail(text, 'Teste Shivao');
|
|
if (r3) r3.ok ? sent.push('email') : failed.push({ channel: 'email', ...r3 });
|
|
return { sent, failed };
|
|
}
|
|
|
|
export function listConfiguredChannels() {
|
|
const channels = [];
|
|
if (env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_IDS) channels.push('telegram');
|
|
if (env.NTFY_TOPIC) channels.push('ntfy');
|
|
if (env.SMTP_HOST && env.SMTP_TO) channels.push('email');
|
|
if (env.TWILIO_ACCOUNT_SID && env.TWILIO_SMS_TO) channels.push('sms');
|
|
if (env.TWILIO_ACCOUNT_SID && env.TWILIO_WHATSAPP_TO) channels.push('whatsapp');
|
|
if (env.WEBHOOK_URL) channels.push('webhook');
|
|
return channels;
|
|
}
|