diff --git a/backend/src/controllers/v1/secretsFolderController.ts b/backend/src/controllers/v1/secretsFolderController.ts new file mode 100644 index 000000000..2e856c2a4 --- /dev/null +++ b/backend/src/controllers/v1/secretsFolderController.ts @@ -0,0 +1,89 @@ +import { Request, Response } from 'express'; +import { Secret } from '../../models'; +import Folder from '../../models/folder'; +import { BadRequestError } from '../../utils/errors'; +import { ROOT_FOLDER_PATH, getFolderPath, getParentPath, normalizePath, validateFolderName } from '../../utils/folder'; +import { ADMIN, MEMBER } from '../../variables'; +import { validateMembership } from '../../helpers/membership'; + +// TODO +// verify workspace id/environment +export const createFolder = async (req: Request, res: Response) => { + const { workspaceId, environment, folderName, parentFolderId } = req.body + if (!validateFolderName(folderName)) { + throw BadRequestError({ message: "Folder name cannot contain spaces. Only underscore and dashes" }) + } + + if (parentFolderId) { + const parentFolder = await Folder.find({ environment: environment, workspace: workspaceId, id: parentFolderId }); + if (!parentFolder) { + throw BadRequestError({ message: "The parent folder doesn't exist" }) + } + } + + let completePath = await getFolderPath(parentFolderId) + if (completePath == ROOT_FOLDER_PATH) { + completePath = "" + } + + const currentFolderPath = completePath + "/" + folderName // construct new path with current folder to be created + const normalizedCurrentPath = normalizePath(currentFolderPath) + const normalizedParentPath = getParentPath(normalizedCurrentPath) + + const existingFolder = await Folder.findOne({ + name: folderName, + workspace: workspaceId, + environment: environment, + parent: parentFolderId, + path: normalizedCurrentPath + }); + + if (existingFolder) { + return res.json(existingFolder) + } + + const newFolder = new Folder({ + name: folderName, + workspace: workspaceId, + environment: environment, + parent: parentFolderId, + path: normalizedCurrentPath, + parentPath: normalizedParentPath + }); + + await newFolder.save(); + + return res.json(newFolder) +} + +export const deleteFolder = async (req: Request, res: Response) => { + const { folderId } = req.params + const queue: any[] = [folderId]; + + const folder = await Folder.findById(folderId); + if (!folder) { + throw BadRequestError({ message: "The folder doesn't exist" }) + } + + // check that user is a member of the workspace + await validateMembership({ + userId: req.user._id.toString(), + workspaceId: folder.workspace as any, + acceptedRoles: [ADMIN, MEMBER] + }); + + while (queue.length > 0) { + const currentFolderId = queue.shift(); + + const childFolders = await Folder.find({ parent: currentFolderId }); + for (const childFolder of childFolders) { + queue.push(childFolder._id); + } + + await Secret.deleteMany({ folder: currentFolderId }); + + await Folder.deleteOne({ _id: currentFolderId }); + } + + res.send() +} \ No newline at end of file diff --git a/backend/src/models/folder.ts b/backend/src/models/folder.ts new file mode 100644 index 000000000..885e320a8 --- /dev/null +++ b/backend/src/models/folder.ts @@ -0,0 +1,36 @@ +import { Schema, Types, model } from 'mongoose'; + +const folderSchema = new Schema({ + name: { + type: String, + required: true, + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true, + }, + environment: { + type: String, + required: true, + }, + parent: { + type: Schema.Types.ObjectId, + ref: 'Folder', + required: false, // optional for root folders + }, + path: { + type: String, + required: true + }, + parentPath: { + type: String, + required: true, + }, +}, { + timestamps: true +}); + +const Folder = model('Folder', folderSchema); + +export default Folder; \ No newline at end of file diff --git a/backend/src/utils/folder.ts b/backend/src/utils/folder.ts new file mode 100644 index 000000000..f12845339 --- /dev/null +++ b/backend/src/utils/folder.ts @@ -0,0 +1,87 @@ +import Folder from "../models/folder"; + +export const ROOT_FOLDER_PATH = "/" + +export const getFolderPath = async (folderId: string) => { + let currentFolder = await Folder.findById(folderId); + const pathSegments = []; + + while (currentFolder) { + pathSegments.unshift(currentFolder.name); + currentFolder = currentFolder.parent ? await Folder.findById(currentFolder.parent) : null; + } + + return '/' + pathSegments.join('/'); +}; + +/** + Returns the folder ID associated with the specified secret path in the given workspace and environment. + @param workspaceId - The ID of the workspace to search in. + @param environment - The environment to search in. + @param secretPath - The secret path to search for. + @returns The folder ID associated with the specified secret path, or undefined if the path is at the root folder level. + @throws Error if the specified secret path is not found. +*/ +export const getFolderIdFromPath = async (workspaceId: string, environment: string, secretPath: string) => { + const secretPathParts = secretPath.split("/").filter(path => path != "") + if (secretPathParts.length <= 1) { + return undefined // root folder, so no folder id + } + + const folderId = await Folder.find({ path: secretPath, workspace: workspaceId, environment: environment }) + if (!folderId) { + throw Error("Secret path not found") + } + + return folderId +} + +/** + * Cleans up a path by removing empty parts, duplicate slashes, + * and ensuring it starts with ROOT_FOLDER_PATH. + * @param path - The input path to clean up. + * @returns The cleaned-up path string. + */ +export const normalizePath = (path: string) => { + if (path == undefined || path == "" || path == ROOT_FOLDER_PATH) { + return ROOT_FOLDER_PATH + } + + const pathParts = path.split("/").filter(part => part != "") + const cleanPathString = ROOT_FOLDER_PATH + pathParts.join("/") + + return cleanPathString +} + +export const getFoldersInDirectory = async (workspaceId: string, environment: string, pathString: string) => { + const normalizedPath = normalizePath(pathString) + const foldersInDirectory = await Folder.find({ + workspace: workspaceId, + environment: environment, + parentPath: normalizedPath, + }); + + return foldersInDirectory; +} + +/** + * Returns the parent path of the given path. + * @param path - The input path. + * @returns The parent path string. + */ +export const getParentPath = (path: string) => { + const normalizedPath = normalizePath(path); + const folderParts = normalizedPath.split('/').filter(part => part !== ''); + + let folderParent = ROOT_FOLDER_PATH; + if (folderParts.length > 1) { + folderParent = ROOT_FOLDER_PATH + folderParts.slice(0, folderParts.length - 1).join('/'); + } + + return folderParent; +} + +export const validateFolderName = (folderName: string) => { + const validNameRegex = /^[a-zA-Z0-9-_]+$/; + return validNameRegex.test(folderName); +} \ No newline at end of file