Skip to content
Merged

Dev #1104

555 changes: 555 additions & 0 deletions api/package-lock.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"@emnapi/runtime": "1.9.1",
"@emnapi/wasi-threads": "1.2.0",
"@wordpress/block-serialization-default-parser": "^5.46.0",
"adm-zip": "^0.5.17",
"archiver": "^8.0.0",
"axios": "^1.16.0",
"cheerio": "^1.2.0",
"chokidar": "^3.6.0",
Expand All @@ -57,6 +59,7 @@
"lodash": "^4.18.1",
"lowdb": "^7.0.1",
"mkdirp": "^3.0.1",
"multer": "^2.2.0",
"mysql2": "^3.16.2",
"p-limit": "^6.2.0",
"php-serialize": "^5.1.3",
Expand All @@ -65,6 +68,8 @@
"winston": "^3.11.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.8",
"@types/archiver": "^8.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.2",
Expand All @@ -73,6 +78,7 @@
"@types/jsdom": "^21.1.7",
"@types/jsonwebtoken": "^9.0.5",
"@types/lodash": "^4.17.0",
"@types/multer": "^2.1.0",
"@types/node": "^20.10.4",
"@types/supertest": "^6.0.3",
"@types/wordpress__block-library": "^2.6.3",
Expand Down Expand Up @@ -106,4 +112,4 @@
"npm": ">=11.17.0"
},
"keywords": []
}
}
64 changes: 64 additions & 0 deletions api/src/controllers/projects.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Request, Response } from "express";
import fs from "node:fs";
import { ZipArchive } from "archiver";
import { projectService } from "../services/projects.service.js";

/**
Expand All @@ -25,6 +27,66 @@ const getProject = async (req: Request, res: Response): Promise<void> => {
res.status(200).json(project);
};

/**
* Exports a project as a zip archive containing the project record and its
* on-disk database folder (content type mappers, field mappers, etc.).
*
* The archive is structured as:
* <projectId>/project.json -> the project record
* <projectId>/<iteration>/*.json -> the mapper stores, mirrored as stored
*
* @param req - The request object.
* @param res - The response object.
* @returns A Promise that resolves to void.
*/
const exportProject = async (req: Request, res: Response): Promise<void> => {
const { project, databasePath } = await projectService.exportProject(req);
const projectId = project?.id;

res.setHeader("Content-Type", "application/zip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${projectId}.zip"`
);

const archive = new ZipArchive({ zlib: { level: 9 } });

// If archiving fails after streaming has begun we can no longer change the
// status code, so just tear the connection down.
archive.on("error", (err: Error) => {
res.destroy(err);
});

archive.pipe(res);

// The project record itself.
archive.append(JSON.stringify(project, null, 2), {
name: `${projectId}/project.json`,
});

// The mapper stores, mirrored under the same folder. The folder may not
// exist yet if no mapping has been done β€” that's fine, we still export the
// project record on its own.
if (fs.existsSync(databasePath)) {
archive.directory(databasePath, projectId);
}

await archive.finalize();
};

/**
* Imports a project from an uploaded zip archive and returns the newly
* created project.
*
* @param req - The request object (expects a multipart `file` field).
* @param res - The response object.
* @returns A Promise that resolves to void.
*/
const importProject = async (req: Request, res: Response): Promise<void> => {
const result = await projectService.importProject(req);
res.status(201).json(result);
};

/**
* Creates a new project.
*
Expand Down Expand Up @@ -179,6 +241,8 @@ const getMigratedStacks = async (req: Request, res: Response): Promise<void> =>
export const projectController = {
getAllProjects,
getProject,
exportProject,
importProject,
createProject,
updateProject,
updateLegacyCMS,
Expand Down
33 changes: 33 additions & 0 deletions api/src/routes/projects.routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from "express";
import multer from "multer";
import { projectController } from "../controllers/projects.controller.js";
import { asyncRouter } from "../utils/async-router.utils.js";
import validator from "../validators/index.js";
Expand All @@ -8,12 +9,44 @@ import validator from "../validators/index.js";
*/
const router = express.Router({ mergeParams: true });

// In-memory upload handling for project import zips (capped at 100 MB).
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 },
});

// GET all projects route
router.get("/", asyncRouter(projectController.getAllProjects));

// GET a single project route
router.get("/:projectId", asyncRouter(projectController.getProject));

// Export a project (project record + mapper stores) as a zip archive
router.get("/:projectId/export", asyncRouter(projectController.exportProject));

// Import a project from an exported zip archive.
//
// `authenticateUser` is mounted on this router in server.ts
// (`app.use('/v2/org/:orgId/project', authenticateUser, projectRoutes)`), so it
// always runs before these handlers and sets `req.body.token_payload`. multer
// then replaces `req.body` with the parsed multipart fields, wiping it. We move
// the payload onto `req` (which multer never touches) before multer runs, then
// restore it onto `req.body` afterwards so the controller/service still see it.
router.post(
"/import",
(req, _res, next) => {
(req as any).tokenPayload = (req as any)?.body?.token_payload;
next();
},
upload.single("file"),
(req, _res, next) => {
(req as any).body = (req as any).body || {};
(req as any).body.token_payload = (req as any).tokenPayload;
next();
},
asyncRouter(projectController.importProject)
);

// Create a new project route
router.post("/", asyncRouter(projectController.createProject));

Expand Down
Loading
Loading