Compare commits

..

20 Commits

Author SHA1 Message Date
c5f842b409 just remove session cookie, if its set 2023-05-14 22:13:17 +02:00
46cf6081af remove debug log 2023-05-14 22:07:51 +02:00
997fe6dc77 change middleware done result to own type 2023-05-14 22:07:08 +02:00
b335bb61bd set crypto version 2023-05-14 20:23:17 +02:00
2e6ce88e80 add exception for sessionSecret 2023-05-14 20:22:37 +02:00
8a3683d2e5 allow undefined arg for cookie, just set cookie if session has entries 2023-05-13 06:29:01 +02:00
96a73035f8 add sessionDestroy func 2023-05-13 06:25:28 +02:00
5392b032d0 add sessionExpire option 2023-05-13 04:58:01 +02:00
d519c7bf9c add sessions, add session example 2023-05-13 04:50:15 +02:00
b2e357227c add example for multiple preprocessors 2023-05-12 15:19:02 +02:00
1376fbdeba replace return with continue 2023-05-12 15:05:06 +02:00
7f1fce92b2 revert last change 2023-05-12 14:51:36 +02:00
784a34ed4d fix memory leak, close file after serve 2023-05-12 14:49:21 +02:00
4e1ac5888a better example html 2023-05-12 14:14:06 +02:00
e6f6b1ddc5 add var to identify static resource requests 2023-05-12 14:10:41 +02:00
06695c5443 add img to html static test 2023-05-12 14:01:51 +02:00
c6cb48b4ff add static example to site html test 2023-05-12 14:00:24 +02:00
452be0cecf export HTTPMethod 2023-05-12 13:57:13 +02:00
ec0df0de60 change HTTPMethod to enum 2023-05-12 13:55:26 +02:00
ca6d23f5ef fix catch block text 2023-05-12 13:34:11 +02:00
3 changed files with 262 additions and 32 deletions

6
example/static/style.css Normal file
View File

@@ -0,0 +1,6 @@
h1 {
color: red;
}
img {
margin-bottom: 15px;
}

View File

