feat: finish the homepage plugin, move service links and canvas out of core (B19)

This commit is contained in:
LukeGus committed 2026-09-24 15:07:25 -05:00
1 parent 21937a6b02
commit d4ec444558
256 files changed
+27365 -9077

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
homepageItems,
homepageLayouts,
dashboardServiceLinks,
} from "./tables.js";
import { createHomepageRepository } from "./repository.js";
import { registerHomepageRoutes } from "./routes.js";
import { registerOutboundRoutes } from "./outbound-routes.js";
import { createHomepageItemsService } from "./services.js";
export type { HomepageItemsV1 } from "./services.js";
export async function activate(ctx: PluginContext) {
const items = await ctx.db.define(homepageItems);
const layouts = await ctx.db.define(homepageLayouts);
const serviceLinks = await ctx.db.define(dashboardServiceLinks);
const repo = createHomepageRepository(ctx.db, {
items,
layouts,
serviceLinks,
});
const router = ctx.http.router<Router>();
registerHomepageRoutes(router, repo, ctx);
registerOutboundRoutes(router, ctx);
ctx.sync.registerEntity({ type: "homepageItems", table: items, order: 80 });
ctx.sync.registerEntity({
type: "dashboardServiceLinks",
table: serviceLinks,
order: 70,
});
ctx.services.provide("homepage.items", createHomepageItemsService(ctx, repo));
ctx.log.info("Homepage routes mounted at /plugin-api/homepage");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -0,0 +1,426 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
const FETCH_TIMEOUT_MS = 8000;
const MAX_FAVICON_BYTES = 512 * 1024;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
interface RssItem {
title: string;
link: string;
pubDate: string | null;
description: string | null;
}
async function readBufferLimited(
response: globalThis.Response,
maxBytes: number,
): Promise<Buffer> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel().catch(() => {});
throw new Error(`Response exceeds ${maxBytes} bytes`);
}
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => {});
throw new Error(`Response exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
return Buffer.concat(chunks, total);
}
async function readTextLimited(
response: globalThis.Response,
maxBytes: number,
): Promise<string> {
const buffer = await readBufferLimited(response, maxBytes);
return buffer.toString("utf8");
}
function parseRss(xml: string): RssItem[] {
const items: RssItem[] = [];
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
let match: RegExpExecArray | null;
const getText = (tag: string, src: string): string | null => {
const m = new RegExp(
`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
"i",
).exec(src);
if (!m) return null;
return (m[1] ?? m[2]).trim();
};
const getLink = (src: string): string => {
// Self-closing <link rel="alternate" href="..." /> (BBC style)
const selfClose = /<link[^>]+href="([^"]+)"[^>]*\/?>/i.exec(src);
if (selfClose) return selfClose[1];
// Plain text <link>url</link>
return getText("link", src) ?? "";
};
while ((match = itemRegex.exec(xml)) !== null) {
const src = match[1];
items.push({
title: getText("title", src) ?? "(no title)",
link: getLink(src),
pubDate: getText("pubDate", src) ?? getText("updated", src),
description: getText("description", src) ?? getText("summary", src),
});
if (items.length >= 50) break;
}
// Atom feed fallback
if (items.length === 0) {
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
while ((match = entryRegex.exec(xml)) !== null) {
const src = match[1];
const linkMatch = /<link[^>]+href="([^"]+)"/.exec(src);
items.push({
title: getText("title", src) ?? "(no title)",
link: linkMatch?.[1] ?? "",
pubDate: getText("published", src) ?? getText("updated", src),
description: getText("summary", src) ?? getText("content", src),
});
if (items.length >= 50) break;
}
}
return items;
}
/**
* The favicon, RSS, ping and proxy routes all make outbound requests. Every
* one goes through ctx.fetch, which keeps the existing safe-outbound rules
* (http/https only, no embedded credentials, DNS pinned, private and
* loopback addresses refused).
*/
export function registerOutboundRoutes(
router: Router,
ctx: PluginContext,
): void {
const faviconCache = new Map<
string,
{ data: Buffer; contentType: string; expires: number }
>();
const FAVICON_CACHE_SIZE = 100;
const FAVICON_CACHE_TTL_MS = 1000 * 60 * 60 * 24;
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
const RSS_CACHE_SIZE = 50;
const RSS_CACHE_TTL_MS = 1000 * 60 * 15;
interface PingCacheEntry {
ok: boolean;
statusCode: number | null;
latencyMs: number;
expires: number;
}
const pingCache = new Map<string, PingCacheEntry>();
const PING_CACHE_SIZE = 200;
const proxyCache = new Map<string, { data: unknown; expires: number }>();
const PROXY_CACHE_SIZE = 50;
/**
* @openapi
* /plugin-api/homepage/favicon:
* get:
* summary: Proxy favicon fetch
* description: Fetches and caches a site favicon server-side to avoid CORS issues.
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Favicon image.
* 400:
* description: Invalid URL.
* 500:
* description: Failed to fetch favicon.
*/
router.get("/favicon", async (req: Request, res: Response) => {
const rawUrl = req.query.url as string;
if (!rawUrl) return res.status(400).json({ error: "url is required" });
let domain: string;
try {
domain = new URL(rawUrl).hostname;
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = faviconCache.get(domain);
if (cached && cached.expires > Date.now()) {
res.setHeader("Content-Type", cached.contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
return res.send(cached.data);
}
const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
try {
const response = await ctx.fetch(faviconUrl, {
timeoutMs: FETCH_TIMEOUT_MS,
});
if (!response.ok)
throw new Error(`Favicon fetch failed: ${response.status}`);
const data = await readBufferLimited(response, MAX_FAVICON_BYTES);
const contentType =
response.headers.get("content-type") || "image/x-icon";
if (faviconCache.size >= FAVICON_CACHE_SIZE) {
const oldest = faviconCache.keys().next().value;
if (oldest) faviconCache.delete(oldest);
}
faviconCache.set(domain, {
data,
contentType,
expires: Date.now() + FAVICON_CACHE_TTL_MS,
});
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.send(data);
} catch {
ctx.log.warn(`Failed to fetch favicon for ${domain}`);
res.status(500).json({ error: "Failed to fetch favicon" });
}
});
/**
* @openapi
* /plugin-api/homepage/rss:
* get:
* summary: Proxy and parse an RSS/Atom feed
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* - in: query
* name: max
* schema:
* type: integer
* default: 10
* responses:
* 200:
* description: Array of feed items.
* 400:
* description: Invalid or missing URL.
* 500:
* description: Failed to fetch or parse the feed.
*/
router.get("/rss", async (req: Request, res: Response) => {
const feedUrl = req.query.url as string;
const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
if (!feedUrl) return res.status(400).json({ error: "url is required" });
try {
new URL(feedUrl);
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = rssCache.get(feedUrl);
if (cached && cached.expires > Date.now()) {
return res.json(cached.data.slice(0, max));
}
try {
const response = await ctx.fetch(feedUrl, {
timeoutMs: FETCH_TIMEOUT_MS,
});
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`);
const xml = await readTextLimited(response, MAX_RESPONSE_BYTES);
const items = parseRss(xml);
if (rssCache.size >= RSS_CACHE_SIZE) {
const oldest = rssCache.keys().next().value;
if (oldest) rssCache.delete(oldest);
}
rssCache.set(feedUrl, {
data: items,
expires: Date.now() + RSS_CACHE_TTL_MS,
});
res.json(items.slice(0, max));
} catch {
ctx.log.warn(`Failed to fetch RSS feed ${feedUrl}`);
res.status(500).json({ error: "Failed to fetch feed" });
}
});
/**
* @openapi
* /plugin-api/homepage/ping:
* get:
* summary: Check the HTTP reachability and latency of a URL
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* - in: query
* name: ttl
* schema:
* type: integer
* description: Cache TTL in seconds (min 10)
* responses:
* 200:
* description: Ping result with ok, statusCode and latencyMs.
* 400:
* description: Invalid or missing URL.
*/
router.get("/ping", async (req: Request, res: Response) => {
let targetUrl = req.query.url as string;
const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
if (!targetUrl) return res.status(400).json({ error: "url is required" });
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
try {
new URL(targetUrl);
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = pingCache.get(targetUrl);
if (cached && cached.expires > Date.now()) {
return res.json({
ok: cached.ok,
statusCode: cached.statusCode,
latencyMs: cached.latencyMs,
});
}
const requestStatus = async (
url: string,
method: "HEAD" | "GET",
): Promise<number | null> => {
const response = await ctx.fetch(url, {
method,
timeoutMs: FETCH_TIMEOUT_MS,
});
await response.body?.cancel().catch(() => {});
return response.status ?? null;
};
const start = performance.now();
try {
let code = await requestStatus(targetUrl, "HEAD");
if (code === 405) {
code = await requestStatus(targetUrl, "GET");
}
const result = {
ok: code !== null && code < 400,
statusCode: code,
latencyMs: Math.round(performance.now() - start),
};
if (pingCache.size >= PING_CACHE_SIZE) {
const oldest = pingCache.keys().next().value;
if (oldest) pingCache.delete(oldest);
}
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
res.json(result);
} catch (err) {
ctx.log.warn(`Ping failed for ${targetUrl}: ${String(err)}`);
res.status(500).json({ error: "Ping failed" });
}
});
/**
* @openapi
* /plugin-api/homepage/proxy:
* get:
* summary: Proxy a JSON API URL and return the parsed response
* tags:
* - Homepage
* parameters:
* - in: query
* name: url
* required: true
* schema:
* type: string
* - in: query
* name: ttl
* schema:
* type: integer
* description: Cache TTL in seconds (min 10, default 60)
* responses:
* 200:
* description: The JSON body returned by the target URL.
* 400:
* description: Invalid or missing URL, or non-JSON response.
* 500:
* description: Failed to fetch the target URL.
*/
router.get("/proxy", async (req: Request, res: Response) => {
const targetUrl = req.query.url as string;
const ttl =
Math.min(
MAX_CACHE_TTL_SECONDS,
Math.max(10, Number(req.query.ttl) || 60),
) * 1000;
if (!targetUrl) return res.status(400).json({ error: "url is required" });
try {
new URL(targetUrl);
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
const cached = proxyCache.get(targetUrl);
if (cached && cached.expires > Date.now()) {
return res.json(cached.data);
}
try {
const response = await ctx.fetch(targetUrl, {
timeoutMs: FETCH_TIMEOUT_MS,
});
if (!response.ok)
throw new Error(`Proxy fetch failed: ${response.status}`);
const text = await readTextLimited(response, MAX_RESPONSE_BYTES);
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error("Response is not valid JSON");
}
if (proxyCache.size >= PROXY_CACHE_SIZE) {
const oldest = proxyCache.keys().next().value;
if (oldest) proxyCache.delete(oldest);
}
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
res.json(data);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.log.warn(`Proxy fetch failed for ${targetUrl}: ${msg}`);
if (msg.includes("not valid JSON")) {
return res.status(400).json({ error: "Response is not valid JSON" });
}
res.status(500).json({ error: "Failed to fetch URL" });
}
});
}
+288
View File
@@ -0,0 +1,288 @@
import { randomUUID } from "node:crypto";
import { and, asc, eq } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface HomepageItemRecord {
id: number;
userId: string;
typeId: string;
title: string | null;
config: string;
folderId: number | null;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export interface HomepageLayoutRecord {
id: number;
userId: string;
layout: string;
updatedAt: string;
}
export interface ServiceLinkRecord {
id: number;
userId: string;
label: string;
url: string;
order: number;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
/* 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;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type HomepageRepository = ReturnType<typeof createHomepageRepository>;
export function createHomepageRepository(
db: PluginDatabase,
tables: { items: Table; layouts: Table; serviceLinks: Table },
) {
const client = () => db.client<Drizzle>();
async function findItemById(
userId: string,
id: number,
): Promise<HomepageItemRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(tables.items)
.where(and(eq(tables.items.id, id), eq(tables.items.userId, userId)))
.limit(1);
return (rows[0] as HomepageItemRecord) ?? null;
}
async function findItemBySyncId(syncId: string): Promise<HomepageItemRecord> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(tables.items)
.where(eq(tables.items.syncId, syncId))
.limit(1);
return rows[0] as HomepageItemRecord;
}
async function findServiceLinkById(
userId: string,
id: number,
): Promise<ServiceLinkRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(tables.serviceLinks)
.where(
and(
eq(tables.serviceLinks.id, id),
eq(tables.serviceLinks.userId, userId),
),
)
.limit(1);
return (rows[0] as ServiceLinkRecord) ?? null;
}
async function findServiceLinkBySyncId(
syncId: string,
): Promise<ServiceLinkRecord> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(tables.serviceLinks)
.where(eq(tables.serviceLinks.syncId, syncId))
.limit(1);
return rows[0] as ServiceLinkRecord;
}
return {
findItemById,
async listItemsByUser(userId: string): Promise<HomepageItemRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(tables.items)
.where(eq(tables.items.userId, userId));
},
async createItem(
userId: string,
input: { typeId: string; title: string | null; config: string },
): Promise<HomepageItemRecord> {
const syncId = randomUUID();
const now = new Date().toISOString();
const drizzle = await client();
await drizzle.insert(tables.items).values({
userId,
typeId: input.typeId,
title: input.title,
config: input.config,
syncId,
createdAt: now,
updatedAt: now,
});
await db.persist();
return findItemBySyncId(syncId);
},
async updateItem(
userId: string,
id: number,
updates: { title?: string | null; config?: string },
): Promise<HomepageItemRecord | null> {
const existing = await findItemById(userId, id);
if (!existing) return null;
const drizzle = await client();
await drizzle
.update(tables.items)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(and(eq(tables.items.id, id), eq(tables.items.userId, userId)));
await db.persist();
return findItemById(userId, id);
},
async deleteItem(
userId: string,
id: number,
): Promise<HomepageItemRecord | null> {
const existing = await findItemById(userId, id);
if (!existing) return null;
const drizzle = await client();
await drizzle
.delete(tables.items)
.where(and(eq(tables.items.id, id), eq(tables.items.userId, userId)));
await db.persist();
return existing;
},
async findLayoutByUser(
userId: string,
): Promise<HomepageLayoutRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(tables.layouts)
.where(eq(tables.layouts.userId, userId))
.limit(1);
return (rows[0] as HomepageLayoutRecord) ?? null;
},
async upsertLayout(
userId: string,
layout: string,
now: string,
): Promise<HomepageLayoutRecord> {
const drizzle = await client();
const existing = await drizzle
.select()
.from(tables.layouts)
.where(eq(tables.layouts.userId, userId))
.limit(1);
if (existing[0]) {
await drizzle
.update(tables.layouts)
.set({ layout, updatedAt: now })
.where(eq(tables.layouts.userId, userId));
} else {
await drizzle.insert(tables.layouts).values({
userId,
layout,
updatedAt: now,
});
}
await db.persist();
const rows = await drizzle
.select()
.from(tables.layouts)
.where(eq(tables.layouts.userId, userId))
.limit(1);
return rows[0] as HomepageLayoutRecord;
},
findServiceLinkById,
async listServiceLinksByUser(userId: string): Promise<ServiceLinkRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(tables.serviceLinks)
.where(eq(tables.serviceLinks.userId, userId))
.orderBy(asc(tables.serviceLinks.order), asc(tables.serviceLinks.id));
},
async createServiceLink(
userId: string,
input: { label: string; url: string },
): Promise<ServiceLinkRecord> {
const drizzle = await client();
const existing = await drizzle
.select({ order: tables.serviceLinks.order })
.from(tables.serviceLinks)
.where(eq(tables.serviceLinks.userId, userId))
.orderBy(asc(tables.serviceLinks.order));
const nextOrder =
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
const syncId = randomUUID();
const now = new Date().toISOString();
await drizzle.insert(tables.serviceLinks).values({
userId,
label: input.label,
url: input.url,
order: nextOrder,
syncId,
createdAt: now,
updatedAt: now,
});
await db.persist();
return findServiceLinkBySyncId(syncId);
},
async updateServiceLink(
userId: string,
id: number,
updates: { label?: string; url?: string },
): Promise<ServiceLinkRecord | null> {
const existing = await findServiceLinkById(userId, id);
if (!existing) return null;
const drizzle = await client();
await drizzle
.update(tables.serviceLinks)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(
and(
eq(tables.serviceLinks.id, id),
eq(tables.serviceLinks.userId, userId),
),
);
await db.persist();
return findServiceLinkById(userId, id);
},
async deleteServiceLink(
userId: string,
id: number,
): Promise<ServiceLinkRecord | null> {
const existing = await findServiceLinkById(userId, id);
if (!existing) return null;
const drizzle = await client();
await drizzle
.delete(tables.serviceLinks)
.where(
and(
eq(tables.serviceLinks.id, id),
eq(tables.serviceLinks.userId, userId),
),
);
await db.persist();
return existing;
},
};
}
+525
View File
@@ -0,0 +1,525 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { HomepageRepository } from "./repository.js";
import { isValidServiceLinkUrl, normalizeServiceLinkUrl } from "./url.js";
function actor(ctx: PluginContext): string {
// Core's plugin router authenticates every request and runs it as that user.
return ctx.currentActor() as string;
}
function parseId(raw: unknown): number | null {
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
return Number.isInteger(id) ? id : null;
}
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function logError(
ctx: PluginContext,
message: string,
error: unknown,
operation: string,
): void {
ctx.log.error(
`${message} (${operation})`,
error instanceof Error ? error : new Error(String(error)),
);
}
/**
* Mounts the homepage routes on the plugin's router, served at
* /plugin-api/homepage with core auth in front.
*/
export function registerHomepageRoutes(
router: Router,
repo: HomepageRepository,
ctx: PluginContext,
): void {
router.use(ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/homepage/items:
* get:
* summary: Get homepage items
* description: Returns all homepage widget items for the authenticated user.
* tags:
* - Homepage
* responses:
* 200:
* description: List of homepage items.
* 500:
* description: Failed to fetch homepage items.
*/
router.get("/items", async (req: Request, res: Response) => {
const userId = actor(ctx);
try {
const items = await repo.listItemsByUser(userId);
res.json(items);
} catch (err) {
logError(
ctx,
"Failed to fetch homepage items",
err,
"homepage_items_list_failed",
);
res.status(500).json({ error: "Failed to fetch homepage items" });
}
});
/**
* @openapi
* /plugin-api/homepage/items:
* post:
* summary: Create homepage item
* description: Creates a new homepage widget item for the authenticated user.
* tags:
* - Homepage
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - typeId
* properties:
* typeId:
* type: string
* title:
* type: string
* config:
* type: object
* responses:
* 201:
* description: Homepage item created.
* 400:
* description: Invalid data.
* 500:
* description: Failed to create homepage item.
*/
router.post("/items", async (req: Request, res: Response) => {
const userId = actor(ctx);
const { typeId, title, config } = req.body ?? {};
if (!isNonEmptyString(typeId)) {
return res.status(400).json({ error: "typeId is required" });
}
try {
const created = await repo.createItem(userId, {
typeId,
title: title ?? null,
config: config ? JSON.stringify(config) : "{}",
});
res.status(201).json(created);
} catch (err) {
logError(
ctx,
"Failed to create homepage item",
err,
"homepage_item_create_failed",
);
res.status(500).json({ error: "Failed to create homepage item" });
}
});
/**
* @openapi
* /plugin-api/homepage/items/{id}:
* put:
* summary: Update homepage item
* description: Updates a homepage widget item's title or config.
* tags:
* - Homepage
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* title:
* type: string
* config:
* type: object
* responses:
* 200:
* description: Homepage item updated.
* 400:
* description: Invalid id.
* 404:
* description: Not found.
* 500:
* description: Failed to update homepage item.
*/
router.put("/items/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const { title, config } = req.body ?? {};
const updates: { title?: string | null; config?: string } = {};
if (title !== undefined) updates.title = title;
if (config !== undefined) updates.config = JSON.stringify(config);
try {
const updated = await repo.updateItem(userId, id, updates);
if (!updated) return res.status(404).json({ error: "Not found" });
res.json(updated);
} catch (err) {
logError(
ctx,
"Failed to update homepage item",
err,
"homepage_item_update_failed",
);
res.status(500).json({ error: "Failed to update homepage item" });
}
});
/**
* @openapi
* /plugin-api/homepage/items/{id}:
* delete:
* summary: Delete homepage item
* description: Deletes a homepage widget item.
* tags:
* - Homepage
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Homepage item deleted.
* 400:
* description: Invalid id.
* 404:
* description: Not found.
* 500:
* description: Failed to delete homepage item.
*/
router.delete("/items/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const deleted = await repo.deleteItem(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
await ctx.sync.recordTombstone(userId, "homepageItems", deleted.syncId);
res.json({ message: "Homepage item deleted" });
} catch (err) {
logError(
ctx,
"Failed to delete homepage item",
err,
"homepage_item_delete_failed",
);
res.status(500).json({ error: "Failed to delete homepage item" });
}
});
/**
* @openapi
* /plugin-api/homepage/layout:
* get:
* summary: Get homepage layout
* description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
* tags:
* - Homepage
* responses:
* 200:
* description: Layout data or null.
* 500:
* description: Failed to fetch homepage layout.
*/
router.get("/layout", async (req: Request, res: Response) => {
const userId = actor(ctx);
try {
const row = await repo.findLayoutByUser(userId);
if (!row) return res.json(null);
const parsed = JSON.parse(row.layout || "{}");
res.json({ ...row, layout: parsed });
} catch (err) {
logError(
ctx,
"Failed to fetch homepage layout",
err,
"homepage_layout_get_failed",
);
res.status(500).json({ error: "Failed to fetch homepage layout" });
}
});
/**
* @openapi
* /plugin-api/homepage/layout:
* put:
* summary: Save homepage layout
* description: Saves or updates the homepage canvas layout for the authenticated user.
* tags:
* - Homepage
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* entries:
* type: array
* pan:
* type: object
* zoom:
* type: number
* responses:
* 200:
* description: Layout saved.
* 500:
* description: Failed to save homepage layout.
*/
router.put("/layout", async (req: Request, res: Response) => {
const userId = actor(ctx);
try {
const layoutJson = JSON.stringify(req.body ?? {});
const now = new Date().toISOString();
const updated = await repo.upsertLayout(userId, layoutJson, now);
const parsed = JSON.parse(updated.layout);
res.json({ ...updated, layout: parsed });
} catch (err) {
logError(
ctx,
"Failed to save homepage layout",
err,
"homepage_layout_save_failed",
);
res.status(500).json({ error: "Failed to save homepage layout" });
}
});
/**
* @openapi
* /plugin-api/homepage/service-links:
* get:
* summary: Get service links
* description: Returns all dashboard service links for the authenticated user.
* tags:
* - Homepage
* responses:
* 200:
* description: List of service links.
* 500:
* description: Failed to fetch service links.
*/
router.get("/service-links", async (req: Request, res: Response) => {
const userId = actor(ctx);
try {
const links = await repo.listServiceLinksByUser(userId);
res.json(links);
} catch (err) {
logError(
ctx,
"Failed to fetch service links",
err,
"service_links_list_failed",
);
res.status(500).json({ error: "Failed to fetch service links" });
}
});
/**
* @openapi
* /plugin-api/homepage/service-links:
* post:
* summary: Create service link
* description: Creates a new dashboard service link for the authenticated user.
* tags:
* - Homepage
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - label
* - url
* properties:
* label:
* type: string
* url:
* type: string
* responses:
* 201:
* description: Service link created.
* 400:
* description: Invalid data.
* 500:
* description: Failed to create service link.
*/
router.post("/service-links", async (req: Request, res: Response) => {
const userId = actor(ctx);
const { label, url } = req.body ?? {};
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
return res.status(400).json({ error: "label and url are required" });
}
const normalizedUrl = normalizeServiceLinkUrl(url);
if (!isValidServiceLinkUrl(normalizedUrl)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const created = await repo.createServiceLink(userId, {
label: label.trim(),
url: normalizedUrl,
});
res.status(201).json(created);
} catch (err) {
logError(
ctx,
"Failed to create service link",
err,
"service_link_create_failed",
);
res.status(500).json({ error: "Failed to create service link" });
}
});
/**
* @openapi
* /plugin-api/homepage/service-links/{id}:
* put:
* summary: Update service link
* description: Updates label or url of a dashboard service link.
* tags:
* - Homepage
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* label:
* type: string
* url:
* type: string
* responses:
* 200:
* description: Service link updated.
* 400:
* description: Invalid data.
* 404:
* description: Not found.
* 500:
* description: Failed to update service link.
*/
router.put("/service-links/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const { label, url } = req.body ?? {};
if (
url !== undefined &&
!isValidServiceLinkUrl(normalizeServiceLinkUrl(url))
) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
const updates: { label?: string; url?: string } = {};
if (isNonEmptyString(label)) updates.label = label.trim();
if (isNonEmptyString(url)) updates.url = normalizeServiceLinkUrl(url);
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "Nothing to update" });
}
try {
const updated = await repo.updateServiceLink(userId, id, updates);
if (!updated) return res.status(404).json({ error: "Not found" });
res.json(updated);
} catch (err) {
logError(
ctx,
"Failed to update service link",
err,
"service_link_update_failed",
);
res.status(500).json({ error: "Failed to update service link" });
}
});
/**
* @openapi
* /plugin-api/homepage/service-links/{id}:
* delete:
* summary: Delete service link
* description: Deletes a dashboard service link by id.
* tags:
* - Homepage
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Service link deleted.
* 400:
* description: Invalid id.
* 404:
* description: Not found.
* 500:
* description: Failed to delete service link.
*/
router.delete("/service-links/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const deleted = await repo.deleteServiceLink(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
await ctx.sync.recordTombstone(
userId,
"dashboardServiceLinks",
deleted.syncId,
);
res.json({ message: "Service link deleted" });
} catch (err) {
logError(
ctx,
"Failed to delete service link",
err,
"service_link_delete_failed",
);
res.status(500).json({ error: "Failed to delete service link" });
}
});
}
+27
View File
@@ -0,0 +1,27 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { HomepageRepository } from "./repository.js";
/** The "homepage.items" service, version 1. Runs as the caller's user. */
export interface HomepageItemsV1 {
list: () => Promise<
Array<{ id: number; typeId: string; title: string | null }>
>;
}
export function createHomepageItemsService(
ctx: PluginContext,
repo: HomepageRepository,
): HomepageItemsV1 {
return {
list: async () => {
const userId = ctx.currentActor();
if (!userId) return [];
const rows = await repo.listItemsByUser(userId);
return rows.map((row) => ({
id: row.id,
typeId: row.typeId,
title: row.title,
}));
},
};
}
+68
View File
@@ -0,0 +1,68 @@
import {
adoptLegacyTable,
defineTable,
id,
integer,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/** One homepage widget tile. folderId points at another row when it sits in a folder. */
export const homepageItems = adoptLegacyTable(
"homepage_items",
defineTable(
"homepage_items",
{
id: id(),
userId: refUser(),
typeId: text().notNull(),
title: text(),
config: text().notNull().default("{}"),
folderId: integer(),
syncId: varchar().unique(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [{ name: "idx_homepage_items_user_id", columns: ["userId"] }],
},
),
);
/** One canvas layout (widget positions, pan, zoom) per user. */
export const homepageLayouts = adoptLegacyTable(
"homepage_layouts",
defineTable("homepage_layouts", {
id: id(),
userId: refUser().unique(),
layout: text().notNull().default("{}"),
updatedAt: timestamp().notNull().defaultNow(),
}),
);
/** Clickable dashboard service link buttons. */
export const dashboardServiceLinks = adoptLegacyTable(
"dashboard_service_links",
defineTable(
"dashboard_service_links",
{
id: id(),
userId: refUser(),
label: text().notNull(),
url: text().notNull(),
order: integer().notNull().default(0),
syncId: varchar().unique(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_dashboard_service_links_user_id", columns: ["userId"] },
],
},
),
);
export const tables = [homepageItems, homepageLayouts, dashboardServiceLinks];
+16
View File
@@ -0,0 +1,16 @@
export function normalizeServiceLinkUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
? trimmed
: `http://${trimmed}`;
}
export function isValidServiceLinkUrl(value: string): boolean {
try {
const parsed = new URL(normalizeServiceLinkUrl(value));
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}