Skip to content

Repository files, collaboration, releases and project activity.

archforge-external-status / src/notifications.jsmain
▤ src/notifications.js3891 bytes
39 lines · Select a line number for a permalink
1'use strict';
2const fs=require('node:fs'); const {spawn}=require('node:child_process');
3const {now,rows,run}=require('./database');
4const {sha}=require('./security');const crypto=require('node:crypto');
5const unsubscribeToken=(cfg,email)=>crypto.createHmac('sha256',Buffer.from(cfg.key,'hex')).update('unsubscribe:'+email.toLowerCase()).digest('base64url');
6function notifyQueue(db,cfg,subject,message){
7 const date=now();
8 if(cfg.discord)run(db,'INSERT INTO messages(channel,destination,subject,body,created_at,next_at) VALUES(?,?,?,?,?,?)','discord','webhook',subject,message,date,date);
9 if(cfg.smtpAvailable){for(const s of rows(db,'SELECT email FROM subscriptions WHERE confirmed=1')){
10 const unsub=cfg.publicUrl.replace(/\/$/,'')+'/unsubscribe/'+unsubscribeToken(cfg,s.email);
11 run(db,'INSERT INTO messages(channel,destination,subject,body,created_at,next_at) VALUES(?,?,?,?,?,?)','email',s.email,subject,message+'\n\nManage your notifications: '+unsub,date,date);
12 }}
14function discordAllowed(url){try{const u=new URL(url);return u.protocol==='https:'&&['discord.com','discordapp.com'].includes(u.hostname)&&/^\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+\/?$/.test(u.pathname)&&!u.username&&!u.password&&!u.search;}catch{return false;}}
15async function deliverDiscord(url,msg,publicUrl="https://status.archforge.nl"){if(!discordAllowed(url))throw Error('Discord webhook URL invalid');const controller=new AbortController();const t=setTimeout(()=>controller.abort(),8000);
16 try{const r=await fetch(url,{method:'POST',redirect:'error',signal:controller.signal,headers:{'content-type':'application/json','user-agent':'ArchForgeStatus/1.0'},body:JSON.stringify({username:'ArchForge Status',content:`**${msg.subject.slice(0,140)}**\n${msg.body.slice(0,1650)}\n${publicUrl}`})});if(!r.ok)throw Error('Discord HTTP '+r.status);}finally{clearTimeout(t);}
18function deliverEmail(destination,subject,body,cfg){return new Promise((resolve,reject)=>{
19 if(!cfg.smtpAvailable)return reject(Error('SMTP not configured'));
20 if(!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(destination))return reject(Error('Invalid address'));
21 const clean=s=>String(s).replace(/[\r\n\0]/g,' ').slice(0,200);
22 const message=`From: ${clean(cfg.smtpFrom)}\nTo: ${clean(destination)}\nSubject: ${clean(subject)}\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\n\n${String(body).slice(0,20000)}\n`;
23 // msmtp uses its private config file, independent from main ArchForge SMTP.
24 const child=spawn('/usr/bin/msmtp',['--file=/etc/archforge-status/msmtprc','--',destination],{stdio:['pipe','ignore','pipe'],env:{PATH:'/usr/bin:/bin'}});
25 let errors='',done=false;const timeout=setTimeout(()=>{child.kill('SIGKILL');},12000);
26 child.stderr.on('data',c=>{errors+=c.toString().slice(0,200);});
27 child.on('error',e=>{if(!done){done=true;clearTimeout(timeout);reject(e);}});
28 child.on('close',code=>{if(!done){done=true;clearTimeout(timeout);code===0?resolve():reject(Error('Mail transfer failed '+code+' '+errors.replace(/[\r\n]/g,' ').slice(0,150)));}});
29 child.stdin.on('error',()=>{});child.stdin.end(message);
30});}
31async function processQueue(db,cfg){for(const msg of rows(db,'SELECT * FROM messages WHERE delivered_at IS NULL AND next_at<=? AND attempts<8 ORDER BY id LIMIT 25',now())){
32 try{
33 if(msg.channel==='discord')await deliverDiscord(cfg.discord,{subject:msg.subject,body:msg.body},cfg.publicUrl);
34 else await deliverEmail(msg.destination,msg.subject,msg.body,cfg);
35 run(db,'UPDATE messages SET delivered_at=?, attempts=attempts+1,last_error=NULL WHERE id=?',now(),msg.id);
36 }catch(e){const attempts=msg.attempts+1;run(db,'UPDATE messages SET attempts=?, next_at=?,last_error=? WHERE id=?',attempts,now()+Math.min(21600,2**attempts*60),String(e.message).slice(0,180),msg.id);}
37}}
38module.exports={notifyQueue,deliverDiscord,deliverEmail,processQueue,discordAllowed,unsubscribeToken};