feat: move snippets backend and tables into a plugin (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:58:53 -05:00
1 parent fdedca39fd
commit e07221eaf3
51 files changed
+28426 -3553

No files matched your search

-2
View File
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
@@ -1764,7 +1763,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
-70
View File
@@ -435,17 +435,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1581,65 +1570,6 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("snippets", "folder", "TEXT");
addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("snippets", "host_filter", "TEXT");
addColumnIfNotExists("snippets", "is_note", "INTEGER NOT NULL DEFAULT 0");
try {
sqlite
.prepare("SELECT id FROM snippet_folders LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS snippet_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create snippet_folders table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS snippet_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'view',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create snippet_access table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
} catch {
@@ -98,23 +98,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Snippets.
{ name: "idx_snippets_user_id", table: "snippets", columns: "user_id" },
{
name: "idx_snippet_access_user_id",
table: "snippet_access",
columns: "user_id",
},
{
name: "idx_snippet_access_snippet_id",
table: "snippet_access",
columns: "snippet_id",
},
{
name: "idx_snippet_access_role_id",
table: "snippet_access",
columns: "role_id",
},
// Per-user history and file manager surfaces.
{
-75
View File
@@ -557,48 +557,6 @@ export const sshCredentialUsage = mysqlTable(
],
);
export const snippets = mysqlTable(
"snippets",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
order: int("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
hostFilter: text("host_filter"),
isNote: boolean("is_note").notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = mysqlTable("snippet_folders", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
color: text("color"),
icon: text("icon"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -616,39 +574,6 @@ export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const snippetAccess = mysqlTable(
"snippet_access",
{
id: int("id").autoincrement().primaryKey(),
snippetId: int("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = mysqlTable(
"ssh_folders",
{
-75
View File
@@ -558,48 +558,6 @@ export const sshCredentialUsage = pgTable(
],
);
export const snippets = pgTable(
"snippets",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
order: integer("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
hostFilter: text("host_filter"),
isNote: boolean("is_note").notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = pgTable("snippet_folders", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
color: text("color"),
icon: text("icon"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -617,39 +575,6 @@ export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = pgTable(
"snippet_access",
{
id: serial("id").primaryKey(),
snippetId: integer("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = pgTable(
"ssh_folders",
{
-75
View File
@@ -550,48 +550,6 @@ export const sshCredentialUsage = sqliteTable(
],
);
export const snippets = sqliteTable(
"snippets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
hostFilter: text("host_filter"),
isNote: integer("is_note", { mode: "boolean" }).notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = sqliteTable("snippet_folders", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -609,39 +567,6 @@ export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = sqliteTable(
"snippet_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
snippetId: integer("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = sqliteTable(
"ssh_folders",
{
@@ -54,7 +54,6 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
@@ -504,13 +503,6 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
);
}
export function createCurrentSnippetRepository(): SnippetRepository {
return new SnippetRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("snippet_repository_write"),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository(
createCurrentRepositoryContext(),
@@ -4,8 +4,6 @@ import {
hosts,
roles,
sharedHostSecrets,
snippetAccess,
snippets,
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -43,29 +41,6 @@ export interface RbacSharedHost {
ownerUsername: string;
}
export interface RbacSharedSnippet {
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}
export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
userId: string;
order: number;
createdAt: string;
updatedAt: string;
isNote: boolean;
}
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
hostFilter: string | null;
}
export interface RbacRoleHostAccessCredentialSource {
hostAccessId: number;
credentialId: number | null;
@@ -93,12 +68,6 @@ export type UpsertHostAccessInput = RbacAccessTarget & {
expiresAt: string | null;
};
export type UpsertSnippetAccessInput = RbacAccessTarget & {
snippetId: number;
grantedBy: string;
expiresAt: string | null;
};
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
@@ -249,76 +218,6 @@ export class RbacAccessRepository {
return rows[0] ?? null;
}
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
const rows = await this.context.drizzle
.select({
id: snippetAccess.id,
userId: snippetAccess.userId,
roleId: snippetAccess.roleId,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
grantedBy: snippetAccess.grantedBy,
grantedByUsername: sql<
string | null
>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
createdAt: snippetAccess.createdAt,
})
.from(snippetAccess)
.leftJoin(users, eq(snippetAccess.userId, users.id))
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
.where(eq(snippetAccess.snippetId, snippetId))
.orderBy(desc(snippetAccess.createdAt));
return rows.map(toAccessListItem);
}
async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{
id: number;
created: boolean;
}> {
const existing = await this.findSnippetAccess(input.snippetId, input);
if (existing) {
await this.context.drizzle
.update(snippetAccess)
.set({ expiresAt: input.expiresAt })
.where(eq(snippetAccess.id, existing.id));
await this.afterWrite();
return { id: existing.id, created: false };
}
const [created] = await insertReturning(this.context, snippetAccess, {
snippetId: input.snippetId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: "view",
expiresAt: input.expiresAt,
});
await this.afterWrite();
return { id: created.id, created: true };
}
async revokeSnippetAccess(
accessId: number,
snippetId: number,
): Promise<void> {
await this.context.drizzle
.delete(snippetAccess)
.where(
and(
eq(snippetAccess.id, accessId),
eq(snippetAccess.snippetId, snippetId),
),
);
await this.afterWrite();
}
async listSharedHosts(
userId: string,
roleIds: number[],
@@ -371,146 +270,6 @@ export class RbacAccessRepository {
.orderBy(desc(hostAccess.createdAt));
}
async listSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacSharedSnippet[]> {
const directShared = await this.context.drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(snippetAccess.userId, userId),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
);
if (roleIds.length === 0) {
return directShared;
}
const directIds = new Set(directShared.map((snippet) => snippet.id));
const roleShared = await this.context.drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
inArray(snippetAccess.roleId, roleIds),
),
);
return [
...directShared,
...roleShared.filter((snippet) => !directIds.has(snippet.id)),
];
}
async listVisibleSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacVisibleSharedSnippet[]> {
return this.context.drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
this.userOrRoleSnippetAccessFilter(userId, roleIds),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
);
}
async findAccessibleSharedSnippet(
snippetId: number,
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacAccessibleSnippet | null> {
const rows = await this.context.drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
hostFilter: snippets.hostFilter,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(snippetAccess.snippetId, snippetId),
this.userOrRoleSnippetAccessFilter(userId, roleIds),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
)
.limit(1);
return rows[0] ?? null;
}
async deleteExpiredHostAccess(
now = new Date().toISOString(),
): Promise<number> {
@@ -670,17 +429,6 @@ export class RbacAccessRepository {
);
}
private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) {
if (roleIds.length === 0) {
return eq(snippetAccess.userId, userId);
}
return or(
eq(snippetAccess.userId, userId),
inArray(snippetAccess.roleId, roleIds),
);
}
private async findHostAccess(hostId: number, target: RbacAccessTarget) {
const rows = await this.context.drizzle
.select()
@@ -698,23 +446,6 @@ export class RbacAccessRepository {
return rows[0] ?? null;
}
private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) {
const rows = await this.context.drizzle
.select()
.from(snippetAccess)
.where(
and(
eq(snippetAccess.snippetId, snippetId),
target.targetType === "user"
? eq(snippetAccess.userId, target.targetUserId)
: eq(snippetAccess.roleId, target.targetRoleId),
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
@@ -1,575 +0,0 @@
import { and, asc, eq, like, or, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } 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 SnippetRecord = typeof snippets.$inferSelect;
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
export interface RenameSnippetFolderResult {
status: "renamed" | "missing" | "conflict";
}
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
export interface NewSnippetInput {
name: string;
content: string;
description?: string | null;
folder?: string | null;
order?: number | null;
hostFilter?: unknown;
isNote?: boolean;
}
export interface SnippetUpdateInput {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
order?: number;
hostFilter?: unknown;
isNote?: boolean;
}
export interface UpdateSnippetResult {
existing: SnippetRecord;
updated: SnippetRecord;
}
export interface SnippetBulkImportResult {
snippetsImported: number;
snippetsSkipped: number;
snippetsUpdated: number;
foldersImported: number;
foldersSkipped: number;
failed: number;
errors: string[];
}
interface BulkImportSnippetInput {
name?: unknown;
content?: unknown;
description?: string | null;
folder?: string | null;
order?: unknown;
hostFilter?: string | null;
}
interface BulkImportFolderInput {
name?: unknown;
color?: string | null;
icon?: string | null;
}
export class SnippetRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findOwnedById(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async listFolders(userId: string): Promise<SnippetFolderRecord[]> {
return this.context.drizzle
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
}
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
return (
this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
// coalesce, not asc(folder): folder is nullable, and NULLs sort first on
// SQLite and MySQL but last on Postgres. An export whose row order depends
// on the engine is not much of an export.
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order))
);
}
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
return this.listFolders(userId);
}
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
async listOwnedSnippetsInFolder(
userId: string,
folder: string,
): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
or(
eq(snippets.folder, folder),
like(snippets.folder, `${folder} / %`),
),
),
);
}
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
asc(snippets.folder),
asc(snippets.order),
sql`${snippets.updatedAt} DESC`,
);
}
async reorderSnippets(
userId: string,
updates: SnippetReorderUpdate[],
): Promise<void> {
for (const update of updates) {
const { id, order, folder } = update;
if (!id || order === undefined) {
continue;
}
const updateFields: Partial<{
order: number;
folder: string | null;
}> = {
order,
};
if (folder !== undefined) {
updateFields.folder = folder?.trim() || null;
}
await this.context.drizzle
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
}
await this.afterWrite();
}
async createSnippet(
userId: string,
input: NewSnippetInput,
): Promise<SnippetRecord> {
const folderValue = input.folder?.trim() || "";
const order =
input.order === undefined || input.order === null
? await this.nextOrderForFolder(userId, folderValue)
: input.order;
const rows = await insertReturning(this.context, snippets, {
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
isNote: input.isNote ?? false,
});
await this.afterWrite();
return rows[0];
}
async updateSnippet(
userId: string,
snippetId: number,
input: SnippetUpdateInput,
): Promise<UpdateSnippetResult | null> {
const existing = await this.findOwnedById(userId, snippetId);
if (!existing) return null;
const updateFields: Partial<{
updatedAt: ReturnType<typeof sql>;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
hostFilter: string | null;
isNote: boolean;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (input.name !== undefined) updateFields.name = input.name.trim();
if (input.content !== undefined)
updateFields.content = input.content.trim();
if (input.description !== undefined)
updateFields.description = input.description?.trim() || null;
if (input.folder !== undefined)
updateFields.folder = input.folder?.trim() || null;
if (input.order !== undefined) updateFields.order = input.order;
if (input.hostFilter !== undefined)
updateFields.hostFilter = input.hostFilter
? JSON.stringify(input.hostFilter)
: null;
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
const rows = await updateReturning(
this.context,
snippets,
updateFields,
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
);
await this.afterWrite();
return { existing, updated: rows[0] };
}
async deleteSnippet(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const existing = await this.findOwnedById(userId, snippetId);
if (!existing) return null;
await this.context.drizzle
.delete(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
await this.afterWrite();
return existing;
}
async deleteByUserId(userId: string): Promise<{
snippetsDeleted: number;
foldersDeleted: number;
}> {
const snippetResult = await this.context.drizzle
.delete(snippets)
.where(eq(snippets.userId, userId));
const result = await this.context.drizzle
.delete(snippetFolders)
.where(eq(snippetFolders.userId, userId));
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
snippetsDeleted: rowsAffected(snippetResult),
foldersDeleted: rowsAffected(result),
};
}
async bulkImport(
userId: string,
snippetsToImport: unknown[] | undefined,
foldersToImport: unknown[] | undefined,
overwrite: boolean,
): Promise<SnippetBulkImportResult> {
const results: SnippetBulkImportResult = {
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 0,
foldersImported: 0,
foldersSkipped: 0,
failed: 0,
errors: [],
};
let changed = false;
if (Array.isArray(foldersToImport)) {
for (const rawFolder of foldersToImport) {
const folder = rawFolder as BulkImportFolderInput;
if (!isNonEmptyString(folder.name)) {
results.failed++;
results.errors.push(`Folder missing name`);
continue;
}
const created = await this.createFolder(
userId,
folder.name,
folder.color,
folder.icon,
false,
);
if (!created) {
results.foldersSkipped++;
continue;
}
changed = true;
results.foldersImported++;
}
}
if (Array.isArray(snippetsToImport)) {
for (let i = 0; i < snippetsToImport.length; i++) {
const snippet = snippetsToImport[i] as BulkImportSnippetInput;
if (
!isNonEmptyString(snippet.name) ||
!isNonEmptyString(snippet.content)
) {
results.failed++;
results.errors.push(
`Snippet ${i + 1}: name and content are required`,
);
continue;
}
const folderVal = snippet.folder?.trim() || null;
const existing = await this.findByNameAndFolder(
userId,
snippet.name.trim(),
folderVal,
);
if (existing) {
if (!overwrite) {
results.snippetsSkipped++;
continue;
}
await this.context.drizzle
.update(snippets)
.set({
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number"
? snippet.order
: existing.order,
hostFilter: snippet.hostFilter || null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(eq(snippets.id, existing.id), eq(snippets.userId, userId)),
);
changed = true;
results.snippetsUpdated++;
continue;
}
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
await this.context.drizzle.insert(snippets).values({
syncId: randomUUID(),
userId,
name: snippet.name.trim(),
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number" ? snippet.order : maxOrder + 1,
hostFilter: snippet.hostFilter || null,
});
changed = true;
results.snippetsImported++;
}
}
if (changed) {
await this.afterWrite();
}
return results;
}
async createFolder(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
triggerSave = true,
): Promise<SnippetFolderRecord | null> {
const existing = await this.findFolderByName(userId, name);
if (existing) return null;
const rows = await insertReturning(this.context, snippetFolders, {
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
});
if (triggerSave) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async updateFolderMetadata(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
): Promise<SnippetFolderRecord | null> {
const existing = await this.findFolderByName(userId, name);
if (!existing) return null;
const updateFields: Partial<{
color: string | null;
icon: string | null;
updatedAt: ReturnType<typeof sql>;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
const rows = await updateReturning(
this.context,
snippetFolders,
updateFields,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ?? null;
}
async renameFolder(
userId: string,
oldName: string,
newName: string,
): Promise<RenameSnippetFolderResult> {
const existing = await this.findFolderByName(userId, oldName);
if (!existing) return { status: "missing" };
const nameExists = await this.findFolderByName(userId, newName);
if (nameExists) return { status: "conflict" };
await this.context.drizzle
.update(snippetFolders)
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
await this.context.drizzle
.update(snippets)
.set({ folder: newName })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
await this.afterWrite();
return { status: "renamed" };
}
async deleteFolder(
userId: string,
name: string,
): Promise<{ syncId: string | null } | null> {
await this.context.drizzle
.update(snippets)
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
const rows = await deleteReturning(
this.context,
snippetFolders,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ? { syncId: rows[0].syncId } : null;
}
private async findFolderByName(
userId: string,
name: string,
): Promise<SnippetFolderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
)
.limit(1);
return rows[0] ?? null;
}
private async nextOrderForFolder(
userId: string,
folder: string,
): Promise<number> {
return (await this.maxOrderForFolder(userId, folder || null)) + 1;
}
private async maxOrderForFolder(
userId: string,
folder: string | null,
): Promise<number> {
const maxOrderResult = await this.context.drizzle
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
return maxOrder;
}
private async findByNameAndFolder(
userId: string,
name: string,
folder: string | null,
): Promise<SnippetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
eq(snippets.name, name),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -28,7 +28,6 @@ import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
@@ -93,7 +92,8 @@ export async function deleteUserAndRelatedData(
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
// snippets, snippet_folders and snippet_access cascade on the user's
// refUser() foreign key, as the snippets plugin's adopted tables.
await createCurrentHostFolderRepository().deleteByUserId(userId);
-378
View File
@@ -30,7 +30,6 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSnippetRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
@@ -1941,383 +1940,6 @@ router.delete(
},
);
// SNIPPET SHARING
/**
* @openapi
* /rbac/snippet/{id}/share:
* post:
* summary: Share a snippet
* description: Shares a snippet with a user or role.
* tags:
* - RBAC
*/
router.post(
"/snippet/:id/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const {
targetType = "user",
targetUserId,
targetRoleId,
durationHours,
} = req.body;
if (!["user", "role"].includes(targetType)) {
return res
.status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
}
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
return res
.status(400)
.json({ error: "Target user ID is required when sharing with user" });
}
if (targetType === "role" && !targetRoleId) {
return res
.status(400)
.json({ error: "Target role ID is required when sharing with role" });
}
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
if (targetType === "user") {
const targetUser =
await createCurrentUserRepository().findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "Target user not found" });
}
} else {
const targetRole =
await createCurrentRoleRepository().findRoleById(targetRoleId);
if (!targetRole) {
return res.status(404).json({ error: "Target role not found" });
}
}
let expiresAt: string | null = null;
if (
durationHours &&
typeof durationHours === "number" &&
durationHours > 0
) {
const expiryDate = new Date();
expiryDate.setHours(expiryDate.getHours() + durationHours);
expiresAt = expiryDate.toISOString();
}
const accessGrant =
await createCurrentRbacAccessRepository().upsertSnippetAccess({
snippetId,
grantedBy: userId,
expiresAt,
...(targetType === "user"
? { targetType: "user" as const, targetUserId: targetUserId! }
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
});
if (!accessGrant.created) {
return res.json({
success: true,
message: "Snippet access updated",
expiresAt,
});
}
databaseLogger.success("Snippet shared successfully", {
operation: "rbac_snippet_share",
userId,
});
res.json({
success: true,
message: `Snippet shared successfully with ${targetType}`,
expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to share snippet", error, {
operation: "share_snippet",
userId,
});
res.status(500).json({ error: "Failed to share snippet" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access/{accessId}:
* delete:
* summary: Revoke snippet access
* description: Revokes a user's or role's access to a snippet.
* tags:
* - RBAC
*/
/**
* @openapi
* /rbac/snippet-folder/share:
* post:
* summary: Share every snippet in a folder
* description: Grants view access to each owned snippet in the folder (and its subfolders) to the given users or roles.
* tags:
* - RBAC
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* folder:
* type: string
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* id:
* oneOf:
* - type: string
* - type: integer
* durationHours:
* type: number
* responses:
* 200:
* description: Folder shared.
* 404:
* description: A target was not found.
*/
router.post(
"/snippet-folder/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
const { folder, durationHours } = req.body ?? {};
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "Folder name is required" });
}
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
error:
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
});
}
try {
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
const found =
target.type === "user"
? await userRepository.findById(target.id as string)
: await roleRepository.findRoleById(target.id as number);
if (!found) {
return res.status(404).json({
error: `Target ${target.type} not found`,
targetId: target.id,
});
}
}
const snippetsInFolder =
await createCurrentSnippetRepository().listOwnedSnippetsInFolder(
userId,
folder,
);
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
for (const snippet of snippetsInFolder) {
for (const target of targets) {
if (target.type === "user" && target.id === userId) continue;
await rbacAccessRepository.upsertSnippetAccess({
snippetId: snippet.id,
grantedBy: userId,
expiresAt,
...(target.type === "user"
? {
targetType: "user" as const,
targetUserId: target.id as string,
}
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
}
}
databaseLogger.success("Snippet folder shared successfully", {
operation: "rbac_snippet_folder_share",
userId,
folder,
snippetsShared: snippetsInFolder.length,
targets: targets.length,
});
res.json({
success: true,
expiresAt,
snippetsShared: snippetsInFolder.length,
});
} catch (error) {
databaseLogger.error("Failed to share snippet folder", error, {
operation: "share_snippet_folder",
folder,
userId,
});
res.status(500).json({ error: "Failed to share snippet folder" });
}
},
);
router.delete(
"/snippet/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
? req.params.accessId[0]
: req.params.accessId;
const snippetId = parseInt(id, 10);
const accessId = parseInt(accessIdParam, 10);
const userId = req.userId!;
if (isNaN(snippetId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
await createCurrentRbacAccessRepository().revokeSnippetAccess(
accessId,
snippetId,
);
res.json({ success: true, message: "Snippet access revoked" });
} catch (error) {
databaseLogger.error("Failed to revoke snippet access", error, {
operation: "revoke_snippet_access",
userId,
});
res.status(500).json({ error: "Failed to revoke access" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access:
* get:
* summary: Get snippet access list
* description: Retrieves the list of users and roles with access to a snippet.
* tags:
* - RBAC
*/
router.get(
"/snippet/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
const accessList =
await createCurrentRbacAccessRepository().listSnippetAccess(snippetId);
res.json({ accessList });
} catch (error) {
databaseLogger.error("Failed to get snippet access list", error, {
operation: "get_snippet_access_list",
userId,
});
res.status(500).json({ error: "Failed to get access list" });
}
},
);
/**
* @openapi
* /rbac/shared-snippets:
* get:
* summary: Get shared snippets
* description: Retrieves snippets shared with the current user.
* tags:
* - RBAC
*/
router.get(
"/shared-snippets",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedSnippets =
await createCurrentRbacAccessRepository().listSharedSnippets(
userId,
roleIds,
);
res.json({ sharedSnippets });
} catch (error) {
databaseLogger.error("Failed to get shared snippets", error, {
operation: "get_shared_snippets",
userId,
});
res.status(500).json({ error: "Failed to get shared snippets" });
}
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
@@ -1,82 +0,0 @@
export interface SnippetExecutionResult {
success: boolean;
output: string;
error?: string;
}
export interface SnippetHostVars {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function replaceVar(content: string, name: string, value?: string): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
/**
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
* backend are separate builds, so this is kept as a small standalone copy
* rather than a shared package for one pure function.
*/
export function resolveSnippetCommand(
content: string,
host: SnippetHostVars | null,
inputValues: Record<string, string> = {},
): string {
let resolved = content;
resolved = replaceVar(resolved, "HOST", host?.ip);
resolved = replaceVar(resolved, "USER", host?.username);
resolved = replaceVar(
resolved,
"PORT",
host?.port !== undefined ? String(host.port) : undefined,
);
resolved = replaceVar(resolved, "NAME", host?.name);
resolved = resolved.replace(
INPUT_PATTERN,
(
fullMatch,
braceDigits: string | undefined,
_label,
plainDigits: string | undefined,
) => {
const key = `INPUT_${braceDigits ?? plainDigits}`;
return key in inputValues ? inputValues[key] : fullMatch;
},
);
return resolved;
}
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
): number | undefined {
if (value === undefined || value.trim() === "") return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
return seconds * 1000;
}
export function createSnippetExecutionResult(
exitCode: number | null,
output: string,
errorOutput: string,
): SnippetExecutionResult {
const success = exitCode === 0 || (exitCode === null && !errorOutput);
return {
success,
output,
...(errorOutput ? { error: errorOutput } : {}),
};
}
@@ -1,33 +0,0 @@
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
type SnippetReorderRequestBody = {
snippets?: unknown;
updates?: unknown;
};
export function extractSnippetReorderUpdates(
body: unknown,
): SnippetReorderUpdate[] | null {
if (!body || typeof body !== "object") {
return null;
}
const payload = body as SnippetReorderRequestBody;
// Keep accepting the legacy `updates` key so older clients do not break
// while the web and desktop helpers converge on `snippets`.
const snippetsUpdates = Array.isArray(payload.snippets)
? payload.snippets
: Array.isArray(payload.updates)
? payload.updates
: null;
if (!snippetsUpdates) {
return null;
}
return snippetsUpdates as SnippetReorderUpdate[];
}
File diff suppressed because it is too large. Load diff
@@ -15,8 +15,6 @@ import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
@@ -95,12 +93,6 @@ export function registerCoreSyncEntities(): void {
references: [CREDENTIAL_REFERENCE],
});
registerEntity(CORE_OWNER, {
type: "snippetFolders",
table: snippetFolders,
order: 40,
});
registerEntity(CORE_OWNER, {
type: "hosts",
table: hosts,
@@ -112,12 +104,6 @@ export function registerCoreSyncEntities(): void {
encryptedFields: ["ssh_data"],
});
registerEntity(CORE_OWNER, {
type: "snippets",
table: snippets,
order: 60,
});
registerEntity(CORE_OWNER, {
type: "dashboardServiceLinks",
table: dashboardServiceLinks,
@@ -12,10 +12,10 @@ import {
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
interface UserPasswordResetRoutesDeps {
authManager: AuthManager;
@@ -73,9 +73,11 @@ export async function resetUserPassword(
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (snippets and anything else keyed by
// refUser()) wipes it on this topic, since the user row itself survives.
pluginEvents.emit(TOPICS.userDataWiped, { userId });
const { UserKeyManager } = await import("../../utils/user-keys.js");
await UserKeyManager.getInstance().rotateUserDEK(userId);
@@ -60,7 +60,7 @@ export function validateDefaultsJson(value: string): boolean {
* /user-preferences:
* get:
* summary: Get preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are legacy fields, kept here read-only for backward compatibility. The authoritative copy is GET /host-sidebar/preferences.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences. foldersCollapsed is likewise legacy and read-only; its authoritative copy is the snippets plugin's own user settings.
* tags:
* - User Preferences
* responses:
@@ -160,7 +160,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /user-preferences:
* put:
* summary: Update preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. foldersCollapsed is no longer accepted either, now that it is a snippets plugin user setting.
* tags:
* - User Preferences
* requestBody:
@@ -259,11 +259,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
rdpDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme,
// foldersCollapsed are no longer writable here -- they moved to
// /host-sidebar/preferences as of the sidebar redesign. The columns stay
// in the table (read once as a migration seed by that route) but this
// endpoint silently ignores them if a stale client still sends them.
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
// sidebar redesign. The columns stay in the table (read once as a
// migration seed by that route) but this endpoint silently ignores them if
// a stale client still sends them. foldersCollapsed similarly moved out,
// to the snippets plugin's own user settings.
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),