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(); }; };