Files
apxtri/models/Alerts.js
T
2026-08-13 14:57:03 +00:00

172 lines
6.4 KiB
JavaScript

const fs = require("fs-extra");
const path = require("path");
const crypto = require("crypto");
const dayjs = require("dayjs");
const Odmdb = require("./Odmdb.js");
const Nations = require("./Nations.js");
const DATA_PATH = process.env.DATAPATH;
const TOWN = process.env.TOWN || "dev";
const log = process.env.ACTIVELOG.split(',').includes('Alerts');
/**
* Sealed read-once alerts addressed to an alias.
*
* - The payload is encrypted CLIENT-SIDE with the recipient's public key; the
* server only stores the ciphertext plus a plaintext envelope (from, type,
* link) so a user connected in another town can be directed to the emitting
* town. Emitters are restricted to druids/mayors/admins (or system).
* - Read-once delivery with grace: the first fetch marks the alert
* `delivered`; it is purged after `graceHours`. Pending alerts are purged
* after `pendingTtlHours`.
* - Alerts live in the town namespace (objects/alerts) so they are covered by
* the Merkle anchoring. Clients aggregate their towns' alerts (intra-town
* and cross-town via the `link`).
*/
class Alerts {
static _db() {
return new Odmdb("alerts", TOWN);
}
static _dir() {
return path.join(DATA_PATH, TOWN, "objects", "alerts", "itm");
}
static _canEmit(role) {
const profs = role.xprofils || [];
return profs.includes("admin") || profs.includes("mayor") || profs.includes("druids");
}
/**
* Creates an alert. The `payload` is expected to already be encrypted to the
* recipient's public key (client-side encryption).
* @param {Object} data - { to, type, payload, link, graceHours, pendingTtlHours }
* @param {Object} role - Session role (druid/mayor/admin/system only)
*/
static async create(data, role) {
try {
const to = data && data.to;
const payload = data && data.payload;
if (!to || !payload) {
return { status: 400, ref: "Alerts", msg: "invalid_alert", data: { to: to || '' } };
}
if (!this._canEmit(role)) {
return { status: 403, ref: "Alerts", msg: "alert_emit_forbidden", data: {} };
}
const config = await Nations.getConfig();
const id = crypto.randomUUID();
const item = {
id,
from: role.xalias,
to,
type: (data && data.type) || "general",
link: (data && data.link) || "",
payload,
status: "pending",
createdAt: new Date().toISOString(),
pendingTtlHours: data && data.pendingTtlHours ? parseFloat(data.pendingTtlHours) : (config.alertPendingHours || 72),
graceHours: data && data.graceHours ? parseFloat(data.graceHours) : (config.alertGraceHours || 24)
};
const r = await this._db().create(item, { xalias: role.xalias, xprofils: role.xprofils });
if (r.status !== 200) return r;
if (log) console.log(`[Alerts] ${role.xalias} -> ${to} (${item.type})`);
return { status: 200, ref: "Alerts", msg: "alert_created", data: { id } };
} catch (e) {
return { status: 500, ref: "Alerts", msg: "alert_error", error: e.message };
}
}
/**
* Lists the pending alerts of an alias (read-once): each pending alert is
* returned once and immediately marked `delivered`. The caller must be the
* recipient (enforced by the route).
* @param {string} alias - Recipient alias
*/
static async listPending(alias) {
try {
const dir = this._dir();
const alerts = [];
if (await fs.pathExists(dir)) {
const files = await fs.readdir(dir);
for (const f of files) {
if (!f.endsWith(".json")) continue;
try {
const item = await fs.readJson(path.join(dir, f));
if (item.to !== alias || item.status !== "pending") continue;
item.status = "delivered";
item.deliveredAt = new Date().toISOString();
await fs.outputJson(path.join(dir, f), item, { spaces: 2 });
alerts.push({
id: item.id,
from: item.from,
to: item.to,
type: item.type || "general",
link: item.link || "",
payload: item.payload || "",
createdAt: item.createdAt,
deliveredAt: item.deliveredAt,
graceHours: item.graceHours
});
} catch {}
}
}
alerts.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
return { status: 200, ref: "Alerts", msg: "alerts_fetched", data: { alerts } };
} catch (e) {
return { status: 500, ref: "Alerts", msg: "alert_error", error: e.message };
}
}
/**
* Purges delivered alerts after their grace period and pending alerts after
* their TTL. Runs hourly via startPurge(); also callable manually.
*/
static async purge() {
try {
const config = await Nations.getConfig();
const grace = config.alertGraceHours || 24;
const pendingTtl = config.alertPendingHours || 72;
const dir = this._dir();
let removed = 0;
if (await fs.pathExists(dir)) {
const files = await fs.readdir(dir);
for (const f of files) {
if (!f.endsWith(".json")) continue;
try {
const item = await fs.readJson(path.join(dir, f));
if (item.status === "delivered") {
const since = item.deliveredAt ? dayjs().diff(dayjs(item.deliveredAt), "hour") : dayjs().diff(dayjs(item.createdAt), "hour");
if (since >= (item.graceHours || grace)) {
await fs.remove(path.join(dir, f));
removed++;
}
} else if (item.status === "pending" && dayjs().diff(dayjs(item.createdAt), "hour") >= (item.pendingTtlHours || pendingTtl)) {
await fs.remove(path.join(dir, f));
removed++;
}
} catch {}
}
}
if (removed && log) console.log(`[Alerts] Purged ${removed} alert(s)`);
return { status: 200, ref: "Alerts", msg: "alerts_purged", data: { removed } };
} catch (e) {
return { status: 500, ref: "Alerts", msg: "alert_error", error: e.message };
}
}
/**
* Starts the hourly purge loop (unref'd so it never keeps the process alive).
*/
static startPurge() {
if (this._purgeTimer) return;
this._purgeTimer = setInterval(() => {
this.purge().catch((e) => console.error(`[Alerts] Purge error: ${e.message}`));
}, 60 * 60 * 1000);
if (this._purgeTimer.unref) this._purgeTimer.unref();
this.purge().catch(() => {});
}
}
module.exports = Alerts;