mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
476 lines
14 KiB
TypeScript
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" });
|
|
}
|
|
},
|
|
);
|
|
}
|