mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: finish the homepage plugin, move service links and canvas out of core (B19)
This commit is contained in:
1 parent
21937a6b02
commit
d4ec444558
256 files changed
+27365
-9077
No files matched your search
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user