shivao-projeto/server/src/notifications.js
PontualTech / Karlão 5b02feae50 chore: initial commit + security hardening (4 runs squad shivao-melhoria)
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>
2026-04-27 13:24:08 -03:00

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, '&lt;')}</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;
}