Files
Termix/src/backend/database/routes/host-folder-routes.ts
T

476 lines
14 KiB
TypeScript

import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import {
folderCredentialIds,
setFolderCredentialDefault,
} from "../../hosts/defaults/service.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDeletePermission: RequestHandler;
requireCredentialEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
};
export function registerHostFolderRoutes(
router: Router,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDeletePermission,
requireCredentialEditPermission,
requireDataAccess,
}: HostFolderRoutesDeps,
): void {
/**
* @openapi
* /host/folders/rename:
* put:
* summary: Rename folder
* description: Renames a folder for SSH hosts and credentials.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* oldName:
* type: string
* newName:
* type: string
* responses:
* 200:
* description: Folder renamed successfully.
* 400:
* description: Old name and new name are required.
* 500:
* description: Failed to rename folder.
*/
router.put(
"/folders/rename",
authenticateJWT,
requireEditPermission,
requireCredentialEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
if (!isNonEmptyString(userId) || !oldName || !newName) {
sshLogger.warn("Invalid data for folder rename");
return res
.status(400)
.json({ error: "Old name and new name are required" });
}
if (oldName === newName) {
return res.json({ message: "Folder name unchanged" });
}
try {
const { updatedHosts, updatedCredentials } =
await createCurrentHostFolderRepository().renameFolder(
userId,
oldName,
newName,
);
await createCurrentFolderAccessRepository().renameFolder(
userId,
oldName,
newName,
);
// Subfolders change parents with a rename, so their chains do too.
void recompute({ userIds: [userId] }).catch(() => {});
res.json({
message: "Folder renamed successfully",
updatedHosts,
updatedCredentials,
});
} catch (err) {
sshLogger.error("Failed to rename folder", err, {
operation: "folder_rename",
userId,
oldName,
newName,
});
res.status(500).json({ error: "Failed to rename folder" });
}
},
);
/**
* @openapi
* /host/folders:
* get:
* summary: Get all folders
* description: Retrieves all folders for the authenticated user.
* tags:
* - SSH
* responses:
* 200:
* description: A list of folders.
* 400:
* description: Invalid user ID.
* 500:
* description: Failed to fetch folders.
*/
router.get(
"/folders",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid user ID" });
}
try {
const [folders, credentials] = await Promise.all([
createCurrentHostFolderRepository().listFolders(userId),
folderCredentialIds(userId),
]);
res.json(
folders.map((folder) => ({
...folder,
credentialId: credentials.get(folder.id) ?? null,
})),
);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
userId,
});
res.status(500).json({ error: "Failed to fetch folders" });
}
},
);
/**
* @openapi
* /host/folders/metadata:
* put:
* summary: Update folder metadata
* description: Updates the metadata (color, icon, assigned credential) of a folder.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* color:
* type: string
* icon:
* type: string
* credentialId:
* type: integer
* nullable: true
* localOnly:
* type: boolean
* description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them.
* responses:
* 200:
* description: Folder metadata updated successfully.
* 400:
* description: Folder name is required.
* 500:
* description: Failed to update folder metadata.
*/
router.put(
"/folders/metadata",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, credentialId, localOnly } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
}
const normalizedCredentialId =
credentialId === undefined
? undefined
: credentialId === null || credentialId === ""
? null
: Number(credentialId);
if (
normalizedCredentialId !== undefined &&
normalizedCredentialId !== null &&
!Number.isInteger(normalizedCredentialId)
) {
return res.status(400).json({ error: "Invalid credential ID" });
}
try {
if (normalizedCredentialId) {
const credential =
await createCurrentCredentialRepository().findByIdForUser(
userId,
normalizedCredentialId,
);
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
}
}
const { folder, created } =
await createCurrentHostFolderRepository().upsertMetadata(
userId,
name,
color,
icon,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
userId,
name,
localOnly,
);
}
if (normalizedCredentialId !== undefined) {
await setFolderCredentialDefault(
userId,
folder.id,
normalizedCredentialId,
);
void recompute({ userIds: [userId] }).catch(() => {});
}
if (!created) {
databaseLogger.info("Updating SSH folder", {
operation: "folder_update",
userId,
folderId: folder.id,
});
} else {
databaseLogger.info("Creating SSH folder", {
operation: "folder_create",
userId,
name,
});
}
res.json({ message: "Folder metadata updated successfully" });
} catch (err) {
sshLogger.error("Failed to update folder metadata", err, {
operation: "update_folder_metadata",
userId,
name,
});
res.status(500).json({ error: "Failed to update folder metadata" });
}
},
);
/**
* @openapi
* /host/folders/reorder:
* put:
* summary: Reorder folders
* description: Sets a manual sortOrder for multiple sibling folders, used by drag-to-reorder in the sidebar's manual sort mode. Folders with no existing metadata row are created.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* positions:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* sortOrder:
* type: integer
* responses:
* 200:
* description: Folders reordered successfully.
* 400:
* description: Invalid positions array.
* 500:
* description: Failed to reorder folders.
*/
router.put(
"/folders/reorder",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
positions?: { name?: unknown; sortOrder?: unknown }[];
};
if (!isNonEmptyString(userId) || !Array.isArray(positions)) {
return res.status(400).json({ error: "positions array is required" });
}
const normalized: { name: string; sortOrder: number }[] = [];
for (const entry of positions) {
if (
typeof entry?.name !== "string" ||
!entry.name ||
typeof entry.sortOrder !== "number" ||
!Number.isFinite(entry.sortOrder)
) {
return res.status(400).json({
error: "Each position requires a name and a numeric sortOrder",
});
}
normalized.push({ name: entry.name, sortOrder: entry.sortOrder });
}
if (normalized.length === 0) {
return res.status(400).json({ error: "positions array is required" });
}
try {
const updated =
await createCurrentHostFolderRepository().reorderFolders(
userId,
normalized,
);
res.json({ updated });
} catch (err) {
sshLogger.error("Failed to reorder folders", err, {
operation: "folders_reorder",
userId,
});
res.status(500).json({ error: "Failed to reorder folders" });
}
},
);
/**
* @openapi
* /host/folders/{name}/hosts:
* delete:
* summary: Delete all hosts in folder
* description: Deletes all SSH hosts within a specific folder.
* tags:
* - SSH
* parameters:
* - in: path
* name: name
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Hosts deleted successfully.
* 400:
* description: Invalid folder name.
* 500:
* description: Failed to delete hosts in folder.
*/
router.delete(
"/folders/:name/hosts",
authenticateJWT,
requireDeletePermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = Array.isArray(req.params.name)
? req.params.name[0]
: req.params.name;
if (!isNonEmptyString(userId) || !folderName) {
return res.status(400).json({ error: "Invalid folder name" });
}
databaseLogger.info("Deleting SSH folder", {
operation: "folder_delete",
userId,
folderId: folderName,
});
try {
const hostFolderRepository = createCurrentHostFolderRepository();
const hostsToDelete = await hostFolderRepository.listHostsInFolder(
userId,
folderName,
);
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
// Plugin rows tied to a host cascade on their refHost() foreign keys.
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
);
await createCurrentRecentActivityRepository().deleteByHostIds(
hostIds,
);
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds,
);
}
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
folderName,
);
try {
for (const host of hostsToDelete) {
pluginEvents.emit(TOPICS.hostDeleted, { hostId: host.id, userId });
}
} catch (err) {
sshLogger.warn("Failed to publish host deletion events", {
operation: "folder_hosts_delete",
folderName,
error: err instanceof Error ? err.message : String(err),
});
}
res.json({
message: "All hosts in folder deleted successfully",
deletedCount: hostsToDelete.length,
});
} catch (err) {
sshLogger.error("Failed to delete hosts in folder", err, {
operation: "delete_folder_hosts",
userId,
folderName,
});
res.status(500).json({ error: "Failed to delete hosts in folder" });
}
},
);
}