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