@@ -1,6 +1,6 @@
import { Status } from "https://deno.land/std@0.186.0/http/http_status.ts";
import prettyTime from "npm:pretty-time";
import { HTTPServer } from "../mod.ts";
import { HTTPServer, SessionExpire } from "../mod.ts";
const JOKES = [
"Why do Java developers often wear glasses? They can't C#.",
@@ -17,13 +17,27 @@ const JOKES = [
const httpServer = new HTTPServer();
// Add as many preprocessors as you want
httpServer.preprocessor((req, _rep) => {
if (req.resourceRequest) {
console.log(`Requested resource ${req.path}`);
}
});
httpServer.preprocessor((_req, rep) => {
rep.header("Access-Control-Allow-Origin", "*");
});
httpServer.middleware(async (req, _rep, done) => {
const processTime = await done();
console.log(`${req.method} - ${req.remoteIpAddr} - ${req.path} - ${prettyTime(processTime)}`);
const result = await done();
const hrArray: number[] = [0, Math.trunc(result.processTime * 1000000)];
if (!req.resourceRequest) {
console.log(
`${req.method} - ${req.remoteIpAddr} - ${req.path} - ${
prettyTime(hrArray)
}`,
);
}
});
httpServer.error((req, _rep) => {
@@ -53,16 +67,98 @@ httpServer.get("/site", (_req, rep) => {
<html>
<head>
<title>HTML Test</title>
<link rel="stylesheet" type="text/css" href="/assets/style.css">
</head>
<body>
<h1>Hello World!</h1>
<button onclick="alert('bruh')">Useless button, do not press.</button>
<img src="/assets/lucoa.gif" id="lucoa" width="150" />
<br>
<button onclick="document.getElementById('lucoa').remove(); alert('omg, you killed her you monster.')">Useless button, do not press.</button>
</body>
</html>
`;
rep.html(htmlTest);
});
httpServer.delete("/session", (req, _rep) => {
const username = req.session.user as string ?? "";
if (username.length > 0) {
req.sessionDestroy();
return {
code: 200,
message: "Logged out!",
};
} else {
return {
code: 403,
message: "Not logged in!",
};
}
});
httpServer.post("/session", (req, _rep) => {
const username = req.queryParam("username") ?? "";
if (username.length > 0) {
req.session.user = username;
return {
code: 200,
message: "Logged in!",
};
} else {
return {
code: 403,
message: "Please enter a Username",
};
}
});
httpServer.get("/session", (req, rep) => {
const headerText = req.session.user
? `Hello, ${req.session.user}!`
: `Please login!`;
const htmlTest = `
<html>
<head>
<title>Session Example</title>
</head>
<body>
<h1>${headerText}</h1>
<input type="text" placeholder="Username" id="username" style="margin-bottom: 15px;" ${req.session.user ? "value='" + req.session.user + "' disabled" : ""}/>
<br>
<button onclick="${req.session.user ? "doLogout" : "doLogin"}()">${
req.session.user ? "Logout" : "Login"
}</button>
</body>
<script type="">
async function doLogout() {
const fetchResult = await fetch("/session", { method: 'DELETE'});
const jsonResult = await fetchResult.json();
if("code" in jsonResult){
if(jsonResult.code == 200){
document.location.reload(true)
}else{
alert(jsonResult.message);
}
}
}
async function doLogin() {
const username = document.getElementById('username').value;
const fetchResult = await fetch("/session?username=" + username, { method: 'POST'});
const jsonResult = await fetchResult.json();
if("code" in jsonResult){
if(jsonResult.code == 200){
document.location.reload(true)
}else{
alert(jsonResult.message);
}
}
}
</script>
</html>
`;
rep.html(htmlTest);
});
httpServer.get("/", (req, rep) => {
rep.status(Status.Teapot)
.header("working", "true")
@@ -98,4 +194,6 @@ httpServer.listen({
port: 8080,
staticLocalDir: "/static",
staticServePath: "/assets",
sessionSecret: "SuperDuperSecret",
sessionExpire: SessionExpire.NEVER
});

182
mod.ts
View File

@@ -4,14 +4,37 @@ import {
} from "https://deno.land/std@0.186.0/http/http_status.ts";
import * as path from "https://deno.land/std@0.185.0/path/mod.ts";
import * as cookie from "https://deno.land/std@0.185.0/http/cookie.ts";
import { Aes } from "https://deno.land/x/crypto@v0.10.0/aes.ts";
import {
Cbc,
Padding,
} from "https://deno.land/x/crypto@v0.10.0/block-modes.ts";
import { cryptoRandomString } from "https://deno.land/x/crypto_random_string@1.0.0/mod.ts";
type ListenOptions = {
type HTTPServerOptions = {
port: number;
host?: string;
staticLocalDir?: string;
staticServePath?: string;
sessionSecret?: string;
sessionExpire?: SessionExpire | number;
};
type HTTPMethod = "GET" | "POST" | "PUSH" | "DELETE";
type MiddlewareResult = {
processTime: number;
};
export enum SessionExpire {
NEVER = 2147483647,
}
export enum HTTPMethod {
GET = "GET",
POST = "POST",
PUSH = "PUSH",
DELETE = "DELETE",
}
type RouteHandler = (
req: RouteRequest,
rep: RouteReply,
@@ -22,7 +45,7 @@ type RouteHandler = (
type RouteMiddlewareHandler = (
req: RouteRequest,
rep: RouteReply,
done: () => Promise<number[]>,
done: () => Promise<MiddlewareResult>,
) => Promise<void>;
type RoutePreprocessor = (
@@ -43,8 +66,19 @@ export class HTTPServer {
private notFoundHandler?: RouteHandler;
private preprocessors: RoutePreprocessor[] = [];
private middlewareHandler?: RouteMiddlewareHandler;
settings?: HTTPServerOptions;
async listen(options: ListenOptions) {
async listen(options: HTTPServerOptions) {
if (options.sessionSecret) {
if (![16, 24, 32].includes(options.sessionSecret.length)) {
const randomString = cryptoRandomString({ length: 32 });
throw new Error(
"\nInvalid key size (must be either 16, 24 or 32 bytes)\nHere is a pregenerated key: " +
randomString,
);
}
}
this.settings = options;
this.server = Deno.listen({
port: options.port,
hostname: options.host,
@@ -111,6 +145,7 @@ export class HTTPServer {
const request = requestEvent.request;
const url = request.url;
const routeRequest = new RouteRequest(
this,
request,
conn,
filepath,
@@ -127,11 +162,11 @@ export class HTTPServer {
preProcessor(routeRequest, routeReply)
);
let resolveAction: (value: number[]) => void = () => {};
let resolveAction: (value: MiddlewareResult) => void = () => {};
let middlewarePromise;
const perStart = performance.now();
if (this.middlewareHandler) {
middlewarePromise = (): Promise<number[]> => {
middlewarePromise = (): Promise<MiddlewareResult> => {
return new Promise((resolve) => {
resolveAction = resolve;
});
@@ -152,9 +187,11 @@ export class HTTPServer {
} catch {
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
resolveAction({
processTime: pt,
});
}
this.processSession(routeRequest, routeReply);
this.handleNotFound(routeRequest, routeReply, requestEvent);
continue;
}
@@ -163,14 +200,18 @@ export class HTTPServer {
const response = new Response(readableStream);
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
resolveAction({
processTime: pt,
});
}
this.processSession(routeRequest, routeReply);
await requestEvent.respondWith(response);
return;
continue;
}
const routeName = `${requestEvent.request.method}@${filepath}`;
const routeName = `${requestEvent.request.method}@${
filepath.replace(/(?!\/)\W\D.*/gm, "")
}`;
let route = this.routes.get(routeName);
if (route) {
@@ -185,9 +226,11 @@ export class HTTPServer {
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
resolveAction({
processTime: pt,
});
}
this.processSession(routeRequest, routeReply);
await requestEvent.respondWith(
new Response(handler as string, {
status: routeReply.statusCode,
@@ -224,9 +267,12 @@ export class HTTPServer {
);
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
resolveAction({
processTime: pt,
});
}
this.processSession(routeRequest, routeReply);
await requestEvent.respondWith(
new Response(handler as string, {
status: routeReply.statusCode,
@@ -238,13 +284,34 @@ export class HTTPServer {
}
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
resolveAction({
processTime: pt,
});
}
this.processSession(routeRequest, routeReply);
this.handleNotFound(routeRequest, routeReply, requestEvent);
}
} catch (_err) {
// Ignore http that where closed before reply was sent
// Ignore http connections that where closed before reply was sent
}
}
private processSession(routeRequest: RouteRequest, routeReply: RouteReply) {
if (this.settings?.sessionSecret) {
const sessionObject = JSON.stringify(routeRequest.session);
if (Object.keys(routeRequest.session).length > 0) {
const encodedSession = encryptData(
sessionObject,
this.settings?.sessionSecret,
);
routeReply.cookie("session", encodedSession, {
maxAge: this.settings.sessionExpire ?? undefined,
});
} else {
if (routeRequest.cookie("session")) {
routeReply.cookie("session", undefined);
}
}
}
}
@@ -267,19 +334,19 @@ export class HTTPServer {
}
get(path: string, handler: RouteHandler) {
this.add("GET", path, handler);
this.add(HTTPMethod.GET, path, handler);
}
post(path: string, handler: RouteHandler) {
this.add("POST", path, handler);
this.add(HTTPMethod.POST, path, handler);
}
push(path: string, handler: RouteHandler) {
this.add("PUSH", path, handler);
this.add(HTTPMethod.PUSH, path, handler);
}
delete(path: string, handler: RouteHandler) {
this.add("DELETE", path, handler);
this.add(HTTPMethod.DELETE, path, handler);
}
add(method: HTTPMethod, path: string, handler: RouteHandler) {
@@ -318,6 +385,34 @@ export const extractRouteParams: (route: string) => RouteParam[] = (route) =>
return accum;
}, []);
function encryptData(data: string, key: string) {
const te = new TextEncoder();
const aeskey = te.encode(key);
const encodeddata = te.encode(data);
const iv = new Uint8Array(16);
const cipher = new Cbc(Aes, aeskey, iv, Padding.PKCS7);
const encrypted = cipher.encrypt(encodeddata);
const hexed = Array.from(encrypted).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
return hexed;
}
function decryptHex(data: string, key: string) {
const te = new TextEncoder();
const td = new TextDecoder();
const byteArray = new Uint8Array(data.length / 2);
for (let i = 0; i < data.length; i += 2) {
const byte = parseInt(data.substring(i, i + 2), 16);
byteArray[Math.floor(i / 2)] = byte;
}
const aeskey = te.encode(key);
const iv = new Uint8Array(16);
const decipher = new Cbc(Aes, aeskey, iv, Padding.PKCS7);
const decrypted = decipher.decrypt(byteArray);
return td.decode(decrypted);
}
export class Route {
routeName: string;
path: string;
@@ -336,21 +431,53 @@ export class RouteRequest {
url: string;
path: string;
headers: Headers;
cookies: Record<string, string>;
method: HTTPMethod;
queryParams: { [key: string]: string };
pathParams: { [key: string]: string };
remoteIpAddr: string;
resourceRequest: boolean;
session: { [key: string]: unknown } = {};
constructor(request: Request, conn: Deno.Conn, path: string, url: string) {
constructor(
httpServer: HTTPServer,
request: Request,
conn: Deno.Conn,
path: string,
url: string,
) {
this.url = url;
this.path = decodeURIComponent(path);
this.headers = request.headers;
this.method = request.method as HTTPMethod;
this.pathParams = {};
this.resourceRequest = httpServer.settings?.staticServePath &&
httpServer.settings?.staticServePath.length > 0
? path.startsWith(httpServer.settings?.staticServePath)
: false;
this.queryParams = Object.fromEntries(new URL(url).searchParams.entries());
this.cookies = cookie.getCookies(this.headers);
this.remoteIpAddr = "hostname" in conn.remoteAddr
? conn.remoteAddr["hostname"]
: "127.0.0.1";
const sessionCookie = this.cookie("session") as string;
if (sessionCookie && httpServer.settings?.sessionSecret) {
const decodedSessionCookie = decryptHex(
sessionCookie,
httpServer.settings.sessionSecret,
);
try {
this.session = JSON.parse(decodedSessionCookie);
} catch (_err) {
console.log(_err);
// Ignore if sessionCookie is not in JSON format
}
}
}
sessionDestroy(): void {
this.session = {};
}
header(name: string): unknown {
@@ -361,9 +488,8 @@ export class RouteRequest {
}
cookie(name: string): unknown {
const allCookies = cookie.getCookies(this.headers);
const allCookieNames = Object.keys(allCookies);
return allCookieNames.includes(name) ? allCookies[name] : undefined;
const allCookieNames = Object.keys(this.cookies);
return allCookieNames.includes(name) ? this.cookies[name] : undefined;
}
pathParam(name: string): string {
@@ -405,7 +531,7 @@ export class RouteReply {
return this;
}
cookie(name: string, value: string, attributes?: {
cookie(name: string, value: string | undefined, attributes?: {
expires?: Date | number;
maxAge?: number;
domain?: string;