172 lines
6.4 KiB
JavaScript
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;
|