Skip to content

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-111 'use strict';
e59b7f7cc6archadmin2026-10-112 const fs=require('node:fs'); const {spawn}=require('node:child_process');
e59b7f7cc6archadmin2026-10-113 const {now,rows,run}=require('./database');
e59b7f7cc6archadmin2026-10-114 const {sha}=require('./security');const crypto=require('node:crypto');
e59b7f7cc6archadmin2026-10-115 const unsubscribeToken=(cfg,email)=>crypto.createHmac('sha256',Buffer.from(cfg.key,'hex')).update('unsubscribe:'+email.toLowerCase()).digest('base64url');
e59b7f7cc6archadmin2026-10-116 function notifyQueue(db,cfg,subject,message){
e59b7f7cc6archadmin2026-10-117  const date=now();
e59b7f7cc6archadmin2026-10-118  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-119  if(cfg.smtpAvailable){for(const s of rows(db,'SELECT email FROM subscriptions WHERE confirmed=1')){
e59b7f7cc6archadmin2026-10-1110  const unsub=cfg.publicUrl.replace(/\/$/,'')+'/unsubscribe/'+unsubscribeToken(cfg,s.email);
e59b7f7cc6archadmin2026-10-1111  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-1112  }}
e59b7f7cc6archadmin2026-10-1113 }
e59b7f7cc6archadmin2026-10-1114 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-1115 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-1116  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-1117 }
e59b7f7cc6archadmin2026-10-1118 function deliverEmail(destination,subject,body,cfg){return new Promise((resolve,reject)=>{
e59b7f7cc6archadmin2026-10-1119  if(!cfg.smtpAvailable)return reject(Error('SMTP not configured'));
e59b7f7cc6archadmin2026-10-1120  if(!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(destination))return reject(Error('Invalid address'));
e59b7f7cc6archadmin2026-10-1121  const clean=s=>String(s).replace(/[\r\n\0]/g,' ').slice(0,200);
e59b7f7cc6archadmin2026-10-1122  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-1123  // msmtp uses its private config file, independent from main ArchForge SMTP.
e59b7f7cc6archadmin2026-10-1124  const child=spawn('/usr/bin/msmtp',['--file=/etc/archforge-status/msmtprc','--',destination],{stdio:['pipe','ignore','pipe'],env:{PATH:'/usr/bin:/bin'}});
e59b7f7cc6archadmin2026-10-1125  let errors='',done=false;const timeout=setTimeout(()=>{child.kill('SIGKILL');},12000);
e59b7f7cc6archadmin2026-10-1126  child.stderr.on('data',c=>{errors+=c.toString().slice(0,200);});
e59b7f7cc6archadmin2026-10-1127  child.on('error',e=>{if(!done){done=true;clearTimeout(timeout);reject(e);}});
e59b7f7cc6archadmin2026-10-1128  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-1129  child.stdin.on('error',()=>{});child.stdin.end(message);
e59b7f7cc6archadmin2026-10-1130 });}
e59b7f7cc6archadmin2026-10-1131 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-1132  try{
e59b7f7cc6archadmin2026-10-1133  if(msg.channel==='discord')await deliverDiscord(cfg.discord,{subject:msg.subject,body:msg.body},cfg.publicUrl);
e59b7f7cc6archadmin2026-10-1134  else await deliverEmail(msg.destination,msg.subject,msg.body,cfg);
e59b7f7cc6archadmin2026-10-1135  run(db,'UPDATE messages SET delivered_at=?, attempts=attempts+1,last_error=NULL WHERE id=?',now(),msg.id);
e59b7f7cc6archadmin2026-10-1136  }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-1137 }}
e59b7f7cc6archadmin2026-10-1138 module.exports={notifyQueue,deliverDiscord,deliverEmail,processQueue,discordAllowed,unsubscribeToken};