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);
+2
View File
@@ -53,6 +53,8 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
"fleet",
"remote-desktop",
"tunnel",
"file-manager",
"file-transfer",
]);
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
+9 -5
View File
@@ -302,10 +302,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { primeKnownPermissions } =
await import("./utils/known-permissions.js");
await primeKnownPermissions();
// Terminal, docker and host-metrics are deliberately absent: the
// ssh-terminal, docker and host-metrics plugins start their own servers,
// so disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
// Terminal, docker, host-metrics and file-manager are deliberately
// absent: the ssh-terminal, docker, host-metrics and file-manager plugins
// start their own servers, so disabling any of them stops its WS/HTTP
// server. See plugins/ssh-terminal, plugins/docker, plugins/host-metrics
// and plugins/file-manager.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint) is absent for the same
// reason: each one serves its routes under /plugin-api/<id>/ through
@@ -313,7 +314,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// dead import here. Automations' scheduler also starts from its own
// activate() rather than here.
await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js");
await import("./hosts/tmux/index.js");
await import("./hosts/serial.js");
await import("./services/dashboard.js");
@@ -350,6 +350,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runProxmoxSettingsMigration } =
await import("./utils/crypto-migration/proxmox-settings-migration.js");
await runProxmoxSettingsMigration();
const { runFileManagerSettingsMigration } =
await import("./utils/crypto-migration/file-manager-settings-migration.js");
await runFileManagerSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -1,193 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { FileManagerBookmarkRepository } from "../../../database/repositories/file-manager-bookmark-repository.js";
describe("FileManagerBookmarkRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<FileManagerBookmarkRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
`);
return new FileManagerBookmarkRepository(context, onWrite);
}
it("upserts and lists recent files by last-opened time", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/var/log/app.log" },
"2026-01-01T00:00:00.000Z",
);
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/opt/app/config.json", name: "Config" },
"2026-01-02T00:00:00.000Z",
);
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/var/log/app.log" },
"2026-01-03T00:00:00.000Z",
);
const recent = await repo.listRecentForHost("user-1", 1);
expect(recent.map((entry) => entry.path)).toEqual([
"/var/log/app.log",
"/opt/app/config.json",
]);
expect(recent[0].lastOpened).toBe("2026-01-03T00:00:00.000Z");
expect(recent[0].name).toBe("app.log");
expect(writeCount).toBe(3);
expect(
await repo.deleteRecentForHostPath("user-1", {
hostId: 1,
path: "/var/log/app.log",
}),
).toBe(1);
expect(writeCount).toBe(4);
});
it("creates pinned files and shortcuts without duplicating paths", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.createPinned(
"user-1",
{ hostId: 1, path: "/srv/www", name: "Web" },
"2026-01-01T00:00:00.000Z",
),
).toBe(true);
expect(
await repo.createPinned("user-1", { hostId: 1, path: "/srv/www" }),
).toBe(false);
expect((await repo.listPinnedForHost("user-1", 1))[0]).toMatchObject({
name: "Web",
path: "/srv/www",
});
expect(
await repo.createShortcut(
"user-1",
{ hostId: 1, path: "/etc/nginx" },
"2026-01-02T00:00:00.000Z",
),
).toBe(true);
expect(
await repo.createShortcut("user-1", { hostId: 1, path: "/etc/nginx" }),
).toBe(false);
expect((await repo.listShortcutsForHost("user-1", 1))[0]).toMatchObject({
name: "nginx",
path: "/etc/nginx",
});
expect(writeCount).toBe(2);
expect(
await repo.deletePinnedForHostPath("user-1", {
hostId: 1,
path: "/srv/www",
}),
).toBe(1);
expect(
await repo.deleteShortcutForHostPath("user-1", {
hostId: 1,
path: "/etc/nginx",
}),
).toBe(1);
expect(writeCount).toBe(4);
});
it("creates import bookmarks without duplicating user path/name pairs", async () => {
const repo = await createRepository();
await expect(
repo.createRecentForImport(
"user-1",
{ hostId: 1, path: "/tmp/a.txt", name: "A" },
"2026-01-01T00:00:00.000Z",
),
).resolves.toBe(true);
await expect(
repo.createRecentForImport("user-1", {
hostId: 2,
path: "/tmp/a.txt",
name: "A",
}),
).resolves.toBe(false);
await expect(
repo.createPinnedForImport("user-1", {
hostId: 1,
path: "/srv/www",
name: "Web",
}),
).resolves.toBe(true);
await expect(
repo.createPinnedForImport("user-1", {
hostId: 2,
path: "/srv/www",
name: "Web",
}),
).resolves.toBe(false);
await expect(
repo.createShortcutForImport("user-1", {
hostId: 1,
path: "/etc/nginx",
name: "Nginx",
}),
).resolves.toBe(true);
await expect(
repo.createShortcutForImport("user-1", {
hostId: 2,
path: "/etc/nginx",
name: "Nginx",
}),
).resolves.toBe(false);
});
it("deletes bookmarks by user, host, and host list", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertRecent("user-1", { hostId: 1, path: "/one" });
await repo.createPinned("user-1", { hostId: 2, path: "/two" });
await repo.createShortcut("user-2", { hostId: 3, path: "/three" });
expect(writeCount).toBe(3);
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2])).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByUserId("user-2")).toBe(0);
expect(writeCount).toBe(6);
});
});
@@ -1,119 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TransferRecentRepository } from "../../../database/repositories/transfer-recent-repository.js";
describe("TransferRecentRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<TransferRecentRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'source', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'dest-a', '10.0.0.1', 22, 'root', 'password'), (3, 'user-1', 'dest-b', '10.0.0.1', 22, 'root', 'password'), (4, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
`);
return new TransferRecentRepository(context, onWrite);
}
it("upserts, lists, and prunes recent transfer destinations", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForDestination(
"user-1",
{ sourceHostId: 1, destHostId: 2, destPath: "/var/www" },
"2026-01-01T00:00:00.000Z",
);
await repo.upsertForDestination(
"user-1",
{
sourceHostId: 1,
destHostId: 3,
destPath: "/opt/app",
destPathLabel: "App",
},
"2026-01-02T00:00:00.000Z",
);
await repo.upsertForDestination(
"user-1",
{ sourceHostId: 1, destHostId: 2, destPath: "/var/www" },
"2026-01-03T00:00:00.000Z",
);
const recent = await repo.listBySourceHost("user-1", 1);
expect(recent).toHaveLength(2);
expect(recent.map((entry) => entry.destPath)).toEqual([
"/var/www",
"/opt/app",
]);
expect(recent[0].lastUsed).toBe("2026-01-03T00:00:00.000Z");
expect(recent[0].destPathLabel).toBe("/var/www");
expect(writeCount).toBe(3);
expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(1);
expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(0);
expect(await repo.listBySourceHost("user-1", 1)).toHaveLength(1);
expect(writeCount).toBe(4);
});
it("deletes recent transfer destinations by user and host references", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForDestination("user-1", {
sourceHostId: 1,
destHostId: 2,
destPath: "/one",
});
await repo.upsertForDestination("user-1", {
sourceHostId: 2,
destHostId: 3,
destPath: "/two",
});
await repo.upsertForDestination("user-2", {
sourceHostId: 4,
destHostId: 1,
destPath: "/other",
});
expect(writeCount).toBe(3);
expect(await repo.deleteByHostId(1)).toBe(2);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(writeCount).toBe(4);
await repo.upsertForDestination("user-1", {
sourceHostId: 1,
destHostId: 2,
destPath: "/three",
});
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
expect(writeCount).toBe(6);
await repo.upsertForDestination("user-2", {
sourceHostId: 4,
destHostId: 1,
destPath: "/last",
});
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByUserId("user-2")).toBe(0);
expect(writeCount).toBe(8);
});
});
@@ -27,7 +27,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentHostRepository: deletingRepo("hosts"),
@@ -117,7 +116,6 @@ describe("resetUserPassword", () => {
expect(outcome).toEqual({ status: "reset", dataWiped: true });
expect(calls.deletedFor).toEqual([
"usage:user-1",
"bookmarks:user-1",
"activity:user-1",
"alerts:user-1",
"hosts:user-1",
@@ -0,0 +1,163 @@
/**
* The file-manager host-columns migration.
*
* An upgrade must be lossless: a host that had file manager disabled, a
* custom default path, or SCP legacy mode on must keep that configuration
* once the ssh_data columns are dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_file_manager: boolean;
default_path: string | null;
scp_legacy: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_file_manager === false ||
row.default_path !== null ||
row.scp_legacy,
),
}),
}));
const { runFileManagerSettingsMigration } =
await import("../../utils/crypto-migration/file-manager-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runFileManagerSettingsMigration", () => {
it("moves a host with file manager disabled into plugin settings", async () => {
hostRows.push({
id: 1,
enable_file_manager: false,
default_path: "/srv/app",
scp_legacy: true,
});
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(1, "defaultPath")).toBe("/srv/app");
expect(storedValue(1, "scpLegacy")).toBe(true);
});
it("does nothing on a fresh install with no non-default hosts", async () => {
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
});
await runFileManagerSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runFileManagerSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
},
{
id: 2,
enable_file_manager: true,
default_path: "/data",
scp_legacy: false,
},
);
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(2, "defaultPath")).toBe("/data");
});
});
@@ -0,0 +1,100 @@
/**
* Moves the three file-manager host columns into the file-manager plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a file-manager settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { getDb } from "../../database/db/index.js";
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
export interface FileManagerSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyFileManagerRow {
id: number;
enable_file_manager: number | boolean | null;
default_path: string | null;
scp_legacy: number | boolean | null;
}
export async function runFileManagerSettingsMigration(): Promise<FileManagerSettingsMigrationResult> {
const result: FileManagerSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
const drizzleDb = getDb();
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await drizzleDb.all<LegacyFileManagerRow>(sql`
SELECT id, enable_file_manager, default_path, scp_legacy
FROM ssh_data
WHERE enable_file_manager = false OR default_path IS NOT NULL
OR scp_legacy = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"file-manager",
"host",
scopeId,
"enableFileManager",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"enableFileManager",
JSON.stringify(row.enable_file_manager !== false),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"defaultPath",
JSON.stringify(row.default_path ?? null),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"scpLegacy",
JSON.stringify(!!row.scp_legacy),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved file manager settings for ${result.moved} host(s) into plugin settings`,
{ operation: "file_manager_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever file-manager settings they already had (none, on a fresh run).
databaseLogger.warn("File manager settings migration failed", {
operation: "file_manager_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
+30 -15
View File
@@ -1,13 +1,35 @@
import { sql } from "drizzle-orm";
import {
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentTransferRecentRepository,
createCurrentUserDataExportRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { getDb } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
// The file manager plugin owns these tables now (p_file_manager_*); read by
// raw SQL rather than importing plugin code into core.
async function listFileManagerExportData(userId: string): Promise<{
recent: unknown[];
pinned: unknown[];
shortcuts: unknown[];
transferRecent: unknown[];
}> {
const db = getDb();
const [recent, pinned, shortcuts, transferRecent] = await Promise.all([
db.all(sql`SELECT * FROM p_file_manager_recent WHERE user_id = ${userId}`),
db.all(sql`SELECT * FROM p_file_manager_pinned WHERE user_id = ${userId}`),
db.all(
sql`SELECT * FROM p_file_manager_shortcuts WHERE user_id = ${userId}`,
),
db.all(
sql`SELECT * FROM p_file_manager_transfer_recent WHERE user_id = ${userId}`,
),
]);
return { recent, pinned, shortcuts, transferRecent };
}
interface UserExportData {
version: string;
exportedAt: string;
@@ -90,19 +112,12 @@ class UserDataExport {
: credentials;
}
const [recentFiles, pinnedFiles, shortcuts, transferRecentData] =
await Promise.all([
createCurrentFileManagerBookmarkRepository().listRecentByUserId(
userId,
),
createCurrentFileManagerBookmarkRepository().listPinnedByUserId(
userId,
),
createCurrentFileManagerBookmarkRepository().listShortcutsByUserId(
userId,
),
createCurrentTransferRecentRepository().listByUserId(userId),
]);
const {
recent: recentFiles,
pinned: pinnedFiles,
shortcuts,
transferRecent: transferRecentData,
} = await listFileManagerExportData(userId);
const alerts =
await createCurrentDismissedAlertRepository().listByUserId(userId);