diff --git a/README.md b/README.md index 7312fef..56a1b65 100644 --- a/README.md +++ b/README.md @@ -1,21 +1 @@ -# Base-REST-API - -A robust, modular, and secure REST API boilerplate built with **Node.js** and **Express**. -It features a **recursive file loader** for routes and schemas, along with a powerful validation middleware using **Zod**. - -## 🚀 Features - -- **Automated Loading**: Recursively loads routes and validation schemas from the file system. -- **Strict Validation**: Request bodies and query parameters are validated using [Zod](https://zod.dev/) before reaching the controller. -- **Clean Architecture**: Separation of concerns with `Routes` (HTTP layer), `Services` (Business logic), and `Schemas` (Validation). -- **Security First**: Inputs are stripped of unknown fields automatically. -- **Custom Logger**: Integrated color-coded logging system for development and file logging for production. -- **Error Handling**: Standardized JSON error responses. - -## 📦 Installation - -1. **Clone the repository** - ```bash - git clone https://gitea.azures.fr/azures04/Base-REST-API.git - cd Base-REST-API - ``` \ No newline at end of file +# Brick.WebService \ No newline at end of file diff --git a/errors/DefaultError.js b/errors/DefaultError.js index 4d8d197..37f92b4 100644 --- a/errors/DefaultError.js +++ b/errors/DefaultError.js @@ -13,7 +13,9 @@ class DefaultError extends Error { serialize() { return { code: this.code, - message: this.message + name: this.name, + cause: this.cause, + message: this.message, } } } diff --git a/routes/products/[productName]/[productPlatform]/download.js b/routes/products/[productName]/[productPlatform]/download.js new file mode 100644 index 0000000..d649a84 --- /dev/null +++ b/routes/products/[productName]/[productPlatform]/download.js @@ -0,0 +1,12 @@ +const express = require("express") +const productsFileService = require("../../../../services/productsFileService") +const router = express.Router({ mergeParams: true }) + +router.get(/.*/, async (req, res, next) => { + const { productName, productPlatform } = req.params + await productsFileService.canAccess(productName, productPlatform) + const file = await productsFileService.getFile(req.url, productName, productPlatform) + return res.status(200).download(file.path) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/products/[productName]/[productPlatform]/manifest.js b/routes/products/[productName]/[productPlatform]/manifest.js new file mode 100644 index 0000000..414c45d --- /dev/null +++ b/routes/products/[productName]/[productPlatform]/manifest.js @@ -0,0 +1,12 @@ +const express = require("express") +const productsFileService = require("../../../../services/productsFileService") +const router = express.Router({ mergeParams: true }) + +router.get("/", async (req, res) => { + const { productName, productPlatform } = req.params + await productsFileService.canAccess(productName, productPlatform) + const files = await productsFileService.getProductFiles(productName, productPlatform) + return res.status(200).json(files) +}) + +module.exports = router \ No newline at end of file diff --git a/schemas/register.js b/schemas/register.js deleted file mode 100644 index cffb6ab..0000000 --- a/schemas/register.js +++ /dev/null @@ -1,25 +0,0 @@ -const z = require("zod") - -module.exports = { - POST: { - headers: z.object({ - "content-type": z.string().regex(/application\/json/i).optional() - }), - body: z.object({ - email: z.string() - .email({ message: "Invalid E-Mail format." }) - .toLowerCase(), - username: z.string() - .min(3, { message: "The username must be at least 3 characters long." }) - .max(16, { message: "The username must be no longer than 16 characters." }), - password: z.string() - .min(8, { message: "The password must be at least 8 characters long." }) - .regex(/[A-Z]/, { message: "The password must contain a capital letter." }) - .regex(/[0-9]/, { message: "The password must contain a number." }), - }), - error: { - code: 422, - message: "Invalid request data" - } - } -} \ No newline at end of file diff --git a/schemas/users/[id].js b/schemas/users/[id].js deleted file mode 100644 index 74f95fc..0000000 --- a/schemas/users/[id].js +++ /dev/null @@ -1,15 +0,0 @@ -const z = require("zod") - -module.exports = { - GET: { - headers: z.object({ - authorization: z.string() - .startsWith("Bearer ", { message: "Token d'authentification manquant ou invalide." }), - "content-type": z.string().regex(/application\/json/i).optional() - }), - error: { - code: 422, - message: "Invalid request data" - } - } -} \ No newline at end of file diff --git a/server.js b/server.js index dcbfb35..25b58db 100644 --- a/server.js +++ b/server.js @@ -72,12 +72,18 @@ app.all(/.*/, (req, res, next) => { req.params = { ...req.params, ...matchedParams } const methodConfig = schemaConfig[req.method] - const errorConfig = methodConfig.error || { status: 400, message: "Validation Error" } + const errorConfig = methodConfig.error || { code: 400, message: "Validation Error" } + + const context = { + method: req.method, + path: req.originalUrl, + ip: req.headers["x-forwarded-for"] || req.socket.remoteAddress + } if (methodConfig.headers) { const headerResult = methodConfig.headers.safeParse(req.headers) if (!headerResult.success) { - return utils.sendValidationError(req, res, headerResult, "headers", currentPath, errorConfig) + throw new ValidationError(headerResult, errorConfig, context) } } @@ -97,7 +103,7 @@ app.all(/.*/, (req, res, next) => { if (dataSchema) { const result = dataSchema.safeParse(dataToValidate) if (!result.success) { - return utils.sendValidationError(req, res, result, validationType, currentPath, errorConfig) + throw new ValidationError(result, errorConfig, context) } if (validationType === "query") { req.query = result.data @@ -117,13 +123,16 @@ for (const route of routes) { for (const layer of router.stack) { if (layer.route && layer.route.methods) { const method = Object.keys(layer.route.methods).join(", ").toUpperCase() - const subPath = routePath === "/" ? "" : routePath - logger.log(`${method.cyan} ${subPath.cyan.bold} route registered`, ["WEB", "yellow"]) + const innerPath = layer.route.path === "/" ? "" : layer.route.path + const mountPrefix = routePath === "/" ? "" : routePath + const fullDisplayPath = mountPrefix + innerPath + logger.log(`${method.cyan} ${fullDisplayPath.cyan.bold} route registered`, ["WEB", "yellow"]) } } } app.use(routePath, router) } catch (error) { + logger.error(route, ["WEB", "yellow"]) logger.error(error.toString(), ["WEB", "yellow"]) } } @@ -133,8 +142,9 @@ app.all(/.*/, (req, res, next) => { }) app.use((err, req, res, next) => { - const statusCode = err.statusCode || 500 + const statusCode = err.statusCode || err.code || 500 + logger.error(`Error occured on: ${req.originalUrl.bold}`, ["API", "red"]) logger.error(err.message, ["API", "red"]) if (typeof err.serialize === "function") { diff --git a/services/productsFileService.js b/services/productsFileService.js index c0b10c6..92a16b9 100644 --- a/services/productsFileService.js +++ b/services/productsFileService.js @@ -3,7 +3,16 @@ const path = require("node:path") const crypto = require("node:crypto") const { DefaultError } = require("../errors/errors") const { pipeline } = require("node:stream/promises") -const gameDataPath = path.join(process.cwd(), "data", "products") +const productsDataPath = path.join(process.cwd(), "data", "products") + +fs.promises.exists = async function exists(path) { + try { + await fs.promises.access(path) + return true + } catch { + return false + } +}; function normalizePath($path) { return $path.split(path.win32.sep).join(path.posix.sep) @@ -21,13 +30,15 @@ async function getFileSha1(filePath) { } } -async function getGameFiles() { - const files = await fs.promises.readdir(gameDataPath, { recursive: true }) - const gameFilesIndex = { root: [] } +async function getProductFiles(productName, platform) { + const finalPath = path.join(productsDataPath, productName, platform) + const files = await fs.promises.readdir(finalPath, { recursive: true }) + const productFilesIndex = { root: [] } for (const file of files) { - const filePath = path.join(gameDataPath, file) + const filePath = path.join(finalPath, file) const fileMetadata = await fs.promises.stat(filePath) - if (!fileMetadata.isDirectory()) { + + if (!fileMetadata.isDirectory() && !filePath.endsWith(".brikcfg")) { const normalizedFilePath = normalizePath(file) const artifact = { path: normalizedFilePath, @@ -41,15 +52,15 @@ async function getGameFiles() { }, name: path.parse(normalizedFilePath).base } - gameFilesIndex.root.push(downloadObject) + productFilesIndex.root.push(downloadObject) } } - return gameFilesIndex + return productFilesIndex } -async function getFile(basePath) { +async function getFile(basePath, productName, productPlatform) { try { - const fixedPath = path.join(gameDataPath, decodeURI(basePath)) + const fixedPath = path.join(productsDataPath, productName, productPlatform, decodeURI(basePath)) const fileMetadata = await fs.promises.stat(fixedPath) if (fileMetadata.isDirectory()) { throw new DefaultError(409, "Can't download a directory", "", "NotDownloadableException") @@ -65,8 +76,31 @@ async function getFile(basePath) { } } +async function canAccess(productName, productPlatform) { + const fixedPath = path.join(productsDataPath, productName, productPlatform, ".brikcfg") + const fileState = await fs.promises.exists(fixedPath, fs.constants.F_OK) + if (!fileState) { + return { code: 200 } + } + + try { + const brikConfig = JSON.parse(await fs.promises.readFile(fixedPath)) + if (!brikConfig.canAccess) { + throw new DefaultError(403, "Forbidden", "Locked resource.", "InsuffisentPrivilegeException") + } + return { code: 200 } + } catch (error) { + if (!error instanceof DefaultError) { + throw new DefaultError(500, "Please contact the maintener", "CONFIGURATION", "0x00") + } + throw error + } + +} + module.exports = { getFile, + canAccess, getFileSha1, - getGameFiles + getProductFiles } \ No newline at end of file