const express = require('express'); const axios = require('axios'); const { Pool } = require('pg'); const nodemailer = require('nodemailer'); const fs = require('fs'); const path = require('path'); const app = express(); const port = process.env.PORT || 3000; app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Basic Auth app.use((req, res, next) => { // Default user 'admin' since only password was specified const user = 'admin'; const pass = 'Klaster1!'; const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':'); if (login && password && login === user && password === pass) { return next(); } res.set('WWW-Authenticate', 'Basic realm="Monitor Stron"'); res.status(401).send('Dostęp zabroniony. Wymagane logowanie.'); }); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const WEBSITES_FILE = path.join(DATA_DIR, 'websites.json'); let storageMode = process.env.DATABASE_URL ? 'db' : 'file'; const pool = storageMode === 'db' ? new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false } }) : null; let websites = []; let statuses = {}; const CHECK_INTERVAL = 900000; // 15 minutes const SMTP_HOST = process.env.SMTP_HOST || 'smtp-relay.brevo.com'; const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587', 10); const SMTP_SECURE = process.env.SMTP_SECURE === 'true' || SMTP_PORT === 465; const SMTP_TIMEOUT = parseInt(process.env.SMTP_TIMEOUT_MS || '10000', 10); const BREVO_API_TIMEOUT = parseInt(process.env.BREVO_API_TIMEOUT_MS || '10000', 10); let emailTransport = null; let emailConfigChecked = false; async function sendMailWithTimeout(transport, mailOptions, timeoutMs = SMTP_TIMEOUT) { return await Promise.race([ transport.sendMail(mailOptions), new Promise((_, reject) => setTimeout(() => reject(new Error('SMTP send timed out')), timeoutMs)) ]); } async function sendViaBrevoApi(message) { const apiKey = process.env.BREVO_API_KEY; if (!apiKey) { throw new Error('Brak BREVO_API_KEY do wysyłki przez API.'); } const recipients = (message.to || '') .split(',') .map(s => s.trim()) .filter(Boolean) .map(email => ({ email })); if (recipients.length === 0) { throw new Error('Brak odbiorców e-mail (EMAIL_TO).'); } const payload = { sender: { email: message.from }, to: recipients, subject: message.subject, textContent: message.text, htmlContent: message.html }; await axios.post('https://api.brevo.com/v3/smtp/email', payload, { headers: { 'api-key': apiKey, accept: 'application/json', 'content-type': 'application/json' }, timeout: BREVO_API_TIMEOUT }); console.log('Email sent via Brevo API'); } async function sendViaBrevoSms(text) { const apiKey = process.env.BREVO_API_KEY; const smsTo = process.env.SMS_TO; if (!apiKey || !smsTo) return; try { await axios.post('https://api.brevo.com/v3/transactionalSMS/sms', { sender: 'Monitor', // Max 11 alphanumeric characters recipient: smsTo, content: text, type: 'transactional' }, { headers: { 'api-key': apiKey, 'accept': 'application/json', 'content-type': 'application/json' }, timeout: BREVO_API_TIMEOUT }); console.log('SMS sent via Brevo API'); } catch (err) { console.error('Failed to send SMS:', err.response ? err.response.data : err.message); } } function describeBrevoError(err) { const status = err?.response?.status; const dataMsg = err?.response?.data?.message || err?.response?.data?.error || ''; const text = err?.message || String(err); return { status, detail: dataMsg || text }; } function getEmailTransport() { if (emailTransport || emailConfigChecked) return emailTransport; const emailFrom = process.env.EMAIL_FROM; const emailTo = process.env.EMAIL_TO; const smtpUser = process.env.SMTP_USER || emailFrom; const smtpPass = process.env.SMTP_PASS || process.env.BREVO_SMTP_PASS || process.env.BREVO_API_KEY; if (!emailFrom || !emailTo || !smtpUser || !smtpPass) { console.warn('Email alerts disabled: set EMAIL_FROM, EMAIL_TO, SMTP_USER, SMTP_PASS (lub BREVO_SMTP_PASS / BREVO_API_KEY)'); emailConfigChecked = true; return null; } emailConfigChecked = true; emailTransport = nodemailer.createTransport({ host: SMTP_HOST, port: SMTP_PORT, secure: SMTP_SECURE, auth: { user: smtpUser, pass: smtpPass }, tls: { rejectUnauthorized: false } }); return emailTransport; } function buildAlertEmail(statusEntry) { const detectedAt = new Date(statusEntry.lastChecked || Date.now()); const since = statusEntry.statusChangedAt ? formatDuration(detectedAt - new Date(statusEntry.statusChangedAt)) : 'N/A'; const errorText = statusEntry.error || 'Brak dodatkowych informacji'; const statusLabel = statusEntry.status || 'DOWN'; const statusCode = statusEntry.statusCode || 0; const responseTime = statusEntry.responseTime || 0; const subject = `ALERT: ${statusEntry.name} niedostępna (${statusCode || statusLabel})`; const text = [ `ALERT: ${statusEntry.name} jest niedostępna`, `URL: ${statusEntry.url}`, `Status: ${statusLabel} (kod: ${statusCode})`, `Czas odpowiedzi: ${responseTime} ms`, `Wykryto: ${detectedAt.toISOString()} (UTC)`, `Nie działa od: ${since}`, `Błąd: ${errorText}`, '', 'Monitor Stron' ].join('\n'); const html = `

Alert: ${statusEntry.name} jest niedostępna

Wykryto problem z domeną ${statusEntry.url}.

Ostatni komunikat/treść błędu (jeśli jest):
${errorText}

Podgląd strony: ${statusEntry.url}


Ta wiadomość została wygenerowana automatycznie przez Monitor Stron.

`; return { subject, text, html }; } async function sendEmail(message) { const emailFrom = process.env.EMAIL_FROM; const emailTo = process.env.EMAIL_TO; if (!emailFrom || !emailTo) { throw new Error('Brak EMAIL_FROM/EMAIL_TO do wysyłki e-mail.'); } // Prefer Brevo API if key is available if (process.env.BREVO_API_KEY) { try { await sendViaBrevoApi(message); return; } catch (err) { const info = describeBrevoError(err); // If auth error, do not fallback to SMTP (likely wrong key) — surface error early if (info.status === 401 || info.status === 403) { throw new Error(`Brevo API auth error (${info.status}): ${info.detail}`); } console.error('Brevo API send failed, falling back to SMTP if available:', info.detail || err); } } const transport = getEmailTransport(); if (!transport) { throw new Error('Brak transportu e-mail (SMTP/API).'); } await sendMailWithTimeout(transport, { from: message.from, to: message.to, subject: message.subject, text: message.text, html: message.html }); } async function sendAlertEmail(statusEntry) { const { subject, text, html } = buildAlertEmail(statusEntry); const emailFrom = process.env.EMAIL_FROM; const emailTo = process.env.EMAIL_TO; await sendEmail({ from: emailFrom, to: emailTo, subject, text, html }); console.log(`Alert email sent for ${statusEntry.url}`); } function buildStatusReportEmail() { const now = new Date(); const subject = `Raport statusu stron (${now.toISOString().slice(0, 16)} UTC)`; const summary = websites.map(site => { const st = statuses[site.url]; if (!st) { return { name: site.name, url: site.url, status: 'PENDING', statusCode: '-', responseTime: '-', since: '-', lastChecked: '-', error: '' }; } return { name: st.name, url: st.url, status: st.status, statusCode: st.statusCode || '-', responseTime: `${st.responseTime || 0} ms`, since: st.statusChangedAt ? formatDuration(now - new Date(st.statusChangedAt)) : 'N/A', lastChecked: st.lastChecked ? new Date(st.lastChecked).toISOString() : '-', error: st.error || '' }; }); const textLines = [ `Raport statusu stron - ${now.toISOString()}`, '' ]; summary.forEach(item => { textLines.push( `${item.name} (${item.url})`, `Status: ${item.status} (kod: ${item.statusCode})`, `Czas odpowiedzi: ${item.responseTime}`, `Ostatnio sprawdzono: ${item.lastChecked}`, `Nie działa od: ${item.since}`, item.error ? `Błąd: ${item.error}` : 'Błąd: brak', '' ); }); const text = textLines.join('\n'); const rows = summary.map(item => ` ${item.name} ${item.url} ${item.status} ${item.statusCode} ${item.responseTime} ${item.since} ${item.lastChecked} ${item.error || ''} `).join(''); const html = `

Raport statusu stron

Data wygenerowania: ${now.toISOString()} (UTC)

${rows}
Nazwa URL Status Kod Czas Stan od Ostatni check Błąd

Raport wygenerowany przez Monitor Stron.

`; return { subject, text, html }; } async function sendStatusReportEmail() { const emailFrom = process.env.EMAIL_FROM; const emailTo = process.env.EMAIL_TO; if (!emailFrom || !emailTo) { throw new Error('Brak konfiguracji e-mail: ustaw EMAIL_FROM i EMAIL_TO.'); } const { subject, text, html } = buildStatusReportEmail(); await sendEmail({ from: emailFrom, to: emailTo, subject, text, html }); console.log('Status report email sent'); } async function initDb() { await pool.query(` CREATE TABLE IF NOT EXISTS websites ( url TEXT PRIMARY KEY, name TEXT NOT NULL ); `); // Optionally seed from bundled file if table is empty const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM websites;'); if (rows[0].count === 0) { try { const seed = require('./websites.json'); if (Array.isArray(seed) && seed.length > 0) { const values = seed.map(item => `('${item.url.replace(/'/g, "''")}','${item.name.replace(/'/g, "''")}')`).join(','); await pool.query(`INSERT INTO websites (url, name) VALUES ${values} ON CONFLICT (url) DO NOTHING;`); console.log(`Seeded ${seed.length} websites into database`); } } catch (err) { console.warn('Seed file websites.json not loaded:', err.message); } } } async function initFileStorage() { await fs.promises.mkdir(DATA_DIR, { recursive: true }); console.log(`Using file storage at ${WEBSITES_FILE}`); // Load from persisted file or fallback to bundled seed let fileData = []; try { const raw = await fs.promises.readFile(WEBSITES_FILE, 'utf-8'); fileData = JSON.parse(raw); } catch (err) { console.log('No persisted websites file found, seeding new one.'); } if (!Array.isArray(fileData) || fileData.length === 0) { try { const seed = require('./websites.json'); if (Array.isArray(seed) && seed.length > 0) { fileData = seed; } } catch (err) { console.warn('Seed file websites.json not loaded:', err.message); } } websites = fileData; await saveWebsitesToFile(); } async function saveWebsitesToFile() { await fs.promises.writeFile(WEBSITES_FILE, JSON.stringify(websites, null, 2), 'utf-8'); } async function loadWebsites() { if (storageMode === 'db') { const { rows } = await pool.query('SELECT url, name FROM websites ORDER BY name;'); websites = rows; } else { try { const raw = await fs.promises.readFile(WEBSITES_FILE, 'utf-8'); websites = JSON.parse(raw); } catch (err) { console.warn('Could not read websites file, reinitializing storage:', err.message); await initFileStorage(); } } } async function checkWebsite(site) { try { const start = Date.now(); const response = await axios.get(site.url, { timeout: 300000 }); // 5 minutes const duration = Date.now() - start; const newStatus = response.status === 200 ? 'UP' : 'DOWN'; updateStatus(site, newStatus, response.status, duration, null); } catch (error) { const status = error.code === 'ECONNABORTED' ? 'TIMEOUT' : 'DOWN'; const statusCode = error.response ? error.response.status : 0; updateStatus(site, status, statusCode, 0, error.message); } } function updateStatus(site, newStatus, statusCode, duration, errorMsg) { const now = new Date(); const prevStatus = statuses[site.url]; let statusChangedAt = now; if (prevStatus && prevStatus.status === newStatus) { statusChangedAt = prevStatus.statusChangedAt; } statuses[site.url] = { name: site.name, url: site.url, status: newStatus, statusCode: statusCode, responseTime: duration, lastChecked: now, statusChangedAt: statusChangedAt, error: errorMsg }; const alertNeeded = newStatus !== 'UP' && (!prevStatus || prevStatus.status !== newStatus); if (alertNeeded) { sendAlertEmail(statuses[site.url]).catch(err => { console.error('Failed to send alert email:', err.message); }); // Send SMS const smsText = `ALERT: ${site.name} returns ${newStatus} (Code: ${statusCode}). URL: ${site.url}`; sendViaBrevoSms(smsText).catch(err => console.error('SMS Error:', err.message)); } } async function checkAllWebsites() { console.log('Checking websites...'); await loadWebsites(); await Promise.all(websites.map(site => checkWebsite(site))); console.log('Check complete.'); } // API endpoint to add a new website app.post('/api/sites', async (req, res) => { const { url } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } // Basic URL validation let validUrl = url; if (!validUrl.startsWith('http')) { validUrl = 'https://' + validUrl; } try { // Check for duplicates in storage if (websites.some(site => site.url === validUrl)) { return res.status(400).json({ error: 'Website already exists' }); } // Extract name from URL if not provided let name = validUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); const newSite = { url: validUrl, name }; if (storageMode === 'db') { await pool.query('INSERT INTO websites (url, name) VALUES ($1, $2);', [newSite.url, newSite.name]); await loadWebsites(); } else { websites.push(newSite); await saveWebsitesToFile(); } // Initial check for the new site checkWebsite(newSite); res.json({ success: true, site: newSite }); } catch (err) { console.error('Error saving website:', err); res.status(500).json({ error: 'Failed to save website' }); } }); // API endpoint to delete a website app.delete('/api/sites', async (req, res) => { const { url } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } try { let removed = false; if (storageMode === 'db') { const result = await pool.query('DELETE FROM websites WHERE url = $1;', [url]); if (result.rowCount === 0) { return res.status(404).json({ error: 'Website not found' }); } removed = true; await loadWebsites(); } else { const initialLength = websites.length; websites = websites.filter(site => site.url !== url); removed = websites.length !== initialLength; if (!removed) { return res.status(404).json({ error: 'Website not found' }); } await saveWebsitesToFile(); } // Remove status delete statuses[url]; res.json({ success: true }); } catch (err) { console.error('Error deleting website:', err); res.status(500).json({ error: 'Failed to save changes' }); } }); async function start() { try { if (storageMode === 'db') { try { await initDb(); } catch (err) { console.warn('Database connection failed, falling back to file storage:', err.message); storageMode = 'file'; } } if (storageMode === 'file') { await initFileStorage(); } else { await loadWebsites(); } await checkAllWebsites(); setInterval(checkAllWebsites, CHECK_INTERVAL); app.listen(port, () => { console.log(`Monitor running at http://localhost:${port}`); }); } catch (err) { console.error('Failed to start application:', err); process.exit(1); } } start(); function formatDuration(ms) { const seconds = Math.floor((ms / 1000) % 60); const minutes = Math.floor((ms / (1000 * 60)) % 60); const hours = Math.floor((ms / (1000 * 60 * 60)) % 24); const days = Math.floor(ms / (1000 * 60 * 60 * 24)); const parts = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes} min`); if (seconds > 0) parts.push(`${seconds} sec`); return parts.length > 0 ? parts.join(', ') : '0 sec'; } app.get('/', (req, res) => { let html = ` Monitor Stron

Monitor Statusu Stron (${websites.length})

`; // Reverse websites to show newest first, or keep order? Let's keep order from JSON. for (const site of websites) { const status = statuses[site.url]; let cardHtml = ''; if (!status) { // Pending state cardHtml = `
${site.name}
HTTP Oczekiwanie...
`; } else { const isUp = status.status === 'UP'; const iconClass = isUp ? 'fa-arrow-circle-up status-up' : 'fa-exclamation-circle status-down'; const statusLabel = isUp ? 'Up' : (status.error ? 'Down' : 'Issue'); const duration = formatDuration(new Date() - new Date(status.statusChangedAt)); cardHtml = `
${status.name}
HTTP ${statusLabel} ${duration}
${status.responseTime}ms |
`; } html += cardHtml; } html += `
`; res.send(html); });