Ctrl K
ComunidadGuías…

server/api/ProjectRoute.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
const { teamRoles, viewerRoles } = require("../../shared/roles.json");
const path = require("path");
const fs = require("fs");
const rateLimit = require("express-rate-limit");
const { nanoid } = require("nanoid");
const _ = require("lodash");
const jwt = require("jsonwebtoken");

const settings = require("../settings");

const { saveDashboardLayout } = require("../modules/dashboardLayout");
const ProjectController = require("../controllers/ProjectController");
const TeamController = require("../controllers/TeamController");
const SharePolicyController = require("../controllers/SharePolicyController");
const verifyToken = require("../modules/verifyToken");
const { saveReportAppearance } = require("../modules/reportAppearance");
const accessControl = require("../modules/accessControl");
const getUserFromToken = require("../modules/getUserFromToken");
const db = require("../models/models");
const { verifyProjectPassword } = require("../modules/projectPassword");
const { validateShareTokenPolicy, verifyShareToken } = require("../modules/shareToken");
const {
  MAX_LOGO_UPLOAD_SIZE_BYTES,
  buildSafeLogoFilename,
  isAllowedLogoMimeType,
  isValidLogoImageBuffer,
  resolveSafeUploadPath,
} = require("../modules/logoUploadSecurity");

const getProjectPasswordInput = (req) => {
  return req.headers.pass || req.query.pass || req.query.password || req.body?.password;
};

const apiLimiter = (max = 10, windowMinutes = 1) => {
  return rateLimit({
    windowMs: windowMinutes * 60 * 1000,
    max,
    skip: (req) => !getProjectPasswordInput(req),
  });
};

