mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: unify alerts into one plugin, channels, rules and inbox
This commit is contained in:
1 parent
fbe736bc20
commit
7c02daccf4
129 files changed
+20957
-2687
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
ANNOUNCEMENT_SOURCE,
|
||||
type AlertItem,
|
||||
type Severity,
|
||||
} from "../types.js";
|
||||
import type { AlertsRepository } from "./repository.js";
|
||||
|
||||
export const FEED_URL =
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Docs/main/termix-alerts.json";
|
||||
export const REFRESH_MS = 30 * 60 * 1000;
|
||||
export const ANNOUNCEMENT_CATEGORY = "termix.announcement";
|
||||
|
||||
/** One entry of termix-alerts.json, as the Docs repo publishes it. */
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt?: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
export function announcementSeverity(entry: Announcement): Severity {
|
||||
if (entry.priority === "critical" || entry.type === "error") {
|
||||
return "critical";
|
||||
}
|
||||
if (entry.type === "warning" || entry.priority === "high") return "warning";
|
||||
if (entry.type === "success") return "success";
|
||||
return "info";
|
||||
}
|
||||
|
||||
export function parseFeed(raw: unknown, now = Date.now()): Announcement[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((entry): entry is Announcement => {
|
||||
if (!entry || typeof entry !== "object") return false;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
if (typeof candidate.id !== "string" || !candidate.id) return false;
|
||||
if (typeof candidate.title !== "string") return false;
|
||||
if (typeof candidate.expiresAt === "string") {
|
||||
const expires = Date.parse(candidate.expiresAt);
|
||||
if (Number.isFinite(expires) && expires <= now) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const dedupeKey = (id: string) => `announcement:${id}`;
|
||||
|
||||
/**
|
||||
* Termix announcements from the Docs repo, brought into each user's inbox
|
||||
* the first time they look after one is published. A user who deletes one
|
||||
* never gets it back.
|
||||
*/
|
||||
export function createAnnouncements(
|
||||
ctx: PluginContext,
|
||||
repository: AlertsRepository,
|
||||
) {
|
||||
let feed: Announcement[] = [];
|
||||
let fetchedAt = 0;
|
||||
let refreshing: Promise<void> | null = null;
|
||||
const syncing = new Map<string, Promise<AlertItem[]>>();
|
||||
|
||||
async function enabled(): Promise<boolean> {
|
||||
return (await ctx.settings.get<boolean>("announcements")) !== false;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (refreshing) return refreshing;
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const response = await ctx.fetch(FEED_URL, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
feed = parseFeed(await response.json());
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Could not fetch Termix announcements: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
fetchedAt = Date.now();
|
||||
refreshing = null;
|
||||
}
|
||||
})();
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
async function current(): Promise<Announcement[]> {
|
||||
if (!(await enabled())) return [];
|
||||
if (Date.now() - fetchedAt > REFRESH_MS) await refresh();
|
||||
return parseFeed(feed);
|
||||
}
|
||||
|
||||
async function syncUser(userId: string): Promise<AlertItem[]> {
|
||||
const added: AlertItem[] = [];
|
||||
const active = await current();
|
||||
if (active.length === 0) return added;
|
||||
const dismissed = await repository.dismissedIds(userId);
|
||||
for (const entry of active) {
|
||||
if (dismissed.has(entry.id)) continue;
|
||||
if (await repository.hasItemWithKey(userId, dedupeKey(entry.id))) {
|
||||
continue;
|
||||
}
|
||||
added.push(
|
||||
await repository.insertItem({
|
||||
userId,
|
||||
source: ANNOUNCEMENT_SOURCE,
|
||||
category: ANNOUNCEMENT_CATEGORY,
|
||||
severity: announcementSeverity(entry),
|
||||
title: entry.title.slice(0, 200),
|
||||
body: typeof entry.message === "string" ? entry.message : null,
|
||||
link:
|
||||
typeof entry.actionUrl === "string" &&
|
||||
/^https?:\/\//i.test(entry.actionUrl)
|
||||
? { url: entry.actionUrl }
|
||||
: null,
|
||||
context: {
|
||||
announcementId: entry.id,
|
||||
...(entry.actionText ? { actionText: entry.actionText } : {}),
|
||||
},
|
||||
dedupeKey: dedupeKey(entry.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
|
||||
/** Adds any announcement the user has not seen yet. Returns what was added. */
|
||||
sync(userId: string): Promise<AlertItem[]> {
|
||||
const running = syncing.get(userId);
|
||||
if (running) return running;
|
||||
const run = syncUser(userId)
|
||||
.catch((error) => {
|
||||
ctx.log.warn(
|
||||
`Could not add announcements to the inbox: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [] as AlertItem[];
|
||||
})
|
||||
.finally(() => syncing.delete(userId));
|
||||
syncing.set(userId, run);
|
||||
return run;
|
||||
},
|
||||
|
||||
/** Remembers that the user removed an announcement from their inbox. */
|
||||
async forget(userId: string, item: AlertItem): Promise<void> {
|
||||
const id = item.context?.announcementId;
|
||||
if (item.source === ANNOUNCEMENT_SOURCE && typeof id === "string") {
|
||||
await repository.dismiss(userId, id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Announcements = ReturnType<typeof createAnnouncements>;
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
PluginNotification,
|
||||
PluginNotifyHub,
|
||||
PluginNotifyResult,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
isSeverity,
|
||||
matchesCategory,
|
||||
severityRank,
|
||||
type AlertLink,
|
||||
type DeliveryResult,
|
||||
} from "../types.js";
|
||||
import type { AlertsRepository, DeliverableChannel } from "./repository.js";
|
||||
import {
|
||||
sendToChannel,
|
||||
type OutgoingAlert,
|
||||
type SenderDeps,
|
||||
} from "./senders.js";
|
||||
import type { AlertStream } from "./stream.js";
|
||||
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_BODY = 4000;
|
||||
const MAX_CONTEXT = 8000;
|
||||
const TAB_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
|
||||
export interface HubDeps {
|
||||
repository: AlertsRepository;
|
||||
stream: AlertStream;
|
||||
senders: SenderDeps;
|
||||
}
|
||||
|
||||
function clip(value: unknown, max: number): string {
|
||||
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
||||
}
|
||||
|
||||
export function cleanLink(link: unknown): AlertLink | null {
|
||||
if (!link || typeof link !== "object") return null;
|
||||
const { tab, url } = link as Record<string, unknown>;
|
||||
const clean: AlertLink = {};
|
||||
if (typeof tab === "string" && TAB_ID.test(tab)) clean.tab = tab;
|
||||
if (typeof url === "string" && /^https?:\/\/\S+$/i.test(url.trim())) {
|
||||
clean.url = url.trim().slice(0, 2000);
|
||||
}
|
||||
return clean.tab || clean.url ? clean : null;
|
||||
}
|
||||
|
||||
function cleanContext(context: unknown): Record<string, unknown> | null {
|
||||
if (!context || typeof context !== "object" || Array.isArray(context)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(context);
|
||||
return text.length <= MAX_CONTEXT
|
||||
? (JSON.parse(text) as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends to each channel and reports how each one went. */
|
||||
export async function deliverToChannels(
|
||||
targets: DeliverableChannel[],
|
||||
alert: OutgoingAlert,
|
||||
senders: SenderDeps,
|
||||
): Promise<DeliveryResult[]> {
|
||||
const results: DeliveryResult[] = [];
|
||||
for (const channel of targets) {
|
||||
if (!channel.config) {
|
||||
results.push({
|
||||
channelId: channel.id,
|
||||
name: channel.name,
|
||||
ok: false,
|
||||
error: "Channel needs to be saved again before it can send",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await sendToChannel(
|
||||
{ type: channel.type, config: channel.config },
|
||||
alert,
|
||||
senders,
|
||||
);
|
||||
results.push({ channelId: channel.id, name: channel.name, ok: true });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
channelId: channel.id,
|
||||
name: channel.name,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function createHub(deps: HubDeps): PluginNotifyHub {
|
||||
const { repository, stream, senders } = deps;
|
||||
|
||||
async function deliverOne(
|
||||
userId: string,
|
||||
source: string,
|
||||
notification: PluginNotification,
|
||||
result: PluginNotifyResult,
|
||||
): Promise<void> {
|
||||
const severity = isSeverity(notification.severity)
|
||||
? notification.severity
|
||||
: "warning";
|
||||
const category = clip(notification.category, 128) || source;
|
||||
const dedupeKey = clip(notification.dedupeKey, 255) || null;
|
||||
if (dedupeKey && (await repository.hasUnreadWithKey(userId, dedupeKey))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = clip(notification.title, MAX_TITLE) || category;
|
||||
const body = clip(notification.body, MAX_BODY);
|
||||
const link = cleanLink(notification.link);
|
||||
const context = cleanContext(notification.context);
|
||||
const item = await repository.insertItem({
|
||||
userId,
|
||||
source,
|
||||
category,
|
||||
severity,
|
||||
title,
|
||||
body: body || null,
|
||||
link,
|
||||
context,
|
||||
dedupeKey,
|
||||
});
|
||||
result.recipients++;
|
||||
stream.publish(userId, "item", item);
|
||||
stream.publish(userId, "unread", {
|
||||
count: await repository.unreadCount(userId),
|
||||
});
|
||||
|
||||
const wanted = new Set<number>(
|
||||
(notification.channelIds ?? []).filter(Number.isInteger),
|
||||
);
|
||||
for (const rule of await repository.listRules(userId)) {
|
||||
if (!rule.enabled) continue;
|
||||
if (severityRank(severity) < severityRank(rule.minSeverity)) continue;
|
||||
if (!matchesCategory(rule.match, category)) continue;
|
||||
for (const id of rule.channelIds) wanted.add(id);
|
||||
}
|
||||
if (wanted.size === 0) return;
|
||||
|
||||
const targets = (await repository.deliverableChannels(userId)).filter(
|
||||
(channel) => channel.enabled && wanted.has(channel.id),
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const deliveries = await deliverToChannels(
|
||||
targets,
|
||||
{ title, body, severity, category, source, link, context },
|
||||
senders,
|
||||
);
|
||||
for (const delivery of deliveries) {
|
||||
if (delivery.ok) result.delivered++;
|
||||
else {
|
||||
result.failures.push({
|
||||
channelId: delivery.channelId,
|
||||
name: delivery.name,
|
||||
error: delivery.error ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
await repository.setDeliveries(item.id, deliveries);
|
||||
stream.publish(userId, "item", { ...item, deliveries });
|
||||
}
|
||||
|
||||
return {
|
||||
async deliver({ source, recipients, notification }) {
|
||||
const result: PluginNotifyResult = {
|
||||
recipients: 0,
|
||||
delivered: 0,
|
||||
failures: [],
|
||||
};
|
||||
for (const userId of new Set(recipients)) {
|
||||
await deliverOne(userId, source, notification, result);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async channels(userId) {
|
||||
return (await repository.listChannels(userId)).map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
enabled: channel.enabled,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createAnnouncements, REFRESH_MS } from "./announcements.js";
|
||||
import { createHub } from "./hub.js";
|
||||
import { readSmtp, sendMail } from "./mail.js";
|
||||
import { createAlertsRepository } from "./repository.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import type { SenderDeps } from "./senders.js";
|
||||
import { createAlertStream } from "./stream.js";
|
||||
|
||||
/** The admin allowlist of private hosts a channel may reach. */
|
||||
const PRIVATE_ALLOWLIST_KEY = "notification_private_endpoint_allowlist";
|
||||
const PRUNE_MS = 6 * 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function parseAllowlist(raw: unknown): string[] {
|
||||
if (typeof raw !== "string" || !raw) return [];
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const repository = await createAlertsRepository(ctx.db, ctx.secrets);
|
||||
const stream = createAlertStream();
|
||||
ctx.disposables.add(() => stream.closeAll());
|
||||
|
||||
const senders: SenderDeps = {
|
||||
fetch: ctx.fetch,
|
||||
privateAllowlist: async () =>
|
||||
parseAllowlist(await ctx.settings.readCore(PRIVATE_ALLOWLIST_KEY)),
|
||||
smtp: () => readSmtp(ctx),
|
||||
sendMail,
|
||||
};
|
||||
const announcements = createAnnouncements(ctx, repository);
|
||||
|
||||
ctx.notify.serve(createHub({ repository, stream, senders }));
|
||||
|
||||
registerRoutes(ctx.http.router<Router>(), {
|
||||
ctx,
|
||||
repository,
|
||||
stream,
|
||||
senders,
|
||||
announcements,
|
||||
});
|
||||
|
||||
ctx.events.on("user.data_wiped", async (payload) => {
|
||||
const userId = (payload as { userId?: string } | undefined)?.userId;
|
||||
if (userId) await repository.wipeUser(userId);
|
||||
});
|
||||
|
||||
const prune = async () => {
|
||||
const days = Number(await ctx.settings.get("retentionDays"));
|
||||
const keep = Number.isFinite(days) && days >= 1 ? days : 90;
|
||||
await repository.pruneItems(
|
||||
new Date(Date.now() - keep * DAY_MS).toISOString(),
|
||||
);
|
||||
};
|
||||
ctx.schedule.after(60_000, prune);
|
||||
ctx.schedule.every(PRUNE_MS, prune);
|
||||
|
||||
// Users with the app open get a new announcement without reloading.
|
||||
ctx.schedule.every(REFRESH_MS, async () => {
|
||||
await announcements.refresh();
|
||||
for (const userId of stream.users()) {
|
||||
const added = await announcements.sync(userId);
|
||||
if (added.length === 0) continue;
|
||||
for (const item of added) stream.publish(userId, "item", item);
|
||||
stream.publish(userId, "unread", {
|
||||
count: await repository.unreadCount(userId),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,46 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { SendMail, SmtpSettings } from "./senders.js";
|
||||
|
||||
const str = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
/** The admin's SMTP server, or null while it is not set up. */
|
||||
export async function readSmtp(
|
||||
ctx: PluginContext,
|
||||
): Promise<SmtpSettings | null> {
|
||||
const all = await ctx.settings.getAll("admin");
|
||||
const host = str(all.smtpHost);
|
||||
const from = str(all.smtpFrom);
|
||||
if (!host || !from) return null;
|
||||
const port = Number(all.smtpPort);
|
||||
return {
|
||||
host,
|
||||
port: Number.isInteger(port) && port > 0 ? port : 587,
|
||||
secure: all.smtpSecure === true,
|
||||
user: str(all.smtpUser),
|
||||
password: typeof all.smtpPassword === "string" ? all.smtpPassword : "",
|
||||
from,
|
||||
};
|
||||
}
|
||||
|
||||
export const sendMail: SendMail = async (smtp, message) => {
|
||||
const transport = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: smtp.user ? { user: smtp.user, pass: smtp.password } : undefined,
|
||||
connectionTimeout: 15_000,
|
||||
greetingTimeout: 15_000,
|
||||
socketTimeout: 30_000,
|
||||
});
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: smtp.from,
|
||||
to: message.to.join(", "),
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
});
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,552 @@
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import type { PluginDatabase, PluginSecrets } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
CHANNEL_TYPES,
|
||||
isSeverity,
|
||||
type AlertItem,
|
||||
type AlertLink,
|
||||
type AlertRule,
|
||||
type ChannelDetail,
|
||||
type ChannelSummary,
|
||||
type ChannelType,
|
||||
type DeliveryResult,
|
||||
type Severity,
|
||||
} from "../types.js";
|
||||
import {
|
||||
channels as channelsDef,
|
||||
dismissed as dismissedDef,
|
||||
items as itemsDef,
|
||||
rules as rulesDef,
|
||||
} from "./tables.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Tables come from ctx.db.define, which the SDK hands back untyped, and the
|
||||
// drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
type Row = Record<string, any>;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface NewItem {
|
||||
userId: string;
|
||||
source: string;
|
||||
category: string;
|
||||
severity: Severity;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
link?: AlertLink | null;
|
||||
context?: Record<string, unknown> | null;
|
||||
dedupeKey?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ItemFilter {
|
||||
unread?: boolean;
|
||||
source?: string;
|
||||
severity?: Severity;
|
||||
limit?: number;
|
||||
beforeId?: number;
|
||||
}
|
||||
|
||||
/** A channel with its config opened, for delivery. */
|
||||
export interface DeliverableChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type AlertsRepository = Awaited<
|
||||
ReturnType<typeof createAlertsRepository>
|
||||
>;
|
||||
|
||||
type Sealer = Pick<PluginSecrets, "seal" | "unseal">;
|
||||
|
||||
function parseJson<T>(value: unknown): T | null {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asChannelType(value: unknown): ChannelType {
|
||||
return (CHANNEL_TYPES as readonly string[]).includes(String(value))
|
||||
? (value as ChannelType)
|
||||
: "webhook";
|
||||
}
|
||||
|
||||
function toItem(row: Row): AlertItem {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
source: row.source,
|
||||
category: row.category,
|
||||
severity: isSeverity(row.severity) ? row.severity : "info",
|
||||
title: row.title,
|
||||
body: row.body ?? null,
|
||||
link: parseJson<AlertLink>(row.link),
|
||||
context: parseJson<Record<string, unknown>>(row.context),
|
||||
deliveries: parseJson<DeliveryResult[]>(row.deliveries),
|
||||
readAt: row.readAt ?? null,
|
||||
createdAt: String(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toRule(row: Row): AlertRule {
|
||||
const channelIds = parseJson<unknown[]>(row.channelIds) ?? [];
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
match: row.pattern,
|
||||
minSeverity: isSeverity(row.minSeverity) ? row.minSeverity : "warning",
|
||||
channelIds: channelIds.map(Number).filter(Number.isInteger),
|
||||
enabled: !!row.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAlertsRepository(
|
||||
db: PluginDatabase,
|
||||
secrets: Sealer,
|
||||
) {
|
||||
const channels: Table = await db.define(channelsDef);
|
||||
const items: Table = await db.define(itemsDef);
|
||||
const rules: Table = await db.define(rulesDef);
|
||||
const dismissed: Table = await db.define(dismissedDef);
|
||||
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
/** Inserts one row and returns its id. MySQL has no RETURNING. */
|
||||
async function insertId(
|
||||
table: Table,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
const drizzle = await client();
|
||||
if (db.dialect === "mysql") {
|
||||
const result = await drizzle.insert(table).values(values);
|
||||
const header = Array.isArray(result) ? result[0] : result;
|
||||
return Number(header?.insertId);
|
||||
}
|
||||
const rows = await drizzle
|
||||
.insert(table)
|
||||
.values(values)
|
||||
.returning({ id: table.id });
|
||||
return Number(rows[0].id);
|
||||
}
|
||||
|
||||
/** Null when the config was never sealed here, i.e. still waiting on its upgrade. */
|
||||
async function openConfig(
|
||||
stored: string | null,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (!stored) return null;
|
||||
const plain = await secrets.unseal(stored);
|
||||
return plain ? parseJson<Record<string, unknown>>(plain) : null;
|
||||
}
|
||||
|
||||
async function summarize(row: Row): Promise<ChannelSummary> {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
type: asChannelType(row.type),
|
||||
enabled: !!row.enabled,
|
||||
createdAt: String(row.createdAt),
|
||||
usable: (await openConfig(row.config)) !== null,
|
||||
};
|
||||
}
|
||||
|
||||
async function channelRows(userId: string): Promise<Row[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(channels)
|
||||
.where(eq(channels.userId, userId))
|
||||
.orderBy(channels.id);
|
||||
}
|
||||
|
||||
async function findChannelRow(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<Row | null> {
|
||||
const drizzle = await client();
|
||||
const [row] = await drizzle
|
||||
.select()
|
||||
.from(channels)
|
||||
.where(and(eq(channels.id, id), eq(channels.userId, userId)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
const repository = {
|
||||
async listChannels(userId: string): Promise<ChannelSummary[]> {
|
||||
return Promise.all((await channelRows(userId)).map(summarize));
|
||||
},
|
||||
|
||||
async getChannel(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<ChannelDetail | null> {
|
||||
const row = await findChannelRow(userId, id);
|
||||
if (!row) return null;
|
||||
const config = await openConfig(row.config);
|
||||
return {
|
||||
...(await summarize(row)),
|
||||
config: config ?? {},
|
||||
};
|
||||
},
|
||||
|
||||
async deliverableChannels(userId: string): Promise<DeliverableChannel[]> {
|
||||
const rows = await channelRows(userId);
|
||||
return Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
type: asChannelType(row.type),
|
||||
enabled: !!row.enabled,
|
||||
config: await openConfig(row.config),
|
||||
})),
|
||||
);
|
||||
},
|
||||
|
||||
async createChannel(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
config: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<number> {
|
||||
const now = new Date().toISOString();
|
||||
const id = await insertId(channels, {
|
||||
userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: await secrets.seal(JSON.stringify(input.config)),
|
||||
enabled: input.enabled !== false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.persist();
|
||||
return id;
|
||||
},
|
||||
|
||||
async updateChannel(
|
||||
userId: string,
|
||||
id: number,
|
||||
patch: {
|
||||
name?: string;
|
||||
type?: ChannelType;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!(await findChannelRow(userId, id))) return false;
|
||||
const values: Record<string, unknown> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (patch.name !== undefined) values.name = patch.name;
|
||||
if (patch.type !== undefined) values.type = patch.type;
|
||||
if (patch.enabled !== undefined) values.enabled = patch.enabled;
|
||||
if (patch.config !== undefined) {
|
||||
values.config = await secrets.seal(JSON.stringify(patch.config));
|
||||
}
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(channels)
|
||||
.set(values)
|
||||
.where(and(eq(channels.id, id), eq(channels.userId, userId)));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async deleteChannel(userId: string, id: number): Promise<boolean> {
|
||||
if (!(await findChannelRow(userId, id))) return false;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(channels)
|
||||
.where(and(eq(channels.id, id), eq(channels.userId, userId)));
|
||||
// Rules keep working with the channels they still name.
|
||||
for (const rule of await repository.listRules(userId)) {
|
||||
if (!rule.channelIds.includes(id)) continue;
|
||||
await drizzle
|
||||
.update(rules)
|
||||
.set({
|
||||
channelIds: JSON.stringify(
|
||||
rule.channelIds.filter((channelId) => channelId !== id),
|
||||
),
|
||||
})
|
||||
.where(eq(rules.id, rule.id));
|
||||
}
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async insertItem(item: NewItem): Promise<AlertItem> {
|
||||
const createdAt = item.createdAt ?? new Date().toISOString();
|
||||
const id = await insertId(items, {
|
||||
userId: item.userId,
|
||||
source: item.source,
|
||||
category: item.category,
|
||||
severity: item.severity,
|
||||
title: item.title,
|
||||
body: item.body ?? null,
|
||||
link: item.link ? JSON.stringify(item.link) : null,
|
||||
context: item.context ? JSON.stringify(item.context) : null,
|
||||
dedupeKey: item.dedupeKey ?? null,
|
||||
createdAt,
|
||||
});
|
||||
await db.persist();
|
||||
return toItem({
|
||||
id,
|
||||
...item,
|
||||
link: item.link ? JSON.stringify(item.link) : null,
|
||||
context: item.context ? JSON.stringify(item.context) : null,
|
||||
deliveries: null,
|
||||
readAt: null,
|
||||
createdAt,
|
||||
});
|
||||
},
|
||||
|
||||
async setDeliveries(id: number, results: DeliveryResult[]): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(items)
|
||||
.set({ deliveries: JSON.stringify(results) })
|
||||
.where(eq(items.id, id));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async hasUnreadWithKey(userId: string, key: string): Promise<boolean> {
|
||||
const drizzle = await client();
|
||||
const rows: Row[] = await drizzle
|
||||
.select({ id: items.id })
|
||||
.from(items)
|
||||
.where(
|
||||
and(
|
||||
eq(items.userId, userId),
|
||||
eq(items.dedupeKey, key),
|
||||
isNull(items.readAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async hasItemWithKey(userId: string, key: string): Promise<boolean> {
|
||||
const drizzle = await client();
|
||||
const rows: Row[] = await drizzle
|
||||
.select({ id: items.id })
|
||||
.from(items)
|
||||
.where(and(eq(items.userId, userId), eq(items.dedupeKey, key)))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
},
|
||||
|
||||
async listItems(
|
||||
userId: string,
|
||||
filter: ItemFilter = {},
|
||||
): Promise<AlertItem[]> {
|
||||
const conditions = [eq(items.userId, userId)];
|
||||
if (filter.unread) conditions.push(isNull(items.readAt));
|
||||
if (filter.source) conditions.push(eq(items.source, filter.source));
|
||||
if (filter.severity) conditions.push(eq(items.severity, filter.severity));
|
||||
if (filter.beforeId) conditions.push(lt(items.id, filter.beforeId));
|
||||
const drizzle = await client();
|
||||
const rows: Row[] = await drizzle
|
||||
.select()
|
||||
.from(items)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(items.id))
|
||||
.limit(Math.min(Math.max(filter.limit ?? 50, 1), 200));
|
||||
return rows.map(toItem);
|
||||
},
|
||||
|
||||
async getItem(userId: string, id: number): Promise<AlertItem | null> {
|
||||
const drizzle = await client();
|
||||
const [row] = await drizzle
|
||||
.select()
|
||||
.from(items)
|
||||
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||
return row ? toItem(row) : null;
|
||||
},
|
||||
|
||||
async unreadCount(userId: string): Promise<number> {
|
||||
const drizzle = await client();
|
||||
const [row] = await drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(items)
|
||||
.where(and(eq(items.userId, userId), isNull(items.readAt)));
|
||||
return Number(row?.count ?? 0);
|
||||
},
|
||||
|
||||
async setRead(
|
||||
userId: string,
|
||||
ids: number[] | "all",
|
||||
read: boolean,
|
||||
): Promise<void> {
|
||||
const conditions = [eq(items.userId, userId)];
|
||||
if (ids !== "all") {
|
||||
if (ids.length === 0) return;
|
||||
conditions.push(inArray(items.id, ids));
|
||||
}
|
||||
conditions.push(read ? isNull(items.readAt) : isNotNull(items.readAt));
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(items)
|
||||
.set({ readAt: read ? new Date().toISOString() : null })
|
||||
.where(and(...conditions));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteItem(userId: string, id: number): Promise<AlertItem | null> {
|
||||
const item = await repository.getItem(userId, id);
|
||||
if (!item) return null;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(items)
|
||||
.where(and(eq(items.id, id), eq(items.userId, userId)));
|
||||
await db.persist();
|
||||
return item;
|
||||
},
|
||||
|
||||
/** Deletes the user's read alerts, or every alert. Returns what went. */
|
||||
async clearItems(userId: string, onlyRead: boolean): Promise<AlertItem[]> {
|
||||
const conditions = [eq(items.userId, userId)];
|
||||
if (onlyRead) conditions.push(isNotNull(items.readAt));
|
||||
const drizzle = await client();
|
||||
const rows: Row[] = await drizzle
|
||||
.select()
|
||||
.from(items)
|
||||
.where(and(...conditions));
|
||||
if (rows.length === 0) return [];
|
||||
await drizzle.delete(items).where(and(...conditions));
|
||||
await db.persist();
|
||||
return rows.map(toItem);
|
||||
},
|
||||
|
||||
async pruneItems(olderThan: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(items).where(lt(items.createdAt, olderThan));
|
||||
await db.persist({ lazy: true });
|
||||
},
|
||||
|
||||
/** Every source and category the user has alerts from, for the rule editor. */
|
||||
async listCategories(
|
||||
userId: string,
|
||||
): Promise<Array<{ source: string; category: string }>> {
|
||||
const drizzle = await client();
|
||||
const rows: Row[] = await drizzle
|
||||
.selectDistinct({ source: items.source, category: items.category })
|
||||
.from(items)
|
||||
.where(eq(items.userId, userId));
|
||||
return rows
|
||||
.map((row) => ({ source: row.source, category: row.category }))
|
||||
.sort((a, b) => a.category.localeCompare(b.category));
|
||||
},
|
||||
|
||||
async listRules(userId: string): Promise<AlertRule[]> {
|
||||
const drizzle = await client();
|
||||
const rows: Row[] = await drizzle
|
||||
.select()
|
||||
.from(rules)
|
||||
.where(eq(rules.userId, userId))
|
||||
.orderBy(rules.id);
|
||||
return rows.map(toRule);
|
||||
},
|
||||
|
||||
async createRule(
|
||||
userId: string,
|
||||
input: Omit<AlertRule, "id">,
|
||||
): Promise<number> {
|
||||
const id = await insertId(rules, {
|
||||
userId,
|
||||
name: input.name,
|
||||
pattern: input.match,
|
||||
minSeverity: input.minSeverity,
|
||||
channelIds: JSON.stringify(input.channelIds),
|
||||
enabled: input.enabled,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
await db.persist();
|
||||
return id;
|
||||
},
|
||||
|
||||
async updateRule(
|
||||
userId: string,
|
||||
id: number,
|
||||
input: Omit<AlertRule, "id">,
|
||||
): Promise<boolean> {
|
||||
const drizzle = await client();
|
||||
const [row] = await drizzle
|
||||
.select({ id: rules.id })
|
||||
.from(rules)
|
||||
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
|
||||
if (!row) return false;
|
||||
await drizzle
|
||||
.update(rules)
|
||||
.set({
|
||||
name: input.name,
|
||||
pattern: input.match,
|
||||
minSeverity: input.minSeverity,
|
||||
channelIds: JSON.stringify(input.channelIds),
|
||||
enabled: input.enabled,
|
||||
})
|
||||
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async deleteRule(userId: string, id: number): Promise<boolean> {
|
||||
const drizzle = await client();
|
||||
const [row] = await drizzle
|
||||
.select({ id: rules.id })
|
||||
.from(rules)
|
||||
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
|
||||
if (!row) return false;
|
||||
await drizzle
|
||||
.delete(rules)
|
||||
.where(and(eq(rules.id, id), eq(rules.userId, userId)));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
|
||||
async dismissedIds(userId: string): Promise<Set<string>> {
|
||||
const drizzle = await client();
|
||||
const rows: Row[] = await drizzle
|
||||
.select({ alertId: dismissed.alertId })
|
||||
.from(dismissed)
|
||||
.where(eq(dismissed.userId, userId));
|
||||
return new Set(rows.map((row) => String(row.alertId)));
|
||||
},
|
||||
|
||||
async dismiss(userId: string, alertId: string): Promise<void> {
|
||||
if ((await repository.dismissedIds(userId)).has(alertId)) return;
|
||||
const drizzle = await client();
|
||||
await drizzle.insert(dismissed).values({
|
||||
userId,
|
||||
alertId,
|
||||
dismissedAt: new Date().toISOString(),
|
||||
});
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
/** A user's data was wiped: nothing of theirs stays. */
|
||||
async wipeUser(userId: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
for (const table of [items, rules, channels, dismissed]) {
|
||||
await drizzle.delete(table).where(eq(table.userId, userId));
|
||||
}
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
return repository;
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
CHANNEL_TYPES,
|
||||
isSeverity,
|
||||
type AlertRule,
|
||||
type ChannelType,
|
||||
} from "../types.js";
|
||||
import type { Announcements } from "./announcements.js";
|
||||
import { deliverToChannels } from "./hub.js";
|
||||
import type { AlertsRepository } from "./repository.js";
|
||||
import { validateChannelConfig, type SenderDeps } from "./senders.js";
|
||||
import type { AlertStream } from "./stream.js";
|
||||
|
||||
interface RouteDeps {
|
||||
ctx: PluginContext;
|
||||
repository: AlertsRepository;
|
||||
stream: AlertStream;
|
||||
senders: SenderDeps;
|
||||
announcements: Announcements;
|
||||
}
|
||||
|
||||
function actor(ctx: PluginContext): string {
|
||||
// Core's plugin router authenticates every request and runs it as that user.
|
||||
return ctx.currentActor() as string;
|
||||
}
|
||||
|
||||
function positiveInt(value: unknown): number | null {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function isChannelType(value: unknown): value is ChannelType {
|
||||
return (CHANNEL_TYPES as readonly string[]).includes(String(value));
|
||||
}
|
||||
|
||||
function idList(value: unknown): number[] {
|
||||
return Array.isArray(value)
|
||||
? value.map(positiveInt).filter((id): id is number => id !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
const { ctx, repository, stream, senders, announcements } = deps;
|
||||
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
const publishUnread = async (userId: string) => {
|
||||
stream.publish(userId, "unread", {
|
||||
count: await repository.unreadCount(userId),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/items:
|
||||
* get:
|
||||
* summary: List the caller's alerts
|
||||
* description: Newest first. Brings in any Termix announcement the caller has not seen yet.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: unread
|
||||
* schema:
|
||||
* type: boolean
|
||||
* - in: query
|
||||
* name: source
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: severity
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [info, success, warning, critical]
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: before
|
||||
* description: Only alerts with a lower id, for paging.
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The alerts and the unread count.
|
||||
* 403:
|
||||
* description: Missing the alerts.use permission.
|
||||
*/
|
||||
router.get("/items", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
await announcements.sync(userId);
|
||||
const severity = isSeverity(req.query.severity)
|
||||
? req.query.severity
|
||||
: undefined;
|
||||
const items = await repository.listItems(userId, {
|
||||
unread: req.query.unread === "true",
|
||||
source: typeof req.query.source === "string" ? req.query.source : "",
|
||||
severity,
|
||||
limit: positiveInt(req.query.limit) ?? 50,
|
||||
beforeId: positiveInt(req.query.before) ?? undefined,
|
||||
});
|
||||
res.json({ items, unread: await repository.unreadCount(userId) });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/unread:
|
||||
* get:
|
||||
* summary: Count the caller's unread alerts
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The unread count.
|
||||
*/
|
||||
router.get("/unread", async (_req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
await announcements.sync(userId);
|
||||
res.json({ count: await repository.unreadCount(userId) });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/stream:
|
||||
* get:
|
||||
* summary: Stream new alerts
|
||||
* description: Server-sent events. Sends "unread" with the count on connect and after every change, and "item" with each new or updated alert.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: An event stream.
|
||||
*/
|
||||
router.get("/stream", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
await announcements.sync(userId);
|
||||
stream.open(req, res, userId, {
|
||||
count: await repository.unreadCount(userId),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/items/read:
|
||||
* post:
|
||||
* summary: Mark alerts read or unread
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ids:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* all:
|
||||
* type: boolean
|
||||
* read:
|
||||
* type: boolean
|
||||
* description: False marks them unread. Defaults to true.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new unread count.
|
||||
*/
|
||||
router.post("/items/read", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const read = body.read !== false;
|
||||
await repository.setRead(
|
||||
userId,
|
||||
body.all === true ? "all" : idList(body.ids),
|
||||
read,
|
||||
);
|
||||
await publishUnread(userId);
|
||||
res.json({ count: await repository.unreadCount(userId) });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/items/{id}:
|
||||
* delete:
|
||||
* summary: Delete an alert
|
||||
* description: A deleted Termix announcement does not come back.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted.
|
||||
* 404:
|
||||
* description: No such alert for the caller.
|
||||
*/
|
||||
router.delete("/items/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = positiveInt(req.params.id);
|
||||
const item = id ? await repository.deleteItem(userId, id) : null;
|
||||
if (!item) return res.status(404).json({ error: "Alert not found" });
|
||||
await announcements.forget(userId, item);
|
||||
await publishUnread(userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/items:
|
||||
* delete:
|
||||
* summary: Clear the caller's alerts
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: read
|
||||
* description: Only clear alerts already read.
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: How many alerts were removed.
|
||||
*/
|
||||
router.delete("/items", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const removed = await repository.clearItems(
|
||||
userId,
|
||||
req.query.read === "true",
|
||||
);
|
||||
for (const item of removed) await announcements.forget(userId, item);
|
||||
await publishUnread(userId);
|
||||
res.json({ removed: removed.length });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/categories:
|
||||
* get:
|
||||
* summary: List the sources and categories of the caller's alerts
|
||||
* description: For building delivery rules.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Source and category pairs.
|
||||
*/
|
||||
router.get("/categories", async (_req: Request, res: Response) => {
|
||||
res.json(await repository.listCategories(actor(ctx)));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/meta:
|
||||
* get:
|
||||
* summary: What channel types this server can use
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The channel types and whether email is set up.
|
||||
*/
|
||||
router.get("/meta", async (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
emailAvailable: (await senders.smtp()) !== null,
|
||||
channelTypes: CHANNEL_TYPES,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/channels:
|
||||
* get:
|
||||
* summary: List the caller's channels
|
||||
* description: Without their config.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The channels.
|
||||
*/
|
||||
router.get("/channels", async (_req: Request, res: Response) => {
|
||||
res.json(await repository.listChannels(actor(ctx)));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/channels/{id}:
|
||||
* get:
|
||||
* summary: Get one of the caller's channels with its config
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The channel.
|
||||
* 404:
|
||||
* description: No such channel for the caller.
|
||||
*/
|
||||
router.get("/channels/:id", async (req: Request, res: Response) => {
|
||||
const id = positiveInt(req.params.id);
|
||||
const channel = id ? await repository.getChannel(actor(ctx), id) : null;
|
||||
if (!channel) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json(channel);
|
||||
});
|
||||
|
||||
const readChannel = (
|
||||
body: Record<string, unknown>,
|
||||
partial: boolean,
|
||||
):
|
||||
| { error: string }
|
||||
| {
|
||||
name?: string;
|
||||
type?: ChannelType;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
} => {
|
||||
const out: {
|
||||
name?: string;
|
||||
type?: ChannelType;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (body.name !== undefined || !partial) {
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!name) return { error: "name is required" };
|
||||
out.name = name.slice(0, 100);
|
||||
}
|
||||
if (body.type !== undefined || !partial) {
|
||||
if (!isChannelType(body.type)) {
|
||||
return { error: `type must be one of ${CHANNEL_TYPES.join(", ")}` };
|
||||
}
|
||||
out.type = body.type;
|
||||
}
|
||||
if (body.config !== undefined || !partial) {
|
||||
if (
|
||||
!body.config ||
|
||||
typeof body.config !== "object" ||
|
||||
Array.isArray(body.config)
|
||||
) {
|
||||
return { error: "config is required" };
|
||||
}
|
||||
out.config = body.config as Record<string, unknown>;
|
||||
}
|
||||
if (body.enabled !== undefined) out.enabled = body.enabled !== false;
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/channels:
|
||||
* post:
|
||||
* summary: Create a channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [name, type, config]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [webhook, ntfy, discord, email]
|
||||
* config:
|
||||
* type: object
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 201:
|
||||
* description: The new channel.
|
||||
* 400:
|
||||
* description: The channel is not valid.
|
||||
*/
|
||||
router.post("/channels", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const input = readChannel(
|
||||
(req.body ?? {}) as Record<string, unknown>,
|
||||
false,
|
||||
);
|
||||
if ("error" in input) return res.status(400).json(input);
|
||||
const problem = validateChannelConfig(input.type!, input.config!);
|
||||
if (problem) return res.status(400).json({ error: problem });
|
||||
const id = await repository.createChannel(userId, {
|
||||
name: input.name!,
|
||||
type: input.type!,
|
||||
config: input.config!,
|
||||
enabled: input.enabled,
|
||||
});
|
||||
res.status(201).json(await repository.getChannel(userId, id));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/channels/{id}:
|
||||
* put:
|
||||
* summary: Update a channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [webhook, ntfy, discord, email]
|
||||
* config:
|
||||
* type: object
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated channel.
|
||||
* 400:
|
||||
* description: The channel is not valid.
|
||||
* 404:
|
||||
* description: No such channel for the caller.
|
||||
*/
|
||||
router.put("/channels/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = positiveInt(req.params.id);
|
||||
const existing = id ? await repository.getChannel(userId, id) : null;
|
||||
if (!id || !existing) {
|
||||
return res.status(404).json({ error: "Channel not found" });
|
||||
}
|
||||
const input = readChannel(
|
||||
(req.body ?? {}) as Record<string, unknown>,
|
||||
true,
|
||||
);
|
||||
if ("error" in input) return res.status(400).json(input);
|
||||
const type = input.type ?? existing.type;
|
||||
if (input.config !== undefined || input.type !== undefined) {
|
||||
const problem = validateChannelConfig(
|
||||
type,
|
||||
input.config ?? existing.config,
|
||||
);
|
||||
if (problem) return res.status(400).json({ error: problem });
|
||||
}
|
||||
await repository.updateChannel(userId, id, input);
|
||||
res.json(await repository.getChannel(userId, id));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/channels/{id}:
|
||||
* delete:
|
||||
* summary: Delete a channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted.
|
||||
* 404:
|
||||
* description: No such channel for the caller.
|
||||
*/
|
||||
router.delete("/channels/:id", async (req: Request, res: Response) => {
|
||||
const id = positiveInt(req.params.id);
|
||||
const deleted = id ? await repository.deleteChannel(actor(ctx), id) : false;
|
||||
if (!deleted) return res.status(404).json({ error: "Channel not found" });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/channels/{id}/test:
|
||||
* post:
|
||||
* summary: Send a test alert to a channel
|
||||
* description: Takes the same path a real alert does, private network opt-in included.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Whether the channel accepted it, with the error when it did not.
|
||||
* 404:
|
||||
* description: No such channel for the caller.
|
||||
*/
|
||||
router.post("/channels/:id/test", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = positiveInt(req.params.id);
|
||||
const channel = id
|
||||
? (await repository.deliverableChannels(userId)).find(
|
||||
(candidate) => candidate.id === id,
|
||||
)
|
||||
: undefined;
|
||||
if (!channel) return res.status(404).json({ error: "Channel not found" });
|
||||
const [result] = await deliverToChannels(
|
||||
[channel],
|
||||
{
|
||||
title: "Termix",
|
||||
body: "This is a test alert from Termix.",
|
||||
severity: "info",
|
||||
category: "alerts.test",
|
||||
source: ctx.pluginId,
|
||||
context: { sourceName: "Test", triggerType: "test" },
|
||||
},
|
||||
senders,
|
||||
);
|
||||
res.json(
|
||||
result.ok ? { success: true } : { success: false, error: result.error },
|
||||
);
|
||||
});
|
||||
|
||||
const readRule = async (
|
||||
userId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ error: string } | Omit<AlertRule, "id">> => {
|
||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||
if (!name) return { error: "name is required" };
|
||||
const match =
|
||||
typeof body.match === "string" && body.match.trim()
|
||||
? body.match.trim().slice(0, 128)
|
||||
: "*";
|
||||
const minSeverity = isSeverity(body.minSeverity)
|
||||
? body.minSeverity
|
||||
: "warning";
|
||||
const owned = new Set(
|
||||
(await repository.listChannels(userId)).map((channel) => channel.id),
|
||||
);
|
||||
const channelIds = [...new Set(idList(body.channelIds))];
|
||||
if (channelIds.length === 0) {
|
||||
return { error: "Pick at least one channel" };
|
||||
}
|
||||
if (channelIds.some((id) => !owned.has(id))) {
|
||||
return { error: "Unknown channel" };
|
||||
}
|
||||
return {
|
||||
name: name.slice(0, 100),
|
||||
match,
|
||||
minSeverity,
|
||||
channelIds,
|
||||
enabled: body.enabled !== false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/rules:
|
||||
* get:
|
||||
* summary: List the caller's delivery rules
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The rules.
|
||||
*/
|
||||
router.get("/rules", async (_req: Request, res: Response) => {
|
||||
res.json(await repository.listRules(actor(ctx)));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/rules:
|
||||
* post:
|
||||
* summary: Create a delivery rule
|
||||
* description: Sends every alert whose category matches and whose severity is at least minSeverity to the listed channels.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [name, channelIds]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* match:
|
||||
* type: string
|
||||
* description: '"*", "automations.*" or an exact category.'
|
||||
* minSeverity:
|
||||
* type: string
|
||||
* enum: [info, success, warning, critical]
|
||||
* channelIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 201:
|
||||
* description: The new rule.
|
||||
* 400:
|
||||
* description: The rule is not valid.
|
||||
*/
|
||||
router.post("/rules", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const input = await readRule(
|
||||
userId,
|
||||
(req.body ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
if ("error" in input) return res.status(400).json(input);
|
||||
const id = await repository.createRule(userId, input);
|
||||
res.status(201).json({ id, ...input });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/rules/{id}:
|
||||
* put:
|
||||
* summary: Update a delivery rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated rule.
|
||||
* 400:
|
||||
* description: The rule is not valid.
|
||||
* 404:
|
||||
* description: No such rule for the caller.
|
||||
*/
|
||||
router.put("/rules/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = positiveInt(req.params.id);
|
||||
if (!id) return res.status(404).json({ error: "Rule not found" });
|
||||
const input = await readRule(
|
||||
userId,
|
||||
(req.body ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
if ("error" in input) return res.status(400).json(input);
|
||||
if (!(await repository.updateRule(userId, id, input))) {
|
||||
return res.status(404).json({ error: "Rule not found" });
|
||||
}
|
||||
res.json({ id, ...input });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/alerts/rules/{id}:
|
||||
* delete:
|
||||
* summary: Delete a delivery rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted.
|
||||
* 404:
|
||||
* description: No such rule for the caller.
|
||||
*/
|
||||
router.delete("/rules/:id", async (req: Request, res: Response) => {
|
||||
const id = positiveInt(req.params.id);
|
||||
const deleted = id ? await repository.deleteRule(actor(ctx), id) : false;
|
||||
if (!deleted) return res.status(404).json({ error: "Rule not found" });
|
||||
res.json({ success: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||
import type { AlertLink, ChannelType, Severity } from "../types.js";
|
||||
|
||||
export interface OutgoingAlert {
|
||||
title: string;
|
||||
body: string;
|
||||
severity: Severity;
|
||||
category: string;
|
||||
source: string;
|
||||
link?: AlertLink | null;
|
||||
context?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
}
|
||||
|
||||
export interface SendMail {
|
||||
(
|
||||
smtp: SmtpSettings,
|
||||
message: {
|
||||
to: string[];
|
||||
subject: string;
|
||||
text: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SenderDeps {
|
||||
fetch: PluginFetch;
|
||||
/** Hosts the admin lets channels reach on a private network. */
|
||||
privateAllowlist: () => Promise<string[]>;
|
||||
smtp: () => Promise<SmtpSettings | null>;
|
||||
sendMail: SendMail;
|
||||
}
|
||||
|
||||
const NTFY_PRIORITY: Record<Severity, number> = {
|
||||
info: 2,
|
||||
success: 2,
|
||||
warning: 3,
|
||||
critical: 5,
|
||||
};
|
||||
|
||||
const NTFY_TAGS: Record<Severity, string> = {
|
||||
info: "information_source",
|
||||
success: "white_check_mark",
|
||||
warning: "warning",
|
||||
critical: "rotating_light",
|
||||
};
|
||||
|
||||
const DISCORD_COLORS: Record<Severity, number> = {
|
||||
info: 3447003,
|
||||
success: 3066993,
|
||||
warning: 16753920,
|
||||
critical: 15158332,
|
||||
};
|
||||
|
||||
export const DISCORD_WEBHOOK =
|
||||
/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i;
|
||||
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const str = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
export function parseRecipients(value: unknown): string[] {
|
||||
return str(value)
|
||||
.split(/[,;\s]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** What is wrong with a channel's config, or null when it can be saved. */
|
||||
export function validateChannelConfig(
|
||||
type: ChannelType,
|
||||
config: Record<string, unknown>,
|
||||
): string | null {
|
||||
switch (type) {
|
||||
case "webhook":
|
||||
return /^https?:\/\//i.test(str(config.url))
|
||||
? null
|
||||
: "webhook config requires an http(s) url";
|
||||
case "ntfy":
|
||||
if (!/^https?:\/\//i.test(str(config.url))) {
|
||||
return "ntfy config requires an http(s) url";
|
||||
}
|
||||
return str(config.topic) ? null : "ntfy config requires a topic";
|
||||
case "discord":
|
||||
return DISCORD_WEBHOOK.test(str(config.url))
|
||||
? null
|
||||
: "discord config requires a Discord webhook URL";
|
||||
case "email": {
|
||||
const to = parseRecipients(config.to);
|
||||
if (to.length === 0) return "email config requires at least one address";
|
||||
return to.every((address) => EMAIL.test(address))
|
||||
? null
|
||||
: "email config has an invalid address";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linkUrl(alert: OutgoingAlert): string | undefined {
|
||||
const url = alert.link?.url;
|
||||
return url && /^https?:\/\//i.test(url) ? url : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one alert to one channel and throws on failure, so the caller can say
|
||||
* which channel failed. Private and loopback targets need both the channel's
|
||||
* allowPrivateNetwork opt-in and an exact host in the admin allowlist.
|
||||
*/
|
||||
export async function sendToChannel(
|
||||
channel: { type: ChannelType; config: Record<string, unknown> },
|
||||
alert: OutgoingAlert,
|
||||
deps: SenderDeps,
|
||||
): Promise<void> {
|
||||
const { config } = channel;
|
||||
|
||||
if (channel.type === "email") {
|
||||
const smtp = await deps.smtp();
|
||||
if (!smtp) throw new Error("Email is not set up on this server");
|
||||
const to = parseRecipients(config.to);
|
||||
if (to.length === 0) throw new Error("Channel has no email address");
|
||||
const url = linkUrl(alert);
|
||||
await deps.sendMail(smtp, {
|
||||
to,
|
||||
subject: `[Termix] ${alert.title}`,
|
||||
text: [alert.body || alert.title, url].filter(Boolean).join("\n\n"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const allowPrivateHosts =
|
||||
config.allowPrivateNetwork === true ? await deps.privateAllowlist() : [];
|
||||
const post = async (
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body: string },
|
||||
) => {
|
||||
const response = await deps.fetch(url, {
|
||||
...init,
|
||||
timeoutMs: 30_000,
|
||||
allowPrivateHosts,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const url = str(config.url);
|
||||
if (!url) throw new Error("Channel is missing a URL");
|
||||
const context = alert.context ?? {};
|
||||
|
||||
switch (channel.type) {
|
||||
case "webhook": {
|
||||
const headers =
|
||||
config.headers && typeof config.headers === "object"
|
||||
? (config.headers as Record<string, string>)
|
||||
: {};
|
||||
return post(url, {
|
||||
method: config.method === "PUT" ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify({
|
||||
title: alert.title,
|
||||
message: alert.body,
|
||||
severity: alert.severity,
|
||||
category: alert.category,
|
||||
source: alert.source,
|
||||
link: linkUrl(alert),
|
||||
hostName: context.hostName,
|
||||
hostId: context.hostId,
|
||||
ruleName: context.sourceName ?? alert.title,
|
||||
ruleId: context.sourceId,
|
||||
triggerType: context.triggerType,
|
||||
value: context.value,
|
||||
threshold: context.threshold,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
case "ntfy": {
|
||||
const topic = str(config.topic);
|
||||
if (!topic) throw new Error("ntfy channel is missing a topic");
|
||||
const headers: Record<string, string> = {
|
||||
Title: alert.title || "Termix",
|
||||
Priority: String(NTFY_PRIORITY[alert.severity]),
|
||||
Tags: NTFY_TAGS[alert.severity],
|
||||
};
|
||||
const click = linkUrl(alert);
|
||||
if (click) headers.Click = click;
|
||||
const token = str(config.token);
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return post(`${url.replace(/\/$/, "")}/${encodeURIComponent(topic)}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: alert.body || alert.title,
|
||||
});
|
||||
}
|
||||
case "discord": {
|
||||
const embed: Record<string, unknown> = {
|
||||
title: alert.title || "Termix",
|
||||
description: alert.body || undefined,
|
||||
color: DISCORD_COLORS[alert.severity],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const click = linkUrl(alert);
|
||||
if (click) embed.url = click;
|
||||
const payload: Record<string, unknown> = { embeds: [embed] };
|
||||
if (str(config.username)) payload.username = str(config.username);
|
||||
if (str(config.avatar_url)) payload.avatar_url = str(config.avatar_url);
|
||||
return post(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
/**
|
||||
* Open event streams by user, so a new alert shows up in the browser the
|
||||
* moment it is stored instead of on the next poll.
|
||||
*/
|
||||
export function createAlertStream() {
|
||||
const clients = new Map<string, Set<Response>>();
|
||||
|
||||
const write = (res: Response, event: string, data: unknown) => {
|
||||
try {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
// The close handler removes it.
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
open(req: Request, res: Response, userId: string, hello: unknown): void {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
let set = clients.get(userId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
clients.set(userId, set);
|
||||
}
|
||||
set.add(res);
|
||||
write(res, "unread", hello);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(": keepalive\n\n");
|
||||
} catch {
|
||||
close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const close = () => {
|
||||
clearInterval(heartbeat);
|
||||
const current = clients.get(userId);
|
||||
current?.delete(res);
|
||||
if (current?.size === 0) clients.delete(userId);
|
||||
};
|
||||
req.on("close", close);
|
||||
},
|
||||
|
||||
/** Users with at least one stream open. */
|
||||
users(): string[] {
|
||||
return [...clients.keys()];
|
||||
},
|
||||
|
||||
publish(userId: string, event: string, data: unknown): void {
|
||||
for (const res of clients.get(userId) ?? []) write(res, event, data);
|
||||
},
|
||||
|
||||
closeAll(): void {
|
||||
for (const set of clients.values()) {
|
||||
for (const res of set) {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
clients.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AlertStream = ReturnType<typeof createAlertStream>;
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
encryptedText,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Where a user's alerts go. The config holds webhook URLs, ntfy tokens and
|
||||
* email addresses, sealed with ctx.secrets.seal so an alert sent with no
|
||||
* acting user (a certificate that failed to renew at night) can still read it.
|
||||
* 2.8 kept channels in core's notification_channels; the upgrade copies them
|
||||
* here with their ids, which automations' notify steps still name.
|
||||
*/
|
||||
export const channels = defineTable(
|
||||
"channels",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
type: varchar(32).notNull(),
|
||||
config: encryptedText(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{ indexes: [{ name: "idx_alerts_channels_user", columns: ["userId"] }] },
|
||||
);
|
||||
|
||||
/** The inbox. One row per recipient, so read state is per user. */
|
||||
export const items = defineTable(
|
||||
"items",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
// The plugin that sent it, or "termix" for an announcement.
|
||||
source: varchar(64).notNull(),
|
||||
category: varchar(128).notNull(),
|
||||
severity: varchar(16).notNull(),
|
||||
title: text().notNull(),
|
||||
body: text(),
|
||||
link: text(),
|
||||
context: text(),
|
||||
dedupeKey: varchar(),
|
||||
// What each channel said when the alert was delivered.
|
||||
deliveries: text(),
|
||||
readAt: timestamp(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ name: "idx_alerts_items_user", columns: ["userId", "createdAt"] },
|
||||
{ name: "idx_alerts_items_unread", columns: ["userId", "readAt"] },
|
||||
{ name: "idx_alerts_items_dedupe", columns: ["userId", "dedupeKey"] },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
/** Which alerts a user sends to which of their channels. */
|
||||
export const rules = defineTable(
|
||||
"rules",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
// A category pattern: "*", "automations.*" or an exact category.
|
||||
pattern: varchar(128).notNull().default("*"),
|
||||
minSeverity: varchar(16).notNull().default("warning"),
|
||||
channelIds: text().notNull(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{ indexes: [{ name: "idx_alerts_rules_user", columns: ["userId"] }] },
|
||||
);
|
||||
|
||||
/**
|
||||
* Announcements a user removed from their inbox, so they are not brought
|
||||
* back. Adopted from core's dismissed_alerts, which held the same thing.
|
||||
*/
|
||||
export const dismissed = adoptLegacyTable(
|
||||
"dismissed_alerts",
|
||||
defineTable(
|
||||
"dismissed",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
alertId: text().notNull(),
|
||||
dismissedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_dismissed_alerts_user_id", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [channels, items, rules, dismissed];
|
||||
Reference in new issue
Block a user