From d76f412b82118c609ee9c2052678c6985fc955fd Mon Sep 17 00:00:00 2001 From: Florian BOUILLON Date: Tue, 27 Jun 2023 18:30:44 +0200 Subject: [PATCH] feat: Add moer element Signed-off-by: Florian BOUILLON --- package-lock.json | 47 ++++++ package.json | 1 + src/content/config.ts | 15 ++ .../docs/errors/unauthorized-access.md | 23 +++ src/env.d.ts | 14 +- src/libs/FilesUtils.ts | 10 +- src/libs/RFCs/RFC7807.ts | 13 +- src/libs/RateLimiter.ts | 70 ++++++++ src/libs/ResponseBuilder.ts | 61 +++++++ src/libs/StatusCode.ts | 71 ++++++++ src/libs/gcodeUtils.ts | 33 +++- src/libs/validateAuth.ts | 36 ++-- src/middleware/apiAuth.ts | 20 +++ src/middleware/apiRateLimit.ts | 18 ++ src/middleware/index.ts | 7 + src/middleware/responseBuilder.ts | 9 + src/pages/api/{ => v1}/process/[configId].ts | 154 +++++++++++------- src/pages/docs/[...page].astro | 24 +++ tailwind.config.cjs | 4 +- 19 files changed, 540 insertions(+), 90 deletions(-) create mode 100644 src/content/config.ts create mode 100644 src/content/docs/errors/unauthorized-access.md create mode 100644 src/libs/RateLimiter.ts create mode 100644 src/libs/ResponseBuilder.ts create mode 100644 src/libs/StatusCode.ts create mode 100644 src/middleware/apiAuth.ts create mode 100644 src/middleware/apiRateLimit.ts create mode 100644 src/middleware/index.ts create mode 100644 src/middleware/responseBuilder.ts rename src/pages/api/{ => v1}/process/[configId].ts (58%) create mode 100644 src/pages/docs/[...page].astro diff --git a/package-lock.json b/package-lock.json index afe9015..705927e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "tailwindcss": "^3.3.2" }, "devDependencies": { + "@tailwindcss/typography": "^0.5.9", "@types/bcryptjs": "^2.4.2", "@types/jsonwebtoken": "^9.0.2", "@types/node": "^20.3.1", @@ -1083,6 +1084,34 @@ "escalade": "^3.1.1" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.9.tgz", + "integrity": "sha512-t8Sg3DyynFysV9f4JDOVISGsjazNb48AeIYQwcL+Bsq5uf4RYL75C1giZ43KISjeDGBaTN3Kxh7Xj/vRSMJUUg==", + "dev": true, + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@types/babel__core": { "version": "7.20.1", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", @@ -4786,6 +4815,24 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", diff --git a/package.json b/package.json index b260831..41a00f8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "tailwindcss": "^3.3.2" }, "devDependencies": { + "@tailwindcss/typography": "^0.5.9", "@types/bcryptjs": "^2.4.2", "@types/jsonwebtoken": "^9.0.2", "@types/node": "^20.3.1", diff --git a/src/content/config.ts b/src/content/config.ts new file mode 100644 index 0000000..f385343 --- /dev/null +++ b/src/content/config.ts @@ -0,0 +1,15 @@ +// 1. Import utilities from `astro:content` +import { defineCollection, z } from 'astro:content' + +// 2. Define your collection(s) +const docsCollection = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string() + }) +}) +// 3. Export a single `collections` object to register your collection(s) +// This key should match your collection directory name in "src/content" +export const collections = { + 'docs': docsCollection, +}; diff --git a/src/content/docs/errors/unauthorized-access.md b/src/content/docs/errors/unauthorized-access.md new file mode 100644 index 0000000..9151889 --- /dev/null +++ b/src/content/docs/errors/unauthorized-access.md @@ -0,0 +1,23 @@ +--- +title: 'Unauthorized Access Error' +--- + +# Unauthorized Access Error + +## Possible Errors + +### Permission Error + +You need to have an API key with the correct permission to use the specified resource + +### Missing API Key + +To have access to most of the API Urls you need to have n API key in your headers like the following + +```http +Authorization: Bearer 935b6640-25fe-402e-8b18-e60de88bc0da +``` + +### Cookies not found + +An alternative to the API Key is to have your cookies set, this is mostly for in browser and will only work while being connected on the website diff --git a/src/env.d.ts b/src/env.d.ts index 092db3c..486617c 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,7 +1,8 @@ +/// /// +/// interface ImportMetaEnv { - CONFIGS_PATH?: string PRUSASLICER_PATH?: string BAMBUSTUDIO_PATH?: string MONGODB?: string @@ -12,3 +13,14 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + + +declare namespace App { + interface Locals { + /** + * authentification key is the api key or the session token + */ + authKey?: string + responseBuilder?: ResponseBuilder + } +} diff --git a/src/libs/FilesUtils.ts b/src/libs/FilesUtils.ts index 7312e57..6660a33 100644 --- a/src/libs/FilesUtils.ts +++ b/src/libs/FilesUtils.ts @@ -1,6 +1,14 @@ import { promises as fs } from 'node:fs' +/** + * File manipulation utility class + */ export default class FilesUtils { + /** + * heck if a file/folder exists at the specified location + * @param path the path to check + * @returns if the file exists or not + */ public static async exists(path: string): Promise { try { await fs.stat(path) @@ -9,4 +17,4 @@ export default class FilesUtils { return false } } -} \ No newline at end of file +} diff --git a/src/libs/RFCs/RFC7807.ts b/src/libs/RFCs/RFC7807.ts index 0fd0887..c7cfb10 100644 --- a/src/libs/RFCs/RFC7807.ts +++ b/src/libs/RFCs/RFC7807.ts @@ -1,3 +1,4 @@ +import ResponseBuilder from '../ResponseBuilder' /** * Add headers: @@ -57,11 +58,9 @@ export default interface RFC7807 { * @param error the error (base items are type, status, title details and instance) * @returns */ -export function buildRFC7807(error: RFC7807 & Record): Response { - return new Response(JSON.stringify(error), { - headers: { - 'Content-Type': 'application/problem+json' - }, - status: error.status ?? 500 - }) +export function buildRFC7807(error: RFC7807 & Record, response: ResponseBuilder = new ResponseBuilder()): Response { + response.addHeader('Content-Type', 'application/problem+json') + .body(JSON.stringify(error)) + .status(error.status ?? 500) + return response.build() } diff --git a/src/libs/RateLimiter.ts b/src/libs/RateLimiter.ts new file mode 100644 index 0000000..3053d2f --- /dev/null +++ b/src/libs/RateLimiter.ts @@ -0,0 +1,70 @@ +import { objectLoop } from '@dzeio/object-util' +import { buildRFC7807 } from './RFCs/RFC7807' +import StatusCode from './StatusCode' + +interface StorageItem { + pointsRemaining: number + timeReset: number +} + +export interface RateLimitHeaders { + "Retry-After"?: string, + "X-RateLimit-Limit": string, + "X-RateLimit-Remaining": string, + "X-RateLimit-Reset": string +} + +export default class RateLimiter { + + /** + * number of points that can be used per {timeSpan} + */ + public static points = 10 + + /** + * timeSpan in seconds + */ + public static timeSpan = 600 + + private static instance: RateLimiter = new RateLimiter() + public static getInstance(): RateLimiter { + return this.instance + } + + private storage: Record = {} + + public consume(key: string, value: number = 1): Response | RateLimitHeaders { + let item = this.storage[key] + const now = (new Date().getTime() / 1000) + if (!item) { + item = { + pointsRemaining: RateLimiter.points, + timeReset: now + RateLimiter.timeSpan + } + } + if (item.timeReset <= now) { + item.timeReset = now + RateLimiter.timeSpan + item.pointsRemaining = RateLimiter.points + } + item.pointsRemaining -= value + this.storage[key] = item + const headers: RateLimitHeaders = { + "X-RateLimit-Limit": RateLimiter.points.toFixed(0), + "X-RateLimit-Remaining": Math.max(item.pointsRemaining, 0).toFixed(0), + "X-RateLimit-Reset": item.timeReset.toFixed(0) + } + if (item.pointsRemaining < 0) { + const resp = buildRFC7807({ + type: '/docs/error/rate-limited', + status: StatusCode.TOO_MANY_REQUESTS, + title: 'You are being rate limited as you have done too many requests to the server' + }) + resp.headers.append('Retry-After', (item.timeReset - now).toFixed(0)) + objectLoop(headers, (value, key) => { + resp.headers.append(key, value) + }) + return resp + } + return headers + } +} diff --git a/src/libs/ResponseBuilder.ts b/src/libs/ResponseBuilder.ts new file mode 100644 index 0000000..25dfe5e --- /dev/null +++ b/src/libs/ResponseBuilder.ts @@ -0,0 +1,61 @@ +import { objectLoop } from '@dzeio/object-util' + +/** + * Simple builde to create a new Response object + */ +export default class ResponseBuilder { + + private _body: BodyInit | null | undefined + public body(body: string | object | null | undefined) { + if (typeof body === 'object') { + this._body = JSON.stringify(body) + this.addHeader('Content-Type', 'application/json') + } else { + this._body = body + } + return this + } + + private _headers: Headers = new Headers() + public headers(headers: HeadersInit ) { + if (headers instanceof Headers) { + this._headers = headers + } else { + this._headers = new Headers(headers) + } + return this + } + + public addHeader(key: string, value: string) { + this._headers.append(key, value) + return this + } + + public addHeaders(headers: Record) { + objectLoop(headers, (value, key) => { + this.addHeader(key, value) + }) + return this + } + + public removeHeader(key: string) { + this._headers.delete(key) + return this + } + + private _status?: number + public status(status: number) { + this._status = status + return this + } + + public build(): Response { + const init: ResponseInit = { + headers: this._headers + } + if (this._status) { + init.status = this._status + } + return new Response(this._body, init) + } +} diff --git a/src/libs/StatusCode.ts b/src/libs/StatusCode.ts new file mode 100644 index 0000000..4bb9c46 --- /dev/null +++ b/src/libs/StatusCode.ts @@ -0,0 +1,71 @@ +enum StatusCode { + CONTINUE = 100, + SWITCHING_PROTOCOLS, + PROCESSING, + EARLY_HINTS, + + OK = 200, + CREATED, + ACCEPTED, + NON_AUTHORITATIVE_INFORMATION, + NO_CONTENT, + RESET_CONTENT, + PARTIAL_CONTENT, + MULTI_STATUS, + ALREADY_REPORTED, + IM_USED = 226, + + MULTIPLE_CHOICES = 300, + MOVED_PERMANENTLY, + FOUND, + SEE_OTHER, + NOT_MODIFIED, + USE_PROXY, + // UNUSED + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT, + + BAD_REQUEST = 400, + UNAUTHORIZED, + PAYMENT_REQUIRED, + FORBIDDEN, + NOT_FOUND, + METHOD_NOT_ALLOWED, + NOT_ACCEPTABLE, + PROXY_AUTHENTIFICATION_REQUIRED, + REQUEST_TIMEOUT, + CONFLICT, + GONE, + LENGTH_REQUIRED, + PRECONDITION_FAILED, + PAYLOAD_TOO_LARGE, + URI_TOO_LONG, + UNSUPPORTED_MEDIA_TYPE, + RANGE_NOT_SATISFIABLE, + EXPECTATION_FAILED, + IM_A_TEAPOT, + MIDIRECTED_REQUEST = 421, + UNPROCESSABLE_CONTENT, + LOCKED, + FAILED_DEPENDENCY, + TOO_EARLY, + UPGRADE_REQUIRED, + PRECONDITION_REQUIRED = 428, + TOO_MANY_REQUESTS, + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + UNAVAILABLE_OR_LEGAL_REASONS = 451, + + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED, + BAD_GATEWAY, + SERVICE_UNAVAILABLE, + GATEWAY_TIMEOUT, + HTTP_VERSION_NOT_SUPPORTED, + VARIANT_ALSO_NEGOTIATES, + INSUFFICIENT_STORAGE, + LOOP_DETECTED, + NOT_EXTENDED = 510, + NETWORK_AUTHENTIFICATION_REQUIRED, +} + +export default StatusCode diff --git a/src/libs/gcodeUtils.ts b/src/libs/gcodeUtils.ts index 65a49a2..2e476dc 100644 --- a/src/libs/gcodeUtils.ts +++ b/src/libs/gcodeUtils.ts @@ -1,3 +1,8 @@ +/** + * try to parse a GCode config string into a number + * @param str the string to try parsing + * @returns a number if parsing happened correctly or undefined + */ function parseNumber(str: string): number | undefined { if (!/^(\d|\.)+$/g.test(str)) { return undefined @@ -13,6 +18,11 @@ function parseNumber(str: string): number | undefined { return int } +/** + * decode a print time to a number of seconds + * @param text the text to decode + * @returns the number of seconds in the text + */ function decodeTime(text: string): number { let timeInSec = 0 for (const it of text.split(' ')) { @@ -39,23 +49,42 @@ function decodeTime(text: string): number { } export function getParams(data: string) { + // get the configuration lines const lines = data.split('\n').filter((it) => it.startsWith(';') && it.includes('=')) + // create the config object const obj: Record = {} + // loop through eacj config for (const line of lines) { + // get its key and value const [key, value] = line.split('=', 2).map((it) => it.slice(1).trim()) - let realKey = key.replace(/ /g, '_').replace(/\[|\]|\(|\)/g, '') + // sip if it has no key or value + if (!key || !value) { + continue + } + // process the key + let realKey = key + // replace spaces by _ + .replace(/ /g, '_') + // remove unparseable characters + .replace(/\[|\]|\(|\)/g, '') + // process the value const realValue = parseNumber(value) ?? value + // add an offset if the key is already cited let offset = 0 while (obj[`${realKey}${offset > 0 ? `_${offset}` : ''}`] && obj[`${realKey}${offset > 0 ? `_${offset}` : ''}`] !== realValue) { offset++ } + // chnge the key to add the offset if (offset > 0) { realKey = `${realKey}_${offset}` } + // detect key collisions if (obj[realKey] && obj[realKey] !== realValue) { throw new Error(`Key collision ${key}=${realValue} ${realKey}=${obj[realKey]}`) } - obj[realKey] = parseNumber(value) ?? value + // set the value to the key + obj[realKey] = realValue + // transform the time to a number of seconds if (realKey === 'estimated_printing_time_normal_mode') { obj['estimated_printing_time_seconds'] = decodeTime(value) } diff --git a/src/libs/validateAuth.ts b/src/libs/validateAuth.ts index 160077e..6b854a2 100644 --- a/src/libs/validateAuth.ts +++ b/src/libs/validateAuth.ts @@ -25,13 +25,13 @@ interface Permission { * * TODO: implement rate limiting * http/2.0 429 TOO MANY REQUESTS - * Content-Type: application/json - * X-RateLimit-Limit: 1000 - * X-RateLimit-Remaining: 0 - * X-RateLimit-Reset: 123456789 - * X-RateLimit-Reset-After: 9 // Number of seconds before the remaining Rate go back to 0 + * Content-Type: application/json+problem + * X-RateLimit-Limit: 1000 // number of request you cn make until hitting the rate limit + * X-RateLimit-Remaining: 0 // number of request remaining until the rate limit is atteined + * X-RateLimit-Reset: 123456789 // EPOCH time when the rate limit reset + * X-RateLimit-Reset-After: 9 // Number of seconds before the remaining Rate reset */ -export async function validateAuth(request: Request, permission: Permission): Promise { +export async function validateAuth(request: Request, permission: Permission): Promise { const apiKeyHeader = request.headers.get('Authorization') const cookieHeader = request.headers.get('Cookie') if (apiKeyHeader) { @@ -58,10 +58,10 @@ export async function validateAuth(request: Request, permission: Permission): Pr // return it.endsWith(permission.name) }) if (match && (permission.api || match.startsWith('admin.'))) { - return null + return apiKey } else if (permission.api) { return buildRFC7807({ - type: '/unauthorized-access', + type: '/docs/errors/unauthorized-access', status: 401, title: 'Unauthorized access', details: `you are missing the permission "${permission.name}" or is not an admin` @@ -69,7 +69,7 @@ export async function validateAuth(request: Request, permission: Permission): Pr } } else if (permission.api && !permission.cookie) { return buildRFC7807({ - type: '/unauthorized-access', + type: '/docs/errors/unauthorized-access', status: 401, title: 'Unauthorized access', details: `You MUST define an API key fo use this endpoint` @@ -82,7 +82,7 @@ export async function validateAuth(request: Request, permission: Permission): Pr const userCookie = cookies.get('userId') if (!userCookie) { return buildRFC7807({ - type: '/unauthorized-access', + type: '/docs/errors/unauthorized-access', status: 401, title: 'Unauthorized access', details: `you must be connected to use this endpoint (missing the userId cookie)` @@ -91,33 +91,33 @@ export async function validateAuth(request: Request, permission: Permission): Pr const dao = await DaoFactory.get('user').get(userCookie) if (!dao) { return buildRFC7807({ - type: '/unauthorized-access', + type: '/docs/errors/unauthorized-access', status: 401, title: 'Unauthorized access', details: `the user does not exists` }) } - return null + return userCookie } else if (!permission.api && permission.cookie) { return buildRFC7807({ - type: '/unauthorized-access', + type: '/docs/errors/unauthorized-access', status: 401, title: 'Unauthorized access', details: `You MUST be connected to your account to use this endpoint` }) } else if (permission.api && permission.cookie) { return buildRFC7807({ - type: '/unauthorized-access', + type: '/docs/errors/unauthorized-access', status: 401, title: 'Unauthorized access', details: `You must be connected or use an API key to access this endpoint` }) } return buildRFC7807({ - type: '/unauthorized-access', - status: 401, - title: 'Unauthorized access', - details: `the following endpoint is not currently accessible by any means` + type: '/docs/errors/page-not-found', + status: 404, + title: 'Page not found', + details: `the following endpoint does not exists` }) } diff --git a/src/middleware/apiAuth.ts b/src/middleware/apiAuth.ts new file mode 100644 index 0000000..0d60795 --- /dev/null +++ b/src/middleware/apiAuth.ts @@ -0,0 +1,20 @@ +import { defineMiddleware } from "astro/middleware" +import { validateAuth } from '../libs/validateAuth' + +// `context` and `next` are automatically typed +export default defineMiddleware(async (context, next) => { + if (!context.request.url.includes('api')) { + return next() + } + const auth = await validateAuth(context.request, { + name: 'slicing.slice', + api: true, + cookie: true + }) + if (typeof auth === 'object') { + return auth + } + context.locals.authKey = auth + + return next() +}) diff --git a/src/middleware/apiRateLimit.ts b/src/middleware/apiRateLimit.ts new file mode 100644 index 0000000..9620e0e --- /dev/null +++ b/src/middleware/apiRateLimit.ts @@ -0,0 +1,18 @@ +import { defineMiddleware } from "astro/middleware" +import RateLimiter from '../libs/RateLimiter' + +// `context` and `next` are automatically typed +export default defineMiddleware(async ({ request, locals }, next) => { + if (!request.url.includes('api')) { + return next() + } + + const limit = RateLimiter.getInstance().consume(locals.authKey as string) + + if ('status' in limit) { + return limit + } + locals.responseBuilder.addHeaders(limit) + + return next() +}) diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..bb0b03b --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,7 @@ +import { sequence } from "astro/middleware" + +import apiAuth from './apiAuth' +import apiRateLimit from './apiRateLimit' +import responseBuilder from './responseBuilder' + +export const onRequest = sequence(responseBuilder, apiAuth, apiRateLimit) diff --git a/src/middleware/responseBuilder.ts b/src/middleware/responseBuilder.ts new file mode 100644 index 0000000..10bc260 --- /dev/null +++ b/src/middleware/responseBuilder.ts @@ -0,0 +1,9 @@ +import { defineMiddleware } from "astro/middleware" +import ResponseBuilder from '../libs/ResponseBuilder' + +// `context` and `next` are automatically typed +export default defineMiddleware(async (context, next) => { + context.locals.responseBuilder = new ResponseBuilder() + + return next() +}) diff --git a/src/pages/api/process/[configId].ts b/src/pages/api/v1/process/[configId].ts similarity index 58% rename from src/pages/api/process/[configId].ts rename to src/pages/api/v1/process/[configId].ts index edce7ac..30c2df3 100644 --- a/src/pages/api/process/[configId].ts +++ b/src/pages/api/v1/process/[configId].ts @@ -3,17 +3,23 @@ import { objectMap, objectOmit } from '@dzeio/object-util' import URLManager from '@dzeio/url-manager' import type { APIRoute } from 'astro' import { evaluate } from 'mathjs' -import { exec as execSync, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { promisify } from 'node:util' -import { buildRFC7807 } from '../../../libs/RFCs/RFC7807' -import { getParams } from '../../../libs/gcodeUtils' -import { validateAuth } from '../../../libs/validateAuth' -import DaoFactory from '../../../models/DaoFactory' +import { buildRFC7807 } from '../../../../libs/RFCs/RFC7807' +import RateLimiter from '../../../../libs/RateLimiter' +import ResponseBuilder from '../../../../libs/ResponseBuilder' +import StatusCode from '../../../../libs/StatusCode' +import { getParams } from '../../../../libs/gcodeUtils' +import { validateAuth } from '../../../../libs/validateAuth' +import DaoFactory from '../../../../models/DaoFactory' + +interface SliceError { + code: number + output: Array +} -const exec = promisify(execSync) let tmpDir: string @@ -23,15 +29,7 @@ let tmpDir: string * price: algorithm from settings * adionnal settings from https://manual.slic3r.org/advanced/command-line */ -export const post: APIRoute = async ({ params, request }) => { - const res = await validateAuth(request, { - name: 'slicing.slice', - api: true, - cookie: true - }) - if (res) { - return res - } +export const post: APIRoute = async ({ params, request, locals }) => { if (!tmpDir) { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'saas-')) } @@ -41,13 +39,24 @@ export const post: APIRoute = async ({ params, request }) => { if (!config) { return buildRFC7807({ - type: '/missing-config', - status: 404, + type: '/docs/errors/missing-config', + status: StatusCode.NOT_FOUND, title: 'The configuration does not exists', details: `The configuration ${configId} does not exists` }) } + const input = new Uint8Array(Buffer.from(await request.arrayBuffer())) + + if (input.length <= 0) { + return buildRFC7807({ + type: '/docs/errors/missing-input-file', + status: StatusCode.BAD_REQUEST, + title: 'You are missing the STL file', + details: `To process a file you need to input the file binary datas as the only body in your request` + }) + } + const query = new URLManager(request.url).query() const processId = (Math.random() * 1000000).toFixed(0) @@ -72,7 +81,7 @@ export const post: APIRoute = async ({ params, request }) => { logger.log('writing STL to filesystem') // write input - await fs.writeFile(stlPath, new Uint8Array(Buffer.from(await request.arrayBuffer())), { + await fs.writeFile(stlPath, input, { encoding: null }) @@ -85,87 +94,112 @@ export const post: APIRoute = async ({ params, request }) => { if (config.type === 'prusa' || true) { slicerPath = import.meta.env.PRUSASLICER_PATH ?? 'prusa-slicer' - additionnalParams += ' --export-gcode' + additionnalParams += ' --export-gcode --loglevel 4' slicerCommand = `${path.normalize(stlPath)} --load ${path.normalize(`${processFolder}/config.ini`)} --output ${path.normalize(gcodePath)} ${additionnalParams}` } + + // TODO: check if it does work on a linux environment + // TODO: Externalise IO for the different slicers try { logger.log('Running', slicerPath, slicerCommand) await new Promise((res, rej) => { - const slicer = spawn(slicerPath, slicerCommand.split(' ')) - slicer.stdout.on('data', (data) => { - logger.log('[stdout]',data.toString('utf8')) + const logs: Array = [] + const slicer = spawn(`"${slicerPath}"`, slicerCommand.split(' '), { + shell: true }) - slicer.stderr.on('data', (data: Buffer) => { - logger.log('[stderr]', data.toString('utf8')) + const log = (data: Buffer) => { + const line = `${data.toString('utf-8')}` + logger.log(line) + logs.push(line) + } + slicer.stdout.on('data', log) + slicer.stderr.on('data', log) + + slicer.on('spawn', () => { + logs.push('Process spawned') + logger.log('Process spawned') }) + slicer.on('error', (err) => { + logs.push('Process error') + logger.log('Process error') logger.log('error', err) + logs.push(err.toString()) rej(err) }) - slicer.on('close', (code, signal) => { - logger.log('code', code) - logger.log('signal', signal) - if (typeof code === 'number' && code > 0) { - rej(code) + slicer.on('close', (code, signal) => { + logs.push('Process closed') + logger.log('Process closed') + logs.push(`with code ${code}`) + logger.log(`with code ${code}`) + logs.push(`and signal ${signal}`) + logger.log(`and signal ${signal}`) + if (typeof code === 'number' && code !== 0) { + rej({ + code: code, + output: logs + } as SliceError) return } res() }) }) } catch (e: any) { + const err = e as SliceError logger.log('request finished in error :(', processId) - const line = e.toString() - logger.error(e) - if (line.includes('Objects could not fit on the bed')) { + const line = err.toString() + logger.error('error', err, typeof err) + if (err.code === 3221226505 || line.includes('Objects could not fit on the bed')) { await fs.rm(stlPath) return buildRFC7807({ - type: '/object-too-large', - status: 413, - title: 'Object is too large' - }) + type: '/docs/errors/object-too-large', + status: StatusCode.PAYLOAD_TOO_LARGE, + title: 'Object is too large', + details: 'The STL you are trying to compile is too large for the configuration you chose' + }, locals.responseBuilder) } else if (line.includes('No such file')) { await fs.rm(stlPath) return buildRFC7807({ - type: '/missing-config-file', - status: 404, + type: '/docs/errors/missing-config-file', + status: StatusCode.NOT_FOUND, title: 'Configuration file is missing', details: `the configuration file "${configId}" is not available on the remote server` - }) + }, locals.responseBuilder) } else if (line.includes('Unknown option')) { await fs.rm(stlPath) return buildRFC7807({ - type: '/slicer-option-unknown', + type: '/docs/errors/slicer-option-unknown', status: 400, title: ' config override doew not exists', details: 'an override does not exists, please contact an administrator or refer to the documentation' - }) + }, locals.responseBuilder) } else if ( line.includes('is not recognized as an internal or external command') || line.includes('.dll was not loaded') ) { await fs.rm(stlPath) return buildRFC7807({ - type: '/slicer-not-found', - status: 408, + type: '/docs/errors/slicer-not-found', + status: StatusCode.SERVICE_UNAVAILABLE, title: 'the slicer used to process this file has not been found', details: 'the server has a misconfiguration meaning that we can\'t process the request, please contact an administrator', additionnalInfo: line.includes('dll') ? 'Missing DLL' : 'Slicer not found ' - }) + }, locals.responseBuilder) } else if (line.includes('ETIMEDOUT')) { await fs.rm(stlPath) return buildRFC7807({ - type: '/timed-out-slicing', - status: 408, + type: '/docs/errors/timed-out-slicing', + status: StatusCode.PAYLOAD_TOO_LARGE, title: 'Timed out slicing file', detail: `The file you are trying to process takes too long to be processed`, processingTimeoutMillis: 60000 - }) + }, locals.responseBuilder) } return buildRFC7807({ - type: '/general-input-output-error', - status: 500, + type: '/docs/errors/general-input-output-error', + status: StatusCode.INTERNAL_SERVER_ERROR, title: 'General I/O error', details: 'A server error make it impossible to slice the file, please contact an administrator with the json error', fileId: processId, @@ -173,8 +207,8 @@ export const post: APIRoute = async ({ params, request }) => { // fileSize: req.body.length, overrides: overrides, serverMessage: - e.toString().replace(new RegExp(stlPath), `***FILE***`).replace(new RegExp(processFolder), '') - }) + err.output.map((line) => line.replace(new RegExp(stlPath), `***FILE***`).replace(new RegExp(processFolder), '')) + }, locals.responseBuilder) } const gcode = await fs.readFile(gcodePath, 'utf-8') await fs.rm(processFolder, { recursive: true, force: true }) @@ -198,35 +232,35 @@ export const post: APIRoute = async ({ params, request }) => { price = tmp.toFixed(2) } else { return buildRFC7807({ - type: '/algorithm-error', + type: '/docs/errors/algorithm-error', status: 500, title: 'Algorithm compilation error', details: 'It seems the algorithm resolution failed', algorithm: algo, algorithmError: 'Algorithm return a Unit', parameters: gcodeParams - }) + }, locals.responseBuilder) } } catch (e) { logger.dir(e) return buildRFC7807({ - type: '/algorithm-error', + type: '/docs/errors/algorithm-error', status: 500, title: 'Algorithm compilation error', details: 'It seems the algorithm resolution failed', algorithm: algo, algorithmError: e, parameters: gcodeParams - }) + }, locals.responseBuilder) } } logger.log('request successfull :)') - return { - status: 200, - body: JSON.stringify({ + return locals.responseBuilder + .body({ price: price ? parseFloat(price) : undefined, ...getParams(gcode), gcode }) - } + .status(200) + .build() } diff --git a/src/pages/docs/[...page].astro b/src/pages/docs/[...page].astro new file mode 100644 index 0000000..aed8784 --- /dev/null +++ b/src/pages/docs/[...page].astro @@ -0,0 +1,24 @@ +--- +import Layout from '../../layouts/Layout.astro' +import { getEntry } from 'astro:content' +import StatusCode from '../../libs/StatusCode' + +const page = Astro.params.page + +let Result: any + +const entry = await getEntry('docs', page as any) +if (!entry) { + Astro.response.status = StatusCode.NOT_FOUND +} else { + const { Content } = await entry.render() + Result = Content +} +--- + + +
+ {Result && } + +
+
diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 82d39ff..3cc3d69 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -4,5 +4,7 @@ module.exports = { theme: { extend: {}, }, - plugins: [], + plugins: [ + require('@tailwindcss/typography'), + ], }