Files
Termix/src/backend/database/repositories/host-folder-repository.ts
T
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00

323 lines
9.6 KiB
TypeScript

import { and, eq, like, or, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HostFolderRecord = typeof sshFolders.$inferSelect;
export type HostFolderHostRecord = typeof hosts.$inferSelect;
export interface RenameFolderResult {
updatedHosts: number;
updatedCredentials: number;
}
export class HostFolderRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async renameFolder(
userId: string,
oldName: string,
newName: string,
now = new Date().toISOString(),
): Promise<RenameFolderResult> {
// CAST target: every engine spells the text type differently enough to
// matter here, MySQL has no `text` cast and wants `char`.
const textType = this.context.dialect === "mysql" ? "char" : "text";
const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `;
const childLike = `${oldPrefix}%`;
// CONCAT, not `||`: MySQL reads `||` as logical OR unless the server runs
// with PIPES_AS_CONCAT, so the child paths would have been rewritten to 0.
// No error, just wrong folder names. CONCAT and SUBSTR mean the same thing
// on all three engines.
//
// The prefix is inlined rather than bound: CONCAT is variadic, so Postgres
// cannot infer a parameter's type from its position and rejects the
// statement with 42P18 before it runs. The value is a folder name the
// caller supplied, so it goes through a bound placeholder in a plain
// concatenation instead of sql.raw.
const renameExpr = (col: SQLiteColumn) =>
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE CONCAT(CAST(${newPrefix} AS ${sql.raw(textType)}), SUBSTR(${col}, ${sql.raw(String(oldPrefix.length + 1))})) END`;
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, oldName), like(col, childLike));
const updatedHosts = await this.context.drizzle
.update(hosts)
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
const updatedCredentials = await this.context.drizzle
.update(sshCredentials)
.set({ folder: renameExpr(sshCredentials.folder), updatedAt: now })
.where(
and(
eq(sshCredentials.userId, userId),
folderMatch(sshCredentials.folder),
),
);
await this.context.drizzle
.update(sshFolders)
.set({ name: renameExpr(sshFolders.name), updatedAt: now })
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
await this.afterWrite();
return {
updatedHosts: rowsAffected(updatedHosts),
updatedCredentials: rowsAffected(updatedCredentials),
};
}
async listFolders(userId: string): Promise<HostFolderRecord[]> {
return this.context.drizzle
.select()
.from(sshFolders)
.where(eq(sshFolders.userId, userId));
}
/**
* Keeps a folder, its subfolders and every host in them on this device
* only, or lets them sync again.
*/
async setLocalOnly(
userId: string,
folderName: string,
localOnly: boolean,
): Promise<void> {
const inFolder = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`));
await this.context.drizzle
.update(sshFolders)
.set({ localOnly })
.where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name)));
await this.context.drizzle
.update(hosts)
.set({ localOnly, updatedAt: new Date().toISOString() })
.where(and(eq(hosts.userId, userId), inFolder(hosts.folder)));
await this.afterWrite();
}
async upsertMetadata(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
now = new Date().toISOString(),
): Promise<{ folder: HostFolderRecord; created: boolean }> {
const existing = await this.findFolder(userId, name);
if (existing) {
const [updated] = await updateReturning(
this.context,
sshFolders,
{
color,
icon,
updatedAt: now,
},
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
await this.afterWrite();
return { folder: updated, created: false };
}
const [created] = await insertReturning(this.context, sshFolders, {
syncId: randomUUID(),
userId,
name,
color,
icon,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return { folder: created, created: true };
}
/**
* Sets a distinct manual sortOrder per sibling folder (drag-to-reorder).
* Folders with no existing sshFolders row are created first (matching the
* empty-folder-persists behavior elsewhere) so the order survives even for
* folders that only ever existed implicitly via host paths.
*/
async reorderFolders(
userId: string,
positions: { name: string; sortOrder: number }[],
now = new Date().toISOString(),
): Promise<number> {
if (positions.length === 0) return 0;
let affected: number;
if (this.context.dialect === "sqlite") {
affected = this.context.drizzle.transaction((tx) => {
let count = 0;
for (const { name, sortOrder } of positions) {
const result = tx
.update(sshFolders)
.set({ sortOrder, updatedAt: now })
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
)
.run();
if (rowsAffected(result) > 0) {
count += rowsAffected(result);
continue;
}
tx.insert(sshFolders)
.values({
syncId: randomUUID(),
userId,
name,
sortOrder,
createdAt: now,
updatedAt: now,
})
.run();
count += 1;
}
return count;
});
} else {
affected = await this.context.drizzle.transaction(async (tx) => {
let count = 0;
for (const { name, sortOrder } of positions) {
const result = await tx
.update(sshFolders)
.set({ sortOrder, updatedAt: now })
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
if (rowsAffected(result) > 0) {
count += rowsAffected(result);
continue;
}
await tx.insert(sshFolders).values({
syncId: randomUUID(),
userId,
name,
sortOrder,
createdAt: now,
updatedAt: now,
});
count += 1;
}
return count;
});
}
if (affected > 0) {
await this.afterWrite();
}
return affected;
}
async listHostsInFolder(
userId: string,
folderName: string,
): Promise<HostFolderHostRecord[]> {
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`));
return this.context.drizzle
.select()
.from(hosts)
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
/** Drops the folder rows (and subfolders) only when no host is left in them. */
async deleteEmptyFolderRecords(
userId: string,
folderName: string,
): Promise<boolean> {
if ((await this.listHostsInFolder(userId, folderName)).length > 0) {
return false;
}
await deleteReturning(
this.context,
sshFolders,
and(
eq(sshFolders.userId, userId),
or(
eq(sshFolders.name, folderName),
like(sshFolders.name, `${folderName} / %`),
),
),
);
await this.afterWrite();
return true;
}
async deleteHostsAndFolderRecords(
userId: string,
folderName: string,
): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> {
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`));
const hostsToDelete = await this.listHostsInFolder(userId, folderName);
if (hostsToDelete.length > 0) {
await this.context.drizzle
.delete(hosts)
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
const deletedFolders = await deleteReturning(
this.context,
sshFolders,
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
);
await this.afterWrite();
return {
hostSyncIds: hostsToDelete
.map((h) => h.syncId)
.filter((id): id is string => !!id),
folderSyncIds: deletedFolders
.map((f) => f.syncId)
.filter((id): id is string => !!id),
};
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(sshFolders)
.where(eq(sshFolders.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async findFolder(
userId: string,
name: string,
): Promise<HostFolderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}