mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-09 23:43:16 +00:00
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled
* perfmon: lower healthcheck status cache, add kv health signal * perf: improve tel + decrease logs * logs
175 lines
7.4 KiB
TypeScript
175 lines
7.4 KiB
TypeScript
import type { RequestHandler } from 'express';
|
|
import { StatusCodes } from 'http-status-codes';
|
|
import type {
|
|
EndpointOptions,
|
|
HttpMethod,
|
|
RouterMethods,
|
|
} from '../../api.d.ts';
|
|
/**
|
|
* Class decorator to set prefix on prototype and register routes on instantiation
|
|
* @argument prefix - prefix for all routes under the class
|
|
* @argument [adminUsernames] - gate all routes behind admin username check
|
|
*/
|
|
export const Controller = (
|
|
prefix: string,
|
|
adminUsernames?: string[],
|
|
allowedAppIds?: string[],
|
|
): ClassDecorator => {
|
|
return (target: Function) => {
|
|
target.prototype.__controllerPrefix = prefix;
|
|
target.prototype.__allowedAppIds = allowedAppIds;
|
|
target.prototype.__adminUsernames = adminUsernames
|
|
? [...adminUsernames, 'admin', 'system']
|
|
: undefined;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Method decorator factory that collects route metadata
|
|
*/
|
|
interface RouteMeta {
|
|
method: HttpMethod;
|
|
path: string;
|
|
options?: EndpointOptions | undefined;
|
|
handler: RequestHandler;
|
|
adminUsernames?: string[];
|
|
allowedAppIds?: string[];
|
|
}
|
|
|
|
const createMethodDecorator = (method: HttpMethod) => {
|
|
return <This>(
|
|
path: string,
|
|
routeOptions?: EndpointOptions & { allowedAppIds?: string[] },
|
|
adminUsernames?: string[],
|
|
) => {
|
|
const { allowedAppIds, ...options } = routeOptions ?? {};
|
|
return <
|
|
P extends Record<string, string | undefined> = Record<
|
|
string,
|
|
string | undefined
|
|
>,
|
|
>(
|
|
target: RequestHandler<P>,
|
|
_context: ClassMethodDecoratorContext<
|
|
This,
|
|
(
|
|
this: This,
|
|
...args: Parameters<RequestHandler<P>>
|
|
) => ReturnType<RequestHandler<P>>
|
|
>,
|
|
) => {
|
|
_context.addInitializer(function () {
|
|
// eslint-disable-next-line no-invalid-this
|
|
const proto = Object.getPrototypeOf(this); // will be bound to class
|
|
if ( ! proto.__routes ) {
|
|
proto.__routes = [];
|
|
}
|
|
proto.__routes.push({
|
|
method,
|
|
path,
|
|
options: options as EndpointOptions | undefined,
|
|
adminUsernames: adminUsernames
|
|
? [...adminUsernames, 'admin', 'system']
|
|
: undefined,
|
|
allowedAppIds,
|
|
handler: target,
|
|
});
|
|
});
|
|
};
|
|
};
|
|
};
|
|
|
|
// HTTP method decorators
|
|
export const Get = createMethodDecorator('get');
|
|
export const Post = createMethodDecorator('post');
|
|
export const Put = createMethodDecorator('put');
|
|
export const Delete = createMethodDecorator('delete');
|
|
// TODO DS: add others as needed (patch, etc)
|
|
|
|
export class HttpError extends Error {
|
|
statusCode: number;
|
|
constructor (statusCode: StatusCodes, message: string, cause?: unknown) {
|
|
super(`${statusCode} - ${message}`, { cause });
|
|
this.statusCode = statusCode;
|
|
}
|
|
}
|
|
|
|
// Registers all routes from a decorated controller instance to an Express router
|
|
export class ExtensionController {
|
|
logger?: Console;
|
|
// TODO DS: make this work with other express-like routers
|
|
registerRoutes () {
|
|
const logger = this.logger || console;
|
|
const prefix = Object.getPrototypeOf(this).__controllerPrefix || '';
|
|
const adminsForController = Object.getPrototypeOf(this).__adminUsernames as
|
|
| string[]
|
|
| undefined;
|
|
const allowedAppIdsForController = Object.getPrototypeOf(this).__allowedAppIds as
|
|
| string[]
|
|
| undefined;
|
|
const routes: RouteMeta[] = Object.getPrototypeOf(this).__routes || [];
|
|
for ( const route of routes ) {
|
|
const fullPath = `${prefix}/${route.path}`.replace(/\/+/g, '/');
|
|
const adminsForRoute = route.adminUsernames
|
|
? adminsForController
|
|
? adminsForController.concat(route.adminUsernames)
|
|
: route.adminUsernames
|
|
: adminsForController
|
|
? adminsForController
|
|
: undefined;
|
|
const allowedAppIds = route.allowedAppIds
|
|
? allowedAppIdsForController
|
|
? allowedAppIdsForController.concat(route.allowedAppIds)
|
|
: route.allowedAppIds
|
|
: allowedAppIdsForController
|
|
? allowedAppIdsForController
|
|
: undefined;
|
|
|
|
if ( ! extension[route.method] ) {
|
|
throw new Error(`Unsupported HTTP method: ${route.method}`);
|
|
} else {
|
|
logger.log(`Registering route: [${route.method.toUpperCase()}] ${fullPath}`);
|
|
|
|
(extension[route.method] as RouterMethods[HttpMethod])(
|
|
fullPath,
|
|
route.options || {},
|
|
async (req, res, next) => {
|
|
try {
|
|
if ( adminsForRoute || allowedAppIds ) {
|
|
if ( ! req.actor ) {
|
|
throw new HttpError(StatusCodes.UNAUTHORIZED, 'Unauthenticated');
|
|
}
|
|
}
|
|
if ( adminsForRoute ) {
|
|
if ( ! adminsForRoute.includes(req.actor!.type.user.username) ) {
|
|
throw new HttpError(StatusCodes.FORBIDDEN,
|
|
'Only admins may request this resource.');
|
|
}
|
|
}
|
|
if ( allowedAppIds ) {
|
|
if ( ( req.actor!.type?.app?.uid && !allowedAppIds.includes(req.actor!.type.app.uid) ) ) {
|
|
throw new HttpError(StatusCodes.FORBIDDEN,
|
|
'This app may not request this resource.');
|
|
}
|
|
}
|
|
await route.handler.bind(this)(req, res, next);
|
|
} catch ( error ) {
|
|
if ( error instanceof HttpError ) {
|
|
res.status(error.statusCode).send({ error: error.message });
|
|
logger.warn('httpError:', error);
|
|
return;
|
|
}
|
|
if ( error instanceof Error ) {
|
|
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: error.message });
|
|
logger.error('Non-http error:', error);
|
|
return;
|
|
}
|
|
res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ error: 'An unknown error occurred' });
|
|
logger.error('An unknown error occurred:', error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|