mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: convert file manager into a plugin
This commit is contained in:
1 parent
b76da9ab52
commit
c85c1fdb86
259 files changed
+54155
-16791
No files matched your search
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -53,6 +53,8 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
|
||||
"fleet",
|
||||
"remote-desktop",
|
||||
"tunnel",
|
||||
"file-manager",
|
||||
"file-transfer",
|
||||
]);
|
||||
|
||||
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user