refactoring everything before moving on

This commit is contained in:
2025-02-11 16:47:32 +03:00
parent 64519e11ff
commit f0ec7a1f00
25 changed files with 272 additions and 114 deletions

5
deno.lock generated
View File

@ -673,6 +673,9 @@
]
}
},
"redirects": {
"https://deno.land/x/sleep/mod.ts": "https://deno.land/x/sleep@v1.3.0/mod.ts"
},
"remote": {
"https://deno.land/std@0.203.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
"https://deno.land/std@0.203.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
@ -686,6 +689,8 @@
"https://deno.land/std@0.203.0/async/pool.ts": "47c1841cfa9c036144943d11747ddd44064f5baf8cb7ece25473ba873c6aceb0",
"https://deno.land/std@0.203.0/async/retry.ts": "296fb9c323e1325a69bee14ba947e7da7409a8dd9dd646d70cb51ea0d301f24e",
"https://deno.land/std@0.203.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757",
"https://deno.land/x/sleep@v1.3.0/mod.ts": "e9955ecd3228a000e29d46726cd6ab14b65cf83904e9b365f3a8d64ec61c1af3",
"https://deno.land/x/sleep@v1.3.0/sleep.ts": "b6abaca093b094b0c2bba94f287b19a60946a8d15764d168f83fcf555f5bb59e",
"https://wilsonl.in/minify-html/deno/0.15.0/index.js": "8e7ee5067ca84fb5d5a1f33118cac4998de0b7d80b3f56cc5c6728b84e6bfb70"
},
"workspace": {

View File

@ -5,7 +5,12 @@ const schema = {
req: z.obj({
password: z.string(),
}),
res: z.result(z.string(), z.any()),
res: z.result(
z.obj({
isMatch: z.boolean(),
}),
z.any(),
),
};
export const loginApi = new Api("/login", "POST", schema);

View File

@ -5,9 +5,8 @@ import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
import authMiddleware from "@src/middleware/auth.ts";
import loggerMiddleware from "@src/middleware/logger.ts";
import { SchemaValidationError, z } from "@shared/utils/validator.ts";
import { Api } from "@src/lib/apiValidator.ts";
import { loginApi } from "./api.ts";
import { err, getMessageFromError, ok } from "@shared/utils/result.ts";
import { err, ok } from "@shared/utils/result.ts";
import admin from "@src/lib/admin.ts";
import {
FailedToParseRequestAsJSON,
@ -49,15 +48,17 @@ router
return c.html(eta.render("./index.html", {}));
})
.get(["/login", "/login.html"], (c) => {
return c.html(eta.render("./login.html", {}));
const alreadyLoggedIn = c.cookies.get("token").map((token) =>
admin.sessions.verifyToken(token)
)
.toBoolean();
console.log(alreadyLoggedIn);
return c.html(eta.render("./login.html", { alreadyLoggedIn }));
});
const schema = {
req: z.obj({
password: z.string().max(1024),
}),
res: z.result(z.void(), z.string()),
};
admin.setPassword("Vermont5481");
router.api(loginApi, async (c) => {
const r = await c
@ -68,7 +69,7 @@ router.api(loginApi, async (c) => {
if (r.isErr()) {
if (r.error.type === "AdminPasswordNotSetError") {
return c.json(
return c.json400(
err({
type: r.error.type,
msg: r.error.message,
@ -78,18 +79,26 @@ router.api(loginApi, async (c) => {
return handleCommonErrors(c, r.error);
}
return admin.sessions.create()
.map(({ value, expires }) => {
c.cookies.set({
name: AUTH_COOKIE_NAME,
value,
expires,
});
return ok();
}).match(
() => c.json(ok()),
(e) => handleCommonErrors(c, e),
);
const isMatch = r.value;
if (isMatch) {
return admin.sessions.create()
.map(({ value, expires }) => {
c.cookies.set({
name: AUTH_COOKIE_NAME,
value,
expires,
});
return ok({ isMatch: true });
}).match(
(v) => c.json(v),
(e) => handleCommonErrors(c, e),
);
} else {
return c.json(ok({
isMatch: false,
}));
}
});
function handleCommonErrors(
@ -106,11 +115,18 @@ function handleCommonErrors(
{ status: 500 },
);
case "FailedToParseRequestAsJSON":
case "SchemaValiationError":
return c.json(
err(error),
{ status: 400 },
);
case "SchemaValiationError":
return c.json(
err({
type: "ValidationError",
msg: error.msg,
}),
{ status: 400 },
);
}
}

View File

View File

@ -1 +1 @@
import{loginApi as o}from"./shared.bundle.js";const s=document.getElementById("loginForm"),m=document.getElementById("passwordInput");s.addEventListener("submit",async e=>{e.preventDefault();const t=m.value,n=await o.makeRequest({password:t},{});console.log(n)});
import{loginApi as s}from"./shared.bundle.js";const o=document.getElementById("loginForm"),i=document.getElementById("passwordInput"),t=document.getElementById("errDiv");o.addEventListener("submit",async n=>{n.preventDefault();const r=i.value,e=(await s.makeRequest({password:r},{})).flatten();e.isErr()?e.error.type==="RequestValidationError"&&(t.innerText=e.error.msg):e.value.isMatch?window.location.href="/":t.innerText="invalid password"});

File diff suppressed because one or more lines are too long

1
server/src/js/index.ts Normal file
View File

@ -0,0 +1 @@

View File

@ -6,13 +6,24 @@ const form = document.getElementById("loginForm") as HTMLFormElement;
const passwordInput = document.getElementById(
"passwordInput",
) as HTMLInputElement;
const errDiv = document.getElementById("errDiv") as HTMLDivElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const password = passwordInput.value;
const res = await loginApi.makeRequest({ password }, {});
const res = (await loginApi.makeRequest({ password }, {})).flatten();
console.log(res);
if (res.isErr()) {
if (res.error.type === "RequestValidationError") {
errDiv.innerText = res.error.msg;
}
} else {
if (!res.value.isMatch) {
errDiv.innerText = "invalid password";
} else {
window.location.href = "/";
}
}
});

View File

@ -1,15 +1,9 @@
import { Result } from "@shared/utils/result.ts";
import {
InferSchemaType,
ResultSchema,
Schema,
z,
} from "@shared/utils/validator.ts";
import { InferSchemaType, Schema } from "@shared/utils/validator.ts";
import {
RequestValidationError,
ResponseValidationError,
} from "@src/lib/errors.ts";
import { errAsync, okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import { ResultAsync } from "@shared/utils/resultasync.ts";
export type ExtractRouteParams<T extends string> = T extends string
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
@ -85,4 +79,16 @@ export class Api<
);
});
}
public makeSafeRequest(
reqBody: InferSchemaType<ReqSchema>,
params: { [K in ExtractRouteParams<Path>]: string },
): ResultAsync<InferSchemaType<ResSchema>, ResponseValidationError> {
return this.makeRequest(reqBody, params).mapErr((e) => {
if (e.type === "RequestValidationError") {
throw "Failed to validate request";
}
return e;
});
}
}

View File

@ -3,19 +3,13 @@ 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 {
Err,
getMessageFromError,
Ok,
type Result,
ResultFromJSON,
} from "@shared/utils/result.ts";
import { getMessageFromError } from "@shared/utils/result.ts";
import {
InferSchemaType,
Schema,
SchemaValidationError,
} from "@shared/utils/validator.ts";
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import { ResultAsync } from "@shared/utils/resultasync.ts";
import log from "@shared/utils/logger.ts";
import { FailedToParseRequestAsJSON } from "@src/lib/errors.ts";
@ -141,7 +135,10 @@ export class Context<
return none;
}
public json(body?: object | string, init: ResponseInit = {}): Response {
public json(
body?: ResSchema extends Schema<infer T> ? T : object | string,
init: ResponseInit = {},
): Response {
const headers = mergeHeaders(
SECURITY_HEADERS,
this._responseHeaders,

83
server/src/lib/devices.ts Normal file
View File

@ -0,0 +1,83 @@
import usbip from "@src/lib/usbip.ts";
import {
type CommandExecutionError,
DeviceDetailed,
type UsbipUnknownError,
} from "@shared/utils/usbip.ts";
import { none } from "@shared/utils/option.ts";
import { InferSchemaType, z } from "@shared/utils/validator.ts";
import log from "@shared/utils/logger.ts";
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
class Devices {
private readonly devices: Map<string, Device> = new Map();
updateDevices(): ResultAsync<
void,
CommandExecutionError | UsbipUnknownError
> {
return usbip.getDevicesDetailed().mapErr((e) => {
log.error("Failed to update devices!");
return e;
}).map((d) => d.unwrapOr([])).map(
(devices) => {
const current = new Set(devices.map((d) => d.busid));
const old = new Set(this.devices.keys());
const connected = current.difference(old);
const disconnected = old.difference(current);
for (const device of devices) {
if (connected.has(device.busid)) {
this.devices.set(
device.busid,
this.deviceFromDetailed(device),
);
}
}
for (const device of disconnected) {
this.devices.delete(device);
}
},
);
}
deviceFromDetailed(d: DeviceDetailed): Device {
return {
busid: d.busid,
usbid: d.usbid,
vendor: d.vendor,
name: d.name,
displayName: none,
description: none,
connectedAt: new Date(),
};
}
}
export const deviceSchema = z.obj({
busid: z.string(),
usbid: z.option(z.string()),
vendor: z.option(z.string()),
name: z.option(z.string()),
displayName: z.option(z.string()),
description: z.option(z.string()),
connectedAt: z.date(),
});
const test = new Devices();
await test.updateDevices();
console.log(test);
import { sleep } from "https://deno.land/x/sleep/mod.ts";
await sleep(5);
await test.updateDevices();
console.log(test);
export type Device = InferSchemaType<typeof deviceSchema>;

View File

@ -1,9 +1,16 @@
import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts";
const DEFAULT_WILDCARD_SYMBOL = "*";
const DEFAULT_WILDCARD = "*";
const DEFAULT_PARAM_PREFIX = ":";
const DEFAULT_PATH_SEPARATOR = "/";
export type Params = Record<string, string>;
interface RouteMatch<T> {
value: T;
params: Params;
}
interface Node<T> {
handler: Option<T>;
paramNames: string[];
@ -29,52 +36,52 @@ class StaticNode<T> implements Node<T> {
this.handler = fromNullableVal(handler);
}
addStaticChild(segment: string, handler?: T): StaticNode<T> {
private addStaticChild(segment: string, handler?: T): StaticNode<T> {
const child = new StaticNode(handler);
this.staticChildren.set(segment, child);
return child;
}
setDynamicChild(handler?: T): DynamicNode<T> {
private createDynamicChild(handler?: T): DynamicNode<T> {
const child = new DynamicNode(handler);
this.dynamicChild = some(child);
return child;
}
setWildcardNode(handler?: T): WildcardNode<T> {
private createWildcardNode(handler?: T): WildcardNode<T> {
const child = new WildcardNode(handler);
this.wildcardChild = some(child);
return child;
}
addChild(
public addChild(
segment: string,
wildcardSymbol: string,
paramPrefixSymbol: string,
handler?: T,
): Node<T> {
if (segment === wildcardSymbol) {
return this.setWildcardNode(handler);
return this.createWildcardNode(handler);
}
if (segment.startsWith(paramPrefixSymbol)) {
return this.setDynamicChild(handler);
return this.createDynamicChild(handler);
}
return this.addStaticChild(segment, handler);
}
getStaticChild(segment: string): Option<StaticNode<T>> {
private getStaticChild(segment: string): Option<StaticNode<T>> {
return fromNullableVal(this.staticChildren.get(segment));
}
getDynamicChild(): Option<DynamicNode<T>> {
public getDynamicChild(): Option<DynamicNode<T>> {
return this.dynamicChild;
}
getWildcardChild(): Option<WildcardNode<T>> {
public getWildcardChild(): Option<WildcardNode<T>> {
return this.wildcardChild;
}
getChild(segment: string): Option<Node<T>> {
public getChild(segment: string): Option<Node<T>> {
return this.getStaticChild(segment)
.orElse(() => this.getWildcardChild())
.orElse(() => this.getDynamicChild());
@ -89,7 +96,6 @@ class StaticNode<T> implements Node<T> {
}
}
// TODO: get rid of fixed param name
class DynamicNode<T> extends StaticNode<T> implements Node<T> {
constructor(
handler?: T,
@ -112,7 +118,7 @@ class WildcardNode<T> implements Node<T> {
// Override to prevent adding children to a wildcard node
public addChild(): Node<T> {
throw new Error("Cannot add child to a WildcardNode.");
throw new Error("Cannot add child to a wildcard (catch-all) node.");
}
public getChild(): Option<Node<T>> {
@ -128,16 +134,13 @@ class WildcardNode<T> implements Node<T> {
}
}
// Using Node<T> as the unified type for tree nodes.
type TreeNode<T> = Node<T>;
export class RouterTree<T> {
public readonly root: StaticNode<T>;
constructor(
handler?: T,
private readonly wildcardSymbol: string = DEFAULT_WILDCARD_SYMBOL,
private readonly paramPrefixSymbol: string = DEFAULT_PARAM_PREFIX,
private readonly wildcardSymbol: string = DEFAULT_WILDCARD,
private readonly paramPrefix: string = DEFAULT_PARAM_PREFIX,
private readonly pathSeparator: string = DEFAULT_PATH_SEPARATOR,
) {
this.root = new StaticNode(handler);
@ -146,7 +149,7 @@ export class RouterTree<T> {
public add(path: string, handler: T): void {
const segments = this.splitPath(path);
const paramNames: string[] = this.extractParams(segments);
let current: TreeNode<T> = this.root;
let current: Node<T> = this.root;
for (const segment of segments) {
current = current
@ -155,7 +158,7 @@ export class RouterTree<T> {
current.addChild(
segment,
this.wildcardSymbol,
this.paramPrefixSymbol,
this.paramPrefix,
)
);
@ -174,7 +177,7 @@ export class RouterTree<T> {
public find(path: string): Option<RouteMatch<T>> {
const segments = this.splitPath(path);
const paramValues: string[] = [];
let current: TreeNode<T> = this.root;
let current: Node<T> = this.root;
let i = 0;
for (; i < segments.length; i++) {
@ -209,7 +212,7 @@ export class RouterTree<T> {
public getHandler(path: string): Option<T> {
const segments = this.splitPath(path);
let current: TreeNode<T> = this.root;
let current: Node<T> = this.root;
for (const segment of segments) {
if (current.isWildcardNode()) break;
@ -224,6 +227,16 @@ export class RouterTree<T> {
return current.handler;
}
private traverseOrCreate(segments: string[]): Node<T> {
let node: Node<T> = this.root;
for (const segment of segments) {
if (node.isWildcardNode()) break;
node = node.getChild(segment).unwrapOrElse(() =>
node.addChild(segment, this.wildcardSymbol, this.paramPrefix)
);
}
}
private splitPath(path: string): string[] {
const trimmed = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
return trimmed ? trimmed.split(this.pathSeparator) : [];
@ -231,18 +244,11 @@ export class RouterTree<T> {
public extractParams(segments: string[]): string[] {
return segments.filter((segment) =>
segment.startsWith(this.paramPrefixSymbol)
segment.startsWith(this.paramPrefix)
).map((segment) => this.stripParamPrefix(segment));
}
public stripParamPrefix(segment: string): string {
return segment.slice(this.paramPrefixSymbol.length);
return segment.slice(this.paramPrefix.length);
}
}
export type Params = Record<string, string>;
interface RouteMatch<T> {
value: T;
params: Params;
}

View File

@ -17,10 +17,6 @@ const authMiddleware: Middleware = async (c, next) => {
return c.redirect("/login");
}
if (path === LOGIN_PATH && isValid) {
return c.redirect("");
}
await next();
}
};

View File

@ -4,7 +4,6 @@ const loggerMiddleware: Middleware = async (c, next) => {
console.log("", c.req.method, c.path);
await next();
console.log("", c.res.status, "\n");
console.log(await c.res.json());
};
export default loggerMiddleware;

View File

@ -1,3 +1,3 @@
<% layout("./layouts/layout.html") %>
this is an index.html
<script src="/public/js/index.js" defer></script>

View File

@ -1,7 +1,17 @@
<% layout("./layouts/basic.html") %>
<main>
<form id=loginForm method=POST>
<p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit>
</form>
</main>
<script defer src=/public/js/login.js type=module></script>
<% if (!it.alreadyLoggedIn) { %>
<main>
<form id=loginForm method=POST>
<p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit>
<div id="errDiv"></div>
</form>
</main>
<script defer src=/public/js/login.js type=module></script>
<% } else { %>
<main>
You are already logged in!
</main>
<script>
setTimeout(() => {window.location.href = "/"}, 1500)
</script>
<% } %>

Binary file not shown.

View File

@ -1 +1,6 @@
<% layout("./layouts/layout.html") %> this is an index.html
<% layout("./layouts/layout.html") %>
devices:
<div id="Devices"></div>
<script defer src=/public/js/index.js></script>

View File

@ -1 +1 @@
<% layout("./layouts/basic.html") %> <main><form id=loginForm method=POST><p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit></form></main><script defer src=/public/js/login.js type=module></script>
<% layout("./layouts/basic.html") %> <% if (!it.alreadyLoggedIn) { %> <main><form id=loginForm method=POST><p>password</p><input id=passwordInput name=password type=password><input value="sign in" type=submit><div id=errDiv></div></form></main><script defer src=/public/js/login.js type=module></script> <% } else { %> <main>You are already logged in!</main><script>setTimeout(() => {window.location.href = "/"}, 1500)</script> <% } %>

View File

@ -371,7 +371,7 @@ export function flattenResult<R extends Result<any, any>>(
currentResult = currentResult.value;
}
return currentResult as FlattenResult<R>;
return ok(currentResult) as FlattenResult<R>;
}
export type UnwrapOption<T> = T extends Option<infer V> ? V : T;

View File

@ -254,13 +254,12 @@ export function errAsync<E, T = never>(err: E): ResultAsync<T, E> {
return new ResultAsync(Promise.resolve(new Err<T, E>(err)));
}
export type FlattenResultAsync<R> = R extends ResultAsync<infer T, infer E>
? T extends ResultAsync<any, any>
? FlattenResultAsync<T> extends ResultAsync<infer V, infer innerE>
? ResultAsync<V, E | innerE>
: never
type FlattenResultAsync<R> = R extends
ResultAsync<infer Inner, infer OuterError>
? Inner extends ResultAsync<infer T, infer InnerError>
? ResultAsync<T, OuterError | InnerError>
: R
: never;
: R;
type UnwrapPromise<Pr extends Promise<unknown>> = Pr extends Promise<infer U>
? U

View File

@ -9,42 +9,42 @@ import {
some,
} from "@shared/utils/option.ts";
class CommandExecutionError extends Error {
code = "CommandExecutionError";
export class CommandExecutionError extends Error {
type = "CommandExecutionError";
constructor(msg: string) {
super(msg);
}
}
class DeviceDoesNotExistError extends Error {
code = "DeviceDoesNotExist";
export class DeviceDoesNotExistError extends Error {
type = "DeviceDoesNotExist";
constructor(msg: string) {
super(msg);
}
}
class DeviceAlreadyBoundError extends Error {
code = "DeviceAlreadyBound";
export class DeviceAlreadyBoundError extends Error {
type = "DeviceAlreadyBound";
constructor(msg: string) {
super(msg);
}
}
class DeviceNotBound extends Error {
code = "DeviceNotBound";
export class DeviceNotBound extends Error {
type = "DeviceNotBound";
constructor(msg: string) {
super(msg);
}
}
class UsbipUknownError extends Error {
code = "UsbipUknownError";
export class UsbipUnknownError extends Error {
type = "UsbipUknownError";
constructor(msg: string) {
super(msg);
}
}
type UsbipCommonError = DeviceDoesNotExistError | UsbipUknownError;
type UsbipCommonError = DeviceDoesNotExistError | UsbipUnknownError;
class UsbipManager {
private readonly listDeatiledCmd = new Deno.Command("usbip", {
@ -84,7 +84,7 @@ class UsbipManager {
return new DeviceDoesNotExistError(stderr);
}
return new UsbipUknownError(stderr);
return new UsbipUnknownError(stderr);
}
private parseDetailedList(stdout: string): Option<DeviceDetailed[]> {
@ -140,7 +140,7 @@ class UsbipManager {
public getDevicesDetailed(): ResultAsync<
Option<DeviceDetailed[]>,
CommandExecutionError | UsbipUknownError
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listDeatiledCmd).andThen(
({ stdout, stderr, success }) => {
@ -153,7 +153,7 @@ class UsbipManager {
return ok(this.parseDetailedList(stdout));
}
return err(new UsbipUknownError(stderr));
return err(new UsbipUnknownError(stderr));
},
);
}
@ -193,7 +193,7 @@ class UsbipManager {
public getDevices(): ResultAsync<
Option<Device[]>,
CommandExecutionError | UsbipUknownError
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listParsableCmd).andThenAsync(
({ stdout, stderr, success }) => {
@ -205,7 +205,7 @@ class UsbipManager {
}
return okAsync(this.parseParsableList(stdout));
}
return errAsync(new UsbipUknownError(stderr));
return errAsync(new UsbipUnknownError(stderr));
},
);
}
@ -268,7 +268,7 @@ class CommandOutput {
}
}
interface DeviceDetailed {
export interface DeviceDetailed {
busid: string;
usbid: Option<string>;
vendor: Option<string>;

View File

@ -0,0 +1,2 @@
export * from "./sleep.ts"

View File

@ -0,0 +1,11 @@
// I buy and sell https://FreedomCash.org
export function sleep(seconds: number) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
export function sleepRandomAmountOfSeconds(minimumSeconds: number, maximumSeconds: number) {
const secondsOfSleep = getRandomArbitrary(minimumSeconds, maximumSeconds)
return new Promise((resolve) => setTimeout(resolve, secondsOfSleep * 1000))
}
function getRandomArbitrary(min: number, max: number) {
return Math.random() * (max - min) + min
}

View File

@ -1,5 +1,11 @@
{
"modules": {
"https://deno.land/x/sleep/mod.ts": {
"headers": {
"location": "/x/sleep@v1.3.0/mod.ts",
"x-deno-warning": "Implicitly using latest version (v1.3.0) for https://deno.land/x/sleep/mod.ts"
}
},
"https://jsr.io/@std/crypto/1.0.3/_wasm/lib/deno_std_wasm_crypto.generated.d.mts": {},
"https://jsr.io/@std/crypto/1.0.3/_wasm/lib/deno_std_wasm_crypto.generated.mjs": {},
"https://jsr.io/@std/net/1.0.4/unstable_get_network_address.ts": {}