feat: system notifications updates, Thingport grab in System health check, node 24+ (#52)

* feat: update system notifications

* feat: add Thingport grab to system health check

* ci: upgrade to Node.js 24 and centralize version management
This commit is contained in:
Tautvydas Derzinskas authored and GitHub committed 2026-10-17 10:44:13 +02:00
1 parent 0bc0f99482
commit 358dfc1a03
47 files changed
+2534 -2784

No files matched your search

+2 -1
View File
@@ -8,7 +8,8 @@
},
"rules": {
"no-underscore-dangle": ["error", { "allow": ["_max", "_count", "_sum", "_avg", "_min"] }],
"no-await-in-loop": "off"
"no-await-in-loop": "off",
"consistent-function-scoping": "off"
},
"env": {
"builtin": true,
+3 -3
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS dependencies
FROM node:24-alpine AS dependencies
WORKDIR /app
RUN apk add --no-cache openssl
COPY package*.json ./
@@ -15,7 +15,7 @@ FROM dependencies AS build
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
FROM node:24-alpine AS runtime
# Set by build-image.yml to the commit that triggered this image's build -- exposed via
# GET /settings/version-check so the admin settings page can tell whether a newer commit
# touching backend/ has landed on main since this image was published. Left "unknown" for a
@@ -31,7 +31,7 @@ COPY --from=build /app/dist ./dist
COPY package*.json ./
COPY prisma ./prisma
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# node:20-alpine already ships a "node" user/group at uid/gid 1000; remove it first so our own
# node:24-alpine already ships a "node" user/group at uid/gid 1000; remove it first so our own
# "appuser"/"appgroup" (which docker-entrypoint.sh re-aligns to PUID/PGID at container start)
# can claim that uid/gid without colliding.
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
+511 -474
View File
File diff suppressed because it is too large. Load diff
+36 -29
View File
@@ -5,7 +5,7 @@
"license": "AGPL-3.0-only",
"type": "commonjs",
"engines": {
"node": ">=20.19"
"node": ">=24"
},
"scripts": {
"dev": "tsx watch src/server.ts",
@@ -18,47 +18,54 @@
"lint": "oxlint ."
},
"dependencies": {
"@prisma/client": "^5.20.0",
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"cheerio": "^1.2.0",
"cors": "^2.8.6",
"dotenv": "^18.0.8",
"express": "^4.22.3",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.2",
"meshoptimizer": "^1.3.0",
"mime-types": "^2.1.35",
"multer": "^2.0.1",
"nodemailer": "^10.0.1",
"mime-types": "^3.0.2",
"multer": "^2.4.0",
"nodemailer": "^10.1.2",
"openid-client": "^6.8.8",
"prisma": "^5.20.0",
"sharp": "^0.35.4",
"prisma": "^5.22.0",
"sharp": "^0.35.5",
"svg-captcha": "^1.4.0",
"three": "^0.160.1",
"three": "^0.186.1",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"yauzl": "^3.2.0",
"zod": "^3.23.8"
"yauzl": "^3.4.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/mime-types": "^2.1.4",
"@types/multer": "^2.2.0",
"@types/node": "^20.14.0",
"@types/nodemailer": "^8.0.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@types/multer": "^2.3.0",
"@types/node": "^24.0.0",
"@types/nodemailer": "^8.0.2",
"@types/supertest": "^7.2.1",
"@types/three": "^0.160.0",
"@types/three": "^0.186.0",
"@types/turndown": "^5.0.6",
"@types/yauzl": "^2.10.3",
"oxlint": "^1.81.0",
"supertest": "^7.2.2",
"tsx": "^4.19.1",
"typescript": "^5.6.3",
"vitest": "^4.1.11"
"@types/yauzl": "^3.4.0",
"oxlint": "^1.88.0",
"supertest": "^7.3.1",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"vitest": "^4.1.12"
},
"overrides": {
"qs": "^6.16.0"
},
"allowScripts": {
"prisma": true,
"@prisma/client": true,
"@prisma/engines": true,
"esbuild": true,
"fsevents": false
}
}
+2
View File
@@ -2,6 +2,7 @@ import express, { type Express, type NextFunction, type Request, type Response }
import cors from "cors";
import { resolveCorsOrigins } from "./cors";
import { HttpError } from "./utils/fileUtils";
import { trackClients } from "./services/clientActivityService";
import healthRoutes from "./routes/health";
import captchaRoutes from "./routes/captcha";
@@ -42,6 +43,7 @@ export function createApp(): Express {
);
app.use(express.json());
app.use("/api", trackClients);
// The frontend's nginx proxies /api/* here unmodified.
app.use("/api", healthRoutes);
+8 -1
View File
@@ -45,9 +45,16 @@ export const TAG_MOSAICS = path.join(STORAGE, "tag-mosaics");
export const NORMALIZED_3MFS = path.join(STORAGE, "normalized-3mf");
// Large multi-part projects take minutes on a slow CPU; the page waits for it, not the request.
export const NORMALIZE_3MF_TIMEOUT_SECONDS = envInt("NORMALIZE_3MF_TIMEOUT_SECONDS", 600);
// A worker can't even start below a few MB, and Node 24 then takes the whole process down with it.
const MIN_PREVIEW_MEMORY_MB = 8;
// Past either limit the render worker is killed and the plate gets no 3D preview. Memory is a
// watchdog check, so a fast burst can overshoot it briefly.
export const MODEL_PREVIEW_MAX_MEMORY_MB = envInt("MODEL_PREVIEW_MAX_MEMORY_MB", defaultPreviewMemoryMb());
export const MODEL_PREVIEW_MAX_MEMORY_MB = Math.max(
MIN_PREVIEW_MEMORY_MB,
envInt("MODEL_PREVIEW_MAX_MEMORY_MB", defaultPreviewMemoryMb()),
);
export const MODEL_PREVIEW_TIMEOUT_SECONDS = envInt("MODEL_PREVIEW_TIMEOUT_SECONDS", 180);
// Only the first run's value: it's copied into the database, and managed there (Admin > Auth) from then on.
@@ -0,0 +1,40 @@
import type { NextFunction, Request, Response } from "express";
import { prisma } from "../db";
import { isExtensionRequest } from "./captchaService";
// When Thingport Grab last reached this instance, for System Health. A web page can't ask whether an extension is
// installed, but Grab makes requests of its own here.
export type Client = "grab";
const SETTING_KEY: Record<Client, string> = { grab: "client_seen_grab" };
// Grab makes requests all day; the last-seen time only needs to be roughly right.
const WRITE_EVERY_MS = 10 * 60 * 1000;
const lastWritten = new Map<Client, number>();
function record(client: Client) {
const now = Date.now();
if (now - (lastWritten.get(client) ?? 0) < WRITE_EVERY_MS) return;
lastWritten.set(client, now);
const value = { seen_at: new Date(now).toISOString() };
// Never holds up or breaks the request it was seen on.
void prisma.setting
.upsert({ where: { key: SETTING_KEY[client] }, create: { key: SETTING_KEY[client], value }, update: { value } })
.catch(() => lastWritten.delete(client));
}
export function trackClients(req: Request, _res: Response, next: NextFunction) {
if (isExtensionRequest(req)) record("grab");
next();
}
export async function clientLastSeen(client: Client): Promise<Date | null> {
const row = await prisma.setting.findUnique({ where: { key: SETTING_KEY[client] } });
const seen = (row?.value as { seen_at?: string } | undefined)?.seen_at;
return seen ? new Date(seen) : null;
}
/** For tests: forget what was written, so the next request records again. */
export function resetClientTracking() {
lastWritten.clear();
}
@@ -11,6 +11,7 @@ import {
SYSTEM_NOTIFICATIONS_URL,
} from "../config";
import { prisma } from "../db";
import { clientLastSeen } from "./clientActivityService";
import { listAiProviderModels } from "./aiCategorizationService";
import { resolveAiConnection } from "./aiConnectionService";
import { testFlaresolverr } from "./flaresolverr";
@@ -29,6 +30,7 @@ export const HEALTH_COMPONENTS = [
"internet",
"flaresolverr",
"thingiverse",
"grab",
"ai",
"smtp",
] as const;
@@ -230,6 +232,18 @@ async function checkSmtp(): Promise<Found> {
}
}
// Seen, not tested: it can't be reached from here, so this is the last time it reached Thingport.
async function checkGrab(): Promise<Found> {
const [seen, connection] = await Promise.all([
clientLastSeen("grab"),
// Connections made before Grab was tracked still count.
prisma.grabConnection.findFirst({ orderBy: { lastUsedAt: { sort: "desc", nulls: "last" } } }),
]);
const connected = connection ? (connection.lastUsedAt ?? connection.createdAt) : null;
const last = seen && connected ? (seen > connected ? seen : connected) : (seen ?? connected);
return last ? found("ok", "ok", { last_seen_at: last.toISOString() }) : found("not_configured", "not_seen");
}
const CHECKS: Record<HealthComponent, () => Promise<Found>> = {
database: checkDatabase,
storage: checkStorage,
@@ -237,6 +251,7 @@ const CHECKS: Record<HealthComponent, () => Promise<Found>> = {
internet: checkInternet,
flaresolverr: checkFlaresolverr,
thingiverse: checkThingiverse,
grab: checkGrab,
ai: checkAi,
smtp: checkSmtp,
};
@@ -3,6 +3,7 @@ import { prisma } from "../db";
import { SYSTEM_NOTIFICATIONS_URL } from "../config";
import { UNREAD_RETENTION_DAYS } from "./notificationService";
import { createKeyedLock } from "../utils/concurrency";
import { getBackendVersion } from "./versionService";
const FETCH_TIMEOUT_MS = 15_000;
const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -19,16 +20,58 @@ const entrySchema = z.object({
.url()
.refine((value) => value.startsWith("https://"))
.nullish(),
// An in-app page opened on click, e.g. "/admin"; "//" would leave the app.
path: z
.string()
.trim()
.regex(/^\/(?!\/)\S*$/)
.max(200)
.nullish(),
published_at: z.coerce.date(),
// Admins by default: most announcements are about running the instance.
audience: z.enum(["admins", "all"]).default("admins"),
// Which app versions get it: "1.0.0", "<1.0.0", "<=1.0.0", ">1.0.0" or ">=1.0.0"; omitted for all.
version: z
.string()
.trim()
.regex(/^(<=|>=|<|>|=)?\s*\d+\.\d+\.\d+$/)
.nullish(),
});
export type SystemNotificationEntry = z.infer<typeof entrySchema>;
/** One bad entry is skipped rather than hiding the rest. */
function compareVersions(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
return 0;
}
/** An entry aimed at certain versions is held back while this instance's version is unknown. */
export function matchesVersion(range: string | null | undefined, version: string | null): boolean {
if (!range) return true;
const own = version?.match(/^\d+\.\d+\.\d+/)?.[0];
if (!own) return false;
const [, op = "=", target] = range.match(/^(<=|>=|<|>|=)?\s*(.+)$/)!;
const diff = compareVersions(own, target);
switch (op) {
case "<":
return diff < 0;
case "<=":
return diff <= 0;
case ">":
return diff > 0;
case ">=":
return diff >= 0;
default:
return diff === 0;
}
}
/** One bad entry is skipped rather than hiding the rest. Versions before 1.0.0 read only `notifications`
* and ignore `version`, so entries they mustn't get live in `entries`. */
export function parseSystemNotifications(data: unknown): SystemNotificationEntry[] {
const list = (data as { notifications?: unknown } | null)?.notifications;
if (!Array.isArray(list)) return [];
const file = data as { notifications?: unknown; entries?: unknown } | null;
const list = [file?.notifications, file?.entries].flatMap((l) => (Array.isArray(l) ? l : []));
return list.flatMap((raw) => {
const parsed = entrySchema.safeParse(raw);
return parsed.success ? [parsed.data] : [];
@@ -51,10 +94,20 @@ const withDeliveryLock = createKeyedLock();
/** Sends each user every entry meant for them they haven't had yet, as an ordinary notification dated
* when it was posted. Future entries wait for their date; ones past the unread retention aren't sent. */
export function deliverSystemNotifications(entries: SystemNotificationEntry[], now = new Date()): Promise<number> {
export function deliverSystemNotifications(
entries: SystemNotificationEntry[],
now = new Date(),
version = getBackendVersion(),
): Promise<number> {
return withDeliveryLock("system", async () => {
const oldest = now.getTime() - UNREAD_RETENTION_DAYS * DAY_MS;
const due = entries.filter((e) => e.published_at.getTime() <= now.getTime() && e.published_at.getTime() >= oldest);
// No receipt for a version mismatch, so an entry still reaches an instance that updates into its range.
const due = entries.filter(
(e) =>
e.published_at.getTime() <= now.getTime() &&
e.published_at.getTime() >= oldest &&
matchesVersion(e.version, version),
);
if (!due.length) return 0;
const [users, receipts] = await Promise.all([
prisma.user.findMany({ select: { id: true, role: true } }),
@@ -83,6 +136,7 @@ export function deliverSystemNotifications(entries: SystemNotificationEntry[], n
title: entry.title,
body: entry.body ?? null,
externalUrl: entry.url ?? null,
internalPath: entry.path ?? null,
createdAt: entry.published_at,
})),
}),
+2 -2
View File
@@ -4,8 +4,8 @@ import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
// Separate file because limits are read when config.ts loads. 1 MB is below a worker's startup
// cost, so every render hits a limit.
// Separate file because limits are read when config.ts loads. 1 MB is raised to the 8 MB floor (any lower and
// the worker can't start, which crashes the whole process on Node 24); 8 MB is still too little for a render.
vi.hoisted(() => {
process.env.MODEL_PREVIEW_MAX_MEMORY_MB = "1";
});
+20
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { resetClientTracking } from "../src/services/clientActivityService";
const app = createApp();
let admin: string;
@@ -36,6 +37,24 @@ afterEach(() => {
});
describe("system health", () => {
it("reports Grab as not seen until it makes a request of its own", async () => {
await prisma.setting.deleteMany({ where: { key: "client_seen_grab" } });
await prisma.grabConnection.deleteMany();
resetClientTracking();
const test = async () =>
resultOf(
(await request(app).post("/api/admin/health/test").set(as(admin)).send({ component: "grab" })).body,
"grab",
)!;
expect(await test()).toMatchObject({ status: "not_configured", code: "not_seen" });
await request(app).get("/api/health").set("X-Thingport-Client", "grab");
// Recorded without holding up the request.
await vi.waitFor(async () => {
expect(await test()).toMatchObject({ status: "ok", facts: { last_seen_at: expect.any(String) } });
});
});
it("lists every component by importance, the database first and SMTP, which is optional, last", async () => {
const res = await request(app).get("/api/admin/health").set(as(admin));
expect(res.status).toBe(200);
@@ -46,6 +65,7 @@ describe("system health", () => {
"internet",
"flaresolverr",
"thingiverse",
"grab",
"ai",
"smtp",
]);
+32 -1
View File
@@ -4,6 +4,7 @@ import { createApp } from "../src/app";
import { prisma } from "../src/db";
import {
deliverSystemNotifications,
matchesVersion,
parseSystemNotifications,
type SystemNotificationEntry,
} from "../src/services/systemNotificationService";
@@ -51,11 +52,19 @@ describe("system notifications", () => {
{ id: "bad-url", title: "x", published_at: "2026-10-01T00:00:00Z", url: "not a url" },
{ id: "plain-http", title: "x", published_at: "2026-10-01T00:00:00Z", url: "http://example.com" },
{ id: "bad-audience", title: "x", published_at: "2026-10-01T00:00:00Z", audience: "everyone" },
{ id: "bad-version", title: "x", published_at: "2026-10-01T00:00:00Z", version: "~1.0" },
{ id: "off-site-path", title: "x", published_at: "2026-10-01T00:00:00Z", path: "//evil.example" },
{ id: "for-old", title: "x", published_at: "2026-10-01T00:00:00Z", version: "<1.0.0" },
],
});
expect(parsed.map((e) => e.id)).toEqual(["ok"]);
expect(parsed.map((e) => e.id)).toEqual(["ok", "for-old"]);
expect(parsed[0].audience).toBe("admins");
expect(parseSystemNotifications({ nope: true })).toEqual([]);
const both = parseSystemNotifications({
notifications: [{ id: "legacy", title: "x", published_at: "2026-10-01T00:00:00Z" }],
entries: [{ id: "new", title: "x", published_at: "2026-10-01T00:00:00Z", version: "1.0.0" }],
});
expect(both.map((e) => e.id)).toEqual(["legacy", "new"]);
});
it("sends due entries to admins only, dated when they were posted", async () => {
@@ -80,6 +89,28 @@ describe("system notifications", () => {
expect((await systemOf(admin.id)).some((n) => n.title === "Title everyone")).toBe(true);
});
it("matches version ranges", () => {
expect(matchesVersion(undefined, null)).toBe(true);
expect(matchesVersion("<1.0.0", "0.9.12")).toBe(true);
expect(matchesVersion("<1.0.0", "1.0.0")).toBe(false);
expect(matchesVersion("1.0.0", "1.0.0")).toBe(true);
expect(matchesVersion("=1.0.0", "1.0.1")).toBe(false);
expect(matchesVersion(">1.0.0", "1.10.0")).toBe(true);
expect(matchesVersion(">=1.0.0", "1.0.0")).toBe(true);
expect(matchesVersion("<=1.0.0", "1.0.1")).toBe(false);
expect(matchesVersion("1.0.0", null)).toBe(false);
});
it("sends a version-targeted entry only once the instance is in range", async () => {
const targeted = entry("v1", 1, { version: "1.0.0", path: "/admin" });
await deliverSystemNotifications([targeted], new Date(), "0.9.0");
expect((await systemOf(admin.id)).some((n) => n.title === "Title v1")).toBe(false);
await deliverSystemNotifications([targeted], new Date(), "1.0.0");
const sent = (await systemOf(admin.id)).filter((n) => n.title === "Title v1");
expect(sent).toHaveLength(1);
expect(sent[0].internalPath).toBe("/admin");
});
it("never sends the same entry twice, even after it was deleted", async () => {
const once = entry("once", 1);
await deliverSystemNotifications([once]);