feat: Add moer element

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

View File

@ -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<boolean> {
try {
await fs.stat(path)
@ -9,4 +17,4 @@ export default class FilesUtils {
return false
}
}
}
}

View File

@ -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<string, any>): Response {
return new Response(JSON.stringify(error), {
headers: {
'Content-Type': 'application/problem+json'
},
status: error.status ?? 500
})
export function buildRFC7807(error: RFC7807 & Record<string, any>, response: ResponseBuilder = new ResponseBuilder()): Response {
response.addHeader('Content-Type', 'application/problem+json')
.body(JSON.stringify(error))
.status(error.status ?? 500)
return response.build()
}

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 {
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<string, string | number> = {}
// 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)
}

View File

@ -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<Response | null> {
export async function validateAuth(request: Request, permission: Permission): Promise<Response | string> {
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`
})
}