module.exports = (app) => {
  const projectController = new ProjectController();
  const teamController = new TeamController();

  const checkPermissions = (actionType = "readOwn") => {
    return async (req, res, next) => {
      const projectId = req.params.id;

      let teamRole;
      let project;
      let teamId;

      if (projectId) {
        project = await db.Project.findByPk(projectId, { attributes: ["id", "team_id"] });
        if (!project) return res.status(404).json({ message: "Project not found" });
        teamId = project.team_id;
      } else {
        teamId = req.params.team_id || req.body?.team_id;
      }

      if (teamId) {
        teamRole = await teamController.getTeamRole(teamId, req.user.id);
      }

      if (!teamRole?.role) {
        return res.status(403).json({ message: "Access denied" });
      }
      req.user.teamRole = teamRole;

      if (teamRoles.includes(teamRole.role)) {
        const permission = accessControl.can(teamRole.role)[actionType]("project");
        if (!permission.granted) {
          return res.status(403).json({ message: "Access denied" });
        }

        return next();
      }

      if (teamRole?.projects?.length > 0) {
        if (projectId) {
          const filteredProjects = teamRole.projects.filter((o) => `${o}` === `${projectId}`);
          if (filteredProjects.length === 0) {
            return res.status(403).json({ message: "Access denied" });
          }
        }

        const permission = accessControl.can(teamRole.role)[actionType]("project");
        if (!permission.granted) {
          return res.status(403).json({ message: "Access denied" });
        }

        req.user.projects = teamRole.projects;

        return next();
      }

      return res.status(403).json({ message: "Access denied" });
    };
  };

  app.put("/project/:id/report-appearance", verifyToken, checkPermissions("updateOwn"), async (req, res) => {
    try {
      return res.json(await saveReportAppearance(req.params.id, req.body));
    } catch (error) {
      return res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : "The appearance could not be saved. Try again." });
    }
  });

  app.put("/project/:id/layout", verifyToken, checkPermissions("updateOwn"), async (req, res) => {
    try {
      await saveDashboardLayout(req.params.id, req.body);
      return res.json(await projectController.findById(req.params.id));
    } catch (error) {
      return res.status(error.statusCode || 500).json({ message: error.statusCode ? error.message : "Could not save the layout. Try again." });
    }
  });

  /*
  ** [MASTER] Route to get all the projects
  */
  app.get("/project", verifyToken, (req, res) => {
    if (!req.user.admin) {
      return res.status(401).send({ error: "Not authorized" });
    }

    return projectController.findAll()
      .then((projects) => {
        return res.status(200).send(projects);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -----------------------------------------

  /*
  ** Route to create a project
  */
  app.post("/project", verifyToken, checkPermissions("createOwn"), (req, res) => {
    return projectController.create(req.user.id, req.body)
      .then((project) => {
        return res.status(200).send(project);
      })
      .catch((error) => {
        if (error.message && error.message.indexOf("401") > -1) {
          return res.status(401).send({ error: "Not authorized" });
        }
        return res.status(400).send(error);
      });
  });
  // -----------------------------------------

  /*
  ** Route to get a project by ID
  */
  app.get("/project/:id", verifyToken, checkPermissions("readOwn"), (req, res) => {
    return projectController.findById(req.params.id, {
      refreshPreparedData: true,
      includeDrafts: !viewerRoles.includes(req.user.teamRole.role),
    })
      .then((project) => {
        return res.status(200).send(project);
      })
      .catch((error) => {
        if (error.message === "401") {
          return res.status(401).send({ error: "Not authorized" });
        }
        if (error.message === "404") {
          return res.status(404).send({ error: "Not Found" });
        }
        return res.status(400).send(error);
      });
  });
  // -----------------------------------------

  /*
  ** Route to update a project ID
  */
  app.put("/project/:id", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    return projectController.update(req.params.id, req.body)
      .then((project) => {
        return res.status(200).send(project);
      })
      .catch((error) => {
        if (error.message === "401") {
          return res.status(401).send({ error: "Not authorized" });
        }
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to update a project's Logo
  */
  app.post("/project/:id/logo", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    if (!req.busboy) {
      return res.status(400).send({ error: "Upload stream is missing" });
    }

    const parseUpload = () => {
      return new Promise((resolve, reject) => {
        let hasFile = false;
        let fileCount = 0;
        let settled = false;
        const uploadDirectory = path.resolve(__dirname, "../uploads");

        const fail = (status, message) => {
          if (settled) return;
          settled = true;
          reject({ status, message });
        };

        const succeed = (value) => {
          if (settled) return;
          settled = true;
          resolve(value);
        };

        req.pipe(req.busboy);

        req.busboy.on("file", (fieldName, file, info) => {
          fileCount += 1;
          if (fileCount > 1) {
            file.resume();
            fail(400, "Only one logo file can be uploaded");
            return;
          }

          hasFile = true;
          const mimeType = `${info?.mimeType || ""}`.toLowerCase();
          if (!isAllowedLogoMimeType(mimeType)) {
            file.resume();
            fail(415, "Unsupported file type. Only png, jpg, gif, webp, and svg are allowed");
            return;
          }

          const chunks = [];
          let totalSize = 0;

          file.on("data", (chunk) => {
            if (settled) return;

            totalSize += chunk.length;
            if (totalSize > MAX_LOGO_UPLOAD_SIZE_BYTES) {
              file.resume();
              fail(413, "Logo exceeds maximum size (5MB)");
              return;
            }

            chunks.push(chunk);
          });

          file.on("error", () => {
            fail(400, "Could not process uploaded file");
          });

          file.on("end", async () => {
            if (settled) return;

            const payload = Buffer.concat(chunks);
            if (payload.length === 0) {
              fail(400, "No file uploaded");
              return;
            }

            if (!isValidLogoImageBuffer(payload, mimeType)) {
              fail(415, "Invalid image content");
              return;
            }

            const fileName = buildSafeLogoFilename(mimeType, () => nanoid(18));
            const uploadPath = resolveSafeUploadPath(uploadDirectory, fileName);
            if (!fileName || !uploadPath) {
              fail(400, "Could not determine safe upload path");
              return;
            }

            try {
              await fs.promises.writeFile(uploadPath, payload, { flag: "wx" });
              succeed(`uploads/${fileName}`);
            } catch (error) {
              fail(400, "Could not save uploaded file");
            }
          });
        });

        req.busboy.on("error", () => {
          fail(400, "Could not parse upload request");
        });

        req.busboy.on("finish", () => {
          if (!settled && !hasFile) {
            fail(400, "No file uploaded");
          }
        });
      });
    };

    return parseUpload()
      .then((logoPath) => {
        return projectController.update(req.params.id, { logo: logoPath });
      })
      .then((project) => {
        return res.status(200).send(project);
      })
      .catch((error) => {
        if (error?.status) {
          return res.status(error.status).send({ error: error.message });
        }
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to remove a project
  */
  app.delete("/project/:id", verifyToken, checkPermissions("deleteAny"), (req, res) => {
    return projectController.remove(req.params.id, req.user.id)
      .then(() => {
        return res.status(200).send({ removed: true });
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route return a list of projects within a team
  */
  app.get("/project/team/:team_id", verifyToken, checkPermissions("readOwn"), (req, res) => {
    return projectController.getTeamProjects(req.params.team_id)
      .then((projects) => {
        let filteredProjects = projects;
        if (req.user.projects) {
          filteredProjects = projects.filter((o) => {
            return req.user.projects.includes(o.id) || o.ghost;
          });
        }

        return res.status(200).send(filteredProjects);
      })
      .catch((error) => {
        if (error.message === "401") {
          return res.status(401).send({ error: "Not authorized" });
        }
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to get a project with a public dashboard
  */
  app.get("/project/dashboard/:brewName", apiLimiter(5, 15), getUserFromToken, async (req, res) => {
    let processedProject;
    try {
      const project = await projectController.getPublicDashboard(req.params.brewName);
      processedProject = _.cloneDeep(project);
      processedProject.setDataValue("password", "");

      if (req.user) {
        // now determine whether to show the dashboard or not
        const teamRole = await teamController.getTeamRole(project.team_id, req.user.id);
        const hasProjectAccess = teamRole?.projects?.some((projectId) => `${projectId}` === `${project.id}`);

        if (
          teamRole
          && (
            teamRoles.includes(teamRole.role)
            || hasProjectAccess
          )
        ) {
          const urlVariables = projectController._extractVariablesFromQuery(req.query);
          if (Object.keys(urlVariables).length > 0) {
            try {
              const updatedProject = await projectController.applyVariablesToCharts(processedProject, urlVariables);
              return res.status(200).send(updatedProject);
            } catch (error) {
              // oxlint-disable-next-line no-console
              console.error("Failed to apply variables to dashboard:", error);
            }
          }

          return res.status(200).send(processedProject);
        }
      }

      // LEGACY/INTERNAL: Check for accessToken first (bypasses SharePolicy for snapshots/internal)
      if (req.query.accessToken) {
        try {
          const decodedToken = jwt.verify(req.query.accessToken, settings.encryptionKey);
          if (decodedToken.project_id === project.id) {
            // Handle variables for legacy accessToken - not implemented yet
            const urlVariables = projectController._extractVariablesFromQuery(req.query);
            if (Object.keys(urlVariables).length > 0) {
              try {
                const updatedProject = await projectController
                  .applyVariablesToCharts(processedProject, urlVariables);
                return res.status(200).send(updatedProject);
              } catch (error) {
                // If variable application fails, return the project without variables
                // oxlint-disable-next-line no-console
                console.error("Failed to apply variables to dashboard:", error);
              }
            }

            return res.status(200).send(processedProject);
          }
        } catch (error) {
          // Token is invalid, continue with SharePolicy flow
        }
      }

      // Check if there's a SharePolicy for this project
      const sharePolicy = await db.SharePolicy.findOne({
        where: {
          entity_type: "Project",
          entity_id: project.id,
        },
      });

      // If SharePolicy exists, check the policy
      if (sharePolicy) {
        if (sharePolicy.visibility === "disabled") {
          return res.status(403).send("Share policy is disabled");
        }

        // SECURITY: SharePolicy tokens only work for public projects
        if (!project.public) {
          return res.status(401).send("Not authorized to access this page");
        }

        if (sharePolicy.visibility !== "public") {
          // SharePolicy exists and requires token verification
          if (!req.query.token) {
            const isPasswordCorrect = await verifyProjectPassword(getProjectPasswordInput(req), project.password);
            if (project.public
              && project.passwordProtected
              && isPasswordCorrect
            ) {
              // Allow password access for backwards compatibility if no token provided
              return res.status(200).send(processedProject);
            }
            return res.status(401).send("Access token required");
          }

          try {
            const decodedToken = verifyShareToken(req.query.token);
            validateShareTokenPolicy(decodedToken, sharePolicy, "Project", project.id);

            // SECURITY: If dashboard is password protected, require password even with valid token
            if (project.passwordProtected) {
              const isPasswordCorrect = await verifyProjectPassword(getProjectPasswordInput(req), project.password);
              if (!isPasswordCorrect) {
                return res.status(403).send("Enter the correct password");
              }
            }

            // Handle variable filtering based on share policy
            const urlVariables = projectController
              ._extractVariablesFromQuery(req.query);
            const finalVariables = projectController
              ._mergeVariablesWithPolicy(urlVariables, sharePolicy);

            // Apply variables to the charts if needed
            if (Object.keys(finalVariables).length > 0) {
              try {
                const updatedProject = await projectController
                  .applyVariablesToCharts(processedProject, finalVariables);
                return res.status(200).send(updatedProject);
              } catch (error) {
                // If variable application fails, return the project without variables
                // oxlint-disable-next-line no-console
                console.error("Failed to apply variables to dashboard:", error);
                return res.status(200).send(processedProject);
              }
            }

            return res.status(200).send(processedProject);
          } catch (tokenError) {
            return res.status(401).send("Invalid or expired token");
          }
        }
      }

      // Handle variables for projects without SharePolicy (backwards compatibility)
      // SECURITY: URL variables only processed for public projects
      if (project.public) {
        const urlVariables = projectController._extractVariablesFromQuery(req.query);
        if (Object.keys(urlVariables).length > 0) {
          // SECURITY: If dashboard is password protected, require password for URL variables
          if (project.passwordProtected) {
            const isPasswordCorrect = await verifyProjectPassword(getProjectPasswordInput(req), project.password);
            if (!isPasswordCorrect) {
              return res.status(403).send("Enter the correct password");
            }
          }

          try {
            const updatedProject = await projectController
              .applyVariablesToCharts(processedProject, urlVariables);
            return res.status(200).send(updatedProject);
          } catch (error) {
            // If variable application fails, return the project without variables
            // oxlint-disable-next-line no-console
            console.error("Failed to apply variables to dashboard:", error);
          }
        }
      }

      if (project.public && !project.passwordProtected) {
        return res.status(200).send(processedProject);
      }

      const isPasswordCorrect = await verifyProjectPassword(getProjectPasswordInput(req), project.password);
      if (project.public && project.passwordProtected && isPasswordCorrect) {
        return res.status(200).send(processedProject);
      }

      if (project.public && project.passwordProtected && !isPasswordCorrect) {
        return res.status(403).send("Enter the correct password");
      }

      if (!project.public) return res.status(401).send("Not authorized to access this page");

      return res.status(400).send("Cannot get the data");
    } catch (error) {
      if (error && error.message === "404") {
        return res.status(404).send(error);
      }
      return res.status(400).send(error);
    }
  });
  // -------------------------------------------

  /*
  ** Route to get a project with a share policy
  */
  app.get("/project/:brew_name/report", apiLimiter(5, 15), getUserFromToken, async (req, res) => {
    return projectController.findBySharePolicy(
      req.params.brew_name, req.headers.pass, req.query, req.user,
    )
      .then((processedProject) => {
        return res.status(200).send(processedProject);
      })
      .catch((error) => {
        const errorMessage = error?.message || `${error}`;
        if (
          errorMessage.indexOf("401") > -1
          || errorMessage === "Invalid share token"
          || errorMessage === "Share policy has expired"
          || error?.name === "JsonWebTokenError"
          || error?.name === "TokenExpiredError"
        ) {
          return res.status(401).send({ error: "Not authorized" });
        }
        if (errorMessage === "Share policy is disabled") {
          return res.status(403).send({ error: "Not authorized" });
        }
        if (errorMessage.indexOf("403") > -1) {
          return res.status(403).send({ error: "Enter the correct password" });
        }
        if (errorMessage.indexOf("413") > -1) {
          return res.status(413).send(error);
        }
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to generate a dashboard template
  */
  app.post("/project/:id/template/:template", verifyToken, checkPermissions("createAny"), (req, res) => {
    return projectController.generateTemplate(
      req.params.id,
      req.body,
      req.params.template,
    )
      .then((result) => {
        return res.status(200).send(result);
      })
      .catch((err) => {
        if (err && err.message && `${err.message}`.indexOf("404") > -1) {
          return res.status(404).send(err);
        }
        if (err && err.message && `${err.message}`.indexOf("403") > -1) {
          return res.status(403).send(err);
        }
        return res.status(400).send(err);
      });
  });
  // -------------------------------------------

  /*
  ** Route to get a project's variables
  */
  app.get("/project/:id/variables", verifyToken, checkPermissions("readOwn"), (req, res) => {
    return projectController.getVariables(req.params.id)
      .then((variables) => {
        return res.status(200).send(variables);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to create a project variable
  */
  app.post("/project/:id/variables", verifyToken, checkPermissions("createOwn"), (req, res) => {
    return projectController.createVariable(req.params.id, req.body)
      .then((variable) => {
        return res.status(200).send(variable);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to update a project variable
  */
  app.put("/project/:id/variables/:variableId", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    return projectController.updateVariable(req.params.id, req.params.variableId, req.body)
      .then((variable) => {
        if (!variable) return res.status(404).send({ error: "Variable not found" });
        return res.status(200).send(variable);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });

  /*
  ** Route to delete a project variable
  */
  app.delete("/project/:id/variables/:variableId", verifyToken, checkPermissions("deleteOwn"), (req, res) => {
    return projectController.deleteVariable(req.params.id, req.params.variableId)
      .then((removed) => {
        if (!removed) return res.status(404).send({ error: "Variable not found" });
        return res.status(200).send({ removed: true });
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to take a snapshot of a project
  */
  app.post("/project/:id/snapshot", verifyToken, checkPermissions("readOwn"), (req, res) => {
    return projectController.takeSnapshot(req.params.id, req.body)
      .then((snapshot) => {
        return res.status(200).send({ snapshot_path: snapshot });
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to create a dashboard filter
  */
  app.post("/project/:id/dashboard-filter", verifyToken, checkPermissions("createOwn"), (req, res) => {
    return projectController.createDashboardFilter(req.params.id, req.body)
      .then((dashboardFilter) => {
        return res.status(200).send(dashboardFilter);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to get a dashboard filter
  */
  app.get("/project/:id/dashboard-filter/:dashboardFilterId", verifyToken, checkPermissions("readOwn"), (req, res) => {
    return projectController.getDashboardFilter(req.params.id, req.params.dashboardFilterId)
      .then((dashboardFilter) => {
        if (!dashboardFilter) return res.status(404).send({ error: "Dashboard filter not found" });
        return res.status(200).send(dashboardFilter);
      });
  });
  // -------------------------------------------

  /*
  ** Route to get all dashboard filters
  */
  app.get("/project/:id/dashboard-filters", verifyToken, checkPermissions("readOwn"), (req, res) => {
    return projectController.getDashboardFilters(req.params.id)
      .then((dashboardFilters) => {
        return res.status(200).send(dashboardFilters);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to reorder all shared dashboard filters
  */
  app.put("/project/:id/dashboard-filters/order", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    return projectController.reorderDashboardFilters(req.params.id, req.body?.filterIds)
      .then((dashboardFilters) => {
        return res.status(200).send(dashboardFilters);
      })
      .catch((error) => {
        return res.status(400).send({ error: error.message });
      });
  });
  // -------------------------------------------

  /*
  ** Route to update a dashboard filter
  */
  app.put("/project/:id/dashboard-filter/:dashboardFilterId", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    return projectController.updateDashboardFilter(req.params.id, req.params.dashboardFilterId, req.body)
      .then((dashboardFilter) => {
        if (!dashboardFilter) return res.status(404).send({ error: "Dashboard filter not found" });
        return res.status(200).send(dashboardFilter);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to delete a dashboard filter
  */
  app.delete("/project/:id/dashboard-filter/:dashboardFilterId", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    return projectController.deleteDashboardFilter(req.params.id, req.params.dashboardFilterId)
      .then((removed) => {
        if (!removed) return res.status(404).send({ error: "Dashboard filter not found" });
        return res.status(200).send({ removed: true });
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to create a project share policy
  */
  app.post("/project/:id/share/policy", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    return projectController.createSharePolicy(req.params.id)
      .then((sharePolicy) => {
        return res.status(200).send(sharePolicy);
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to generate a project share token
  */
  app.post("/project/:id/share/token", verifyToken, checkPermissions("updateOwn"), (req, res) => {
    return projectController.generateShareToken(req.params.id, req.body)
      .then(({ token, url, sharePolicy }) => {
        return res.status(200).send({ token, url, sharePolicy });
      })
      .catch((error) => {
        return res.status(400).send(error);
      });
  });
  // -------------------------------------------

  /*
  ** Route to get all share policies for a project
  */
  app.get("/project/:id/share/policy", verifyToken, checkPermissions("readAny"), async (req, res) => {
    try {
      const policies = await SharePolicyController.findByEntityId("Project", req.params.id);
      return res.status(200).send(policies);
    } catch (error) {
      return res.status(400).send(error);
    }
  });
  // -------------------------------------------

  /*
  ** Route to update a project share policy
  */
  app.put("/project/:id/share/policy/:policy_id", verifyToken, checkPermissions("updateOwn"), async (req, res) => {
    try {
      const updatedPolicy = await SharePolicyController.updateSharePolicy(
        req.params.policy_id,
        req.body,
        "Project",
        req.params.id
      );
      return res.status(200).send(updatedPolicy);
    } catch (error) {
      if (error?.message === "404") {
        return res.status(404).send({ error: "Share policy not found" });
      }
      return res.status(400).send(error);
    }
  });
  // -------------------------------------------

  /*
  ** Route to delete a project share policy
  */
  app.delete("/project/:id/share/policy/:policy_id", verifyToken, checkPermissions("updateOwn"), async (req, res) => {
    try {
      await SharePolicyController.deleteSharePolicy(req.params.policy_id, "Project", req.params.id);
      return res.status(200).send({ deleted: true });
    } catch (error) {
      if (error?.message === "404") {
        return res.status(404).send({ error: "Share policy not found" });
      }
      return res.status(400).send(error);
    }
  });
  // -------------------------------------------

  return (req, res, next) => {
    next();
  };
};