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

+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci --no-audit --no-fund
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: ${{ inputs.project_dir }}/package-lock.json
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: extension/package-lock.json
- run: npm ci
@@ -61,7 +61,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: extension/package-lock.json
- run: npm ci
@@ -48,7 +48,7 @@ jobs:
- uses: actions/setup-node@v4
if: steps.gate.outputs.run != 'false'
with:
node-version: 22 # semantic-release 25 needs Node 22.14+
node-version-file: .nvmrc
cache: npm
cache-dependency-path: extension/package-lock.json
- if: steps.gate.outputs.run != 'false'
@@ -107,7 +107,7 @@ jobs:
- uses: actions/setup-node@v4
if: steps.creds.outputs.ok != 'false'
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: extension/package-lock.json
- if: steps.creds.outputs.ok != 'false'
@@ -139,7 +139,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: extension/package-lock.json
- run: npm ci
@@ -168,7 +168,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: extension/package-lock.json
- run: npm ci
@@ -183,7 +183,7 @@ jobs:
VERSION: ${{ needs.release.outputs.version }}
TAG: ${{ needs.release.outputs.tag }}
run: |
jq -n --arg notes "Automated update to version $VERSION. Changes: https://github.com/TautvydasDerzinskas/Thingport/releases/tag/$TAG -- the bundles aren't minified; to rebuild them from the attached source (Node 20+): npm ci && npm run build:firefox, output in dist/firefox. Documentation: https://thingport.net/docs/grab/" \
jq -n --arg notes "Automated update to version $VERSION. Changes: https://github.com/TautvydasDerzinskas/Thingport/releases/tag/$TAG -- the bundles aren't minified; to rebuild them from the attached source (Node 24+): npm ci && npm run build:firefox, output in dist/firefox. Documentation: https://thingport.net/docs/grab/" \
'{version: {approval_notes: $notes}}' > dist/amo-metadata.json
# Mozilla may hold a listed version for manual review, so don't wait for the signed file.
- name: Submit to addons.mozilla.org
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
node-version-file: .nvmrc
- uses: actions/setup-go@v5
with:
go-version-file: bridge/go.mod
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
node-version-file: .nvmrc
cache: npm
cache-dependency-path: regression/package-lock.json
- run: npm ci --no-audit --no-fund
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22 # semantic-release 25 needs Node 22.14+
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
node-version-file: .nvmrc
cache: npm
cache-dependency-path: web/package-lock.json
# Supplies the Pages URL and base path. Skipped for PR builds, which may come from forks.
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: web/.nvmrc
node-version-file: .nvmrc
- run: node .github/wiki/build.mjs "$RUNNER_TEMP/wiki-out"
- name: Push to the wiki
env:
+1
View File
@@ -0,0 +1 @@
24
+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]);
+10 -4
View File
@@ -15,7 +15,7 @@ instance, follow the Docker Compose instructions in the [README](../README.md) i
## Prerequisites
- **Node.js 20 or newer.** `backend/package.json` sets `engines.node >= 20`, and CI builds on 20.
- **Node.js 24 or newer.** The root `.nvmrc` says 24 (`nvm use` picks it up), and CI and the Docker images run on it.
- **Docker**, to run Postgres. Everything else runs natively so you get hot reload.
- **Go 1.21+** -- only if you're changing `bridge/`.
@@ -226,18 +226,19 @@ Windows and Linux binaries cross-compile with `CGO_ENABLED=0`.
Announcements for every instance, such as a known bug or a new feature, live in
[`system-notifications.json`](../system-notifications.json) at the repo root. Each backend fetches it
from `main` at startup and every 6 hours, and sends each entry once to each user it's meant for, as an
ordinary notification (the System tab). To post one, add an entry and merge it:
ordinary notification (the System tab). To post one, add an entry to `entries` and merge it:
```json
{
"notifications": [
"entries": [
{
"id": "2026-10-imports-fail-on-printables",
"title": "Printables imports fail since Oct 10",
"body": "Printables changed its API. A fix is on the way; update when it lands.",
"url": "https://github.com/TautvydasDerzinskas/Thingport/issues/123",
"published_at": "2026-10-11T12:00:00Z",
"audience": "admins"
"audience": "admins",
"version": "<1.2.0"
}
]
}
@@ -245,8 +246,13 @@ ordinary notification (the System tab). To post one, add an entry and merge it:
- `id` must be unique and never reused: it's what stops an entry being sent twice.
- `body` and `url` are optional; `url` must be `https://`, and the notification shows its domain.
- `path` is an optional in-app page opened on click, e.g. `/admin`.
- `published_at` is the date shown; a future date waits until then.
- `audience` is `admins` (the default) or `all` for every user.
- `version` limits it to instances on certain versions: `1.2.0` exactly, or `<`, `<=`, `>`, `>=` a
version. Leave it out for all versions. An instance that later updates into the range still gets it.
- Versions before 1.0.0 read only the `notifications` list and ignore `version`, so add there only what
every one of them should get; everything else goes in `entries`.
- Entries older than 90 days aren't sent, so old ones can stay in the file or be removed.
- `SYSTEM_NOTIFICATIONS_URL` points a backend at another file; an empty value turns fetching off.
+12 -9
View File
@@ -16,15 +16,16 @@ for.
## What's checked
| Part | Needed | What it's for, and what breaks without it |
| ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Database | Required | Holds every model, collection, user and setting. Without it nothing in Thingport works. |
| File storage | Required | Where model files, pictures and 3D previews are kept. Uploads, imports and downloads fail if it isn't writable or runs out of space. |
| Memory | Required | Building 3D previews of large models takes the most. With too little, previews fail or the server restarts. |
| FlareSolverr | Recommended | Gets past Cloudflare's checks, mostly on Thingiverse and Printables. Without it, imports and syncs from those sites are blocked whenever Cloudflare challenges the server. |
| Thingiverse | Recommended | The instance's Thingiverse Access Token, used for every Thingiverse import, synced collection and author lookup. Not checked while Thingiverse is [turned off](../PROVIDER_SETUP.md#turning-a-provider-off). |
| AI provider | Optional | The default AI model behind [AI categorization and improved descriptions](ai.md). Everything else works without it. |
| Email (SMTP) | Optional | Sends invitations, email confirmations and password resets. Without it, invitation links are shown to copy instead, and new accounts don't confirm their address. |
| Part | Needed | What it's for, and what breaks without it |
| -------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Database | Required | Holds every model, collection, user and setting. Without it nothing in Thingport works. |
| File storage | Required | Where model files, pictures and 3D previews are kept. Uploads, imports and downloads fail if it isn't writable or runs out of space. |
| Memory | Required | Building 3D previews of large models takes the most. With too little, previews fail or the server restarts. |
| FlareSolverr | Recommended | Gets past Cloudflare's checks, mostly on Thingiverse and Printables. Without it, imports and syncs from those sites are blocked whenever Cloudflare challenges the server. |
| Thingiverse | Recommended | The instance's Thingiverse Access Token, used for every Thingiverse import, synced collection and author lookup. Not checked while Thingiverse is [turned off](../PROVIDER_SETUP.md#turning-a-provider-off). |
| Thingport Grab | Recommended | The [browser extension](../../extension/README.md) for importing while you browse MakerWorld, Printables and Thingiverse, whole collections included, and for bringing over Printables likes. Shown as seen once any browser uses it with this instance. |
| AI provider | Optional | The default AI model behind [AI categorization and improved descriptions](ai.md). Everything else works without it. |
| Email (SMTP) | Optional | Sends invitations, email confirmations and password resets. Without it, invitation links are shown to copy instead, and new accounts don't confirm their address. |
## What the colours mean
@@ -35,6 +36,8 @@ for.
broken until it's fixed.
- **Not configured** (grey): not set up. Fine for an optional part you don't use. For FlareSolverr or Thingiverse,
imports from those sites will fail sooner or later.
Thingport can't look into anyone's browser, so Grab stays grey until it's first used with this instance; get it
from the **Downloads** page.
- **Not tested**: hasn't been checked yet. Click **Test** or **Run full test**.
Every problem comes with what to do about it, for example: check that the database container is running, make sure
+16 -16
View File
@@ -8,7 +8,7 @@ workflow shared by the whole repo is in the root **[CONTRIBUTING.md](../CONTRIBU
## Getting started
The extension is TypeScript, bundled with [esbuild](https://esbuild.github.io/), with styles in
SCSS. You need Node.js 20 or newer. Everything below runs from this `extension/` folder:
SCSS. You need Node.js 24 or newer. Everything below runs from this `extension/` folder:
```bash
npm install
@@ -73,20 +73,20 @@ uses the light one.
### Scripts
| Command | What it does |
| ------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `npm run dev` / `dev:firefox` | Watch build of `dist/chrome` / `dist/firefox` |
| `npm run build` | Builds `dist/chrome`, `dist/firefox` and `dist/edge` |
| `npm run build:chrome` / `build:firefox` / `build:edge` | Builds one browser |
| `npm run zip` | Builds everything and packages the store zips (below) |
| `npm run zip:chrome` / `zip:firefox` / `zip:edge` | Same for one store |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run lint` | oxlint (also run on commit by the repo's pre-commit hook) |
| `npm run lint:firefox` | Builds for Firefox and runs `web-ext lint` on the result |
| `npm run screenshots` | Regenerates the README screenshots (see below) |
| `npm run store-assets` | Renders the store logo, promotional tiles and PNG screenshots (see below) |
| `npm run verify` | Typecheck + lint + build |
| `npm run release:dry-run` | Shows the next version semantic-release would release, and why (Node 22.14+) |
| Command | What it does |
| ------------------------------------------------------- | ------------------------------------------------------------------------- |
| `npm run dev` / `dev:firefox` | Watch build of `dist/chrome` / `dist/firefox` |
| `npm run build` | Builds `dist/chrome`, `dist/firefox` and `dist/edge` |
| `npm run build:chrome` / `build:firefox` / `build:edge` | Builds one browser |
| `npm run zip` | Builds everything and packages the store zips (below) |
| `npm run zip:chrome` / `zip:firefox` / `zip:edge` | Same for one store |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run lint` | oxlint (also run on commit by the repo's pre-commit hook) |
| `npm run lint:firefox` | Builds for Firefox and runs `web-ext lint` on the result |
| `npm run screenshots` | Regenerates the README screenshots (see below) |
| `npm run store-assets` | Renders the store logo, promotional tiles and PNG screenshots (see below) |
| `npm run verify` | Typecheck + lint + build |
| `npm run release:dry-run` | Shows the next version semantic-release would release, and why |
The only difference between the browser builds is `manifest.json` (see `scripts/manifest.ts`):
Chrome and Edge run the background as a service worker, Firefox as an event page
@@ -225,7 +225,7 @@ follow from this:
The commit messages also become the changelog and the GitHub release notes, so write them for a
reader.
To see what the next release would be, without releasing anything (needs Node 22.14+):
To see what the next release would be, without releasing anything:
```bash
npm run release:dry-run
+806 -793
View File
File diff suppressed because it is too large. Load diff
+11 -6
View File
@@ -28,18 +28,23 @@
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^11.0.1",
"@types/chrome": "^0.3.0",
"@types/node": "^20.19.0",
"@types/chrome": "^0.3.5",
"@types/node": "^24.0.0",
"esbuild": "^0.28.2",
"fflate": "^0.8.3",
"jszip": "^3.10.2",
"oxlint": "^1.85.0",
"playwright": "^1.63.0",
"sass": "^1.105.0",
"oxlint": "^1.88.0",
"playwright": "^1.64.1",
"sass": "^1.105.2",
"semantic-release": "^25.0.9",
"semantic-release-monorepo": "^8.0.2",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"web-ext": "^8.10.0"
"web-ext": "^10.8.0"
},
"allowScripts": {
"esbuild": true,
"@parcel/watcher": true,
"fsevents": false
}
}
+3 -1
View File
@@ -13,7 +13,9 @@
"jsx-no-new-object-as-prop": "off",
"jsx-no-jsx-as-prop": "off",
"jsx-no-new-array-as-prop": "off",
"set-state-in-effect": "off"
"set-state-in-effect": "off",
"incompatible-library": "off",
"consistent-function-scoping": "off"
},
"env": {
"builtin": true,
+1 -1
View File
@@ -1,7 +1,7 @@
# Installing and building run on the build machine's own platform: their output, the static dist, is the
# same for every architecture, and running Node under QEMU for the arm64 image is slow and has crashed
# npm ci mid-install. Only the nginx stage below is built per platform.
FROM --platform=$BUILDPLATFORM node:20-alpine AS dependencies
FROM --platform=$BUILDPLATFORM node:24-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund
+271 -377
View File
File diff suppressed because it is too large. Load diff
+22 -18
View File
@@ -18,31 +18,35 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.0",
"@mui/material": "^6.1.0",
"@tanstack/react-virtual": "3.14.13",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@tanstack/react-virtual": "3.14.21",
"fflate": "0.8.3",
"i18next": "^23.15.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.2.1",
"occt-import-js": "0.0.23",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.7.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.30.6",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"three": "^0.160.0"
"three": "^0.186.1"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/three": "^0.160.0",
"oxlint": "^1.81.0",
"typescript": "5.6.3",
"vite": "^6.4.3",
"vitest": "3.2.6"
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@types/three": "^0.186.0",
"oxlint": "^1.88.0",
"typescript": "5.9.3",
"vite": "^6.4.4",
"vitest": "4.1.12"
},
"allowScripts": {
"esbuild": true,
"fsevents": false
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ export type AdminUser = {
};
export type HealthComponent =
"database" | "storage" | "memory" | "internet" | "flaresolverr" | "thingiverse" | "ai" | "smtp";
"database" | "storage" | "memory" | "internet" | "flaresolverr" | "thingiverse" | "grab" | "ai" | "smtp";
export type HealthStatus = "ok" | "warning" | "error" | "not_configured";
export type HealthResult = {
@@ -46,7 +46,9 @@ export default function LightBurnPreview({ url, assetId, filename, imgSx }: Ligh
if (!payload) {
throw new Error("Preview not found in LightBurn file");
}
const objectUrl = URL.createObjectURL(new Blob([payload.data], { type: payload.mime }));
const objectUrl = URL.createObjectURL(
new Blob([payload.data as Uint8Array<ArrayBuffer>], { type: payload.mime }),
);
previewCache.set(cacheKey, objectUrl);
if (alive) {
setPreview(objectUrl);
+10 -1
View File
@@ -551,6 +551,7 @@
"testAll": "Run full test",
"test": "Test",
"settings": "Settings",
"download": "Download",
"testedAgo": "Tested {{when}}",
"neverTested": "Not tested yet",
"lastFullTest": "Last full test: {{when}} ({{ago}})",
@@ -572,7 +573,8 @@
"memory": "{{available}} available of {{total}}",
"memoryContainer": "{{available}} available of the container's {{total}}",
"process": "Thingport uses {{used}}",
"latency": "answered in {{ms}} ms"
"latency": "answered in {{ms}} ms",
"lastSeen": "last seen {{when}}"
},
"problems": {
"unknown": "This component reported a problem. The details below come from it directly."
@@ -632,6 +634,13 @@
"timeout": "Thingiverse didn't answer in time. Try again later."
}
},
"grab": {
"name": "Thingport Grab",
"description": "The browser extension for importing models from MakerWorld, Printables and Thingiverse while you browse them, including whole collections, with no links to copy. It also brings over Printables likes, which only you can see.",
"problems": {
"not_seen": "No browser has used Grab with this instance yet. Install it from Downloads and connect it to Thingport. It's the easiest way to import, and some sites only let a browser get their files."
}
},
"ai": {
"name": "AI provider",
"description": "The default AI model, behind AI categorization and improved descriptions. Everything else works without it.",
+10 -1
View File
@@ -552,6 +552,7 @@
"testAll": "Patikrinti viską",
"test": "Patikrinti",
"settings": "Nustatymai",
"download": "Atsisiųsti",
"testedAgo": "Patikrinta {{when}}",
"neverTested": "Dar netikrinta",
"lastFullTest": "Paskutinis pilnas patikrinimas: {{when}} ({{ago}})",
@@ -573,7 +574,8 @@
"memory": "laisva {{available}} iš {{total}}",
"memoryContainer": "laisva {{available}} iš konteineriui skirtų {{total}}",
"process": "Thingport naudoja {{used}}",
"latency": "atsakė per {{ms}} ms"
"latency": "atsakė per {{ms}} ms",
"lastSeen": "paskutinį kartą {{when}}"
},
"problems": {
"unknown": "Šis komponentas pranešė apie problemą. Žemiau – jo paties pateikta informacija."
@@ -633,6 +635,13 @@
"timeout": "Thingiverse laiku neatsakė. Bandykite vėliau."
}
},
"grab": {
"name": "Thingport Grab",
"description": "Naršyklės plėtinys, kuriuo modelius iš MakerWorld, Printables ir Thingiverse importuojate tiesiog naršydami, net ištisas kolekcijas, be nuorodų kopijavimo. Jis taip pat perkelia Printables patiktukus, kuriuos matote tik jūs.",
"problems": {
"not_seen": "Su šiuo serveriu Grab dar nenaudota nė vienoje naršyklėje. Įdiekite jį skiltyje „Atsisiuntimai“ ir prijunkite prie Thingport. Taip importuoti paprasčiausia, o kai kurios svetainės failus duoda tik naršyklei."
}
},
"ai": {
"name": "DI tiekėjas",
"description": "Numatytasis DI modelis, naudojamas DI kategorizavimui ir aprašymų tobulinimui. Visa kita veikia ir be jo.",
@@ -12,6 +12,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
import ExtensionOutlinedIcon from "@mui/icons-material/ExtensionOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import LanguageIcon from "@mui/icons-material/Language";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
@@ -37,8 +38,11 @@ type Props = {
type Need = "required" | "recommended" | "optional";
/** How much Thingport needs each one, and where it's set up. Order comes from the server: by importance. */
const COMPONENTS: Record<HealthComponent, { icon: React.ReactNode; need: Need; settings?: string }> = {
type ComponentInfo = { icon: React.ReactNode; need: Need; settings?: string; download?: string };
/** How much Thingport needs each one, and where it's set up or, for Grab, downloaded. Order comes from the
* server: by importance. */
const COMPONENTS: Record<HealthComponent, ComponentInfo> = {
database: { icon: <StorageIcon />, need: "required", settings: "/admin-connections?tab=database" },
storage: { icon: <FolderOutlinedIcon />, need: "required", settings: "/admin-storage" },
memory: { icon: <MemoryIcon />, need: "required", settings: "/admin-rendering" },
@@ -46,6 +50,7 @@ const COMPONENTS: Record<HealthComponent, { icon: React.ReactNode; need: Need; s
internet: { icon: <LanguageIcon />, need: "recommended" },
flaresolverr: { icon: <ShieldOutlinedIcon />, need: "recommended", settings: "/admin-connections?tab=flaresolverr" },
thingiverse: { icon: <CloudOutlinedIcon />, need: "recommended", settings: "/admin-providers" },
grab: { icon: <ExtensionOutlinedIcon />, need: "recommended", download: "/downloads" },
ai: { icon: <AutoAwesomeOutlinedIcon />, need: "optional", settings: "/admin-ai" },
smtp: { icon: <MailOutlineIcon />, need: "optional", settings: "/admin-connections?tab=smtp" },
};
@@ -58,7 +63,11 @@ const STATUS_COLOR: Record<HealthStatus, "success" | "warning" | "error" | "defa
};
/** The facts worth a glance, in words; the rest stay out of the way. */
function factsLine(result: HealthResult, t: (key: string, options?: Record<string, unknown>) => string): string[] {
function factsLine(
result: HealthResult,
t: (key: string, options?: Record<string, unknown>) => string,
language: string,
): string[] {
const { facts } = result;
const lines: string[] = [];
const bytes = (key: string) => (typeof facts[key] === "number" ? formatFileSize(facts[key] as number) : null);
@@ -78,6 +87,9 @@ function factsLine(result: HealthResult, t: (key: string, options?: Record<strin
);
lines.push(t("adminSettings.health.facts.process", { used: bytes("process_bytes") }));
}
if (typeof facts.last_seen_at === "string") {
lines.push(t("adminSettings.health.facts.lastSeen", { when: relativeTime(facts.last_seen_at, language) }));
}
if (result.latency_ms !== null) lines.push(t("adminSettings.health.facts.latency", { ms: result.latency_ms }));
return lines;
}
@@ -121,9 +133,9 @@ function ComponentRow({
<Typography variant="body2" color="text.secondary">
{t(`${base}.description`)}
</Typography>
{result && result.status !== "not_configured" && factsLine(result, t).length > 0 && (
{result && result.status !== "not_configured" && factsLine(result, t, i18n.language).length > 0 && (
<Typography variant="caption" color="text.secondary">
{factsLine(result, t).join(" · ")}
{factsLine(result, t, i18n.language).join(" · ")}
</Typography>
)}
{problem && (
@@ -151,6 +163,11 @@ function ComponentRow({
sx={{ flexShrink: 0, pl: { xs: 4.75, sm: 0 } }}
>
<Stack direction="row" spacing={1}>
{info.download && (
<Button size="small" component={RouterLink} to={info.download}>
{t("adminSettings.health.download")}
</Button>
)}
{info.settings && (
<Button size="small" component={RouterLink} to={info.settings}>
{t("adminSettings.health.settings")}
@@ -67,7 +67,7 @@ export default function ActivitySection({ onUnauthorized }: Props) {
}, [range, onUnauthorized, t]);
// The year list comes with the first answer; until then, this year.
const years = loaded?.data.years ?? [new Date().getFullYear()];
const years = loaded?.data.years ?? [thisYear];
return (
<Box>
+1 -1
View File
@@ -84,7 +84,7 @@ export function buildUploadEntriesFromZip(
const parts = path.split("/");
const filename = parts[parts.length - 1];
const type = guessMimeType(filename);
const file = new File([content], filename, type ? { type } : undefined);
const file = new File([content as Uint8Array<ArrayBuffer>], filename, type ? { type } : undefined);
const relativePath = normalizedBase ? `${normalizedBase}/${path}` : path;
entries.push({ file, relativePath });
}
+82 -85
View File
@@ -11,8 +11,8 @@
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^11.0.1",
"husky": "^9.1.7",
"lint-staged": "^15.2.10",
"prettier": "^3.9.9",
"lint-staged": "^16.4.0",
"prettier": "^3.9.10",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-sh": "^0.20.2",
"semantic-release": "^25.0.9",
@@ -1657,17 +1657,34 @@
}
},
"node_modules/cli-truncate": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
"integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
"integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^5.0.0",
"string-width": "^7.0.0"
"slice-ansi": "^8.0.0",
"string-width": "^8.2.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/string-width": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -1716,13 +1733,13 @@
"license": "MIT"
},
"node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/compare-func": {
@@ -2753,13 +2770,16 @@
}
},
"node_modules/is-fullwidth-code-point": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -2962,19 +2982,6 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -2983,41 +2990,50 @@
"license": "MIT"
},
"node_modules/lint-staged": {
"version": "15.5.2",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz",
"integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==",
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
"commander": "^13.1.0",
"debug": "^4.4.0",
"execa": "^8.0.1",
"lilconfig": "^3.1.3",
"listr2": "^8.2.5",
"micromatch": "^4.0.8",
"pidtree": "^0.6.0",
"commander": "^14.0.3",
"listr2": "^9.0.5",
"picomatch": "^4.0.3",
"string-argv": "^0.3.2",
"yaml": "^2.7.0"
"tinyexec": "^1.0.4",
"yaml": "^2.8.2"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
"node": ">=18.12.0"
"node": ">=20.17"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/lint-staged/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/listr2": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz",
"integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^4.0.0",
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
"eventemitter3": "^5.0.1",
"log-update": "^6.1.0",
@@ -3025,7 +3041,7 @@
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
}
},
"node_modules/load-json-file": {
@@ -3134,22 +3150,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
@@ -5662,19 +5662,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidtree": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz",
"integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==",
"dev": true,
"license": "MIT",
"bin": {
"pidtree": "bin/pidtree.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/pify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
@@ -5776,9 +5763,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
"integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
"dev": true,
"license": "MIT",
"bin": {
@@ -6879,17 +6866,17 @@
}
},
"node_modules/slice-ansi": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
"integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.0.0",
"is-fullwidth-code-point": "^4.0.0"
"ansi-styles": "^6.2.3",
"is-fullwidth-code-point": "^5.1.0"
},
"engines": {
"node": ">=12"
"node": ">=20"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
@@ -7232,6 +7219,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+2 -2
View File
@@ -7,8 +7,8 @@
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^11.0.1",
"husky": "^9.1.7",
"lint-staged": "^15.2.10",
"prettier": "^3.9.9",
"lint-staged": "^16.4.0",
"prettier": "^3.9.10",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-sh": "^0.20.2",
"semantic-release": "^25.0.9",
+20 -20
View File
@@ -7,19 +7,19 @@
"name": "thingport-regression",
"license": "AGPL-3.0-only",
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^20.14.0",
"@playwright/test": "^1.64.1",
"@types/node": "^24.0.0",
"fflate": "^0.8.3"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -29,13 +29,13 @@
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/fflate": {
@@ -46,13 +46,13 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -62,9 +62,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -75,9 +75,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
}
+2 -2
View File
@@ -8,8 +8,8 @@
"screenshots": "node screenshots.mjs"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^20.14.0",
"@playwright/test": "^1.64.1",
"@types/node": "^24.0.0",
"fflate": "^0.8.3"
}
}
+20
View File
@@ -7,6 +7,26 @@
"url": "https://github.com/TautvydasDerzinskas/Thingport",
"published_at": "2026-10-11T12:00:00Z",
"audience": "all"
},
{
"id": "2026-10-update-to-1.0.0",
"title": "Thingport 1.0.0 is out",
"body": "The first stable release brings a remade administration panel, single sign-on providers, and many bug fixes and UI improvements. Update to get them.",
"url": "https://github.com/TautvydasDerzinskas/Thingport/releases",
"published_at": "2026-10-17T12:00:00Z",
"audience": "admins",
"version": "<1.0.0"
}
],
"entries": [
{
"id": "2026-10-welcome-to-1.0.0",
"title": "Welcome to Thingport 1.0.0",
"body": "Thanks for updating to the first stable release! Run a system health check in Administration to make sure everything is set up right. From now on, releases will come less often but pack more changes.",
"path": "/admin",
"published_at": "2026-10-17T12:00:00Z",
"audience": "admins",
"version": "1.0.0"
}
]
}
-1
View File
@@ -1 +0,0 @@
22
+1 -1
View File
@@ -13,7 +13,7 @@ npm run build # static output in dist/
npm run check # type-check
```
Requires Node 22.12+ (see `.nvmrc`).
Requires Node 24+ (see the root `.nvmrc`).
## Where the content lives
+455 -903
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -5,7 +5,7 @@
"license": "AGPL-3.0-only",
"type": "module",
"engines": {
"node": ">=22.12.0"
"node": ">=24"
},
"scripts": {
"dev": "astro dev",
@@ -14,15 +14,18 @@
"check": "astro check"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.3.1",
"@astrojs/markdown-remark": "^7.3.3",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.4",
"astro": "^7.3.4",
"astro": "^7.4.0",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"sharp": "^0.35.4",
"sharp": "^0.35.5",
"typescript": "^6.0.3"
},
"allowScripts": {
"fsevents": false
}
}