mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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",
|
||||
|
||||
Reference in new issue
Block a user