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, ReqSchema extends Schema, ResSchema extends Schema, > = C extends Context ? Context & { schema: { req: ReqSchema; res: ResSchema }; } : never; export class Context< S extends string = string, ReqSchema extends Schema = Schema, ResSchema extends Schema = Schema, > { private _url?: URL; private _hostname?: string; private _port?: number; private _cookies?: Record; public res = new Response(); constructor( public readonly req: Request, public readonly info: Deno.ServeHandlerInfo, public readonly params: Params>, ) {} public schema?: { req: ReqSchema; res: ResSchema }; public setSchema< Req extends Schema, Res extends Schema, >( schema: { req: Req; res: Res }, ): Context & { schema: { req: Req; res: Res } } { const ctx = new Context(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 & { schema: { req: Req; res: Res } }; } public setParams( params: Params, ): Context { const ctx = new Context( 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; } public parseBody(): ResultAsync< InferSchemaType, 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 { return this.url.pathname as ExtractPath; } 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 { 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 { 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 ? 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 ? T : object | string, init: ResponseInit = {}, ): Response { return this.json(body, { ...init, status: 400 }); } public json500( body?: ResSchema extends Schema ? 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 => { 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 `${infer _Start}:${infer Param}/${infer Rest}` ? string : S extends `${infer _Start}/:${infer Param}` ? string : S extends `${infer _Start}*` ? string : S;