Files
Termix/src/backend/database/routes/snippets.ts
T
Luke Gustafson 9de904dd4c release-2.5.0 (#994)
* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: fix ci checks (#966)

* Fix dashboard service link creation (#950)

* Fix jump host SOCKS5 proxy selection (#951)

* Fix jump host SOCKS5 proxy selection

* fix: type jump host socks proxy config

* Fix tmux detection path handling (#949)

* Support GUACD_URL environment config (#952)

* Retry autostart tunnel host fetches (#953)

* Retry autostart tunnel host fetches

* chore: format tunnel route

* Fix PUID html ownership in Docker entrypoint (#954)

* feat: allow custom tunnel endpoints (#977)

* fix: skip metrics start for non-ssh hosts (#976)

* Fix Proxmox import auth fallback (#956)

* Fix Proxmox import auth fallback

* chore: format proxmox import auth

* Fix SSH heading syntax highlighting (#955)

* Fix SSH heading syntax highlighting

* chore: format terminal highlighter

* Initialize auth before fullscreen terminal routes (#957)

* Add WebAuthn passkey authentication (#959)

* chore: add Biome tooling (#965)

* chore: add biome tooling

* chore: support tailwind syntax in biome

* Fix VNC required argument handshake (#968)

* Prioritize host results in command palette search (#969)

* Prevent sidebar host hover layout shift (#970)

* Add terminal font zoom with mouse wheel (#971)

* Add host temperature metrics card (#972)

* Make file downloads reliable in desktop app (#973)

* Add app rail hover expansion setting (#974)

* fix: use correct translation key for nav.close (#964)

* fix(tunnel): skip endpoint credential validation for direct tunnels (#963)

* fix: SSH port connection bug (#975)

* fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948)

* feat: support SSH agent auth across SSH features (#960)

* feat: add Podman container runtime support (#958)

* chore: update readme and release notes

* fix: stabilize Windows app icon (#978)

* Add safe host sharing export (#979)

* Add external editor support for file manager (#985)

* Rework SSH credential password handling (#984)

* Support password fallback for SSH key credentials

* Complete SSH credential password fallback

* Fix runtime base path for auth callbacks (#982)

* Fix TUI terminal output highlighting (#983)

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Add app fullscreen mode (#993)

* Fix host metrics startup polling (#986)

* chore: update release notes

* fix: line chart text overlap

* chore: update RELEASE_NOTES.md

* chore: add donation goal to readme

* chore: update readme

* fix: hide full-screen button in electron app

* fix: failed unit tests

* chore: lint, format, and bump version to 2.5.0

* chore: remove timeout from crowdin translate

* chore: sync Crowdin translations for 2.5.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
2026-06-29 13:28:26 -05:00

1682 lines
46 KiB
TypeScript

import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
snippets,
snippetFolders,
snippetAccess,
users,
userRoles,
} from "../db/schema.js";
import { eq, and, desc, asc, sql, or, isNull, gte } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
async function getUserRoleIds(userId: string): Promise<number[]> {
const rows = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
return rows.map((row) => row.roleId);
}
function roleIdFilter(roleIds: number[]) {
if (roleIds.length === 0) {
return undefined;
}
return sql`${snippetAccess.roleId} IN (${sql.join(
roleIds.map((id) => sql`${id}`),
sql`, `,
)})`;
}
function activeSnippetAccessFilter(userId: string, roleIds: number[]) {
const roleFilter = roleIdFilter(roleIds);
const targetFilter = roleFilter
? or(eq(snippetAccess.userId, userId), roleFilter)
: eq(snippetAccess.userId, userId);
return and(
targetFilter,
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, new Date().toISOString()),
),
);
}
function sortSnippets<
T extends { folder: string | null; order: number; updatedAt: string },
>(a: T, b: T) {
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);
}
async function getAccessibleSnippet(snippetId: number, userId: string) {
const owned = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.limit(1);
if (owned.length > 0) {
return owned[0];
}
const roleIds = await getUserRoleIds(userId);
const shared = await db
.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,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.where(
and(
eq(snippetAccess.snippetId, snippetId),
activeSnippetAccessFilter(userId, roleIds),
),
)
.limit(1);
return shared[0] ?? null;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
/**
* @openapi
* /snippets/folders:
* get:
* summary: Get all snippet folders
* description: Retrieves all snippet folders for the authenticated user.
* tags:
* - Snippets
* responses:
* 200:
* description: A list of snippet folders.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to fetch snippet folders.
*/
router.get(
"/folders",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippet folders fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
res.json(result);
} catch (err) {
authLogger.error("Failed to fetch snippet folders", err);
res.status(500).json({ error: "Failed to fetch snippet folders" });
}
},
);
/**
* @openapi
* /snippets/folders:
* post:
* summary: Create a new snippet folder
* description: Creates a new snippet folder for the authenticated user.
* 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 successfully.
* 400:
* description: Folder name is required.
* 409:
* description: Folder with this name already exists.
* 500:
* description: Failed to create snippet folder.
*/
router.post(
"/folders",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon } = req.body;
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
authLogger.warn("Invalid snippet folder creation data", {
operation: "snippet_folder_create",
userId,
hasName: !!name,
});
return res.status(400).json({ error: "Folder name is required" });
}
try {
const existing = await db
.select()
.from(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
if (existing.length > 0) {
return res
.status(409)
.json({ error: "Folder with this name already exists" });
}
const insertData = {
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
};
const result = await db
.insert(snippetFolders)
.values(insertData)
.returning();
authLogger.success(`Snippet folder created: ${name} by user ${userId}`, {
operation: "snippet_folder_create_success",
userId,
name,
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to create snippet folder", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to create snippet folder",
});
}
},
);
/**
* @openapi
* /snippets/folders/{name}/metadata:
* put:
* summary: Update snippet folder metadata
* description: Updates the metadata (color, icon) of a snippet folder.
* 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 successfully.
* 400:
* description: Invalid request.
* 404:
* description: Folder not found.
* 500:
* description: Failed to update snippet folder metadata.
*/
router.put(
"/folders/:name/metadata",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const name = Array.isArray(req.params.name)
? req.params.name[0]
: req.params.name;
const { color, icon } = req.body;
if (!isNonEmptyString(userId) || !name) {
authLogger.warn("Invalid request for snippet folder metadata update");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Folder not found" });
}
const updateFields: Partial<{
color: string | null;
icon: string | null;
updatedAt: ReturnType<typeof sql.raw>;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
await db
.update(snippetFolders)
.set(updateFields)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
const updated = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
authLogger.success(
`Snippet folder metadata updated: ${name} by user ${userId}`,
{
operation: "snippet_folder_metadata_update_success",
userId,
name,
},
);
res.json(updated[0]);
} catch (err) {
authLogger.error("Failed to update snippet folder metadata", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to update snippet folder metadata",
});
}
},
);
/**
* @openapi
* /snippets/folders/rename:
* put:
* summary: Rename a snippet folder
* description: Renames a snippet folder for the authenticated user.
* tags:
* - Snippets
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* oldName:
* type: string
* newName:
* type: string
* responses:
* 200:
* description: Folder renamed successfully.
* 400:
* description: Invalid request.
* 404:
* description: Folder not found.
* 409:
* description: Folder with new name already exists.
* 500:
* description: Failed to rename snippet folder.
*/
router.put(
"/folders/rename",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(oldName) ||
!isNonEmptyString(newName)
) {
authLogger.warn("Invalid request for snippet folder rename");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Folder not found" });
}
const nameExists = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, newName),
),
);
if (nameExists.length > 0) {
return res
.status(409)
.json({ error: "Folder with new name already exists" });
}
await db
.update(snippetFolders)
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
await db
.update(snippets)
.set({ folder: newName })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
authLogger.success(
`Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`,
{
operation: "snippet_folder_rename_success",
userId,
oldName,
newName,
},
);
res.json({ success: true, oldName, newName });
} catch (err) {
authLogger.error("Failed to rename snippet folder", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to rename snippet folder",
});
}
},
);
/**
* @openapi
* /snippets/folders/{name}:
* delete:
* summary: Delete a snippet folder
* description: Deletes a snippet folder and moves its snippets to the root.
* tags:
* - Snippets
* parameters:
* - in: path
* name: name
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Snippet folder deleted successfully.
* 400:
* description: Invalid request.
* 500:
* description: Failed to delete snippet folder.
*/
router.delete(
"/folders/:name",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const name = Array.isArray(req.params.name)
? req.params.name[0]
: req.params.name;
if (!isNonEmptyString(userId) || !name) {
authLogger.warn("Invalid request for snippet folder delete");
return res.status(400).json({ error: "Invalid request" });
}
try {
const folderName = decodeURIComponent(name);
await db
.update(snippets)
.set({ folder: null })
.where(
and(eq(snippets.userId, userId), eq(snippets.folder, folderName)),
);
await db
.delete(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, folderName),
),
);
authLogger.success(
`Snippet folder deleted: ${folderName} by user ${userId}`,
{
operation: "snippet_folder_delete_success",
userId,
name: folderName,
},
);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete snippet folder", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to delete snippet folder",
});
}
},
);
/**
* @openapi
* /snippets/reorder:
* put:
* summary: Reorder snippets
* description: Bulk updates the order and folder of snippets. Accepts
* `snippets` and the legacy `updates` payload 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 successfully.
* 400:
* description: Invalid request.
* 500:
* description: Failed to reorder snippets.
*/
router.put(
"/reorder",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const snippetUpdates = extractSnippetReorderUpdates(req.body);
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippet reorder");
return res.status(400).json({ error: "Invalid userId" });
}
if (!snippetUpdates || snippetUpdates.length === 0) {
authLogger.warn("Invalid snippet reorder data", {
operation: "snippet_reorder",
userId,
});
return res
.status(400)
.json({ error: "snippets array is required and must not be empty" });
}
try {
for (const update of snippetUpdates) {
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 db
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
}
authLogger.success(`Snippets reordered by user ${userId}`, {
operation: "snippet_reorder_success",
userId,
count: snippetUpdates.length,
});
res.json({ success: true, updated: snippetUpdates.length });
} catch (err) {
authLogger.error("Failed to reorder snippets", err);
res.status(500).json({
error:
err instanceof Error ? err.message : "Failed to reorder snippets",
});
}
},
);
/**
* @openapi
* /snippets/execute:
* post:
* summary: Execute a snippet on a host
* description: Executes a snippet on a specified host.
* tags:
* - Snippets
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* snippetId:
* type: integer
* hostId:
* type: integer
* responses:
* 200:
* description: Snippet executed successfully.
* 400:
* description: Snippet ID and Host ID are required.
* 404:
* description: Snippet or host not found.
* 500:
* description: Failed to execute snippet.
*/
router.post(
"/execute",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { snippetId, hostId } = req.body;
if (!isNonEmptyString(userId) || !snippetId || !hostId) {
authLogger.warn("Invalid snippet execution request", {
userId,
snippetId,
hostId,
});
return res
.status(400)
.json({ error: "Snippet ID and Host ID are required" });
}
try {
const snippet = await getAccessibleSnippet(parseInt(snippetId), userId);
if (!snippet) {
return res.status(404).json({ error: "Snippet not found" });
}
const { Client } = await import("ssh2");
const { hosts, sshCredentials } = await import("../db/schema.js");
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const hostResult = await SimpleDBOps.select(
db
.select()
.from(hosts)
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (hostResult.length === 0) {
return res.status(404).json({ error: "Host not found" });
}
const host = hostResult[0];
let password = host.password;
let privateKey = host.key;
let passphrase = host.keyPassword;
let authType = host.authType;
if (host.credentialId) {
const credResult = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credResult.length > 0) {
const cred = credResult[0];
authType = (cred.authType || authType) as string;
password = (cred.password || undefined) as string | undefined;
privateKey = (cred.privateKey || cred.key || undefined) as
| string
| undefined;
passphrase = (cred.keyPassword || undefined) as string | undefined;
}
}
const conn = new Client();
let output = "";
let errorOutput = "";
/* eslint-disable no-async-promise-executor */
const executePromise = new Promise<{
success: boolean;
output: string;
error?: string;
}>(async (resolve, reject) => {
const timeout = setTimeout(() => {
conn.end();
reject(new Error("Command execution timeout (30s)"));
}, 30000);
conn.on("ready", () => {
conn.exec(snippet.content, (err, stream) => {
if (err) {
clearTimeout(timeout);
conn.end();
return reject(err);
}
stream.on("close", () => {
clearTimeout(timeout);
conn.end();
if (errorOutput) {
resolve({ success: false, output, error: errorOutput });
} else {
resolve({ success: true, output });
}
});
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
});
});
conn.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
const config: Record<string, unknown> = {
host: host.ip,
port: host.port,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 30000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
timeout: 30000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (authType === "password" && password) {
config.password = password;
} else if (authType === "key" && privateKey) {
const cleanKey = (privateKey as string)
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (passphrase) {
config.passphrase = passphrase;
}
} else if (authType === "agent") {
const result = await applyAgentAuth(
config,
host.terminalConfig as Record<string, unknown> | string | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
} else if (password) {
config.password = password;
} else if (privateKey) {
const cleanKey = (privateKey as string)
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (passphrase) {
config.passphrase = passphrase;
}
}
conn.connect(config);
});
/* eslint-enable no-async-promise-executor */
const result = await executePromise;
authLogger.success(
`Snippet executed: ${snippet.name} on host ${hostId}`,
{
operation: "snippet_execute_success",
userId,
snippetId,
hostId,
},
);
res.json(result);
} catch (err) {
authLogger.error("Failed to execute snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to execute snippet",
});
}
},
);
/**
* @openapi
* /snippets/export:
* get:
* summary: Export all snippets and folders as JSON
* description: Returns all snippets and snippet folders for the authenticated user as a JSON export.
* tags:
* - Snippets
* responses:
* 200:
* description: Export object containing snippets and folders arrays.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to export snippets.
*/
router.get(
"/export",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippet export");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const allSnippets = await db
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(asc(snippets.folder), asc(snippets.order));
const allFolders = await db
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
const exportedSnippets = allSnippets.map((s) => ({
name: s.name,
content: s.content,
description: s.description,
folder: s.folder,
order: s.order,
hostFilter: s.hostFilter,
}));
const exportedFolders = allFolders.map((f) => ({
name: f.name,
color: f.color,
icon: f.icon,
}));
authLogger.success(`Snippets exported by user ${userId}`, {
operation: "snippet_export",
userId,
snippetCount: exportedSnippets.length,
folderCount: exportedFolders.length,
});
res.json({ snippets: exportedSnippets, folders: exportedFolders });
} catch (err) {
authLogger.error("Failed to export snippets", err);
res.status(500).json({ error: "Failed to export snippets" });
}
},
);
/**
* @openapi
* /snippets/bulk-import:
* post:
* summary: Bulk import snippets and folders from JSON
* description: Imports snippets and folders. Existing folders are skipped; existing snippets (matched by name+folder) can be 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.
* 500:
* description: Failed to import snippets.
*/
router.post(
"/bulk-import",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
snippets: snippetsToImport,
folders: foldersToImport,
overwrite,
} = req.body;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!Array.isArray(snippetsToImport) && !Array.isArray(foldersToImport)) {
return res
.status(400)
.json({ error: "snippets or folders array is required" });
}
const results = {
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 0,
foldersImported: 0,
foldersSkipped: 0,
failed: 0,
errors: [] as string[],
};
try {
if (Array.isArray(foldersToImport)) {
for (const folder of foldersToImport) {
if (!isNonEmptyString(folder.name)) {
results.failed++;
results.errors.push(`Folder missing name`);
continue;
}
const existing = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, folder.name.trim()),
),
)
.limit(1);
if (existing.length > 0) {
results.foldersSkipped++;
continue;
}
await db.insert(snippetFolders).values({
userId,
name: folder.name.trim(),
color: folder.color?.trim() || null,
icon: folder.icon?.trim() || null,
});
results.foldersImported++;
}
}
if (Array.isArray(snippetsToImport)) {
for (let i = 0; i < snippetsToImport.length; i++) {
const s = snippetsToImport[i];
if (!isNonEmptyString(s.name) || !isNonEmptyString(s.content)) {
results.failed++;
results.errors.push(
`Snippet ${i + 1}: name and content are required`,
);
continue;
}
const folderVal = s.folder?.trim() || null;
const existing = await db
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
eq(snippets.name, s.name.trim()),
folderVal
? eq(snippets.folder, folderVal)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
)
.limit(1);
if (existing.length > 0) {
if (!overwrite) {
results.snippetsSkipped++;
continue;
}
await db
.update(snippets)
.set({
content: s.content.trim(),
description: s.description?.trim() || null,
folder: folderVal,
order:
typeof s.order === "number" ? s.order : existing[0].order,
hostFilter: s.hostFilter || null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(
eq(snippets.id, existing[0].id),
eq(snippets.userId, userId),
),
);
results.snippetsUpdated++;
continue;
}
const maxOrderResult = await db
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folderVal
? eq(snippets.folder, folderVal)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
await db.insert(snippets).values({
userId,
name: s.name.trim(),
content: s.content.trim(),
description: s.description?.trim() || null,
folder: folderVal,
order: typeof s.order === "number" ? s.order : maxOrder + 1,
hostFilter: s.hostFilter || null,
});
results.snippetsImported++;
}
}
authLogger.success(`Snippets bulk-imported by user ${userId}`, {
operation: "snippet_bulk_import",
userId,
...results,
});
res.json({ success: true, ...results });
} catch (err) {
authLogger.error("Failed to bulk import snippets", err);
res.status(500).json({ error: "Failed to import snippets" });
}
},
);
/**
* @openapi
* /snippets:
* get:
* summary: Get all snippets
* description: Retrieves all snippets for the authenticated user.
* tags:
* - Snippets
* responses:
* 200:
* description: A list of snippets.
* 400:
* description: Invalid userId.
* 500:
* description: Failed to fetch snippets.
*/
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippets fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const ownedSnippets = await db
.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),
desc(snippets.updatedAt),
);
const roleIds = await getUserRoleIds(userId);
const sharedSnippets = await db
.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,
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(activeSnippetAccessFilter(userId, roleIds));
const visibleSnippets = new Map<number, Record<string, unknown>>();
for (const snippet of ownedSnippets) {
visibleSnippets.set(snippet.id, { ...snippet, isShared: false });
}
for (const snippet of sharedSnippets) {
if (visibleSnippets.has(snippet.id)) continue;
visibleSnippets.set(snippet.id, { ...snippet, isShared: true });
}
const result = Array.from(visibleSnippets.values()).sort((a, b) =>
sortSnippets(
a as { folder: string | null; order: number; updatedAt: string },
b as { folder: string | null; order: number; updatedAt: string },
),
);
res.json(result);
} catch (err) {
authLogger.error("Failed to fetch snippets", err);
res.status(500).json({ error: "Failed to fetch snippets" });
}
},
);
/**
* @openapi
* /snippets/{id}:
* get:
* summary: Get a specific snippet
* description: Retrieves a specific snippet by its ID.
* 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.
* 500:
* description: Failed to fetch snippet.
*/
router.get(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
authLogger.warn("Invalid request for snippet fetch: invalid ID", {
userId,
id,
});
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) {
authLogger.error("Failed to fetch snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch snippet",
});
}
},
);
/**
* @openapi
* /snippets:
* post:
* summary: Create a new snippet
* description: Creates a new snippet for the authenticated user.
* 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
* responses:
* 201:
* description: Snippet created successfully.
* 400:
* description: Name and content are required.
* 500:
* description: Failed to create snippet.
*/
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, content, description, folder, order, hostFilter } = req.body;
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(name) ||
!isNonEmptyString(content)
) {
authLogger.warn("Invalid snippet creation data validation failed", {
operation: "snippet_create",
userId,
hasName: !!name,
hasContent: !!content,
});
return res.status(400).json({ error: "Name and content are required" });
}
try {
let snippetOrder = order;
if (snippetOrder === undefined || snippetOrder === null) {
const folderValue = folder?.trim() || "";
const maxOrderResult = await db
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folderValue
? eq(snippets.folder, folderValue)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
snippetOrder = maxOrder + 1;
}
const insertData = {
userId,
name: name.trim(),
content: content.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
order: snippetOrder,
hostFilter: hostFilter ? JSON.stringify(hostFilter) : null,
};
const result = await db.insert(snippets).values(insertData).returning();
databaseLogger.info("Command snippet created", {
operation: "snippet_create",
userId,
snippetId: result[0].id,
name,
});
const { ipAddress: scIp, userAgent: scUa } = getRequestMeta(req);
const scActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: scActor[0]?.username ?? userId,
action: "create_snippet",
resourceType: "snippet",
resourceId: String(result[0].id),
resourceName: name,
ipAddress: scIp,
userAgent: scUa,
success: true,
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to create snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to create snippet",
});
}
},
);
/**
* @openapi
* /snippets/{id}:
* put:
* summary: Update a snippet
* description: Updates a specific snippet by its ID.
* 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
* responses:
* 200:
* description: The updated snippet.
* 400:
* description: Invalid request.
* 404:
* description: Snippet not found.
* 500:
* description: Failed to update snippet.
*/
router.put(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const updateData = req.body;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet update");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
const updateFields: Partial<{
updatedAt: ReturnType<typeof sql.raw>;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
hostFilter: string | null;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
if (updateData.content !== undefined)
updateFields.content = updateData.content.trim();
if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
if (updateData.folder !== undefined)
updateFields.folder = updateData.folder?.trim() || null;
if (updateData.order !== undefined) updateFields.order = updateData.order;
if (updateData.hostFilter !== undefined)
updateFields.hostFilter = updateData.hostFilter
? JSON.stringify(updateData.hostFilter)
: null;
await db
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
const updated = await db
.select()
.from(snippets)
.where(eq(snippets.id, parseInt(id)));
databaseLogger.info("Command snippet updated", {
operation: "snippet_update",
userId,
snippetId: parseInt(id),
});
const { ipAddress: suIp, userAgent: suUa } = getRequestMeta(req);
const suActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: suActor[0]?.username ?? userId,
action: "update_snippet",
resourceType: "snippet",
resourceId: id,
resourceName: existing[0].name,
ipAddress: suIp,
userAgent: suUa,
success: true,
});
res.json(updated[0]);
} catch (err) {
authLogger.error("Failed to update snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to update snippet",
});
}
},
);
/**
* @openapi
* /snippets/{id}:
* delete:
* summary: Delete a snippet
* description: Deletes a specific snippet by its ID.
* tags:
* - Snippets
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Snippet deleted successfully.
* 400:
* description: Invalid request.
* 404:
* description: Snippet not found.
* 500:
* description: Failed to delete snippet.
*/
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet delete");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
await db
.delete(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
databaseLogger.info("Command snippet deleted", {
operation: "snippet_delete",
userId,
snippetId: parseInt(id),
});
const { ipAddress: sdIp, userAgent: sdUa } = getRequestMeta(req);
const sdActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: sdActor[0]?.username ?? userId,
action: "delete_snippet",
resourceType: "snippet",
resourceId: id,
resourceName: existing[0].name,
ipAddress: sdIp,
userAgent: sdUa,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete snippet",
});
}
},
);
export default router;