feat: Add moer element
Signed-off-by: Florian BOUILLON <f.bouillon@aptatio.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
70
src/libs/RateLimiter.ts
Normal 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
|
||||
}
|
||||
}
|
61
src/libs/ResponseBuilder.ts
Normal file
61
src/libs/ResponseBuilder.ts
Normal 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
71
src/libs/StatusCode.ts
Normal 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
|
@ -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)
|
||||
}
|
||||
|
@ -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`
|
||||
})
|
||||
|
||||
}
|
||||
|
Reference in New Issue
Block a user