From 3656caf16867be77bf45aff879ed77d737f43ca7 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 23 Sep 2026 08:59:16 -0500 Subject: [PATCH] feat: add the snippets plugin (B2) --- plugins/snippets/locales/en.json | 98 ++ plugins/snippets/manifest.json | 85 ++ .../mysql/0001_adopt_snippets_tables.sql | 43 + .../postgres/0001_adopt_snippets_tables.sql | 47 + plugins/snippets/migrations/snapshot.json | 162 +++ .../sqlite/0001_adopt_snippets_tables.sql | 47 + plugins/snippets/package.json | 15 + plugins/snippets/src/backend/execution.ts | 82 ++ plugins/snippets/src/backend/index.ts | 156 +++ plugins/snippets/src/backend/reorder.ts | 29 + plugins/snippets/src/backend/repository.ts | 896 ++++++++++++ plugins/snippets/src/backend/routes.ts | 1204 +++++++++++++++++ plugins/snippets/src/backend/tables.ts | 73 + .../snippets/src/frontend/SnippetsPanel.tsx | 742 ++++++++++ plugins/snippets/src/frontend/index.tsx | 67 + .../src/frontend/snippet-variables.ts | 75 + plugins/snippets/src/frontend/snippets-api.ts | 125 ++ plugins/snippets/src/frontend/types.ts | 71 + .../src/frontend/use-snippet-runner.tsx | 164 +++ .../snippets/tests/backend/activate.test.ts | 112 ++ .../snippets/tests/backend/execute.test.ts | 129 ++ plugins/snippets/tests/backend/helpers.ts | 94 ++ .../snippets/tests/backend/migrations.test.ts | 186 +++ .../snippets/tests/backend/repository.test.ts | 186 +++ plugins/snippets/tests/backend/routes.test.ts | 235 ++++ .../snippets/tests/frontend/activate.test.tsx | 84 ++ .../tests/frontend/snippet-variables.test.ts | 79 ++ plugins/snippets/tsconfig.json | 4 + plugins/snippets/vitest.config.ts | 3 + 29 files changed, 5293 insertions(+) create mode 100644 plugins/snippets/locales/en.json create mode 100644 plugins/snippets/manifest.json create mode 100644 plugins/snippets/migrations/mysql/0001_adopt_snippets_tables.sql create mode 100644 plugins/snippets/migrations/postgres/0001_adopt_snippets_tables.sql create mode 100644 plugins/snippets/migrations/snapshot.json create mode 100644 plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql create mode 100644 plugins/snippets/package.json create mode 100644 plugins/snippets/src/backend/execution.ts create mode 100644 plugins/snippets/src/backend/index.ts create mode 100644 plugins/snippets/src/backend/reorder.ts create mode 100644 plugins/snippets/src/backend/repository.ts create mode 100644 plugins/snippets/src/backend/routes.ts create mode 100644 plugins/snippets/src/backend/tables.ts create mode 100644 plugins/snippets/src/frontend/SnippetsPanel.tsx create mode 100644 plugins/snippets/src/frontend/index.tsx create mode 100644 plugins/snippets/src/frontend/snippet-variables.ts create mode 100644 plugins/snippets/src/frontend/snippets-api.ts create mode 100644 plugins/snippets/src/frontend/types.ts create mode 100644 plugins/snippets/src/frontend/use-snippet-runner.tsx create mode 100644 plugins/snippets/tests/backend/activate.test.ts create mode 100644 plugins/snippets/tests/backend/execute.test.ts create mode 100644 plugins/snippets/tests/backend/helpers.ts create mode 100644 plugins/snippets/tests/backend/migrations.test.ts create mode 100644 plugins/snippets/tests/backend/repository.test.ts create mode 100644 plugins/snippets/tests/backend/routes.test.ts create mode 100644 plugins/snippets/tests/frontend/activate.test.tsx create mode 100644 plugins/snippets/tests/frontend/snippet-variables.test.ts create mode 100644 plugins/snippets/tsconfig.json create mode 100644 plugins/snippets/vitest.config.ts diff --git a/plugins/snippets/locales/en.json b/plugins/snippets/locales/en.json new file mode 100644 index 000000000..da5ef0032 --- /dev/null +++ b/plugins/snippets/locales/en.json @@ -0,0 +1,98 @@ +{ + "nav": { + "snippets": "Snippets" + }, + "onboarding": { + "feature_snippets": "Snippets", + "feature_snippets_desc": "Save commands you run often and send them to a terminal in one click." + }, + "permissions": { + "view": { + "title": "View snippets", + "description": "See your own snippets, notes and shared snippets." + }, + "create": { + "title": "Create snippets", + "description": "Create new snippets, notes and folders." + }, + "edit": { + "title": "Edit snippets", + "description": "Rename or change existing snippets and folders." + }, + "delete": { + "title": "Delete snippets", + "description": "Delete snippets and folders." + }, + "share": { + "title": "Share snippets", + "description": "Share snippets and folders with other users or roles." + } + }, + "settings": { + "foldersCollapsed": { + "label": "Collapse folders by default", + "description": "Start each snippet folder collapsed when you open the Snippets panel." + } + }, + "cancel": "Cancel", + "loading": "Loading...", + "searchPlaceholder": "Search snippets", + "newFolder": "New folder", + "newSnippet": "New snippet", + "noFolder": "No folder", + "noSnippetsInFolder": "No snippets in this folder", + "noTerminalTabsOpen": "No terminal tab is open", + "run": "Run", + "pasteToTerminal": "Paste to terminal", + "copySuccess": "Copied to clipboard", + "shareTitle": "Shared", + "importExport": "Import / Export", + "importFailed": "Failed to import snippets", + + "nameLabel": "Name", + "namePlaceholder": "Snippet name", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Optional description", + "folderLabel": "Folder", + "typeLabel": "Type", + "typeCommand": "Command", + "typeNote": "Note", + "commandLabel": "Command", + "commandPlaceholder": "Command to run, e.g. systemctl status $HOST", + "noteLabel": "Note", + "notePlaceholder": "Text to paste into the terminal", + + "createSnippetTitle": "Create snippet", + "createSnippetDescription": "Save a command or note you can run or paste into a terminal.", + "createSnippetButton": "Create snippet", + "createSuccess": "Snippet created", + "createFailed": "Failed to create snippet", + + "editSnippetTitle": "Edit snippet", + "editSnippetDescription": "Change this snippet's name, content or folder.", + "saveSnippetButton": "Save snippet", + "updateSuccess": "Snippet updated", + "updateFailed": "Failed to update snippet", + "deleteFailed": "Failed to delete snippet", + + "createFolderTitle": "Create folder", + "createFolderButton": "Create folder", + "folderCreateSuccess": "Folder created", + "folderCreateFailed": "Failed to create folder", + "editFolderTitle": "Edit folder", + "saveFolderButton": "Save folder", + "folderEditSuccess": "Folder updated", + "folderEditFailed": "Failed to update folder", + "folderNameLabel": "Folder name", + "folderNamePlaceholder": "Folder name", + "folderIconLabel": "Icon", + "folderDeleteSuccess": "Folder deleted", + "folderDeleteFailed": "Failed to delete folder", + + "confirmRunMessage": "Run \"{{name}}\"?", + "confirmRunButton": "Run", + "runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)", + "pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)", + + "foldersCollapsedLabel": "Collapse folders by default" +} diff --git a/plugins/snippets/manifest.json b/plugins/snippets/manifest.json new file mode 100644 index 000000000..08baecf9a --- /dev/null +++ b/plugins/snippets/manifest.json @@ -0,0 +1,85 @@ +{ + "id": "snippets", + "name": "Snippets", + "version": "1.0.0", + "description": "Saved commands and notes you can run or paste into a terminal, with variables and sharing.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Productivity", + "icon": "Play", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": [ + "db:own", + "network:serve", + "ssh:connect", + "credentials:use", + "ui:surface" + ], + "provides": [ + { + "service": "snippets.access", + "version": "1.0.0", + "permission": "snippets.view" + } + ], + "locales": "locales", + "platforms": ["linux", "win32", "darwin"], + "contributes": { + "panels": [ + { + "id": "snippets", + "titleKey": "nav.snippets", + "icon": "Play" + } + ], + "permissions": [ + { + "name": "view", + "titleKey": "permissions.view.title", + "descriptionKey": "permissions.view.description", + "defaultRoles": ["user"] + }, + { + "name": "create", + "titleKey": "permissions.create.title", + "descriptionKey": "permissions.create.description", + "defaultRoles": ["user"] + }, + { + "name": "edit", + "titleKey": "permissions.edit.title", + "descriptionKey": "permissions.edit.description", + "defaultRoles": ["user"] + }, + { + "name": "delete", + "titleKey": "permissions.delete.title", + "descriptionKey": "permissions.delete.description", + "defaultRoles": ["user"] + }, + { + "name": "share", + "titleKey": "permissions.share.title", + "descriptionKey": "permissions.share.description", + "defaultRoles": ["user"] + } + ], + "settings": { + "user": [ + { + "key": "foldersCollapsed", + "type": "boolean", + "labelKey": "settings.foldersCollapsed.label", + "descriptionKey": "settings.foldersCollapsed.description", + "default": true + } + ] + } + } +} diff --git a/plugins/snippets/migrations/mysql/0001_adopt_snippets_tables.sql b/plugins/snippets/migrations/mysql/0001_adopt_snippets_tables.sql new file mode 100644 index 000000000..fa81ab2a9 --- /dev/null +++ b/plugins/snippets/migrations/mysql/0001_adopt_snippets_tables.sql @@ -0,0 +1,43 @@ +-- snippets 0001: adopt_snippets_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `snippets` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `content` text NOT NULL, + `description` text, + `folder` text, + `order` int NOT NULL DEFAULT 0, + `sync_id` varchar(255) UNIQUE, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `host_filter` text, + `is_note` boolean NOT NULL DEFAULT false, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `snippets` TO `p_snippets_snippets`; +CREATE TABLE IF NOT EXISTS `snippet_folders` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `name` text NOT NULL, + `color` text, + `icon` text, + `sync_id` varchar(255) UNIQUE, + `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 +); +RENAME TABLE `snippet_folders` TO `p_snippets_snippet_folders`; +CREATE TABLE IF NOT EXISTS `snippet_access` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `snippet_id` int NOT NULL, + `user_id` varchar(255), + `role_id` int, + `granted_by` varchar(255) NOT NULL, + `permission_level` text NOT NULL DEFAULT ('view'), + `expires_at` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`granted_by`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `snippet_access` TO `p_snippets_snippet_access`; diff --git a/plugins/snippets/migrations/postgres/0001_adopt_snippets_tables.sql b/plugins/snippets/migrations/postgres/0001_adopt_snippets_tables.sql new file mode 100644 index 000000000..7c7a0106a --- /dev/null +++ b/plugins/snippets/migrations/postgres/0001_adopt_snippets_tables.sql @@ -0,0 +1,47 @@ +-- snippets 0001: adopt_snippets_tables +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "snippets" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "content" text NOT NULL, + "description" text, + "folder" text, + "order" integer NOT NULL DEFAULT 0, + "sync_id" varchar(255) UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "host_filter" text, + "is_note" boolean NOT NULL DEFAULT false, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_snippets_user_id" ON "snippets" ("user_id"); +ALTER TABLE "snippets" RENAME TO "p_snippets_snippets"; +CREATE TABLE IF NOT EXISTS "snippet_folders" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "name" text NOT NULL, + "color" text, + "icon" text, + "sync_id" varchar(255) UNIQUE, + "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 +); +ALTER TABLE "snippet_folders" RENAME TO "p_snippets_snippet_folders"; +CREATE TABLE IF NOT EXISTS "snippet_access" ( + "id" serial PRIMARY KEY, + "snippet_id" integer NOT NULL, + "user_id" varchar(255), + "role_id" integer, + "granted_by" varchar(255) NOT NULL, + "permission_level" text NOT NULL DEFAULT 'view', + "expires_at" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("granted_by") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_user_id" ON "snippet_access" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_snippet_id" ON "snippet_access" ("snippet_id"); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_role_id" ON "snippet_access" ("role_id"); +ALTER TABLE "snippet_access" RENAME TO "p_snippets_snippet_access"; diff --git a/plugins/snippets/migrations/snapshot.json b/plugins/snippets/migrations/snapshot.json new file mode 100644 index 000000000..3f8f00f75 --- /dev/null +++ b/plugins/snippets/migrations/snapshot.json @@ -0,0 +1,162 @@ +{ + "version": 1, + "tables": { + "snippets": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "userId": { + "type": "refUser", + "length": 255, + "notNull": true + }, + "name": { + "type": "text", + "notNull": true + }, + "content": { + "type": "text", + "notNull": true + }, + "description": { + "type": "text" + }, + "folder": { + "type": "text" + }, + "order": { + "type": "integer", + "notNull": true, + "defaultValue": 0 + }, + "syncId": { + "type": "varchar", + "length": 255, + "unique": true + }, + "createdAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + }, + "updatedAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + }, + "hostFilter": { + "type": "text" + }, + "isNote": { + "type": "boolean", + "notNull": true, + "defaultValue": false + } + }, + "indexes": [ + { + "name": "idx_snippets_user_id", + "columns": ["userId"] + } + ], + "adopts": "snippets" + }, + "snippet_folders": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "userId": { + "type": "refUser", + "length": 255, + "notNull": true + }, + "name": { + "type": "text", + "notNull": true + }, + "color": { + "type": "text" + }, + "icon": { + "type": "text" + }, + "syncId": { + "type": "varchar", + "length": 255, + "unique": true + }, + "createdAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + }, + "updatedAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + } + }, + "indexes": [], + "adopts": "snippet_folders" + }, + "snippet_access": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "snippetId": { + "type": "integer", + "notNull": true + }, + "userId": { + "type": "varchar", + "length": 255 + }, + "roleId": { + "type": "integer" + }, + "grantedBy": { + "type": "refUser", + "length": 255, + "notNull": true + }, + "permissionLevel": { + "type": "text", + "notNull": true, + "defaultValue": "view" + }, + "expiresAt": { + "type": "timestamp" + }, + "createdAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + } + }, + "indexes": [ + { + "name": "idx_snippet_access_user_id", + "columns": ["userId"] + }, + { + "name": "idx_snippet_access_snippet_id", + "columns": ["snippetId"] + }, + { + "name": "idx_snippet_access_role_id", + "columns": ["roleId"] + } + ], + "adopts": "snippet_access" + } + } +} diff --git a/plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql b/plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql new file mode 100644 index 000000000..545da73e4 --- /dev/null +++ b/plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql @@ -0,0 +1,47 @@ +-- snippets 0001: adopt_snippets_tables +-- Generated by termix-plugin migrations. Review before committing. + +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, + "folder" text, + "order" integer NOT NULL DEFAULT 0, + "sync_id" text UNIQUE, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "host_filter" text, + "is_note" integer NOT NULL DEFAULT 0, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_snippets_user_id" ON "snippets" ("user_id"); +ALTER TABLE "snippets" RENAME TO "p_snippets_snippets"; +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, + "sync_id" text UNIQUE, + "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 +); +ALTER TABLE "snippet_folders" RENAME TO "p_snippets_snippet_folders"; +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 ("granted_by") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_user_id" ON "snippet_access" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_snippet_id" ON "snippet_access" ("snippet_id"); +CREATE INDEX IF NOT EXISTS "idx_snippet_access_role_id" ON "snippet_access" ("role_id"); +ALTER TABLE "snippet_access" RENAME TO "p_snippets_snippet_access"; diff --git a/plugins/snippets/package.json b/plugins/snippets/package.json new file mode 100644 index 000000000..163543def --- /dev/null +++ b/plugins/snippets/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/snippets", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/snippets/src/backend/execution.ts b/plugins/snippets/src/backend/execution.ts new file mode 100644 index 000000000..99da7c0a1 --- /dev/null +++ b/plugins/snippets/src/backend/execution.ts @@ -0,0 +1,82 @@ +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 { + 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 } : {}), + }; +} diff --git a/plugins/snippets/src/backend/index.ts b/plugins/snippets/src/backend/index.ts new file mode 100644 index 000000000..3753de417 --- /dev/null +++ b/plugins/snippets/src/backend/index.ts @@ -0,0 +1,156 @@ +import type { Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { snippetAccess, snippetFolders, snippets } from "./tables.js"; +import { createSnippetRepository } from "./repository.js"; +import { registerSnippetRoutes } from "./routes.js"; +import { resolveSnippetCommand } from "./execution.js"; + +/** What other plugins get from ctx.services.get("snippets.access", {userId}). */ +export interface SnippetsService { + /** The calling user's own snippets (not shared ones), full content included. */ + list: () => Promise< + Array<{ + id: number; + name: string; + content: string; + description: string | null; + isNote: boolean; + folder: string | null; + }> + >; + /** A snippet the calling user owns or was shared with them. */ + get: (id: number) => Promise<{ + id: number; + name: string; + content: string; + isNote: boolean; + } | null>; + resolveCommand: ( + id: number, + vars: { ip?: string; username?: string; port?: number; name?: string }, + inputValues?: Record, + ) => Promise; + /** Creates a snippet owned by the calling user. */ + create: (input: { + name: string; + content: string; + description?: string | null; + folder?: string | null; + }) => Promise<{ id: number; name: string }>; + /** Updates a snippet the calling user owns. Throws if not found. */ + update: ( + id: number, + changes: { + name?: string; + content?: string; + description?: string | null; + folder?: string | null; + }, + ) => Promise; + /** Deletes a snippet the calling user owns. Returns whether one was deleted. */ + remove: (id: number) => Promise; +} + +export async function activate(ctx: PluginContext) { + const snippetsTable = await ctx.db.define(snippets); + const foldersTable = await ctx.db.define(snippetFolders); + const accessTable = await ctx.db.define(snippetAccess); + + const repo = createSnippetRepository( + ctx.db, + snippetsTable, + foldersTable, + accessTable, + ); + + registerSnippetRoutes(ctx.http.router(), repo, ctx); + + ctx.sync.registerEntity({ + type: "snippetFolders", + table: foldersTable, + order: 40, + }); + ctx.sync.registerEntity({ + type: "snippets", + table: snippetsTable, + order: 60, + }); + + // The user row survives a password-reset data wipe, so it never triggers + // our refUser() cascade; this topic is core's way of telling us to drop + // our copy of that user's data too. + ctx.events.on("user.data_wiped", (payload) => { + const userId = (payload as { userId?: string } | null)?.userId; + if (userId) void repo.deleteByUserId(userId); + }); + + async function findAccessible(userId: string, id: number) { + const owned = await repo.findOwnedById(userId, id); + if (owned) return owned; + const roleIds = await repo.listUserRoleIds(userId); + return repo.findAccessibleSharedSnippet(id, userId, roleIds); + } + + const service: SnippetsService = { + list: async () => { + const userId = ctx.currentActor(); + if (!userId) return []; + const owned = await repo.listOwnedSnippets(userId); + return owned.map((s) => ({ + id: s.id, + name: s.name, + content: s.content, + description: s.description, + isNote: s.isNote, + folder: s.folder, + })); + }, + get: async (id) => { + const userId = ctx.currentActor(); + if (!userId) return null; + const snippet = await findAccessible(userId, id); + if (!snippet) return null; + return { + id: snippet.id, + name: snippet.name, + content: snippet.content, + isNote: snippet.isNote, + }; + }, + resolveCommand: async (id, vars, inputValues = {}) => { + const userId = ctx.currentActor(); + if (!userId) return null; + const snippet = await findAccessible(userId, id); + if (!snippet) return null; + return resolveSnippetCommand(snippet.content, vars, inputValues); + }, + create: async (input) => { + const userId = ctx.currentActor(); + if (!userId) throw new Error("snippets.access.create needs an actor"); + const created = await repo.createSnippet(userId, input); + return { id: created.id, name: created.name }; + }, + update: async (id, changes) => { + const userId = ctx.currentActor(); + if (!userId) throw new Error("snippets.access.update needs an actor"); + const result = await repo.updateSnippet(userId, id, changes); + if (!result) throw new Error("Snippet not found"); + }, + remove: async (id) => { + const userId = ctx.currentActor(); + if (!userId) throw new Error("snippets.access.remove needs an actor"); + const existing = await repo.deleteSnippet(userId, id); + if (existing?.syncId) { + await ctx.sync.recordTombstone(userId, "snippets", existing.syncId); + } + return !!existing; + }, + }; + ctx.services.provide("snippets.access", service); + + ctx.log.info("Snippets routes mounted at /plugin-api/snippets"); +} + +export async function deactivate() { + // Everything above was registered through ctx and is disposed by core. +} diff --git a/plugins/snippets/src/backend/reorder.ts b/plugins/snippets/src/backend/reorder.ts new file mode 100644 index 000000000..3bb74ee43 --- /dev/null +++ b/plugins/snippets/src/backend/reorder.ts @@ -0,0 +1,29 @@ +import type { SnippetReorderUpdate } from "./repository.js"; + +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[]; +} diff --git a/plugins/snippets/src/backend/repository.ts b/plugins/snippets/src/backend/repository.ts new file mode 100644 index 000000000..716b5a4ed --- /dev/null +++ b/plugins/snippets/src/backend/repository.ts @@ -0,0 +1,896 @@ +import { randomUUID } from "node:crypto"; +import { + and, + asc, + desc, + eq, + gte, + inArray, + isNull, + like, + or, + sql, +} from "drizzle-orm"; +import type { PluginDatabase } from "@termix/plugin-sdk/backend"; + +export interface SnippetRecord { + id: number; + userId: string; + name: string; + content: string; + description: string | null; + folder: string | null; + order: number; + syncId: string | null; + createdAt: string; + updatedAt: string; + hostFilter: string | null; + isNote: boolean; +} + +export interface SnippetFolderRecord { + id: number; + userId: string; + name: string; + color: string | null; + icon: string | null; + syncId: string | null; + createdAt: string; + updatedAt: string; +} + +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 interface RbacAccessListItem { + id: number; + targetType: "user" | "role"; + userId: string | null; + roleId: number | null; + username: string | null; + roleName: string | null; + roleDisplayName: string | null; + grantedBy: string; + grantedByUsername: string | null; + permissionLevel: string; + expiresAt: string | null; + createdAt: string; +} + +export type SnippetAccessTarget = + | { targetType: "user"; targetUserId: string } + | { targetType: "role"; targetRoleId: number }; + +export type UpsertSnippetAccessInput = SnippetAccessTarget & { + snippetId: number; + grantedBy: string; + expiresAt: string | null; +}; + +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; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// The tables come from ctx.db.define/ctx.db.refs, which the SDK hands back +// untyped, and the drizzle handle is the server's own. Typed at this module's +// edge instead. +type Table = any; +type Drizzle = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function toAccessListItem( + row: Omit, +): RbacAccessListItem { + return { ...row, targetType: row.userId ? "user" : "role" }; +} + +export type SnippetRepository = ReturnType; + +/** + * Snippet, folder and sharing storage. Written without RETURNING so it runs + * the same on all three engines: an insert or update is read back by id/sync + * id rather than relying on `.returning()`. + */ +export function createSnippetRepository( + db: PluginDatabase, + snippets: Table, + folders: Table, + access: Table, +) { + const client = () => db.client(); + const refs = () => + db.refs<{ users: Table; roles: Table; userRoles: Table }>(); + + async function findOwnedById( + userId: string, + snippetId: number, + ): Promise { + const drizzle = await client(); + const rows = await drizzle + .select() + .from(snippets) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) + .limit(1); + return rows[0] ?? null; + } + + async function findFolderByName( + userId: string, + name: string, + ): Promise { + const drizzle = await client(); + const rows = await drizzle + .select() + .from(folders) + .where(and(eq(folders.userId, userId), eq(folders.name, name))) + .limit(1); + return rows[0] ?? null; + } + + async function maxOrderForFolder( + userId: string, + folder: string | null, + ): Promise { + const drizzle = await client(); + const result = await drizzle + .select({ maxOrder: sql`MAX(${snippets.order})` }) + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + folder + ? eq(snippets.folder, folder) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ); + return result[0]?.maxOrder ?? -1; + } + + async function nextOrderForFolder( + userId: string, + folder: string, + ): Promise { + return (await maxOrderForFolder(userId, folder || null)) + 1; + } + + async function findByNameAndFolder( + userId: string, + name: string, + folder: string | null, + ): Promise { + const drizzle = await client(); + const rows = await 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; + } + + function userOrRoleAccessFilter(userId: string, roleIds: number[]) { + if (roleIds.length === 0) return eq(access.userId, userId); + return or(eq(access.userId, userId), inArray(access.roleId, roleIds)); + } + + async function findSnippetAccess( + snippetId: number, + target: SnippetAccessTarget, + ) { + const drizzle = await client(); + const rows = await drizzle + .select() + .from(access) + .where( + and( + eq(access.snippetId, snippetId), + target.targetType === "user" + ? eq(access.userId, target.targetUserId) + : eq(access.roleId, target.targetRoleId), + ), + ) + .limit(1); + return rows[0] ?? null; + } + + return { + findOwnedById, + + async listFolders(userId: string): Promise { + const drizzle = await client(); + return drizzle + .select() + .from(folders) + .where(eq(folders.userId, userId)) + .orderBy(asc(folders.name)); + }, + + async listSnippetsForExport(userId: string): Promise { + const drizzle = await client(); + return drizzle + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order)); + }, + + async listFoldersForExport(userId: string): Promise { + const drizzle = await client(); + return drizzle + .select() + .from(folders) + .where(eq(folders.userId, userId)) + .orderBy(asc(folders.name)); + }, + + async listOwnedSnippetsInFolder( + userId: string, + folder: string, + ): Promise { + const drizzle = await client(); + return drizzle + .select() + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + or( + eq(snippets.folder, folder), + like(snippets.folder, `${folder} / %`), + ), + ), + ); + }, + + async listOwnedSnippets(userId: string): Promise { + const drizzle = await client(); + return 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 { + const drizzle = await client(); + 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 drizzle + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); + } + await db.persist(); + }, + + async createSnippet( + userId: string, + input: NewSnippetInput, + ): Promise { + const folderValue = input.folder?.trim() || ""; + const order = + input.order === undefined || input.order === null + ? await nextOrderForFolder(userId, folderValue) + : input.order; + + const syncId = randomUUID(); + const drizzle = await client(); + await drizzle.insert(snippets).values({ + syncId, + 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 db.persist(); + + const rows = await drizzle + .select() + .from(snippets) + .where(eq(snippets.syncId, syncId)) + .limit(1); + return rows[0]; + }, + + async updateSnippet( + userId: string, + snippetId: number, + input: SnippetUpdateInput, + ): Promise { + const existing = await findOwnedById(userId, snippetId); + if (!existing) return null; + + const updateFields: Record = { + 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 drizzle = await client(); + await drizzle + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))); + await db.persist(); + + const updated = await findOwnedById(userId, snippetId); + return { existing, updated: updated as SnippetRecord }; + }, + + async deleteSnippet( + userId: string, + snippetId: number, + ): Promise { + const existing = await findOwnedById(userId, snippetId); + if (!existing) return null; + + const drizzle = await client(); + await drizzle + .delete(snippets) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))); + await db.persist(); + return existing; + }, + + async bulkImport( + userId: string, + snippetsToImport: unknown[] | undefined, + foldersToImport: unknown[] | undefined, + overwrite: boolean, + ): Promise { + const results: SnippetBulkImportResult = { + snippetsImported: 0, + snippetsSkipped: 0, + snippetsUpdated: 0, + foldersImported: 0, + foldersSkipped: 0, + failed: 0, + errors: [], + }; + + const drizzle = await client(); + 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 existing = await findFolderByName(userId, folder.name); + if (existing) { + results.foldersSkipped++; + continue; + } + + await drizzle.insert(folders).values({ + syncId: randomUUID(), + userId, + name: folder.name.trim(), + color: folder.color?.trim() || null, + icon: folder.icon?.trim() || null, + }); + 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 findByNameAndFolder( + userId, + snippet.name.trim(), + folderVal, + ); + + if (existing) { + if (!overwrite) { + results.snippetsSkipped++; + continue; + } + + await 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 maxOrderForFolder(userId, folderVal); + await 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 db.persist(); + return results; + }, + + async createFolder( + userId: string, + name: string, + color: string | null | undefined, + icon: string | null | undefined, + ): Promise { + const existing = await findFolderByName(userId, name); + if (existing) return null; + + const syncId = randomUUID(); + const drizzle = await client(); + await drizzle.insert(folders).values({ + syncId, + userId, + name: name.trim(), + color: color?.trim() || null, + icon: icon?.trim() || null, + }); + await db.persist(); + + const rows = await drizzle + .select() + .from(folders) + .where(eq(folders.syncId, syncId)) + .limit(1); + return rows[0] ?? null; + }, + + async updateFolderMetadata( + userId: string, + name: string, + color: string | null | undefined, + icon: string | null | undefined, + ): Promise { + const existing = await findFolderByName(userId, name); + if (!existing) return null; + + const updateFields: Record = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + if (color !== undefined) updateFields.color = color?.trim() || null; + if (icon !== undefined) updateFields.icon = icon?.trim() || null; + + const drizzle = await client(); + await drizzle + .update(folders) + .set(updateFields) + .where(and(eq(folders.userId, userId), eq(folders.name, name))); + await db.persist(); + + return findFolderByName(userId, name); + }, + + async renameFolder( + userId: string, + oldName: string, + newName: string, + ): Promise { + const existing = await findFolderByName(userId, oldName); + if (!existing) return { status: "missing" }; + + const nameExists = await findFolderByName(userId, newName); + if (nameExists) return { status: "conflict" }; + + const drizzle = await client(); + await drizzle + .update(folders) + .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where(and(eq(folders.userId, userId), eq(folders.name, oldName))); + + await drizzle + .update(snippets) + .set({ folder: newName }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); + + await db.persist(); + return { status: "renamed" }; + }, + + async deleteFolder( + userId: string, + name: string, + ): Promise<{ syncId: string | null } | null> { + const drizzle = await client(); + await drizzle + .update(snippets) + .set({ folder: null }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); + + const existing = await findFolderByName(userId, name); + if (!existing) { + await db.persist(); + return null; + } + + await drizzle + .delete(folders) + .where(and(eq(folders.userId, userId), eq(folders.name, name))); + await db.persist(); + return { syncId: existing.syncId }; + }, + + /** Wipes a user's snippets and folders, for the password-reset data-wipe flow. */ + async deleteByUserId(userId: string): Promise { + const drizzle = await client(); + await drizzle.delete(snippets).where(eq(snippets.userId, userId)); + await drizzle.delete(folders).where(eq(folders.userId, userId)); + await db.persist(); + }, + + // Sharing (moved from core's RbacAccessRepository) + + async listSnippetAccess(snippetId: number): Promise { + const drizzle = await client(); + const { users, roles } = await refs(); + const rows = await drizzle + .select({ + id: access.id, + userId: access.userId, + roleId: access.roleId, + username: users.username, + roleName: roles.name, + roleDisplayName: roles.displayName, + grantedBy: access.grantedBy, + grantedByUsername: sql< + string | null + >`(SELECT username FROM users WHERE id = ${access.grantedBy})`, + permissionLevel: access.permissionLevel, + expiresAt: access.expiresAt, + createdAt: access.createdAt, + }) + .from(access) + .leftJoin(users, eq(access.userId, users.id)) + .leftJoin(roles, eq(access.roleId, roles.id)) + .where(eq(access.snippetId, snippetId)) + .orderBy(desc(access.createdAt)); + + return rows.map(toAccessListItem); + }, + + async upsertSnippetAccess( + input: UpsertSnippetAccessInput, + ): Promise<{ id: number; created: boolean }> { + const existing = await findSnippetAccess(input.snippetId, input); + const drizzle = await client(); + + if (existing) { + await drizzle + .update(access) + .set({ expiresAt: input.expiresAt }) + .where(eq(access.id, existing.id)); + await db.persist(); + return { id: existing.id, created: false }; + } + + const now = new Date().toISOString(); + await drizzle.insert(access).values({ + 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, + createdAt: now, + }); + await db.persist(); + + const created = await findSnippetAccess(input.snippetId, input); + return { id: created!.id, created: true }; + }, + + async revokeSnippetAccess( + accessId: number, + snippetId: number, + ): Promise { + const drizzle = await client(); + await drizzle + .delete(access) + .where(and(eq(access.id, accessId), eq(access.snippetId, snippetId))); + await db.persist(); + }, + + async listSharedSnippets( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const drizzle = await client(); + const { users } = await refs(); + + const directShared = await drizzle + .select({ + id: snippets.id, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + ownerUsername: users.username, + permissionLevel: access.permissionLevel, + expiresAt: access.expiresAt, + }) + .from(access) + .innerJoin(snippets, eq(access.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + eq(access.userId, userId), + or(isNull(access.expiresAt), gte(access.expiresAt, now)), + ), + ); + + if (roleIds.length === 0) return directShared; + + const directIds = new Set(directShared.map((s: { id: number }) => s.id)); + const roleShared = await drizzle + .select({ + id: snippets.id, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + ownerUsername: users.username, + permissionLevel: access.permissionLevel, + expiresAt: access.expiresAt, + }) + .from(access) + .innerJoin(snippets, eq(access.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + or(isNull(access.expiresAt), gte(access.expiresAt, now)), + inArray(access.roleId, roleIds), + ), + ); + + return [ + ...directShared, + ...roleShared.filter((s: { id: number }) => !directIds.has(s.id)), + ]; + }, + + async listVisibleSharedSnippets( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const drizzle = await client(); + const { users } = await refs(); + return 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: access.permissionLevel, + expiresAt: access.expiresAt, + }) + .from(access) + .innerJoin(snippets, eq(access.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + userOrRoleAccessFilter(userId, roleIds), + or(isNull(access.expiresAt), gte(access.expiresAt, now)), + ), + ); + }, + + async findAccessibleSharedSnippet( + snippetId: number, + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const drizzle = await client(); + const { users } = await refs(); + const rows = await 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: access.permissionLevel, + expiresAt: access.expiresAt, + }) + .from(access) + .innerJoin(snippets, eq(access.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + eq(access.snippetId, snippetId), + userOrRoleAccessFilter(userId, roleIds), + or(isNull(access.expiresAt), gte(access.expiresAt, now)), + ), + ) + .limit(1); + + return rows[0] ?? null; + }, + + /** A user's role ids, for the sharing filters above. */ + async listUserRoleIds(userId: string): Promise { + const drizzle = await client(); + const { userRoles } = await refs(); + const rows = await drizzle + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + return rows.map((r: { roleId: number }) => r.roleId); + }, + }; +} diff --git a/plugins/snippets/src/backend/routes.ts b/plugins/snippets/src/backend/routes.ts new file mode 100644 index 000000000..cf9c142bc --- /dev/null +++ b/plugins/snippets/src/backend/routes.ts @@ -0,0 +1,1204 @@ +import type { Request, Response, Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import type { SnippetRepository } from "./repository.js"; +import { extractSnippetReorderUpdates } from "./reorder.js"; +import { + createSnippetExecutionResult, + getSnippetExecutionTimeoutMs, + resolveSnippetCommand, +} from "./execution.js"; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function paramId(raw: string | string[] | undefined): number { + const value = Array.isArray(raw) ? raw[0] : raw; + return value === undefined ? NaN : parseInt(value, 10); +} + +function sortSnippets< + T extends { folder: string | null; order: number; updatedAt: string }, +>(a: T, b: T): number { + const aFolder = a.folder || ""; + const bFolder = b.folder || ""; + + if (!aFolder && bFolder) return -1; + if (aFolder && !bFolder) return 1; + if (aFolder !== bFolder) return aFolder.localeCompare(bFolder); + if (a.order !== b.order) return a.order - b.order; + + return b.updatedAt.localeCompare(a.updatedAt); +} + +function actor(ctx: PluginContext): string { + // Core's plugin router authenticates every request and runs it as that user. + return ctx.currentActor() as string; +} + +function logError( + ctx: PluginContext, + message: string, + error: unknown, + operation: string, +): void { + ctx.log.error( + `${message} (${operation})`, + error instanceof Error ? error : new Error(String(error)), + ); +} + +/** + * Mounts every snippets route on the plugin's router, served at + * /plugin-api/snippets. Most routes gate on their own permission; /execute + * only needs auth plus an accessible snippet and host, matching how running a + * snippet has never required a snippets.* grant. + */ +export function registerSnippetRoutes( + router: Router, + repo: SnippetRepository, + ctx: PluginContext, +): void { + async function getAccessibleSnippet(snippetId: number, userId: string) { + const owned = await repo.findOwnedById(userId, snippetId); + if (owned) return owned; + + const roleIds = await repo.listUserRoleIds(userId); + return repo.findAccessibleSharedSnippet(snippetId, userId, roleIds); + } + + /** + * @openapi + * /plugin-api/snippets/folders: + * get: + * summary: List the current user's snippet folders + * tags: + * - Snippets + * responses: + * 200: + * description: A list of snippet folders. + */ + router.get( + "/folders", + ctx.rbac.require("view") as never, + async (_req: Request, res: Response) => { + const userId = actor(ctx); + try { + res.json(await repo.listFolders(userId)); + } catch (err) { + logError( + ctx, + "Failed to fetch snippet folders", + err, + "snippet_folder_list_failed", + ); + res.status(500).json({ error: "Failed to fetch snippet folders" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/folders: + * post: + * summary: Create a snippet folder + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * color: + * type: string + * icon: + * type: string + * responses: + * 201: + * description: Snippet folder created. + * 400: + * description: Folder name is required. + * 409: + * description: A folder with this name already exists. + */ + router.post( + "/folders", + ctx.rbac.require("create") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { name, color, icon } = req.body ?? {}; + + if (!isNonEmptyString(name)) { + return res.status(400).json({ error: "Folder name is required" }); + } + + try { + const created = await repo.createFolder(userId, name, color, icon); + if (!created) { + return res + .status(409) + .json({ error: "Folder with this name already exists" }); + } + res.status(201).json(created); + } catch (err) { + logError( + ctx, + "Failed to create snippet folder", + err, + "snippet_folder_create_failed", + ); + res.status(500).json({ error: "Failed to create snippet folder" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/folders/{name}/metadata: + * put: + * summary: Update a snippet folder's color or icon + * tags: + * - Snippets + * parameters: + * - in: path + * name: name + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * color: + * type: string + * icon: + * type: string + * responses: + * 200: + * description: Snippet folder metadata updated. + * 400: + * description: Invalid request. + * 404: + * description: Folder not found. + */ + router.put( + "/folders/:name/metadata", + ctx.rbac.require("edit") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const name = Array.isArray(req.params.name) + ? req.params.name[0] + : req.params.name; + const { color, icon } = req.body ?? {}; + + if (!name) return res.status(400).json({ error: "Invalid request" }); + + try { + const updated = await repo.updateFolderMetadata( + userId, + decodeURIComponent(name), + color, + icon, + ); + if (!updated) + return res.status(404).json({ error: "Folder not found" }); + res.json(updated); + } catch (err) { + logError( + ctx, + "Failed to update snippet folder metadata", + err, + "snippet_folder_metadata_update_failed", + ); + res + .status(500) + .json({ error: "Failed to update snippet folder metadata" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/folders/rename: + * put: + * summary: Rename a snippet folder + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * oldName: + * type: string + * newName: + * type: string + * responses: + * 200: + * description: Folder renamed. + * 400: + * description: Invalid request. + * 404: + * description: Folder not found. + * 409: + * description: A folder with the new name already exists. + */ + router.put( + "/folders/rename", + ctx.rbac.require("edit") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { oldName, newName } = req.body ?? {}; + + if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) { + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const result = await repo.renameFolder(userId, oldName, newName); + if (result.status === "missing") { + return res.status(404).json({ error: "Folder not found" }); + } + if (result.status === "conflict") { + return res + .status(409) + .json({ error: "Folder with new name already exists" }); + } + res.json({ success: true, oldName, newName }); + } catch (err) { + logError( + ctx, + "Failed to rename snippet folder", + err, + "snippet_folder_rename_failed", + ); + res.status(500).json({ error: "Failed to rename snippet folder" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/folders/{name}: + * delete: + * summary: Delete a snippet folder + * description: Moves the folder's snippets to the root instead of deleting them. + * tags: + * - Snippets + * parameters: + * - in: path + * name: name + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Snippet folder deleted. + * 400: + * description: Invalid request. + */ + router.delete( + "/folders/:name", + ctx.rbac.require("delete") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const name = Array.isArray(req.params.name) + ? req.params.name[0] + : req.params.name; + + if (!name) return res.status(400).json({ error: "Invalid request" }); + + try { + const folderName = decodeURIComponent(name); + const deleted = await repo.deleteFolder(userId, folderName); + await ctx.sync.recordTombstone( + userId, + "snippetFolders", + deleted?.syncId, + ); + res.json({ success: true }); + } catch (err) { + logError( + ctx, + "Failed to delete snippet folder", + err, + "snippet_folder_delete_failed", + ); + res.status(500).json({ error: "Failed to delete snippet folder" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/reorder: + * put: + * summary: Bulk update the order and folder of snippets + * description: Accepts a "snippets" array, and the legacy "updates" key. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * snippets: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * order: + * type: integer + * folder: + * type: string + * responses: + * 200: + * description: Snippets reordered. + * 400: + * description: Invalid request. + */ + router.put( + "/reorder", + ctx.rbac.require("edit") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const updates = extractSnippetReorderUpdates(req.body); + + if (!updates || updates.length === 0) { + return res + .status(400) + .json({ error: "snippets array is required and must not be empty" }); + } + + try { + await repo.reorderSnippets(userId, updates); + res.json({ success: true, updated: updates.length }); + } catch (err) { + logError( + ctx, + "Failed to reorder snippets", + err, + "snippet_reorder_failed", + ); + res.status(500).json({ error: "Failed to reorder snippets" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/execute: + * post: + * summary: Run a snippet on a host over SSH + * description: Needs no snippets.* permission beyond an accessible snippet and host - running one has never required a separate grant. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * snippetId: + * type: integer + * hostId: + * type: integer + * inputValues: + * type: object + * description: > + * Resolved values for $INPUT_n placeholders, keyed by + * "INPUT_n". $HOST/$USER/$PORT/$NAME are resolved + * server-side from the target host. + * additionalProperties: + * type: string + * responses: + * 200: + * description: Snippet executed. + * 400: + * description: Snippet ID and host ID are required, or the snippet is a note. + * 404: + * description: Snippet or host not found. + */ + router.post("/execute", async (req: Request, res: Response) => { + const userId = actor(ctx); + const { snippetId, hostId, inputValues } = req.body ?? {}; + + if (!snippetId || !hostId) { + return res + .status(400) + .json({ error: "Snippet ID and Host ID are required" }); + } + + let connection: Awaited> | undefined; + try { + const snippet = await getAccessibleSnippet( + parseInt(snippetId, 10), + userId, + ); + if (!snippet) return res.status(404).json({ error: "Snippet not found" }); + + if (snippet.isNote) { + return res + .status(400) + .json({ error: "Notes cannot be executed on a host" }); + } + + connection = await ctx.ssh.connect(parseInt(hostId, 10), { + purpose: "plugin", + timeoutMs: 30000, + }); + + const resolvedCommand = resolveSnippetCommand( + snippet.content, + { + ip: connection.host.ip, + username: connection.host.username, + port: connection.host.port, + name: connection.host.name as string | undefined, + }, + inputValues && typeof inputValues === "object" ? inputValues : {}, + ); + + const client = connection.client as import("ssh2").Client; + let output = ""; + let errorOutput = ""; + + const result = await new Promise< + ReturnType + >((resolve, reject) => { + const timeoutMs = getSnippetExecutionTimeoutMs(); + let timeout: NodeJS.Timeout | undefined; + + client.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + client.exec(resolvedCommand, (err, stream) => { + if (err) return reject(err); + + if (timeoutMs) { + timeout = setTimeout(() => { + stream.close(); + reject( + new Error(`Command execution timeout (${timeoutMs / 1000}s)`), + ); + }, timeoutMs); + } + + stream.on("close", (exitCode: number | null) => { + clearTimeout(timeout); + resolve( + createSnippetExecutionResult(exitCode, output, errorOutput), + ); + }); + stream.on("data", (data: Buffer) => { + output += data.toString(); + }); + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + }); + }); + }); + + res.json(result); + } catch (err) { + logError(ctx, "Failed to execute snippet", err, "snippet_execute_failed"); + res.status(500).json({ error: "Failed to execute snippet" }); + } finally { + connection?.dispose(); + } + }); + + /** + * @openapi + * /plugin-api/snippets/export: + * get: + * summary: Export every snippet and folder as JSON + * tags: + * - Snippets + * responses: + * 200: + * description: Export object with snippets and folders arrays. + */ + router.get( + "/export", + ctx.rbac.require("view") as never, + async (_req: Request, res: Response) => { + const userId = actor(ctx); + try { + const allSnippets = await repo.listSnippetsForExport(userId); + const allFolders = await repo.listFoldersForExport(userId); + + res.json({ + snippets: allSnippets.map((s) => ({ + name: s.name, + content: s.content, + description: s.description, + folder: s.folder, + order: s.order, + hostFilter: s.hostFilter, + })), + folders: allFolders.map((f) => ({ + name: f.name, + color: f.color, + icon: f.icon, + })), + }); + } catch (err) { + logError( + ctx, + "Failed to export snippets", + err, + "snippet_export_failed", + ); + res.status(500).json({ error: "Failed to export snippets" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/bulk-import: + * post: + * summary: Import snippets and folders from JSON + * description: Existing folders are skipped. Existing snippets, matched by name and folder, are skipped or overwritten. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * snippets: + * type: array + * folders: + * type: array + * overwrite: + * type: boolean + * responses: + * 200: + * description: Import results with counts. + * 400: + * description: Invalid request body. + */ + router.post( + "/bulk-import", + ctx.rbac.require("create") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { + snippets: snippetsToImport, + folders: foldersToImport, + overwrite, + } = req.body ?? {}; + + if (!Array.isArray(snippetsToImport) && !Array.isArray(foldersToImport)) { + return res + .status(400) + .json({ error: "snippets or folders array is required" }); + } + + try { + const results = await repo.bulkImport( + userId, + snippetsToImport, + foldersToImport, + !!overwrite, + ); + res.json({ success: true, ...results }); + } catch (err) { + logError( + ctx, + "Failed to bulk import snippets", + err, + "snippet_bulk_import_failed", + ); + res.status(500).json({ error: "Failed to import snippets" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets: + * get: + * summary: List the current user's visible snippets + * description: Owned snippets plus snippets shared with the caller directly or through a role, tagged with isShared. + * tags: + * - Snippets + * responses: + * 200: + * description: A list of snippets. + */ + router.get( + "/", + ctx.rbac.require("view") as never, + async (_req: Request, res: Response) => { + const userId = actor(ctx); + try { + const owned = await repo.listOwnedSnippets(userId); + const roleIds = await repo.listUserRoleIds(userId); + const shared = await repo.listVisibleSharedSnippets(userId, roleIds); + + const visible = new Map>(); + for (const snippet of owned) { + visible.set(snippet.id, { ...snippet, isShared: false }); + } + for (const snippet of shared) { + if (visible.has(snippet.id)) continue; + visible.set(snippet.id, { ...snippet, isShared: true }); + } + + res.json( + Array.from(visible.values()).sort((a, b) => + sortSnippets( + a as { folder: string | null; order: number; updatedAt: string }, + b as { folder: string | null; order: number; updatedAt: string }, + ), + ), + ); + } catch (err) { + logError(ctx, "Failed to fetch snippets", err, "snippet_list_failed"); + res.status(500).json({ error: "Failed to fetch snippets" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/shared: + * get: + * summary: List snippets shared with the current user + * description: Registered before /:id so the literal path "shared" is not swallowed by the id parameter. + * tags: + * - Snippets + * responses: + * 200: + * description: Snippets shared with the caller. + */ + router.get("/shared", async (_req: Request, res: Response) => { + const userId = actor(ctx); + try { + const roleIds = await repo.listUserRoleIds(userId); + const sharedSnippets = await repo.listSharedSnippets(userId, roleIds); + res.json({ sharedSnippets }); + } catch (err) { + logError( + ctx, + "Failed to get shared snippets", + err, + "shared_snippets_list_failed", + ); + res.status(500).json({ error: "Failed to get shared snippets" }); + } + }); + + /** + * @openapi + * /plugin-api/snippets/{id}: + * get: + * summary: Get one snippet + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: The requested snippet. + * 400: + * description: Invalid request parameters. + * 404: + * description: Snippet not found. + */ + router.get( + "/:id", + ctx.rbac.require("view") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = Array.isArray(req.params.id) + ? req.params.id[0] + : req.params.id; + const snippetId = parseInt(id, 10); + + if (isNaN(snippetId)) { + return res.status(400).json({ error: "Invalid request parameters" }); + } + + try { + const result = await getAccessibleSnippet(snippetId, userId); + if (!result) + return res.status(404).json({ error: "Snippet not found" }); + res.json(result); + } catch (err) { + logError(ctx, "Failed to fetch snippet", err, "snippet_get_failed"); + res.status(500).json({ error: "Failed to fetch snippet" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets: + * post: + * summary: Create a snippet + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * content: + * type: string + * description: + * type: string + * folder: + * type: string + * order: + * type: integer + * isNote: + * type: boolean + * description: When true, the snippet is a note (copy/paste only, not directly executable on a host). + * responses: + * 201: + * description: Snippet created. + * 400: + * description: Name and content are required. + */ + router.post( + "/", + ctx.rbac.require("create") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { name, content, description, folder, order, hostFilter, isNote } = + req.body ?? {}; + + if (!isNonEmptyString(name) || !isNonEmptyString(content)) { + return res.status(400).json({ error: "Name and content are required" }); + } + + try { + const result = await repo.createSnippet(userId, { + name, + content, + description, + folder, + order, + hostFilter, + isNote, + }); + res.status(201).json(result); + } catch (err) { + logError(ctx, "Failed to create snippet", err, "snippet_create_failed"); + res.status(500).json({ error: "Failed to create snippet" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/{id}: + * put: + * summary: Update a snippet + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * content: + * type: string + * description: + * type: string + * folder: + * type: string + * order: + * type: integer + * isNote: + * type: boolean + * responses: + * 200: + * description: The updated snippet. + * 400: + * description: Invalid request. + * 404: + * description: Snippet not found. + */ + router.put( + "/:id", + ctx.rbac.require("edit") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = Array.isArray(req.params.id) + ? req.params.id[0] + : req.params.id; + if (!id) return res.status(400).json({ error: "Invalid request" }); + + try { + const result = await repo.updateSnippet( + userId, + parseInt(id, 10), + req.body ?? {}, + ); + if (!result) + return res.status(404).json({ error: "Snippet not found" }); + res.json(result.updated); + } catch (err) { + logError(ctx, "Failed to update snippet", err, "snippet_update_failed"); + res.status(500).json({ error: "Failed to update snippet" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/{id}: + * delete: + * summary: Delete a snippet + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Snippet deleted. + * 400: + * description: Invalid request. + * 404: + * description: Snippet not found. + */ + router.delete( + "/:id", + ctx.rbac.require("delete") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = Array.isArray(req.params.id) + ? req.params.id[0] + : req.params.id; + if (!id) return res.status(400).json({ error: "Invalid request" }); + + try { + const existing = await repo.deleteSnippet(userId, parseInt(id, 10)); + if (!existing) + return res.status(404).json({ error: "Snippet not found" }); + await ctx.sync.recordTombstone(userId, "snippets", existing.syncId); + res.json({ success: true }); + } catch (err) { + logError(ctx, "Failed to delete snippet", err, "snippet_delete_failed"); + res.status(500).json({ error: "Failed to delete snippet" }); + } + }, + ); + + // Sharing (moved from core's /rbac/snippet* routes) + + /** + * @openapi + * /plugin-api/snippets/{id}/share: + * post: + * summary: Share a snippet with a user or role + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * targetType: + * type: string + * enum: [user, role] + * targetUserId: + * type: string + * targetRoleId: + * type: integer + * expiresAt: + * type: string + * responses: + * 200: + * description: Snippet access updated. + * 400: + * description: Invalid request. + * 403: + * description: Not the snippet's owner. + */ + router.post( + "/:id/share", + ctx.rbac.require("share") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = Array.isArray(req.params.id) + ? req.params.id[0] + : req.params.id; + const snippetId = parseInt(id, 10); + if (isNaN(snippetId)) + return res.status(400).json({ error: "Invalid snippet ID" }); + + const { targetType, targetUserId, targetRoleId, expiresAt } = + req.body ?? {}; + if (targetType !== "user" && targetType !== "role") { + return res + .status(400) + .json({ error: "targetType must be user or role" }); + } + if (targetType === "user" && !isNonEmptyString(targetUserId)) { + return res.status(400).json({ error: "targetUserId is required" }); + } + if (targetType === "role" && typeof targetRoleId !== "number") { + return res.status(400).json({ error: "targetRoleId is required" }); + } + + try { + const snippet = await repo.findOwnedById(userId, snippetId); + if (!snippet) + return res.status(403).json({ error: "Not snippet owner" }); + + await repo.upsertSnippetAccess({ + snippetId, + grantedBy: userId, + expiresAt: expiresAt ?? null, + ...(targetType === "user" + ? { targetType: "user" as const, targetUserId } + : { targetType: "role" as const, targetRoleId }), + }); + + res.json({ + success: true, + message: `Snippet shared successfully with ${targetType}`, + }); + } catch (err) { + logError(ctx, "Failed to share snippet", err, "snippet_share_failed"); + res.status(500).json({ error: "Failed to share snippet" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/folder/share: + * put: + * summary: Share every owned snippet in a folder + * description: Also shares snippets in subfolders ("Parent / Child"). + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * folder: + * type: string + * targetType: + * type: string + * enum: [user, role] + * targetUserId: + * type: string + * targetRoleId: + * type: integer + * expiresAt: + * type: string + * responses: + * 200: + * description: Snippet folder shared. + * 400: + * description: Invalid request. + */ + router.put( + "/folder/share", + ctx.rbac.require("share") as never, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { folder, targetType, targetUserId, targetRoleId, expiresAt } = + req.body ?? {}; + + if (!isNonEmptyString(folder)) { + return res.status(400).json({ error: "folder is required" }); + } + if (targetType !== "user" && targetType !== "role") { + return res + .status(400) + .json({ error: "targetType must be user or role" }); + } + if (targetType === "user" && !isNonEmptyString(targetUserId)) { + return res.status(400).json({ error: "targetUserId is required" }); + } + if (targetType === "role" && typeof targetRoleId !== "number") { + return res.status(400).json({ error: "targetRoleId is required" }); + } + + try { + const snippetsInFolder = await repo.listOwnedSnippetsInFolder( + userId, + folder, + ); + for (const snippet of snippetsInFolder) { + await repo.upsertSnippetAccess({ + snippetId: snippet.id, + grantedBy: userId, + expiresAt: expiresAt ?? null, + ...(targetType === "user" + ? { targetType: "user" as const, targetUserId } + : { targetType: "role" as const, targetRoleId }), + }); + } + + res.json({ success: true, snippetsShared: snippetsInFolder.length }); + } catch (err) { + logError( + ctx, + "Failed to share snippet folder", + err, + "snippet_folder_share_failed", + ); + res.status(500).json({ error: "Failed to share snippet folder" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/{id}/access/{accessId}: + * delete: + * summary: Revoke a snippet share + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: path + * name: accessId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Snippet access revoked. + * 400: + * description: Invalid request. + * 403: + * description: Not the snippet's owner. + */ + router.delete( + "/:id/access/:accessId", + async (req: Request, res: Response) => { + const userId = actor(ctx); + const snippetId = paramId(req.params.id); + const accessId = paramId(req.params.accessId); + if (isNaN(snippetId) || isNaN(accessId)) { + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const snippet = await repo.findOwnedById(userId, snippetId); + if (!snippet) + return res.status(403).json({ error: "Not snippet owner" }); + + await repo.revokeSnippetAccess(accessId, snippetId); + res.json({ success: true, message: "Snippet access revoked" }); + } catch (err) { + logError( + ctx, + "Failed to revoke snippet access", + err, + "snippet_access_revoke_failed", + ); + res.status(500).json({ error: "Failed to revoke snippet access" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/{id}/access: + * get: + * summary: List who a snippet is shared with + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: List of access grants. + * 400: + * description: Invalid snippet ID. + * 403: + * description: Not the snippet's owner. + */ + router.get("/:id/access", async (req: Request, res: Response) => { + const userId = actor(ctx); + const snippetId = paramId(req.params.id); + if (isNaN(snippetId)) + return res.status(400).json({ error: "Invalid snippet ID" }); + + try { + const snippet = await repo.findOwnedById(userId, snippetId); + if (!snippet) return res.status(403).json({ error: "Not snippet owner" }); + + res.json(await repo.listSnippetAccess(snippetId)); + } catch (err) { + logError( + ctx, + "Failed to get snippet access list", + err, + "snippet_access_list_failed", + ); + res.status(500).json({ error: "Failed to get snippet access list" }); + } + }); +} diff --git a/plugins/snippets/src/backend/tables.ts b/plugins/snippets/src/backend/tables.ts new file mode 100644 index 000000000..629960447 --- /dev/null +++ b/plugins/snippets/src/backend/tables.ts @@ -0,0 +1,73 @@ +import { + adoptLegacyTable, + boolean, + defineTable, + id, + integer, + refUser, + text, + timestamp, + varchar, +} from "@termix/plugin-sdk/db"; + +export const snippets = adoptLegacyTable( + "snippets", + defineTable( + "snippets", + { + id: id(), + userId: refUser(), + name: text().notNull(), + content: text().notNull(), + description: text(), + folder: text(), + order: integer().notNull().default(0), + syncId: varchar().unique(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), + hostFilter: text(), + isNote: boolean().notNull().default(false), + }, + { indexes: [{ name: "idx_snippets_user_id", columns: ["userId"] }] }, + ), +); + +export const snippetFolders = adoptLegacyTable( + "snippet_folders", + defineTable("snippet_folders", { + id: id(), + userId: refUser(), + name: text().notNull(), + color: text(), + icon: text(), + syncId: varchar().unique(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), + }), +); + +export const snippetAccess = adoptLegacyTable( + "snippet_access", + defineTable( + "snippet_access", + { + id: id(), + snippetId: integer().notNull(), + userId: varchar(), + roleId: integer(), + grantedBy: refUser(), + permissionLevel: text().notNull().default("view"), + expiresAt: timestamp(), + createdAt: timestamp().notNull().defaultNow(), + }, + { + indexes: [ + { name: "idx_snippet_access_user_id", columns: ["userId"] }, + { name: "idx_snippet_access_snippet_id", columns: ["snippetId"] }, + { name: "idx_snippet_access_role_id", columns: ["roleId"] }, + ], + }, + ), +); + +export const tables = [snippets, snippetFolders, snippetAccess]; diff --git a/plugins/snippets/src/frontend/SnippetsPanel.tsx b/plugins/snippets/src/frontend/SnippetsPanel.tsx new file mode 100644 index 000000000..2c3776714 --- /dev/null +++ b/plugins/snippets/src/frontend/SnippetsPanel.tsx @@ -0,0 +1,742 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + usePluginApi, + useTranslation, + useSettings, + usePermission, + type PanelProps, +} from "@termix/plugin-sdk/frontend"; +import { toast } from "sonner"; +import { + ChevronDown, + ChevronRight, + Copy, + Download, + Folder, + FolderPlus, + Pencil, + Play, + Plus, + Search, + Share2, + Trash2, + Upload, +} from "lucide-react"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Select2, + Switch, + Textarea, +} from "@termix/plugin-sdk/ui"; +import { createSnippetsApi } from "./snippets-api"; +import { useSnippetRunner } from "./use-snippet-runner"; +import { + errorMessage, + FOLDER_ICONS, + type Snippet, + type SnippetFolder, +} from "./types"; + +const ROOT_FOLDER = "__root__"; + +interface EditState { + id: number | null; + name: string; + content: string; + description: string; + folder: string; + isNote: boolean; +} + +const EMPTY_EDIT: EditState = { + id: null, + name: "", + content: "", + description: "", + folder: "", + isNote: false, +}; + +function groupByFolder(snippets: Snippet[]): Map { + const groups = new Map(); + for (const snippet of snippets) { + const key = snippet.folder || ROOT_FOLDER; + const list = groups.get(key) ?? []; + list.push(snippet); + groups.set(key, list); + } + return groups; +} + +function SnippetEditDialog({ + open, + initial, + folders, + onClose, + onSave, +}: { + open: boolean; + initial: EditState; + folders: SnippetFolder[]; + onClose: () => void; + onSave: (state: EditState) => Promise; +}) { + const { t } = useTranslation(); + const [state, setState] = useState(initial); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) setState(initial); + }, [open, initial]); + + const isEdit = initial.id !== null; + + return ( + !v && onClose()}> + + + + {t(isEdit ? "editSnippetTitle" : "createSnippetTitle")} + + + {t(isEdit ? "editSnippetDescription" : "createSnippetDescription")} + + +
+
+ + + setState((s) => ({ ...s, name: e.target.value })) + } + /> +
+
+ + + setState((s) => ({ ...s, description: e.target.value })) + } + /> +
+
+ + + setState((s) => ({ ...s, folder: e.target.value })) + } + > + + {folders.map((f) => ( + + ))} + +
+
+ +
+ {t("typeCommand")} + + setState((s) => ({ ...s, isNote: checked })) + } + /> + {t("typeNote")} +
+
+
+ +