mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: move snippets backend and tables into a plugin (B2)
This commit is contained in:
1 parent
fdedca39fd
commit
e07221eaf3
51 files changed
+28426
-3553
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user