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

@@ -0,0 +1,240 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { dashboardLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { createCurrentRecentActivityRepository } from "../repositories/factory.js";
import { recordRecentActivity } from "../../services/recent-activity.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const serverStartTime = Date.now();
function isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
/**
* @openapi
* /dashboard/uptime:
* get:
* summary: Get server uptime
* description: Returns the uptime of the server in various formats.
* tags:
* - Dashboard
* responses:
* 200:
* description: Server uptime information.
* content:
* application/json:
* schema:
* type: object
* properties:
* uptimeMs:
* type: number
* uptimeSeconds:
* type: number
* formatted:
* type: string
* 500:
* description: Failed to get uptime.
*/
router.get("/uptime", authenticateJWT, async (req: Request, res: Response) => {
try {
const uptimeMs = Date.now() - serverStartTime;
const uptimeSeconds = Math.floor(uptimeMs / 1000);
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
res.json({
uptimeMs,
uptimeSeconds,
formatted: `${days}d ${hours}h ${minutes}m`,
});
} catch (err) {
dashboardLogger.error("Failed to get uptime", err);
res.status(500).json({ error: "Failed to get uptime" });
}
});
/**
* @openapi
* /dashboard/activity/recent:
* get:
* summary: Get recent activity
* description: Fetches the most recent activities for the authenticated user.
* tags:
* - Dashboard
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* description: The maximum number of activities to return.
* responses:
* 200:
* description: A list of recent activities.
* 401:
* description: Session expired.
* 500:
* description: Failed to get recent activity.
*/
router.get(
"/activity/recent",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const limit = Number(req.query.limit) || 20;
const activities =
await createCurrentRecentActivityRepository().listByUserId(
userId,
limit,
);
res.json(activities);
} catch (err) {
dashboardLogger.error("Failed to get recent activity", err);
res.status(500).json({ error: "Failed to get recent activity" });
}
},
);
/**
* @openapi
* /dashboard/activity/log:
* post:
* summary: Log a new activity
* description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited.
* tags:
* - Dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* type:
* type: string
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
* hostId:
* type: integer
* hostName:
* type: string
* responses:
* 200:
* description: Activity logged successfully or rate-limited.
* 400:
* description: Invalid request body.
* 401:
* description: Session expired.
* 404:
* description: Host not found or access denied.
* 500:
* description: Failed to log activity.
*/
router.post(
"/activity/log",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const { type, hostId, hostName } = req.body;
if (!type || !hostId || !hostName) {
return res.status(400).json({
error: "Missing required fields: type, hostId, hostName",
});
}
const result = await recordRecentActivity(userId, {
type,
hostId,
hostName,
});
if (result.status === "invalid_type") {
return res.status(400).json({
error:
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
});
}
if (result.status === "rate_limited") {
return res.json({
message: "Activity already logged recently (rate limited)",
});
}
if (result.status === "denied") {
return res
.status(404)
.json({ error: "Host not found or access denied" });
}
res.json({ message: "Activity logged", id: result.id });
} catch (err) {
dashboardLogger.error("Failed to log activity", err);
res.status(500).json({ error: "Failed to log activity" });
}
},
);
/**
* @openapi
* /dashboard/activity/reset:
* delete:
* summary: Reset recent activity
* description: Clears all recent activity for the authenticated user.
* tags:
* - Dashboard
* responses:
* 200:
* description: Recent activity cleared.
* 401:
* description: Session expired.
* 500:
* description: Failed to reset activity.
*/
router.delete(
"/activity/reset",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId;
await createCurrentRecentActivityRepository().deleteByUserId(userId);
dashboardLogger.success("Recent activity cleared", {
operation: "reset_recent_activity",
userId,
});
res.json({ message: "Recent activity cleared" });
} catch (err) {
dashboardLogger.error("Failed to reset activity", err);
res.status(500).json({ error: "Failed to reset activity" });
}
},
);
export default router;
@@ -1,263 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { dashboardLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { isNonEmptyString } from "./host-normalizers.js";
import {
createCurrentDashboardServiceLinkRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
export const dashboardServiceLinksRouter = express.Router();
function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/**
* @openapi
* /service-links:
* get:
* summary: Get service links
* description: Returns all dashboard service links for the authenticated user.
* tags:
* - Dashboard
* responses:
* 200:
* description: List of service links.
* 500:
* description: Failed to fetch service links.
*/
dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const links =
await createCurrentDashboardServiceLinkRepository().listByUserId(userId);
res.json(links);
} catch (err) {
dashboardLogger.error("Failed to fetch service links", err);
res.status(500).json({ error: "Failed to fetch service links" });
}
});
/**
* @openapi
* /service-links:
* post:
* summary: Create service link
* description: Creates a new dashboard service link for the authenticated user.
* tags:
* - Dashboard
* 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.
*/
dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { label, url } = req.body;
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
return res.status(400).json({ error: "label and url are required" });
}
if (!isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const created =
await createCurrentDashboardServiceLinkRepository().createForUser(
userId,
{
label: label.trim(),
url: url.trim(),
},
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_created");
res.status(201).json(created);
} catch (err) {
dashboardLogger.error("Failed to create service link", err);
res.status(500).json({ error: "Failed to create service link" });
}
});
/**
* @openapi
* /service-links/{id}:
* delete:
* summary: Delete service link
* description: Deletes a dashboard service link by ID.
* tags:
* - Dashboard
* 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.
*/
dashboardServiceLinksRouter.delete(
"/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const idParam = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(idParam);
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
try {
const existing =
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
userId,
id,
);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const deleted =
await createCurrentDashboardServiceLinkRepository().deleteForUser(
userId,
id,
);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"dashboardServiceLinks",
deleted.syncId,
);
}
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
res.json({ message: "Service link deleted" });
} catch (err) {
dashboardLogger.error("Failed to delete service link", err);
res.status(500).json({ error: "Failed to delete service link" });
}
},
);
/**
* @openapi
* /service-links/{id}:
* put:
* summary: Update service link
* description: Updates label or url of a dashboard service link.
* tags:
* - Dashboard
* 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.
*/
dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const idParam = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(idParam);
const { label, url } = req.body;
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
if (url !== undefined && !isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const existing =
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
userId,
id,
);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const updates: Partial<{ label: string; url: string }> = {};
if (isNonEmptyString(label)) updates.label = label.trim();
if (isNonEmptyString(url)) updates.url = url.trim();
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "Nothing to update" });
}
const updated =
await createCurrentDashboardServiceLinkRepository().updateForUser(
userId,
id,
updates,
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated");
res.json(updated);
} catch (err) {
dashboardLogger.error("Failed to update service link", err);
res.status(500).json({ error: "Failed to update service link" });
}
});
@@ -4,10 +4,7 @@ import {
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentCredentialRepository,
createCurrentDashboardServiceLinkRepository,
createCurrentDismissedAlertRepository,
createCurrentHomepageItemRepository,
createCurrentHomepageLayoutRepository,
createCurrentHostFolderRepository,
createCurrentHostRepository,
createCurrentHostSidebarPreferenceRepository,
@@ -97,9 +94,10 @@ export async function deleteUserAndRelatedData(
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
await createCurrentHomepageItemRepository().deleteByUserId(userId);
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
// homepage_items, homepage_layouts and dashboard_service_links cascade on
// the user's refUser() foreign key, as the homepage plugin's adopted
// tables.
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId);
@@ -1,120 +0,0 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import https from "https";
import http from "http";
export const homepageFaviconRouter = express.Router();
// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number }
const faviconCache = new Map<
string,
{ data: Buffer; contentType: string; expires: number }
>();
const CACHE_SIZE = 100;
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
const MAX_FAVICON_BYTES = 512 * 1024;
function evictIfNeeded() {
if (faviconCache.size >= CACHE_SIZE) {
const oldest = faviconCache.keys().next().value;
if (oldest) faviconCache.delete(oldest);
}
}
function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
return new Promise((resolve, reject) => {
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { timeout: 5000 }, (res) => {
const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
res.destroy();
reject(error);
};
res.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_FAVICON_BYTES) {
fail(new Error("Favicon response too large"));
return;
}
chunks.push(chunk);
});
res.on("end", () => {
if (settled) return;
settled = true;
resolve({
data: Buffer.concat(chunks),
contentType: res.headers["content-type"] || "image/x-icon",
});
});
res.on("error", fail);
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Favicon fetch timeout"));
});
});
}
/**
* @openapi
* /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.
*/
homepageFaviconRouter.get("/", 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 { data, contentType } = await fetchUrl(faviconUrl);
evictIfNeeded();
faviconCache.set(domain, {
data,
contentType,
expires: Date.now() + CACHE_TTL_MS,
});
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.send(data);
} catch {
homepageLogger.warn("Failed to fetch favicon", { domain });
res.status(500).json({ error: "Failed to fetch favicon" });
}
});
@@ -1,202 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
createCurrentHomepageItemRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
export const homepageItemsRouter = express.Router();
/**
* @openapi
* /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.
*/
homepageItemsRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const items =
await createCurrentHomepageItemRepository().listByUserId(userId);
res.json(items);
} catch (err) {
homepageLogger.error("Failed to fetch homepage items", err);
res.status(500).json({ error: "Failed to fetch homepage items" });
}
});
/**
* @openapi
* /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.
*/
homepageItemsRouter.post("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { typeId, title, config } = req.body;
if (!typeId || typeof typeId !== "string") {
return res.status(400).json({ error: "typeId is required" });
}
try {
const created = await createCurrentHomepageItemRepository().createForUser(
userId,
{
typeId,
title: title ?? null,
config: config ? JSON.stringify(config) : "{}",
},
);
res.status(201).json(created);
} catch (err) {
homepageLogger.error("Failed to create homepage item", err);
res.status(500).json({ error: "Failed to create homepage item" });
}
});
/**
* @openapi
* /homepage/items/{id}:
* put:
* summary: Update homepage item
* description: Updates a homepage widget item.
* 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 data.
* 404:
* description: Not found.
* 500:
* description: Failed to update homepage item.
*/
homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(req.params.id as string);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
const { title, config } = req.body;
try {
const itemRepository = createCurrentHomepageItemRepository();
const existing = await itemRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const updates: Parameters<typeof itemRepository.updateForUser>[2] = {};
if (title !== undefined) updates.title = title;
if (config !== undefined) updates.config = JSON.stringify(config);
const updated = await itemRepository.updateForUser(userId, id, updates);
res.json(updated ?? existing);
} catch (err) {
homepageLogger.error("Failed to update homepage item", err);
res.status(500).json({ error: "Failed to update homepage item" });
}
});
/**
* @openapi
* /homepage/items/{id}:
* delete:
* summary: Delete homepage item
* description: Deletes a homepage widget item and cascades deletion to children if a folder.
* 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.
*/
homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(req.params.id as string);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const itemRepository = createCurrentHomepageItemRepository();
const existing = await itemRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const deleted = await itemRepository.deleteForUser(userId, id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"homepageItems",
deleted.syncId,
);
}
res.json({ message: "Homepage item deleted" });
} catch (err) {
homepageLogger.error("Failed to delete homepage item", err);
res.status(500).json({ error: "Failed to delete homepage item" });
}
});
@@ -1,86 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js";
export const homepageLayoutRouter = express.Router();
/**
* @openapi
* /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.
*/
homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const row =
await createCurrentHomepageLayoutRepository().findByUserId(userId);
if (!row) {
return res.json(null);
}
const parsed = JSON.parse(row.layout || "{}");
res.json({ ...row, layout: parsed });
} catch (err) {
homepageLogger.error("Failed to fetch homepage layout", err);
res.status(500).json({ error: "Failed to fetch homepage layout" });
}
});
/**
* @openapi
* /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.
*/
homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const layoutData = req.body;
try {
const layoutJson = JSON.stringify(layoutData);
const now = new Date().toISOString();
const updated = await createCurrentHomepageLayoutRepository().upsertForUser(
userId,
layoutJson,
now,
);
const parsed = JSON.parse(updated.layout);
res.json({ ...updated, layout: parsed });
} catch (err) {
homepageLogger.error("Failed to save homepage layout", err);
res.status(500).json({ error: "Failed to save homepage layout" });
}
});
@@ -1,108 +0,0 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
export const homepagePingRouter = express.Router();
interface PingCacheEntry {
ok: boolean;
statusCode: number | null;
latencyMs: number;
expires: number;
}
const pingCache = new Map<string, PingCacheEntry>();
const CACHE_SIZE = 200;
const FETCH_TIMEOUT_MS = 5000;
async function requestStatus(
url: string,
method: "HEAD" | "GET",
): Promise<number | null> {
const res = await safeOutboundFetch(url, {
method,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
// Discard the body without buffering it.
await res.body?.cancel().catch(() => {});
return res.status ?? null;
}
async function pingUrl(
url: string,
): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
const start = performance.now();
const elapsed = () => Math.round(performance.now() - start);
try {
let code = await requestStatus(url, "HEAD");
if (code === 405) {
code = await requestStatus(url, "GET");
}
return {
ok: code !== null && code < 400,
statusCode: code,
latencyMs: elapsed(),
};
} catch {
return { ok: false, statusCode: null, latencyMs: elapsed() };
}
}
/**
* @openapi
* /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.
*/
homepagePingRouter.get("/", 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,
});
}
try {
const result = await pingUrl(targetUrl);
if (pingCache.size >= 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) {
homepageLogger.warn("Ping failed", { targetUrl });
res.status(500).json({ error: "Ping failed" });
}
});
@@ -1,95 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
export const homepageProxyRouter = express.Router();
interface ProxyCacheEntry {
data: unknown;
expires: number;
}
const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
async function fetchJson(rawUrl: string): Promise<unknown> {
const response = await safeOutboundFetch(rawUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`);
const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES);
try {
return JSON.parse(text);
} catch {
throw new Error("Response is not valid JSON");
}
}
/**
* @openapi
* /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.
*/
homepageProxyRouter.get("/", 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 data = await fetchJson(targetUrl);
if (proxyCache.size >= 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 = getErrorMessage(err);
homepageLogger.warn("Proxy fetch failed", { 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" });
}
});
@@ -1,142 +0,0 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
export const homepageRssRouter = express.Router();
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
interface RssItem {
title: string;
link: string;
pubDate: string | null;
description: string | null;
}
function fetchXml(url: string): Promise<string> {
return safeOutboundFetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}).then(async (res) => {
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
return readResponseTextLimited(res, MAX_RESPONSE_BYTES);
});
}
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;
}
/**
* @openapi
* /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.
*/
homepageRssRouter.get("/", 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 xml = await fetchXml(feedUrl);
const items = parseRss(xml);
if (rssCache.size >= CACHE_SIZE) {
const oldest = rssCache.keys().next().value;
if (oldest) rssCache.delete(oldest);
}
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
res.json(items.slice(0, max));
} catch {
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
res.status(500).json({ error: "Failed to fetch feed" });
}
});
@@ -1,16 +0,0 @@
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;
}
}
+2 -13
View File
@@ -16,8 +16,6 @@ import {
sshCredentials,
sshFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
userPreferences,
} from "../db/schema.js";
import {
@@ -104,17 +102,8 @@ export function registerCoreSyncEntities(): void {
encryptedFields: ["ssh_data"],
});
registerEntity(CORE_OWNER, {
type: "dashboardServiceLinks",
table: dashboardServiceLinks,
order: 70,
});
registerEntity(CORE_OWNER, {
type: "homepageItems",
table: homepageItems,
order: 80,
});
// dashboardServiceLinks (order 70) and homepageItems (order 80) moved to
// the homepage plugin, which registers them itself at activation.
registerEntity(CORE_OWNER, {
type: "userPreferences",