Keyborg/server/src/lib/context.ts
2025-02-13 18:31:44 +03:00

273 lines
8.6 KiB
TypeScript

import { type Params } from "@lib/router.ts";
import { type ExtractRouteParams } from "@lib/router.ts";
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
import { deleteCookie, getCookies, setCookie } from "@std/http/cookie";
import { type Cookie } from "@std/http/cookie";
import { getMessageFromError, ok } from "@shared/utils/result.ts";
import {
InferSchemaType,
Schema,
SchemaValidationError,
} from "@shared/utils/validator.ts";
import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import log from "@shared/utils/logger.ts";
import {
FailedToParseRequestAsJSONError,
failedToParseRequestAsJSONError,
} from "@src/lib/errors.ts";
import { RequestValidationError } from "@src/lib/errors.ts";
import { requestValidationError } from "@src/lib/errors.ts";
// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
const SECURITY_HEADERS: Headers = new Headers({
"X-Frame-Option": "DENY",
"X-Content-Type-Options": "nosniff",
//"Referrer-Policy": "strict-origin-when-cross-origin",
//"Cross-Origin-Opener-Policy": "same-origin",
//"Cross-Origin-Resource-Policy": "same-site",
"Permissions-Policy": "geolocation=(), camera=(), microphone=()",
Server: "webserver",
//"Content-Security-Policy":
// "default-src 'self'; script-src 'self' 'unsafe-inline'",
});
const HTML_CONTENT_TYPE: string = "text/html; charset=UTF-8";
const JSON_CONTENT_TYPE: string = "application/json; charset=utf-8";
function mergeHeaders(...headers: Headers[]): Headers {
const merged = new Headers();
for (const hdr of headers) {
hdr.forEach((value, key) => merged.set(key, value));
}
return merged;
}
export type ContextWithSchema<
C extends Context<string, any, any>,
ReqSchema extends Schema<any>,
ResSchema extends Schema<any>,
> = C extends Context<infer S, any, any> ? Context<S, ReqSchema, ResSchema> & {
schema: { req: ReqSchema; res: ResSchema };
}
: never;
export class Context<
S extends string = string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
> {
private _url?: URL;
private _hostname?: string;
private _port?: number;
private _cookies?: Record<string, string>;
public res = new Response();
constructor(
public readonly req: Request,
public readonly info: Deno.ServeHandlerInfo<Deno.Addr>,
public readonly params: Params<ExtractRouteParams<S>>,
) {}
public schema?: { req: ReqSchema; res: ResSchema };
public setSchema<
Req extends Schema<any>,
Res extends Schema<any>,
>(
schema: { req: Req; res: Res },
): Context<S, Req, Res> & { schema: { req: Req; res: Res } } {
const ctx = new Context<S, Req, Res>(this.req, this.info, this.params);
ctx._url = this._url;
ctx._hostname = this._hostname;
ctx._port = this._port;
ctx._cookies = this._cookies;
ctx.res = this.res;
ctx.schema = schema;
return ctx as Context<S, Req, Res> & { schema: { req: Req; res: Res } };
}
public setParams(
params: Params<string>,
): Context<S, ReqSchema, ResSchema> {
const ctx = new Context<S, ReqSchema, ResSchema>(
this.req,
this.info,
params,
);
ctx._url = this._url;
ctx._hostname = this._hostname;
ctx._port = this._port;
ctx._cookies = this._cookies;
ctx.res = this.res;
ctx.schema = this.schema;
return ctx as Context<S, ReqSchema, ResSchema>;
}
public parseBody(): ResultAsync<
InferSchemaType<ReqSchema>,
RequestValidationError | FailedToParseRequestAsJSONError
> {
return ResultAsync
.fromPromise(
this.req.json(),
(e) => failedToParseRequestAsJSONError(getMessageFromError(e)),
)
.andThen((data: unknown) => {
if (!this.schema) {
return ok(data);
}
return this.schema?.req.parse(data).mapErr((e) =>
requestValidationError(e.info)
);
});
}
get url(): URL {
return this._url ?? (this._url = new URL(this.req.url));
}
get path(): ExtractPath<S> {
return this.url.pathname as ExtractPath<S>;
}
get preferredType(): Option<"json" | "html"> {
const accept = this.req.headers.get("accept");
if (!accept) return none;
const types = accept
.split(",")
.map((t) => t.split(";")[0].trim());
if (types.includes("text/html")) return some("html");
if (types.includes("application/json")) return some("json");
return none;
}
matchPreferredType(
html: () => Response,
json: () => Response,
other: () => Response,
): Response {
switch (this.preferredType.unwrapOr("other")) {
case "json":
return json();
case "html":
return html();
case "other":
return other();
}
}
get hostname(): Option<string> {
if (this._hostname) return some(this._hostname);
const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._hostname = remoteAddr.hostname;
return some(remoteAddr.hostname);
}
return none;
}
get port(): Option<number> {
if (this._port) return some(this._port);
const remoteAddr = this.info.remoteAddr;
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
this._port = remoteAddr.port;
return some(remoteAddr.port);
}
return none;
}
private buildHeaders(
initHeaders?: HeadersInit,
contentType?: string,
): Headers {
const merged = mergeHeaders(
SECURITY_HEADERS,
this.res.headers,
new Headers(initHeaders),
);
if (contentType) merged.set("Content-Type", contentType);
return merged;
}
public json(
body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {},
): Response {
const headers = this.buildHeaders(init.headers, JSON_CONTENT_TYPE);
let status = init.status ?? 200;
let responseBody: BodyInit | null = null;
if (typeof body === "string") {
responseBody = body;
} else if (body !== undefined) {
try {
responseBody = JSON.stringify(body);
} catch (error) {
console.error("Failed to serialize JSON body:", error);
responseBody = JSON.stringify({
err: "Internal Server Error",
});
status = 500;
}
}
this.res = new Response(responseBody, {
status,
headers,
});
return this.res;
}
public json400(
body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {},
): Response {
return this.json(body, { ...init, status: 400 });
}
public json500(
body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {},
) {
return this.json(body, { ...init, status: 500 });
}
public html(body?: BodyInit | null, init: ResponseInit = {}): Response {
const headers = this.buildHeaders(init.headers, HTML_CONTENT_TYPE);
const status = init.status ?? 200;
this.res = new Response(body ?? null, { status, headers });
return this.res;
}
public redirect(url: string, permanent = false): Response {
const headers = mergeHeaders(
this.res.headers,
new Headers({ location: url }),
);
this.res = new Response(null, {
status: permanent ? 301 : 302,
headers,
});
return this.res;
}
public get cookies() {
return {
get: (name: string): Option<string> => {
this._cookies ??= getCookies(this.req.headers);
return fromNullableVal(this._cookies[name]);
},
set: (cookie: Cookie) => setCookie(this.res.headers, cookie),
delete: (name: string) => deleteCookie(this.res.headers, name),
};
}
}
type ExtractPath<S extends string> = S extends
`${infer _Start}:${infer Param}/${infer Rest}` ? string
: S extends `${infer _Start}/:${infer Param}` ? string
: S extends `${infer _Start}*` ? string
: S;