feat: convert file manager into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 16:27:29 -05:00
1 parent b76da9ab52
commit c85c1fdb86
259 files changed
+54155 -16791

No files matched your search

+117 -71
View File
@@ -48,7 +48,6 @@ import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostRepository,
createCurrentSettingsRepository,
createCurrentSshCredentialUsageRepository,
@@ -63,7 +62,8 @@ import type {
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import { DatabaseSaveTrigger } from "./db/index.js";
import { DatabaseSaveTrigger, getDb } from "./db/index.js";
import { sql } from "drizzle-orm";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -855,7 +855,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_recent (
CREATE TABLE p_file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
@@ -864,7 +864,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_pinned (
CREATE TABLE p_file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
@@ -873,7 +873,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_shortcuts (
CREATE TABLE p_file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
@@ -882,7 +882,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transfer_recent (
CREATE TABLE p_file_manager_transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
@@ -1031,56 +1031,84 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
// The file manager plugin owns these tables now (p_file_manager_*);
// read them by raw SQL rather than importing plugin code into core.
const drizzleDb = getDb();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
fileManagerRepository.listRecentByUserId(userId),
fileManagerRepository.listPinnedByUserId(userId),
fileManagerRepository.listShortcutsByUserId(userId),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
last_opened: string;
}>(
sql`SELECT id, user_id, host_id, name, path, last_opened FROM p_file_manager_recent WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
pinned_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, pinned_at FROM p_file_manager_pinned WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
created_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, created_at FROM p_file_manager_shortcuts WHERE user_id = ${userId}`,
),
]);
const insertRecent = exportDb.prepare(`
INSERT INTO file_manager_recent (id, user_id, host_id, name, path, last_opened)
INSERT INTO p_file_manager_recent (id, user_id, host_id, name, path, last_opened)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of recentFiles) {
insertRecent.run(
item.id,
item.userId,
item.hostId,
item.user_id,
item.host_id,
item.name,
item.path,
item.lastOpened,
item.last_opened,
);
}
const insertPinned = exportDb.prepare(`
INSERT INTO file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
INSERT INTO p_file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of pinnedFiles) {
insertPinned.run(
item.id,
item.userId,
item.hostId,
item.user_id,
item.host_id,
item.name,
item.path,
item.pinnedAt,
item.pinned_at,
);
}
const insertShortcut = exportDb.prepare(`
INSERT INTO file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
INSERT INTO p_file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of shortcuts) {
insertShortcut.run(
item.id,
item.userId,
item.hostId,
item.user_id,
item.host_id,
item.name,
item.path,
item.createdAt,
item.created_at,
);
}
@@ -1445,70 +1473,88 @@ app.post(
);
}
// The file manager plugin owns these tables now (p_file_manager_*).
// Older export files still have the legacy name, so try that too.
const fileManagerTables = [
{
table: "file_manager_recent",
tableNames: ["p_file_manager_recent", "file_manager_recent"],
physicalTable: "p_file_manager_recent",
dateColumn: "last_opened",
key: "fileManagerItemsImported",
},
{
table: "file_manager_pinned",
tableNames: ["p_file_manager_pinned", "file_manager_pinned"],
physicalTable: "p_file_manager_pinned",
dateColumn: "pinned_at",
key: "fileManagerItemsImported",
},
{
table: "file_manager_shortcuts",
tableNames: [
"p_file_manager_shortcuts",
"file_manager_shortcuts",
],
physicalTable: "p_file_manager_shortcuts",
dateColumn: "created_at",
key: "fileManagerItemsImported",
},
];
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
const importDrizzleDb = getDb();
for (const { table, key } of fileManagerTables) {
try {
const importedItems = importDb
.prepare(`SELECT * FROM ${table}`)
.all();
for (const item of importedItems) {
try {
const bookmark = {
hostId: item.host_id,
name: item.name,
path: item.path,
};
const created =
table === "file_manager_recent"
? await fileManagerRepository.createRecentForImport(
userId,
bookmark,
item.last_opened,
)
: table === "file_manager_pinned"
? await fileManagerRepository.createPinnedForImport(
userId,
bookmark,
item.pinned_at,
)
: await fileManagerRepository.createShortcutForImport(
userId,
bookmark,
item.created_at,
);
if (created) {
result.summary[key]++;
} else {
result.summary.skippedItems++;
}
} catch (itemError) {
result.summary.errors.push(
`${table} import error: ${itemError.message}`,
);
}
for (const {
tableNames,
physicalTable,
dateColumn,
key,
} of fileManagerTables) {
let importedItems: Array<Record<string, unknown>> | null = null;
let sourceTable = "";
for (const tableName of tableNames) {
try {
importedItems = importDb
.prepare(`SELECT * FROM ${tableName}`)
.all() as Array<Record<string, unknown>>;
sourceTable = tableName;
break;
} catch {
// try the next name
}
} catch {
}
if (importedItems === null) {
apiLogger.info(
`${table} table not found in import file, skipping`,
`${physicalTable} table not found in import file, skipping`,
);
continue;
}
for (const item of importedItems) {
try {
const hostId = item.host_id as number;
const path = item.path as string;
const name =
(item.name as string) || path.split("/").pop() || "Unknown";
const dateValue =
(item[dateColumn] as string) || new Date().toISOString();
const existing = await importDrizzleDb.all<{ id: number }>(
sql`SELECT id FROM ${sql.raw(physicalTable)} WHERE user_id = ${userId} AND host_id = ${hostId} AND path = ${path}`,
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
await importDrizzleDb.run(
sql`INSERT INTO ${sql.raw(physicalTable)} (user_id, host_id, path, name, ${sql.raw(dateColumn)}) VALUES (${userId}, ${hostId}, ${path}, ${name}, ${dateValue})`,
);
result.summary[key]++;
} catch (itemError) {
result.summary.errors.push(
`${sourceTable} import error: ${itemError.message}`,
);
}
}
}
+4 -45
View File
@@ -350,51 +350,10 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
dest_host_id INTEGER NOT NULL,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (source_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (dest_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
-- file_manager_recent, file_manager_pinned, file_manager_shortcuts and
-- transfer_recent are owned by the file-manager plugin now. Its own
-- migration creates p_file_manager_* on a fresh install and adopts the
-- legacy tables by rename on an upgrade.
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+3 -20
View File
@@ -110,26 +110,9 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "command_history",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_recent_user",
table: "file_manager_recent",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_pinned_user",
table: "file_manager_pinned",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_shortcuts_user",
table: "file_manager_shortcuts",
columns: "user_id, host_id",
},
{
name: "idx_transfer_recent_user",
table: "transfer_recent",
columns: "user_id",
},
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent are owned by the file-manager plugin now; its adopted
// table definitions carry these same indexes across the rename.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
+2 -85
View File
@@ -383,91 +383,8 @@ export const hosts = mysqlTable(
],
);
export const fileManagerRecent = mysqlTable(
"file_manager_recent",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Every file manager surface is read for one user on one host at a time.
(table) => [
index("idx_file_manager_recent_user").on(table.userId, table.hostId),
],
);
export const fileManagerPinned = mysqlTable(
"file_manager_pinned",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_file_manager_pinned_user").on(table.userId, table.hostId),
],
);
export const fileManagerShortcuts = mysqlTable(
"file_manager_shortcuts",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_file_manager_shortcuts_user").on(table.userId, table.hostId),
],
);
export const transferRecent = mysqlTable(
"transfer_recent",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceHostId: int("source_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destHostId: int("dest_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destPath: text("dest_path").notNull(),
destPathLabel: text("dest_path_label").notNull(),
lastUsed: text("last_used")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_transfer_recent_user").on(table.userId)],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = mysqlTable(
"dismissed_alerts",
+2 -85
View File
@@ -384,91 +384,8 @@ export const hosts = pgTable(
],
);
export const fileManagerRecent = pgTable(
"file_manager_recent",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Every file manager surface is read for one user on one host at a time.
(table) => [
index("idx_file_manager_recent_user").on(table.userId, table.hostId),
],
);
export const fileManagerPinned = pgTable(
"file_manager_pinned",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_pinned_user").on(table.userId, table.hostId),
],
);
export const fileManagerShortcuts = pgTable(
"file_manager_shortcuts",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_shortcuts_user").on(table.userId, table.hostId),
],
);
export const transferRecent = pgTable(
"transfer_recent",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceHostId: integer("source_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destHostId: integer("dest_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destPath: text("dest_path").notNull(),
destPathLabel: text("dest_path_label").notNull(),
lastUsed: text("last_used")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_transfer_recent_user").on(table.userId)],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = pgTable(
"dismissed_alerts",
+2 -85
View File
@@ -376,91 +376,8 @@ export const hosts = sqliteTable(
],
);
export const fileManagerRecent = sqliteTable(
"file_manager_recent",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Every file manager surface is read for one user on one host at a time.
(table) => [
index("idx_file_manager_recent_user").on(table.userId, table.hostId),
],
);
export const fileManagerPinned = sqliteTable(
"file_manager_pinned",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_pinned_user").on(table.userId, table.hostId),
],
);
export const fileManagerShortcuts = sqliteTable(
"file_manager_shortcuts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_shortcuts_user").on(table.userId, table.hostId),
],
);
export const transferRecent = sqliteTable(
"transfer_recent",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceHostId: integer("source_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destHostId: integer("dest_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destPath: text("dest_path").notNull(),
destPathLabel: text("dest_path_label").notNull(),
lastUsed: text("last_used")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_transfer_recent_user").on(table.userId)],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = sqliteTable(
"dismissed_alerts",
+2 -14
View File
@@ -14,7 +14,6 @@ import { CommandHistoryRepository } from "./command-history-repository.js";
import { CredentialRepository } from "./credential-repository.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
import { HomepageItemRepository } from "./homepage-item-repository.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
@@ -57,7 +56,6 @@ import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
import { TransferRecentRepository } from "./transfer-recent-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -229,12 +227,7 @@ export function createCurrentDismissedAlertRepository(): DismissedAlertRepositor
);
}
export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository {
return new FileManagerBookmarkRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"),
);
}
// File manager recent/pinned/shortcuts moved to the file-manager plugin.
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
return new HomepageItemRepository(
@@ -512,12 +505,7 @@ export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepositor
);
}
export function createCurrentTransferRecentRepository(): TransferRecentRepository {
return new TransferRecentRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("transfer_recent_repository_write"),
);
}
// transfer_recent moved to the file-manager plugin.
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
return new TrustedDeviceRepository(
@@ -1,522 +0,0 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import {
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
export type FileManagerShortcutRecord =
typeof fileManagerShortcuts.$inferSelect;
export interface FileManagerBookmarkInput {
hostId: number;
path: string;
name?: string | null;
}
function resolveBookmarkName(path: string, name?: string | null): string {
return name || path.split("/").pop() || "Unknown";
}
export class FileManagerBookmarkRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listRecentForHost(
userId: string,
hostId: number,
limit = 20,
): Promise<FileManagerRecentRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
),
)
.orderBy(desc(fileManagerRecent.lastOpened))
.limit(limit);
}
async listRecentByUserId(userId: string): Promise<FileManagerRecentRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
}
async upsertRecent(
userId: string,
input: FileManagerBookmarkInput,
lastOpened = new Date().toISOString(),
): Promise<void> {
const [existing] = await this.context.drizzle
.select({ id: fileManagerRecent.id })
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, input.hostId),
eq(fileManagerRecent.path, input.path),
),
)
.limit(1);
if (existing) {
await this.context.drizzle
.update(fileManagerRecent)
.set({ lastOpened })
.where(eq(fileManagerRecent.id, existing.id));
} else {
await this.context.drizzle.insert(fileManagerRecent).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
lastOpened,
});
}
await this.afterWrite();
}
async createRecentForImport(
userId: string,
input: FileManagerBookmarkInput,
lastOpened = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsRecentImportItem(userId, input);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerRecent).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
lastOpened,
});
await this.afterWrite();
return true;
}
async deleteRecentForHostPath(
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, input.hostId),
eq(fileManagerRecent.path, input.path),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async listPinnedForHost(
userId: string,
hostId: number,
): Promise<FileManagerPinnedRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
),
)
.orderBy(desc(fileManagerPinned.pinnedAt));
}
async listPinnedByUserId(userId: string): Promise<FileManagerPinnedRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
}
async createPinned(
userId: string,
input: FileManagerBookmarkInput,
pinnedAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsPinned(userId, input.hostId, input.path);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerPinned).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
pinnedAt,
});
await this.afterWrite();
return true;
}
async createPinnedForImport(
userId: string,
input: FileManagerBookmarkInput,
pinnedAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsPinnedImportItem(userId, input);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerPinned).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
pinnedAt,
});
await this.afterWrite();
return true;
}
async deletePinnedForHostPath(
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, input.hostId),
eq(fileManagerPinned.path, input.path),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async listShortcutsForHost(
userId: string,
hostId: number,
): Promise<FileManagerShortcutRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
),
)
.orderBy(desc(fileManagerShortcuts.createdAt));
}
async listShortcutsByUserId(
userId: string,
): Promise<FileManagerShortcutRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
}
async createShortcut(
userId: string,
input: FileManagerBookmarkInput,
createdAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsShortcut(userId, input.hostId, input.path);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerShortcuts).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
createdAt,
});
await this.afterWrite();
return true;
}
async createShortcutForImport(
userId: string,
input: FileManagerBookmarkInput,
createdAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsShortcutImportItem(userId, input);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerShortcuts).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
createdAt,
});
await this.afterWrite();
return true;
}
async deleteShortcutForHostPath(
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, input.hostId),
eq(fileManagerShortcuts.path, input.path),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const total =
(await this.deleteRecentByUserId(userId)) +
(await this.deletePinnedByUserId(userId)) +
(await this.deleteShortcutsByUserId(userId));
if (total > 0) {
await this.afterWrite();
}
return total;
}
async deleteByHostId(hostId: number): Promise<number> {
const total =
(await this.deleteRecentByHostId(hostId)) +
(await this.deletePinnedByHostId(hostId)) +
(await this.deleteShortcutsByHostId(hostId));
if (total > 0) {
await this.afterWrite();
}
return total;
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const total =
(await this.deleteRecentByHostIds(hostIds)) +
(await this.deletePinnedByHostIds(hostIds)) +
(await this.deleteShortcutsByHostIds(hostIds));
if (total > 0) {
await this.afterWrite();
}
return total;
}
private async existsPinned(
userId: string,
hostId: number,
path: string,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerPinned.id })
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
eq(fileManagerPinned.path, path),
),
)
.limit(1);
return rows.length > 0;
}
private async existsRecentImportItem(
userId: string,
input: FileManagerBookmarkInput,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerRecent.id })
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.path, input.path),
eq(
fileManagerRecent.name,
resolveBookmarkName(input.path, input.name),
),
),
)
.limit(1);
return rows.length > 0;
}
private async existsPinnedImportItem(
userId: string,
input: FileManagerBookmarkInput,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerPinned.id })
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.path, input.path),
eq(
fileManagerPinned.name,
resolveBookmarkName(input.path, input.name),
),
),
)
.limit(1);
return rows.length > 0;
}
private async existsShortcutImportItem(
userId: string,
input: FileManagerBookmarkInput,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerShortcuts.id })
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.path, input.path),
eq(
fileManagerShortcuts.name,
resolveBookmarkName(input.path, input.name),
),
),
)
.limit(1);
return rows.length > 0;
}
private async existsShortcut(
userId: string,
hostId: number,
path: string,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerShortcuts.id })
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
eq(fileManagerShortcuts.path, path),
),
)
.limit(1);
return rows.length > 0;
}
private async deleteRecentByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
return rowsAffected(result);
}
private async deletePinnedByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
return rowsAffected(result);
}
private async deleteShortcutsByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
return rowsAffected(result);
}
private async deleteRecentByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, hostId));
return rowsAffected(result);
}
private async deletePinnedByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.hostId, hostId));
return rowsAffected(result);
}
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, hostId));
return rowsAffected(result);
}
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(inArray(fileManagerRecent.hostId, hostIds));
return rowsAffected(result);
}
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(inArray(fileManagerPinned.hostId, hostIds));
return rowsAffected(result);
}
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(inArray(fileManagerShortcuts.hostId, hostIds));
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,168 +0,0 @@
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { transferRecent } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
export interface TransferRecentDestinationInput {
sourceHostId: number;
destHostId: number;
destPath: string;
destPathLabel?: string | null;
}
export class TransferRecentRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<TransferRecentRecord[]> {
return this.context.drizzle
.select()
.from(transferRecent)
.where(eq(transferRecent.userId, userId));
}
async listBySourceHost(
userId: string,
sourceHostId: number,
limit = 10,
): Promise<TransferRecentRecord[]> {
return this.context.drizzle
.select()
.from(transferRecent)
.where(
and(
eq(transferRecent.userId, userId),
eq(transferRecent.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecent.lastUsed))
.limit(limit);
}
async upsertForDestination(
userId: string,
input: TransferRecentDestinationInput,
lastUsed = new Date().toISOString(),
): Promise<void> {
const [existing] = await this.context.drizzle
.select({ id: transferRecent.id })
.from(transferRecent)
.where(
and(
eq(transferRecent.userId, userId),
eq(transferRecent.sourceHostId, input.sourceHostId),
eq(transferRecent.destHostId, input.destHostId),
eq(transferRecent.destPath, input.destPath),
),
)
.limit(1);
if (existing) {
await this.context.drizzle
.update(transferRecent)
.set({ lastUsed })
.where(eq(transferRecent.id, existing.id));
} else {
await this.context.drizzle.insert(transferRecent).values({
userId,
sourceHostId: input.sourceHostId,
destHostId: input.destHostId,
destPath: input.destPath,
destPathLabel: input.destPathLabel || input.destPath,
lastUsed,
});
}
await this.afterWrite();
}
async pruneSourceHost(
userId: string,
sourceHostId: number,
keep = 10,
): Promise<number> {
const rows = await this.context.drizzle
.select({ id: transferRecent.id })
.from(transferRecent)
.where(
and(
eq(transferRecent.userId, userId),
eq(transferRecent.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecent.lastUsed));
const idsToDelete = rows.slice(keep).map((row) => row.id);
if (idsToDelete.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(transferRecent)
.where(inArray(transferRecent.id, idsToDelete));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(transferRecent)
.where(eq(transferRecent.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
eq(transferRecent.sourceHostId, hostId),
eq(transferRecent.destHostId, hostId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
inArray(transferRecent.sourceHostId, hostIds),
inArray(transferRecent.destHostId, hostIds),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -9,7 +9,6 @@ import {
createCurrentCredentialRepository,
createCurrentDashboardServiceLinkRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHomepageItemRepository,
createCurrentHomepageLayoutRepository,
createCurrentHostHealthRepository,
@@ -35,7 +34,6 @@ import {
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
createCurrentTransferRecentRepository,
createCurrentVaultProfileRepository,
createCurrentSecretSourceRepository,
createCurrentSharedCredentialSecretsRepository,
@@ -85,9 +83,9 @@ export async function deleteUserAndRelatedData(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentTransferRecentRepository().deleteByUserId(userId);
// file manager recent/pinned/shortcuts and transfer_recent cascade on the
// user's refUser() foreign key, as the file-manager plugin's adopted
// tables.
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
@@ -1,544 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentFileManagerBookmarkRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostFileManagerBookmarkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /host/file_manager/recent:
* get:
* summary: Get recent files
* description: Retrieves a list of recent files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch recent files.
*/
router.get(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for recent files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for recent files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const recentFiles =
await createCurrentFileManagerBookmarkRepository().listRecentForHost(
userId,
hostId,
20,
);
res.json(recentFiles);
} catch (err) {
sshLogger.error("Failed to fetch recent files", err);
res.status(500).json({ error: "Failed to fetch recent files" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* post:
* summary: Add recent file
* description: Adds a file to the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Recent file added.
* 400:
* description: Invalid data.
* 500:
* description: Failed to add recent file.
*/
router.post(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().upsertRecent(
userId,
{
hostId,
path,
name,
},
);
res.json({ message: "Recent file added" });
} catch (err) {
sshLogger.error("Failed to add recent file", err);
res.status(500).json({ error: "Failed to add recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* delete:
* summary: Remove recent file
* description: Removes a file from the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Recent file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove recent file.
*/
router.delete(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deleteRecentForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Recent file removed" });
} catch (err) {
sshLogger.error("Failed to remove recent file", err);
res.status(500).json({ error: "Failed to remove recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* get:
* summary: Get pinned files
* description: Retrieves a list of pinned files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of pinned files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch pinned files.
*/
router.get(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for pinned files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for pinned files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const pinnedFiles =
await createCurrentFileManagerBookmarkRepository().listPinnedForHost(
userId,
hostId,
);
res.json(pinnedFiles);
} catch (err) {
sshLogger.error("Failed to fetch pinned files", err);
res.status(500).json({ error: "Failed to fetch pinned files" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* post:
* summary: Add pinned file
* description: Adds a file to the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: File pinned.
* 400:
* description: Invalid data.
* 409:
* description: File already pinned.
* 500:
* description: Failed to pin file.
*/
router.post(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const created =
await createCurrentFileManagerBookmarkRepository().createPinned(
userId,
{ hostId, path, name },
);
if (!created) {
return res.status(409).json({ error: "File already pinned" });
}
res.json({ message: "File pinned" });
} catch (err) {
sshLogger.error("Failed to pin file", err);
res.status(500).json({ error: "Failed to pin file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* delete:
* summary: Remove pinned file
* description: Removes a file from the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Pinned file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove pinned file.
*/
router.delete(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deletePinnedForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Pinned file removed" });
} catch (err) {
sshLogger.error("Failed to remove pinned file", err);
res.status(500).json({ error: "Failed to remove pinned file" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* get:
* summary: Get shortcuts
* description: Retrieves a list of shortcuts for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of shortcuts.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch shortcuts.
*/
router.get(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for shortcuts fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for shortcuts fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const shortcuts =
await createCurrentFileManagerBookmarkRepository().listShortcutsForHost(
userId,
hostId,
);
res.json(shortcuts);
} catch (err) {
sshLogger.error("Failed to fetch shortcuts", err);
res.status(500).json({ error: "Failed to fetch shortcuts" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* post:
* summary: Add shortcut
* description: Adds a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Shortcut added.
* 400:
* description: Invalid data.
* 409:
* description: Shortcut already exists.
* 500:
* description: Failed to add shortcut.
*/
router.post(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const created =
await createCurrentFileManagerBookmarkRepository().createShortcut(
userId,
{ hostId, path, name },
);
if (!created) {
return res.status(409).json({ error: "Shortcut already exists" });
}
res.json({ message: "Shortcut added" });
} catch (err) {
sshLogger.error("Failed to add shortcut", err);
res.status(500).json({ error: "Failed to add shortcut" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* delete:
* summary: Remove shortcut
* description: Removes a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Shortcut removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove shortcut.
*/
router.delete(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deleteShortcutForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Shortcut removed" });
} catch (err) {
sshLogger.error("Failed to remove shortcut", err);
res.status(500).json({ error: "Failed to remove shortcut" });
}
},
);
}
@@ -5,14 +5,12 @@ import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -400,13 +398,9 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
await createCurrentFileManagerBookmarkRepository().deleteByHostIds(
hostIds,
);
await createCurrentTransferRecentRepository().deleteByHostIds(
hostIds,
);
// file manager recent/pinned/shortcuts and transfer_recent cascade
// on the host's refHost() foreign key, as the file-manager
// plugin's adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostIds(
hostIds,
+5 -91
View File
@@ -18,12 +18,10 @@ import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js"
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
@@ -55,7 +53,6 @@ import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
@@ -2550,13 +2547,9 @@ router.delete(
const numericHostId = Number(hostId);
await createCurrentFileManagerBookmarkRepository().deleteByHostId(
numericHostId,
);
await createCurrentTransferRecentRepository().deleteByHostId(
numericHostId,
);
// file manager recent/pinned/shortcuts and transfer_recent cascade on
// the host's refHost() foreign key, as the file-manager plugin's
// adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostId(
numericHostId,
@@ -2635,87 +2628,8 @@ router.delete(
},
);
registerHostFileManagerBookmarkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
router.get(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
? req.query.sourceHostId[0]
: req.query.sourceHostId;
const sourceHostId = sourceHostIdQuery
? parseInt(sourceHostIdQuery as string)
: null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!sourceHostId) {
return res.status(400).json({ error: "Source host ID is required" });
}
try {
const recent =
await createCurrentTransferRecentRepository().listBySourceHost(
userId,
sourceHostId,
10,
);
res.json(recent);
} catch (err) {
sshLogger.error("Failed to fetch transfer recent destinations", err);
res.status(500).json({ error: "Failed to fetch recent destinations" });
}
},
);
router.post(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
if (
!isNonEmptyString(userId) ||
!sourceHostId ||
!destHostId ||
!destPath
) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const transferRecentRepository = createCurrentTransferRecentRepository();
await transferRecentRepository.upsertForDestination(userId, {
sourceHostId,
destHostId,
destPath,
destPathLabel,
});
await transferRecentRepository.pruneSourceHost(userId, sourceHostId, 10);
res.json({ message: "Recent destination saved" });
} catch (err) {
sshLogger.error("Failed to save transfer recent destination", err);
res.status(500).json({ error: "Failed to save recent destination" });
}
},
);
// File manager recent/pinned/shortcuts and transfer/recent routes moved to
// the file-manager plugin, under /plugin-api/file-manager/.
registerHostCommandHistoryRoutes(
router,
authenticateJWT,
@@ -8,7 +8,6 @@ import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
@@ -70,7 +69,6 @@ export async function resetUserPassword(
await userRepository.update(userId, { passwordHash });
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);