Repository files, collaboration, releases and project activity.
ARCHFORGE HISTORY
Blame · src/notifications.js
Branch main · the Commit author is not necessarily a verified ArchForge account.
e59b7f7cc6archadmin2026-10-11
1 'use strict';e59b7f7cc6archadmin2026-10-11
2 const fs=require('node:fs'); const {spawn}=require('node:child_process');e59b7f7cc6archadmin2026-10-11
3 const {now,rows,run}=require('./database');e59b7f7cc6archadmin2026-10-11
4 const {sha}=require('./security');const crypto=require('node:crypto');e59b7f7cc6archadmin2026-10-11
5 const unsubscribeToken=(cfg,email)=>crypto.createHmac('sha256',Buffer.from(cfg.key,'hex')).update('unsubscribe:'+email.toLowerCase()).digest('base64url');e59b7f7cc6archadmin2026-10-11
6 function notifyQueue(db,cfg,subject,message){e59b7f7cc6archadmin2026-10-11
7 const date=now();e59b7f7cc6archadmin2026-10-11
8 if(cfg.discord)run(db,'INSERT INTO messages(channel,destination,subject,body,created_at,next_at) VALUES(?,?,?,?,?,?)','discord','webhook',subject,message,date,date);e59b7f7cc6archadmin2026-10-11
9 if(cfg.smtpAvailable){for(const s of rows(db,'SELECT email FROM subscriptions WHERE confirmed=1')){e59b7f7cc6archadmin2026-10-11
10 const unsub=cfg.publicUrl.replace(/\/$/,'')+'/unsubscribe/'+unsubscribeToken(cfg,s.email);e59b7f7cc6archadmin2026-10-11
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);e59b7f7cc6archadmin2026-10-11
12 }}e59b7f7cc6archadmin2026-10-11
13 }e59b7f7cc6archadmin2026-10-11
14 function 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;}}e59b7f7cc6archadmin2026-10-11
15 async 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);e59b7f7cc6archadmin2026-10-11
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);}e59b7f7cc6archadmin2026-10-11
17 }e59b7f7cc6archadmin2026-10-11
18 function deliverEmail(destination,subject,body,cfg){return new Promise((resolve,reject)=>{e59b7f7cc6archadmin2026-10-11
19 if(!cfg.smtpAvailable)return reject(Error('SMTP not configured'));e59b7f7cc6archadmin2026-10-11
20 if(!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(destination))return reject(Error('Invalid address'));e59b7f7cc6archadmin2026-10-11
21 const clean=s=>String(s).replace(/[\r\n\0]/g,' ').slice(0,200);e59b7f7cc6archadmin2026-10-11
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`;e59b7f7cc6archadmin2026-10-11
23 // msmtp uses its private config file, independent from main ArchForge SMTP.e59b7f7cc6archadmin2026-10-11
24 const child=spawn('/usr/bin/msmtp',['--file=/etc/archforge-status/msmtprc','--',destination],{stdio:['pipe','ignore','pipe'],env:{PATH:'/usr/bin:/bin'}});e59b7f7cc6archadmin2026-10-11
25 let errors='',done=false;const timeout=setTimeout(()=>{child.kill('SIGKILL');},12000);e59b7f7cc6archadmin2026-10-11
26 child.stderr.on('data',c=>{errors+=c.toString().slice(0,200);});e59b7f7cc6archadmin2026-10-11
27 child.on('error',e=>{if(!done){done=true;clearTimeout(timeout);reject(e);}});e59b7f7cc6archadmin2026-10-11
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)));}});e59b7f7cc6archadmin2026-10-11
29 child.stdin.on('error',()=>{});child.stdin.end(message);e59b7f7cc6archadmin2026-10-11
30 });}e59b7f7cc6archadmin2026-10-11
31 async 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())){e59b7f7cc6archadmin2026-10-11
32 try{e59b7f7cc6archadmin2026-10-11
33 if(msg.channel==='discord')await deliverDiscord(cfg.discord,{subject:msg.subject,body:msg.body},cfg.publicUrl);e59b7f7cc6archadmin2026-10-11
34 else await deliverEmail(msg.destination,msg.subject,msg.body,cfg);e59b7f7cc6archadmin2026-10-11
35 run(db,'UPDATE messages SET delivered_at=?, attempts=attempts+1,last_error=NULL WHERE id=?',now(),msg.id);e59b7f7cc6archadmin2026-10-11
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);}e59b7f7cc6archadmin2026-10-11
37 }}e59b7f7cc6archadmin2026-10-11
38 module.exports={notifyQueue,deliverDiscord,deliverEmail,processQueue,discordAllowed,unsubscribeToken};