mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
9de904dd4c
* 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>
1682 lines
46 KiB
TypeScript
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;
|