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

+2
View File
@@ -22,6 +22,7 @@ import vaultRoutes from "./routes/vault.js";
import secretSourceRoutes from "./routes/secret-sources.js";
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
import syncRoutes from "./routes/sync.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import { mountPluginApi } from "./routes/plugin-api-routes.js";
import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
@@ -1807,6 +1808,7 @@ app.use("/vault", vaultRoutes);
app.use("/secret-sources", secretSourceRoutes);
app.use("/", notificationChannelsRoutes);
app.use("/sync", syncRoutes);
app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes);
app.use(
"/plugin-assets",
-76
View File
@@ -883,17 +883,6 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
sqlite.exec(`
CREATE TABLE IF NOT EXISTS dashboard_service_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
url TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
@@ -2024,52 +2013,6 @@ const migrateSchema = () => {
// --- alerts end ---
// --- homepage begin ---
try {
sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS homepage_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type_id TEXT NOT NULL,
title TEXT,
config TEXT NOT NULL DEFAULT '{}',
folder_id INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create homepage_items table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM homepage_layouts LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS homepage_layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
layout TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create homepage_layouts table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- homepage end ---
// --- sync begin ---
// Stable per-row identity used to match rows across two independently-
// seeded databases (the embedded desktop backend and a connected remote
@@ -2086,23 +2029,6 @@ const migrateSchema = () => {
addColumnIfNotExists("snippets", "sync_id", "TEXT");
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
// SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
// "non-constant default" reason -- add nullable, then backfill from
// created_at below and rely on the repository layer to keep it current.
addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
try {
sqlite.exec(
"UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
databaseLogger.warn(
`Failed to backfill dashboard_service_links.updated_at: ${message}`,
{ operation: "schema_migration", table: "dashboard_service_links" },
);
}
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
@@ -2121,8 +2047,6 @@ const migrateSchema = () => {
"snippets",
"snippet_folders",
"vault_profiles",
"dashboard_service_links",
"homepage_items",
];
for (const table of syncIdTables) {
@@ -113,11 +113,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "user_open_tabs",
columns: "user_id",
},
{
name: "idx_homepage_items_user_id",
table: "homepage_items",
columns: "user_id",
},
{
name: "idx_dismissed_alerts_user_id",
table: "dismissed_alerts",
+3 -52
View File
@@ -916,22 +916,7 @@ export const uiPreferences = mysqlTable("ui_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const dashboardServiceLinks = mysqlTable("dashboard_service_links", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
label: text("label").notNull(),
url: text("url").notNull(),
order: int("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
@@ -1023,42 +1008,8 @@ export const notificationChannels = mysqlTable("notification_channels", {
// --- alerts end ---
// --- homepage begin ---
export const homepageItems = mysqlTable(
"homepage_items",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
typeId: text("type_id").notNull(),
title: text("title"),
config: text("config").notNull().default("{}"),
folderId: int("folder_id"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_homepage_items_user_id").on(table.userId)],
);
export const homepageLayouts = mysqlTable("homepage_layouts", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
layout: text("layout").notNull().default("{}"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
// --- homepage end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
+3 -52
View File
@@ -917,22 +917,7 @@ export const uiPreferences = pgTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const dashboardServiceLinks = pgTable("dashboard_service_links", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
label: text("label").notNull(),
url: text("url").notNull(),
order: integer("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
@@ -1024,42 +1009,8 @@ export const notificationChannels = pgTable("notification_channels", {
// --- alerts end ---
// --- homepage begin ---
export const homepageItems = pgTable(
"homepage_items",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
typeId: text("type_id").notNull(),
title: text("title"),
config: text("config").notNull().default("{}"),
folderId: integer("folder_id"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_homepage_items_user_id").on(table.userId)],
);
export const homepageLayouts = pgTable("homepage_layouts", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
layout: text("layout").notNull().default("{}"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- homepage end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
+3 -52
View File
@@ -913,22 +913,7 @@ export const uiPreferences = sqliteTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
label: text("label").notNull(),
url: text("url").notNull(),
order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
// --- termix-id begin ---
// A user claims a unique public handle. Their published SSH public keys are
@@ -1020,42 +1005,8 @@ export const notificationChannels = sqliteTable("notification_channels", {
// --- alerts end ---
// --- homepage begin ---
export const homepageItems = sqliteTable(
"homepage_items",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
typeId: text("type_id").notNull(),
title: text("title"),
config: text("config").notNull().default("{}"),
folderId: integer("folder_id"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_homepage_items_user_id").on(table.userId)],
);
export const homepageLayouts = sqliteTable("homepage_layouts", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
layout: text("layout").notNull().default("{}"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- homepage end ---
// homepage_items and homepage_layouts moved to the homepage plugin
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
@@ -1,137 +0,0 @@
import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { dashboardServiceLinks } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type DashboardServiceLinkRecord =
typeof dashboardServiceLinks.$inferSelect;
export type DashboardServiceLinkUpdate = Partial<{
label: string;
url: string;
}>;
export class DashboardServiceLinkRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<DashboardServiceLinkRecord[]> {
return this.context.drizzle
.select()
.from(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
}
async createForUser(
userId: string,
input: { label: string; url: string },
createdAt = new Date().toISOString(),
): Promise<DashboardServiceLinkRecord> {
const existing = await this.context.drizzle
.select({ order: dashboardServiceLinks.order })
.from(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.orderBy(asc(dashboardServiceLinks.order));
const nextOrder =
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
const [created] = await insertReturning(
this.context,
dashboardServiceLinks,
{
syncId: randomUUID(),
userId,
label: input.label,
url: input.url,
order: nextOrder,
createdAt,
updatedAt: createdAt,
},
);
await this.afterWrite();
return created;
}
async findByIdForUser(
userId: string,
id: number,
): Promise<DashboardServiceLinkRecord | null> {
const rows = await this.context.drizzle
.select()
.from(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
)
.limit(1);
return rows[0] ?? null;
}
async updateForUser(
userId: string,
id: number,
updates: DashboardServiceLinkUpdate,
): Promise<DashboardServiceLinkRecord | null> {
const [updated] = await updateReturning(
this.context,
dashboardServiceLinks,
{ ...updates, updatedAt: new Date().toISOString() },
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (updated) {
await this.afterWrite();
}
return updated ?? null;
}
async deleteForUser(
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await deleteReturning(
this.context,
dashboardServiceLinks,
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (rows.length === 0) return null;
await this.afterWrite();
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+1 -24
View File
@@ -8,10 +8,7 @@ import { NotificationChannelRepository } from "./notification-channel-repository
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { CredentialRepository } from "./credential-repository.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { HomepageItemRepository } from "./homepage-item-repository.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
import { HostRepository } from "./host-repository.js";
import { HostResolutionRepository } from "./host-resolution-repository.js";
@@ -167,13 +164,6 @@ export function createCurrentCredentialRepository(): CredentialRepository {
);
}
export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
return new DashboardServiceLinkRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
);
}
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
return new SyncTombstoneRepository(
createCurrentRepositoryContext(),
@@ -189,20 +179,7 @@ export function createCurrentDismissedAlertRepository(): DismissedAlertRepositor
}
// File manager recent/pinned/shortcuts moved to the file-manager plugin.
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
return new HomepageItemRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("homepage_item_repository_write"),
);
}
export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository {
return new HomepageLayoutRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("homepage_layout_repository_write"),
);
}
// Homepage items/layout and dashboard service links moved to the homepage plugin.
export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository(
@@ -1,121 +0,0 @@
import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { homepageItems } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HomepageItemRecord = typeof homepageItems.$inferSelect;
export interface HomepageItemCreateInput {
typeId: string;
title: string | null;
config: string;
}
export type HomepageItemUpdateInput = Partial<{
title: string | null;
config: string;
}>;
export class HomepageItemRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<HomepageItemRecord[]> {
return this.context.drizzle
.select()
.from(homepageItems)
.where(eq(homepageItems.userId, userId))
.orderBy(asc(homepageItems.id));
}
async createForUser(
userId: string,
input: HomepageItemCreateInput,
now = new Date().toISOString(),
): Promise<HomepageItemRecord> {
const [created] = await insertReturning(this.context, homepageItems, {
syncId: randomUUID(),
userId,
typeId: input.typeId,
title: input.title,
config: input.config,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
}
async findByIdForUser(
userId: string,
id: number,
): Promise<HomepageItemRecord | null> {
const rows = await this.context.drizzle
.select()
.from(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async updateForUser(
userId: string,
id: number,
updates: HomepageItemUpdateInput,
updatedAt = new Date().toISOString(),
): Promise<HomepageItemRecord | null> {
const [updated] = await updateReturning(
this.context,
homepageItems,
{ ...updates, updatedAt },
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
);
if (updated) {
await this.afterWrite();
}
return updated ?? null;
}
async deleteForUser(
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await deleteReturning(
this.context,
homepageItems,
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
);
if (rows.length === 0) return null;
await this.afterWrite();
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(homepageItems)
.where(eq(homepageItems.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,67 +0,0 @@
import { eq } from "drizzle-orm";
import { homepageLayouts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
export class HomepageLayoutRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserId(userId: string): Promise<HomepageLayoutRecord | null> {
const rows = await this.context.drizzle
.select()
.from(homepageLayouts)
.where(eq(homepageLayouts.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async upsertForUser(
userId: string,
layout: string,
updatedAt = new Date().toISOString(),
): Promise<HomepageLayoutRecord> {
const existing = await this.findByUserId(userId);
if (!existing) {
const [created] = await insertReturning(this.context, homepageLayouts, {
userId,
layout,
updatedAt,
});
await this.afterWrite();
return created;
}
const [updated] = await updateReturning(
this.context,
homepageLayouts,
{ layout, updatedAt },
eq(homepageLayouts.userId, userId),
);
await this.afterWrite();
return updated;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(homepageLayouts)
.where(eq(homepageLayouts.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -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",
-262
View File
@@ -1,262 +0,0 @@
import express from "express";
import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import { dashboardLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
import { dashboardServiceLinksRouter } from "../database/routes/dashboard-service-links-routes.js";
import { createCurrentRecentActivityRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { recordRecentActivity } from "./recent-activity.js";
import { listenOnServicePort } from "../utils/service-listen.js";
const app = express();
app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
const serverStartTime = Date.now();
function isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use(authManager.createAuthMiddleware());
/**
* @openapi
* /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.
*/
app.get("/uptime", async (req, res) => {
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
* /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.
*/
app.get("/activity/recent", async (req, res) => {
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
* /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.
*/
app.post("/activity/log", async (req, res) => {
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
* /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.
*/
app.delete("/activity/reset", async (req, res) => {
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",
});
}
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" });
}
});
app.use("/service-links", dashboardServiceLinksRouter);
const PORT = 30006;
listenOnServicePort({
app,
port: PORT,
logger: dashboardLogger,
serviceName: "dashboard",
onListening: async () => {
try {
await authManager.initialize();
} catch (err) {
dashboardLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
},
});
-45
View File
@@ -1,45 +0,0 @@
import express from "express";
import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import { AuthManager } from "../utils/auth-manager.js";
import { listenOnServicePort } from "../utils/service-listen.js";
import { homepageLogger } from "../utils/logger.js";
import { homepageItemsRouter } from "../database/routes/homepage-items-routes.js";
import { homepageLayoutRouter } from "../database/routes/homepage-layout-routes.js";
import { homepageFaviconRouter } from "../database/routes/homepage-favicon-routes.js";
import { homepageRssRouter } from "../database/routes/homepage-rss-routes.js";
import { homepagePingRouter } from "../database/routes/homepage-ping-routes.js";
import { homepageProxyRouter } from "../database/routes/homepage-proxy-routes.js";
const app = express();
app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
const PORT = 30012;
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use(authManager.createAuthMiddleware());
app.use("/homepage/items", homepageItemsRouter);
app.use("/homepage/layout", homepageLayoutRouter);
app.use("/homepage/favicon", homepageFaviconRouter);
app.use("/homepage/rss", homepageRssRouter);
app.use("/homepage/ping", homepagePingRouter);
app.use("/homepage/proxy", homepageProxyRouter);
listenOnServicePort({
app,
port: PORT,
logger: homepageLogger,
serviceName: "homepage",
});
export default app;
+6 -6
View File
@@ -317,14 +317,14 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
// plugins/file-manager and plugins/tmux-monitor.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial) is absent
// for the same reason: each one serves its routes under /plugin-api/<id>/
// (or a WS route under /plugin-ws/<id>/) through ctx on activate, so
// disabling it answers 503 instead of leaving a dead import here.
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial, Homepage)
// is absent for the same reason: each one serves its routes under
// /plugin-api/<id>/ (or a WS route under /plugin-ws/<id>/) through ctx on
// activate, so disabling it answers 503 instead of leaving a dead import
// here. The dashboard's own uptime and recent-activity routes are core
// and are mounted on the main server in database.ts.
// Automations' scheduler and tunnel autostart also start from their own
// activate() rather than here.
await import("./services/dashboard.js");
await import("./services/homepage.js");
// Initialize log level from database settings
const { getCurrentSettingValue } =
@@ -1,120 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DashboardServiceLinkRepository } from "../../../database/repositories/dashboard-service-link-repository.js";
describe("DashboardServiceLinkRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<DashboardServiceLinkRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new DashboardServiceLinkRepository(context, onWrite);
}
it("creates and lists links ordered by order then id", async () => {
const repo = await createRepository();
const first = await repo.createForUser(
"user-1",
{ label: "Docs", url: "https://docs.example.com" },
"2026-06-27T00:00:00.000Z",
);
const second = await repo.createForUser("user-1", {
label: "Status",
url: "https://status.example.com",
});
await repo.createForUser("user-2", {
label: "Other",
url: "https://other.example.com",
});
expect(first).toMatchObject({
userId: "user-1",
label: "Docs",
order: 0,
createdAt: "2026-06-27T00:00:00.000Z",
});
expect(second.order).toBe(1);
expect(
(await repo.listByUserId("user-1")).map((link) => link.label),
).toEqual(["Docs", "Status"]);
});
it("finds, updates, and deletes a user-owned link", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const link = await repo.createForUser("user-1", {
label: "Docs",
url: "https://docs.example.com",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", link.id)).toBeNull();
const updated = await repo.updateForUser("user-1", link.id, {
label: "Docs renamed",
});
expect(updated).toMatchObject({
id: link.id,
label: "Docs renamed",
url: "https://docs.example.com",
});
expect(writeCount).toBe(2);
expect(await repo.updateForUser("user-2", link.id, { label: "Nope" })).toBe(
null,
);
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
expect(await repo.deleteForUser("user-1", link.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3);
});
it("deletes all dashboard links for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", {
label: "Docs",
url: "https://docs.example.com",
});
await repo.createForUser("user-1", {
label: "Status",
url: "https://status.example.com",
});
await repo.createForUser("user-2", {
label: "Other",
url: "https://other.example.com",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((link) => link.label),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -1,131 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HomepageItemRepository } from "../../../database/repositories/homepage-item-repository.js";
describe("HomepageItemRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HomepageItemRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new HomepageItemRepository(context, onWrite);
}
it("creates and lists homepage items by id", async () => {
const repo = await createRepository();
const first = await repo.createForUser(
"user-1",
{ typeId: "clock", title: "Clock", config: "{}" },
"2026-06-27T00:00:00.000Z",
);
const second = await repo.createForUser("user-1", {
typeId: "terminal",
title: null,
config: '{"hostId":1}',
});
await repo.createForUser("user-2", {
typeId: "other",
title: "Other",
config: "{}",
});
expect(first).toMatchObject({
userId: "user-1",
typeId: "clock",
title: "Clock",
config: "{}",
createdAt: "2026-06-27T00:00:00.000Z",
});
expect((await repo.listByUserId("user-1")).map((item) => item.id)).toEqual([
first.id,
second.id,
]);
});
it("finds, updates, and deletes user-owned homepage items", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const item = await repo.createForUser("user-1", {
typeId: "clock",
title: "Clock",
config: "{}",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", item.id)).toBeNull();
const updated = await repo.updateForUser(
"user-1",
item.id,
{ title: "Clock renamed", config: '{"timezone":"UTC"}' },
"2026-06-27T01:00:00.000Z",
);
expect(updated).toMatchObject({
id: item.id,
title: "Clock renamed",
config: '{"timezone":"UTC"}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
expect(writeCount).toBe(2);
expect(
await repo.updateForUser("user-2", item.id, { title: "Nope" }),
).toBeNull();
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
expect(await repo.deleteForUser("user-1", item.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3);
});
it("deletes all homepage items for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", {
typeId: "clock",
title: "Clock",
config: "{}",
});
await repo.createForUser("user-1", {
typeId: "terminal",
title: "Terminal",
config: "{}",
});
await repo.createForUser("user-2", {
typeId: "other",
title: "Other",
config: "{}",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((item) => item.title),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -1,85 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HomepageLayoutRepository } from "../../../database/repositories/homepage-layout-repository.js";
describe("HomepageLayoutRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HomepageLayoutRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new HomepageLayoutRepository(context, onWrite);
}
it("finds, creates, and updates a layout by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
const created = await repo.upsertForUser(
"user-1",
JSON.stringify({ entries: [{ id: "w1" }], zoom: 1 }),
"2026-06-27T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-1",
layout: '{"entries":[{"id":"w1"}],"zoom":1}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
const updated = await repo.upsertForUser(
"user-1",
JSON.stringify({ entries: [], zoom: 1.25 }),
"2026-06-27T01:00:00.000Z",
);
expect(updated).toMatchObject({
id: created.id,
userId: "user-1",
layout: '{"entries":[],"zoom":1.25}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
});
it("triggers writes after create and update", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", "{}");
await repo.upsertForUser("user-1", '{"zoom":2}');
expect(writeCount).toBe(2);
});
it("deletes a layout for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", '{"zoom":1}');
await repo.upsertForUser("user-2", '{"zoom":2}');
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserId("user-1")).toBeNull();
expect((await repo.findByUserId("user-2"))?.layout).toBe('{"zoom":2}');
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
unlocked: true,
activity: [] as unknown[],
recordResult: { status: "logged", id: 1 } as {
status: string;
id?: number;
},
}));
vi.mock("../../../utils/logger.js", () => ({
dashboardLogger: { warn: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: () => (state.unlocked ? "key" : null),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRecentActivityRepository: () => ({
listByUserId: async () => state.activity,
deleteByUserId: async () => {},
}),
}));
vi.mock("../../../services/recent-activity.js", () => ({
recordRecentActivity: async () => state.recordResult,
}));
const { default: dashboardRoutes } =
await import("../../../database/routes/dashboard-routes.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.unlocked = true;
state.activity = [];
state.recordResult = { status: "logged", id: 1 };
const app = express();
app.use(express.json());
app.use("/dashboard", dashboardRoutes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("GET /dashboard/uptime", () => {
it("returns a formatted uptime", async () => {
const res = await fetch(`${baseUrl}/dashboard/uptime`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({
uptimeMs: expect.any(Number),
formatted: expect.stringMatching(/^\d+d \d+h \d+m$/),
});
});
});
describe("GET /dashboard/activity/recent", () => {
it("401s when the user's data key is not unlocked", async () => {
state.unlocked = false;
const res = await fetch(`${baseUrl}/dashboard/activity/recent`);
expect(res.status).toBe(401);
});
it("returns the user's recent activity", async () => {
state.activity = [{ id: 1, type: "terminal" }];
const res = await fetch(`${baseUrl}/dashboard/activity/recent`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(state.activity);
});
});
describe("POST /dashboard/activity/log", () => {
it("rejects a body missing required fields", async () => {
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "terminal" }),
});
expect(res.status).toBe(400);
});
it("logs a valid activity", async () => {
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "terminal", hostId: 1, hostName: "prod" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ message: "Activity logged", id: 1 });
});
it("404s a host the user cannot access", async () => {
state.recordResult = { status: "denied" };
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "terminal", hostId: 1, hostName: "prod" }),
});
expect(res.status).toBe(404);
});
});
describe("DELETE /dashboard/activity/reset", () => {
it("clears the user's recent activity", async () => {
const res = await fetch(`${baseUrl}/dashboard/activity/reset`, {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ message: "Recent activity cleared" });
});
});
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isValidServiceLinkUrl,
normalizeServiceLinkUrl,
} from "../../../database/routes/service-link-url.js";
describe("service link URL handling", () => {
it("keeps explicit http and https URLs", () => {
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
"https://example.com",
);
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
});
it("adds http to bare service addresses", () => {
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
});
it("rejects unsupported schemes", () => {
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
});
});
@@ -34,8 +34,6 @@ describe("isValidEntityType", () => {
"sshCredentials",
"sshFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
]) {
expect(isValidEntityType(type)).toBe(true);
@@ -96,13 +96,15 @@ describe("core sync entities", () => {
registerCoreSyncEntities();
});
// The frozen array also lists networkTopology and snippets/snippetFolders,
// now registered by the network-topology and snippets plugins rather than
// core.
// The frozen array also lists networkTopology, snippets/snippetFolders and
// dashboardServiceLinks/homepageItems, now registered by the
// network-topology, snippets and homepage plugins rather than core.
const PLUGIN_OWNED_TYPES = new Set([
"networkTopology",
"snippets",
"snippetFolders",
"dashboardServiceLinks",
"homepageItems",
]);
it("registers every core-owned entity the Electron client knows about", () => {
-1
View File
@@ -308,7 +308,6 @@ export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#f97316");
export const pluginLogger = new Logger("PLUGIN", "🧩", "#84cc16");
export const logger = systemLogger;
-4
View File
@@ -26,10 +26,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
url: "http://localhost:30004",
description: "SSH file manager server",
},
{
url: "http://localhost:30006",
description: "Dashboard server",
},
],
components: {
securitySchemes: {