feat: Add moer element

Signed-off-by: Florian BOUILLON <f.bouillon@aptatio.com>
This commit is contained in:
Florian Bouillon 2023-06-27 18:30:44 +02:00
parent 4cd2a365ae
commit d76f412b82
19 changed files with 540 additions and 90 deletions

47
package-lock.json generated
View File

@ -21,6 +21,7 @@
"tailwindcss": "^3.3.2" "tailwindcss": "^3.3.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
@ -1083,6 +1084,34 @@
"escalade": "^3.1.1" "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": { "node_modules/@types/babel__core": {
"version": "7.20.1", "version": "7.20.1",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.1.tgz", "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", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "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": { "node_modules/lodash.once": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",

View File

@ -25,6 +25,7 @@
"tailwindcss": "^3.3.2" "tailwindcss": "^3.3.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",

15
src/content/config.ts Normal file
View File

@ -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,
};

View File

@ -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

14
src/env.d.ts vendored
View File

@ -1,7 +1,8 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" /> /// <reference types="astro/client" />
/// <reference types="./libs/ResponseBuilder" />
interface ImportMetaEnv { interface ImportMetaEnv {
CONFIGS_PATH?: string
PRUSASLICER_PATH?: string PRUSASLICER_PATH?: string
BAMBUSTUDIO_PATH?: string BAMBUSTUDIO_PATH?: string
MONGODB?: string MONGODB?: string
@ -12,3 +13,14 @@ interface ImportMetaEnv {
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
} }
declare namespace App {
interface Locals {
/**
* authentification key is the api key or the session token
*/
authKey?: string
responseBuilder?: ResponseBuilder
}
}

View File

@ -1,6 +1,14 @@
import { promises as fs } from 'node:fs' import { promises as fs } from 'node:fs'
/**
* File manipulation utility class
*/
export default class FilesUtils { 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<boolean> { public static async exists(path: string): Promise<boolean> {
try { try {
await fs.stat(path) await fs.stat(path)

View File

@ -1,3 +1,4 @@
import ResponseBuilder from '../ResponseBuilder'
/** /**
* Add headers: * Add headers:
@ -57,11 +58,9 @@ export default interface RFC7807 {
* @param error the error (base items are type, status, title details and instance) * @param error the error (base items are type, status, title details and instance)
* @returns * @returns
*/ */
export function buildRFC7807(error: RFC7807 & Record<string, any>): Response { export function buildRFC7807(error: RFC7807 & Record<string, any>, response: ResponseBuilder = new ResponseBuilder()): Response {
return new Response(JSON.stringify(error), { response.addHeader('Content-Type', 'application/problem+json')
headers: { .body(JSON.stringify(error))
'Content-Type': 'application/problem+json' .status(error.status ?? 500)
}, return response.build()
status: error.status ?? 500
})
} }

70
src/libs/RateLimiter.ts Normal file
View File

@ -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<string, StorageItem> = {}
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
}
}

View File

@ -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<string, string>) {
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)
}
}

71
src/libs/StatusCode.ts Normal file
View File

@ -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

View File

@ -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 { function parseNumber(str: string): number | undefined {
if (!/^(\d|\.)+$/g.test(str)) { if (!/^(\d|\.)+$/g.test(str)) {
return undefined return undefined
@ -13,6 +18,11 @@ function parseNumber(str: string): number | undefined {
return int 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 { function decodeTime(text: string): number {
let timeInSec = 0 let timeInSec = 0
for (const it of text.split(' ')) { for (const it of text.split(' ')) {
@ -39,23 +49,42 @@ function decodeTime(text: string): number {
} }
export function getParams(data: string) { export function getParams(data: string) {
// get the configuration lines
const lines = data.split('\n').filter((it) => it.startsWith(';') && it.includes('=')) const lines = data.split('\n').filter((it) => it.startsWith(';') && it.includes('='))
// create the config object
const obj: Record<string, string | number> = {} const obj: Record<string, string | number> = {}
// loop through eacj config
for (const line of lines) { for (const line of lines) {
// get its key and value
const [key, value] = line.split('=', 2).map((it) => it.slice(1).trim()) 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 const realValue = parseNumber(value) ?? value
// add an offset if the key is already cited
let offset = 0 let offset = 0
while (obj[`${realKey}${offset > 0 ? `_${offset}` : ''}`] && obj[`${realKey}${offset > 0 ? `_${offset}` : ''}`] !== realValue) { while (obj[`${realKey}${offset > 0 ? `_${offset}` : ''}`] && obj[`${realKey}${offset > 0 ? `_${offset}` : ''}`] !== realValue) {
offset++ offset++
} }
// chnge the key to add the offset
if (offset > 0) { if (offset > 0) {
realKey = `${realKey}_${offset}` realKey = `${realKey}_${offset}`
} }
// detect key collisions
if (obj[realKey] && obj[realKey] !== realValue) { if (obj[realKey] && obj[realKey] !== realValue) {
throw new Error(`Key collision ${key}=${realValue} ${realKey}=${obj[realKey]}`) 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') { if (realKey === 'estimated_printing_time_normal_mode') {
obj['estimated_printing_time_seconds'] = decodeTime(value) obj['estimated_printing_time_seconds'] = decodeTime(value)
} }

View File

@ -25,13 +25,13 @@ interface Permission {
* *
* TODO: implement rate limiting * TODO: implement rate limiting
* http/2.0 429 TOO MANY REQUESTS * http/2.0 429 TOO MANY REQUESTS
* Content-Type: application/json * Content-Type: application/json+problem
* X-RateLimit-Limit: 1000 * X-RateLimit-Limit: 1000 // number of request you cn make until hitting the rate limit
* X-RateLimit-Remaining: 0 * X-RateLimit-Remaining: 0 // number of request remaining until the rate limit is atteined
* X-RateLimit-Reset: 123456789 * X-RateLimit-Reset: 123456789 // EPOCH time when the rate limit reset
* X-RateLimit-Reset-After: 9 // Number of seconds before the remaining Rate go back to 0 * X-RateLimit-Reset-After: 9 // Number of seconds before the remaining Rate reset
*/ */
export async function validateAuth(request: Request, permission: Permission): Promise<Response | null> { export async function validateAuth(request: Request, permission: Permission): Promise<Response | string> {
const apiKeyHeader = request.headers.get('Authorization') const apiKeyHeader = request.headers.get('Authorization')
const cookieHeader = request.headers.get('Cookie') const cookieHeader = request.headers.get('Cookie')
if (apiKeyHeader) { if (apiKeyHeader) {
@ -58,10 +58,10 @@ export async function validateAuth(request: Request, permission: Permission): Pr
// return it.endsWith(permission.name) // return it.endsWith(permission.name)
}) })
if (match && (permission.api || match.startsWith('admin.'))) { if (match && (permission.api || match.startsWith('admin.'))) {
return null return apiKey
} else if (permission.api) { } else if (permission.api) {
return buildRFC7807({ return buildRFC7807({
type: '/unauthorized-access', type: '/docs/errors/unauthorized-access',
status: 401, status: 401,
title: 'Unauthorized access', title: 'Unauthorized access',
details: `you are missing the permission "${permission.name}" or is not an admin` 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) { } else if (permission.api && !permission.cookie) {
return buildRFC7807({ return buildRFC7807({
type: '/unauthorized-access', type: '/docs/errors/unauthorized-access',
status: 401, status: 401,
title: 'Unauthorized access', title: 'Unauthorized access',
details: `You MUST define an API key fo use this endpoint` 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') const userCookie = cookies.get('userId')
if (!userCookie) { if (!userCookie) {
return buildRFC7807({ return buildRFC7807({
type: '/unauthorized-access', type: '/docs/errors/unauthorized-access',
status: 401, status: 401,
title: 'Unauthorized access', title: 'Unauthorized access',
details: `you must be connected to use this endpoint (missing the userId cookie)` 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) const dao = await DaoFactory.get('user').get(userCookie)
if (!dao) { if (!dao) {
return buildRFC7807({ return buildRFC7807({
type: '/unauthorized-access', type: '/docs/errors/unauthorized-access',
status: 401, status: 401,
title: 'Unauthorized access', title: 'Unauthorized access',
details: `the user does not exists` details: `the user does not exists`
}) })
} }
return null return userCookie
} else if (!permission.api && permission.cookie) { } else if (!permission.api && permission.cookie) {
return buildRFC7807({ return buildRFC7807({
type: '/unauthorized-access', type: '/docs/errors/unauthorized-access',
status: 401, status: 401,
title: 'Unauthorized access', title: 'Unauthorized access',
details: `You MUST be connected to your account to use this endpoint` details: `You MUST be connected to your account to use this endpoint`
}) })
} else if (permission.api && permission.cookie) { } else if (permission.api && permission.cookie) {
return buildRFC7807({ return buildRFC7807({
type: '/unauthorized-access', type: '/docs/errors/unauthorized-access',
status: 401, status: 401,
title: 'Unauthorized access', title: 'Unauthorized access',
details: `You must be connected or use an API key to access this endpoint` details: `You must be connected or use an API key to access this endpoint`
}) })
} }
return buildRFC7807({ return buildRFC7807({
type: '/unauthorized-access', type: '/docs/errors/page-not-found',
status: 401, status: 404,
title: 'Unauthorized access', title: 'Page not found',
details: `the following endpoint is not currently accessible by any means` details: `the following endpoint does not exists`
}) })
} }

20
src/middleware/apiAuth.ts Normal file
View File

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

View File

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

7
src/middleware/index.ts Normal file
View File

@ -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)

View File

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

View File

@ -3,17 +3,23 @@ import { objectMap, objectOmit } from '@dzeio/object-util'
import URLManager from '@dzeio/url-manager' import URLManager from '@dzeio/url-manager'
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import { evaluate } from 'mathjs' 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 fs from 'node:fs/promises'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { promisify } from 'node:util' import { buildRFC7807 } from '../../../../libs/RFCs/RFC7807'
import { buildRFC7807 } from '../../../libs/RFCs/RFC7807' import RateLimiter from '../../../../libs/RateLimiter'
import { getParams } from '../../../libs/gcodeUtils' import ResponseBuilder from '../../../../libs/ResponseBuilder'
import { validateAuth } from '../../../libs/validateAuth' import StatusCode from '../../../../libs/StatusCode'
import DaoFactory from '../../../models/DaoFactory' import { getParams } from '../../../../libs/gcodeUtils'
import { validateAuth } from '../../../../libs/validateAuth'
import DaoFactory from '../../../../models/DaoFactory'
interface SliceError {
code: number
output: Array<string>
}
const exec = promisify(execSync)
let tmpDir: string let tmpDir: string
@ -23,15 +29,7 @@ let tmpDir: string
* price: algorithm from settings * price: algorithm from settings
* adionnal settings from https://manual.slic3r.org/advanced/command-line * adionnal settings from https://manual.slic3r.org/advanced/command-line
*/ */
export const post: APIRoute = async ({ params, request }) => { export const post: APIRoute = async ({ params, request, locals }) => {
const res = await validateAuth(request, {
name: 'slicing.slice',
api: true,
cookie: true
})
if (res) {
return res
}
if (!tmpDir) { if (!tmpDir) {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'saas-')) tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'saas-'))
} }
@ -41,13 +39,24 @@ export const post: APIRoute = async ({ params, request }) => {
if (!config) { if (!config) {
return buildRFC7807({ return buildRFC7807({
type: '/missing-config', type: '/docs/errors/missing-config',
status: 404, status: StatusCode.NOT_FOUND,
title: 'The configuration does not exists', title: 'The configuration does not exists',
details: `The configuration ${configId} 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 query = new URLManager(request.url).query()
const processId = (Math.random() * 1000000).toFixed(0) const processId = (Math.random() * 1000000).toFixed(0)
@ -72,7 +81,7 @@ export const post: APIRoute = async ({ params, request }) => {
logger.log('writing STL to filesystem') logger.log('writing STL to filesystem')
// write input // write input
await fs.writeFile(stlPath, new Uint8Array(Buffer.from(await request.arrayBuffer())), { await fs.writeFile(stlPath, input, {
encoding: null encoding: null
}) })
@ -85,87 +94,112 @@ export const post: APIRoute = async ({ params, request }) => {
if (config.type === 'prusa' || true) { if (config.type === 'prusa' || true) {
slicerPath = import.meta.env.PRUSASLICER_PATH ?? 'prusa-slicer' 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}` 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 { try {
logger.log('Running', slicerPath, slicerCommand) logger.log('Running', slicerPath, slicerCommand)
await new Promise<void>((res, rej) => { await new Promise<void>((res, rej) => {
const slicer = spawn(slicerPath, slicerCommand.split(' ')) const logs: Array<string> = []
slicer.stdout.on('data', (data) => { const slicer = spawn(`"${slicerPath}"`, slicerCommand.split(' '), {
logger.log('[stdout]',data.toString('utf8')) shell: true
}) })
slicer.stderr.on('data', (data: Buffer) => { const log = (data: Buffer) => {
logger.log('[stderr]', data.toString('utf8')) 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) => { slicer.on('error', (err) => {
logs.push('Process error')
logger.log('Process error')
logger.log('error', err) logger.log('error', err)
logs.push(err.toString())
rej(err) 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 return
} }
res() res()
}) })
}) })
} catch (e: any) { } catch (e: any) {
const err = e as SliceError
logger.log('request finished in error :(', processId) logger.log('request finished in error :(', processId)
const line = e.toString() const line = err.toString()
logger.error(e) logger.error('error', err, typeof err)
if (line.includes('Objects could not fit on the bed')) { if (err.code === 3221226505 || line.includes('Objects could not fit on the bed')) {
await fs.rm(stlPath) await fs.rm(stlPath)
return buildRFC7807({ return buildRFC7807({
type: '/object-too-large', type: '/docs/errors/object-too-large',
status: 413, status: StatusCode.PAYLOAD_TOO_LARGE,
title: 'Object is 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')) { } else if (line.includes('No such file')) {
await fs.rm(stlPath) await fs.rm(stlPath)
return buildRFC7807({ return buildRFC7807({
type: '/missing-config-file', type: '/docs/errors/missing-config-file',
status: 404, status: StatusCode.NOT_FOUND,
title: 'Configuration file is missing', title: 'Configuration file is missing',
details: `the configuration file "${configId}" is not available on the remote server` details: `the configuration file "${configId}" is not available on the remote server`
}) }, locals.responseBuilder)
} else if (line.includes('Unknown option')) { } else if (line.includes('Unknown option')) {
await fs.rm(stlPath) await fs.rm(stlPath)
return buildRFC7807({ return buildRFC7807({
type: '/slicer-option-unknown', type: '/docs/errors/slicer-option-unknown',
status: 400, status: 400,
title: ' config override doew not exists', title: ' config override doew not exists',
details: 'an override does not exists, please contact an administrator or refer to the documentation' details: 'an override does not exists, please contact an administrator or refer to the documentation'
}) }, locals.responseBuilder)
} else if ( } else if (
line.includes('is not recognized as an internal or external command') || line.includes('is not recognized as an internal or external command') ||
line.includes('.dll was not loaded') line.includes('.dll was not loaded')
) { ) {
await fs.rm(stlPath) await fs.rm(stlPath)
return buildRFC7807({ return buildRFC7807({
type: '/slicer-not-found', type: '/docs/errors/slicer-not-found',
status: 408, status: StatusCode.SERVICE_UNAVAILABLE,
title: 'the slicer used to process this file has not been found', 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', 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 ' additionnalInfo: line.includes('dll') ? 'Missing DLL' : 'Slicer not found '
}) }, locals.responseBuilder)
} else if (line.includes('ETIMEDOUT')) { } else if (line.includes('ETIMEDOUT')) {
await fs.rm(stlPath) await fs.rm(stlPath)
return buildRFC7807({ return buildRFC7807({
type: '/timed-out-slicing', type: '/docs/errors/timed-out-slicing',
status: 408, status: StatusCode.PAYLOAD_TOO_LARGE,
title: 'Timed out slicing file', title: 'Timed out slicing file',
detail: `The file you are trying to process takes too long to be processed`, detail: `The file you are trying to process takes too long to be processed`,
processingTimeoutMillis: 60000 processingTimeoutMillis: 60000
}) }, locals.responseBuilder)
} }
return buildRFC7807({ return buildRFC7807({
type: '/general-input-output-error', type: '/docs/errors/general-input-output-error',
status: 500, status: StatusCode.INTERNAL_SERVER_ERROR,
title: 'General I/O 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', details: 'A server error make it impossible to slice the file, please contact an administrator with the json error',
fileId: processId, fileId: processId,
@ -173,8 +207,8 @@ export const post: APIRoute = async ({ params, request }) => {
// fileSize: req.body.length, // fileSize: req.body.length,
overrides: overrides, overrides: overrides,
serverMessage: 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') const gcode = await fs.readFile(gcodePath, 'utf-8')
await fs.rm(processFolder, { recursive: true, force: true }) await fs.rm(processFolder, { recursive: true, force: true })
@ -198,35 +232,35 @@ export const post: APIRoute = async ({ params, request }) => {
price = tmp.toFixed(2) price = tmp.toFixed(2)
} else { } else {
return buildRFC7807({ return buildRFC7807({
type: '/algorithm-error', type: '/docs/errors/algorithm-error',
status: 500, status: 500,
title: 'Algorithm compilation error', title: 'Algorithm compilation error',
details: 'It seems the algorithm resolution failed', details: 'It seems the algorithm resolution failed',
algorithm: algo, algorithm: algo,
algorithmError: 'Algorithm return a Unit', algorithmError: 'Algorithm return a Unit',
parameters: gcodeParams parameters: gcodeParams
}) }, locals.responseBuilder)
} }
} catch (e) { } catch (e) {
logger.dir(e) logger.dir(e)
return buildRFC7807({ return buildRFC7807({
type: '/algorithm-error', type: '/docs/errors/algorithm-error',
status: 500, status: 500,
title: 'Algorithm compilation error', title: 'Algorithm compilation error',
details: 'It seems the algorithm resolution failed', details: 'It seems the algorithm resolution failed',
algorithm: algo, algorithm: algo,
algorithmError: e, algorithmError: e,
parameters: gcodeParams parameters: gcodeParams
}) }, locals.responseBuilder)
} }
} }
logger.log('request successfull :)') logger.log('request successfull :)')
return { return locals.responseBuilder
status: 200, .body({
body: JSON.stringify({
price: price ? parseFloat(price) : undefined, price: price ? parseFloat(price) : undefined,
...getParams(gcode), ...getParams(gcode),
gcode gcode
}) })
} .status(200)
.build()
} }

View File

@ -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
}
---
<Layout title={entry?.data.title ?? ''}>
<main class="prose">
{Result && <Result />}
</main>
</Layout>

View File

@ -4,5 +4,7 @@ module.exports = {
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [
require('@tailwindcss/typography'),
],
} }