working admin auth

This commit is contained in:
2025-02-13 18:31:44 +03:00
parent cafb669fd1
commit 74cd00e62b
25 changed files with 776 additions and 134 deletions

View File

@ -1,16 +1,68 @@
import { Api } from "@src/lib/apiValidator.ts"; import { Api } from "@src/lib/apiValidator.ts";
import { z } from "@shared/utils/validator.ts"; import { createValidationError, z } from "@shared/utils/validator.ts";
import {
adminPasswordAlreadySetErrorSchema,
adminPasswordNotSetErrorSchema,
failedToParseRequestAsJSONErrorSchema,
invalidPasswordErrorSchema,
passwordsMustMatchErrorSchema,
queryExecutionErrorSchema,
requestValidationErrorSchema,
tooManyRequestsErrorSchema,
} from "@src/lib/errors.ts";
const schema = { const loginApiSchema = {
req: z.obj({ req: z.obj({
password: z.string(), password: z.string(),
}), }),
res: z.result( res: z.result(
z.obj({ z.void(),
isMatch: z.boolean(), z.union([
}), adminPasswordNotSetErrorSchema,
z.any(), queryExecutionErrorSchema,
failedToParseRequestAsJSONErrorSchema,
requestValidationErrorSchema,
tooManyRequestsErrorSchema,
invalidPasswordErrorSchema,
]),
), ),
}; };
export const loginApi = new Api("/login", "POST", schema); export const loginApi = new Api("/login", "POST", loginApiSchema);
const passwordSetupApiSchema = {
req: z.obj({
password: z.string().min(
10,
"Password must be at least 10 characters long",
).regex(
/^[a-zA-Z0-9]+$/,
"Password must consist of lower or upper case latin letters and numbers",
),
passwordRepeat: z.string(),
}).addCheck((v) => {
if (v.passwordRepeat !== v.password) {
return createValidationError(v, {
kind: "general",
msg: "Passwords must match",
});
}
}),
res: z.result(
z.void(),
z.union([
passwordsMustMatchErrorSchema,
adminPasswordAlreadySetErrorSchema,
queryExecutionErrorSchema,
failedToParseRequestAsJSONErrorSchema,
requestValidationErrorSchema,
tooManyRequestsErrorSchema,
]),
),
};
export const passwordSetupApi = new Api(
"/setup",
"POST",
passwordSetupApiSchema,
);

View File

@ -1,6 +1,6 @@
{ {
"tasks": { "tasks": {
"dev": "deno run --allow-read --allow-write --allow-sys --allow-env --allow-run ./autoBundler.ts & deno serve --allow-read --allow-write --allow-sys --allow-env --allow-ffi --watch -R main.ts" "dev": "deno run --allow-read --allow-write --allow-sys --allow-env --allow-run ./autoBundler.ts & deno serve --allow-read --allow-write --allow-sys --allow-env --allow-ffi --watch --allow-run -R main.ts"
}, },
"imports": { "imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.12.0", "@db/sqlite": "jsr:@db/sqlite@^0.12.0",

View File

@ -5,21 +5,26 @@ import rateLimitMiddleware from "@src/middleware/rateLimiter.ts";
import authMiddleware from "@src/middleware/auth.ts"; import authMiddleware from "@src/middleware/auth.ts";
import loggerMiddleware from "@src/middleware/logger.ts"; import loggerMiddleware from "@src/middleware/logger.ts";
import { SchemaValidationError, z } from "@shared/utils/validator.ts"; import { SchemaValidationError, z } from "@shared/utils/validator.ts";
import { loginApi } from "./api.ts"; import { loginApi, passwordSetupApi } from "./api.ts";
import { err, ok } from "@shared/utils/result.ts"; import { err, ok } from "@shared/utils/result.ts";
import admin from "@src/lib/admin.ts"; import admin from "@src/lib/admin.ts";
import {
FailedToParseRequestAsJSON,
QueryExecutionError,
} from "@src/lib/errors.ts";
import { Context } from "@src/lib/context.ts"; import { Context } from "@src/lib/context.ts";
import {
FailedToParseRequestAsJSONError,
invalidPasswordError,
passwordsMustMatchError,
QueryExecutionError,
queryExecutionError,
RequestValidationError,
} from "@src/lib/errors.ts";
import devices from "@src/lib/devices.ts";
const AUTH_COOKIE_NAME = "token"; const AUTH_COOKIE_NAME = "token";
const router = new HttpRouter(); const router = new HttpRouter();
const views = Deno.cwd() + "/views/"; const views = Deno.cwd() + "/views/";
const eta = new Eta({ views }); export const eta = new Eta({ views });
router.use(loggerMiddleware); router.use(loggerMiddleware);
router.use(rateLimitMiddleware); router.use(rateLimitMiddleware);
@ -45,9 +50,21 @@ router.get("/public/*", async (c) => {
router router
.get(["", "/index.html"], (c) => { .get(["", "/index.html"], (c) => {
console.log(devices.list());
return c.html(eta.render("./index.html", {})); return c.html(eta.render("./index.html", {}));
}) })
.get(["/login", "/login.html"], (c) => { .get(["/login", "/login.html"], (c) => {
const isSet = admin.isPasswordSet();
if (isSet.isErr()) {
return c.html(eta.render("./internal_error.html", {}));
}
if (!isSet.value) {
return c.redirect("/setup");
}
const alreadyLoggedIn = c.cookies.get("token").map((token) => const alreadyLoggedIn = c.cookies.get("token").map((token) =>
admin.sessions.verifyToken(token) admin.sessions.verifyToken(token)
) )
@ -56,10 +73,21 @@ router
console.log(alreadyLoggedIn); console.log(alreadyLoggedIn);
return c.html(eta.render("./login.html", { alreadyLoggedIn })); return c.html(eta.render("./login.html", { alreadyLoggedIn }));
})
.get("/setup", (c) => {
return admin.isPasswordSet()
.match(
(isSet) => {
if (isSet) {
return c.redirect("/login");
} else {
return c.html(eta.render("./setup.html", {}));
}
},
(e) => c.html(eta.render("./internal_error.html", {})),
);
}); });
admin.setPassword("Vermont5481");
router.api(loginApi, async (c) => { router.api(loginApi, async (c) => {
const r = await c const r = await c
.parseBody() .parseBody()
@ -72,7 +100,7 @@ router.api(loginApi, async (c) => {
return c.json400( return c.json400(
err({ err({
type: r.error.type, type: r.error.type,
msg: r.error.message, info: r.error.info,
}), }),
); );
} }
@ -89,42 +117,56 @@ router.api(loginApi, async (c) => {
value, value,
expires, expires,
}); });
return ok({ isMatch: true }); return ok();
}).match( }).match(
(v) => c.json(v), (v) => c.json(v),
(e) => handleCommonErrors(c, e), (e) => handleCommonErrors(c, e),
); );
} else { } else {
return c.json(ok({ return c.json(err(invalidPasswordError("Invalid login or password")));
isMatch: false,
}));
} }
}); });
router.api(passwordSetupApi, async (c) => {
const r = await c.parseBody();
if (r.isErr()) {
return handleCommonErrors(c, r.error);
}
const v = r.value;
if (v.password !== v.passwordRepeat) {
return c.json400(err(passwordsMustMatchError("Passwords must match")));
}
return admin.setPassword(v.password).match(
() => c.json(ok()),
(e) => c.json400(err(e)),
);
});
function handleCommonErrors( function handleCommonErrors(
c: Context<any, any, any>, c: Context<any, any, any>,
error: error:
| QueryExecutionError | QueryExecutionError
| FailedToParseRequestAsJSON | FailedToParseRequestAsJSONError
| SchemaValidationError, | RequestValidationError,
): Response { ): Response {
switch (error.type) { switch (error.type) {
case "QueryExecutionError": case "QueryExecutionError":
return c.json( return c.json(
err(new QueryExecutionError("Server failed to execute query")), err(queryExecutionError("Server failed to execute query")),
{ status: 500 }, { status: 500 },
); );
case "FailedToParseRequestAsJSON": case "FailedToParseRequestAsJSONError":
return c.json( return c.json(
err(error), err(error),
{ status: 400 }, { status: 400 },
); );
case "SchemaValiationError": case "RequestValidationError":
return c.json( return c.json(
err({ err(error),
type: "ValidationError",
msg: error.msg,
}),
{ status: 400 }, { status: 400 },
); );
} }

View File

@ -1 +1 @@
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"}); import{loginApi as o}from"./shared.bundle.js";const r=document.getElementById("loginForm"),s=document.getElementById("passwordInput"),i=document.getElementById("errDiv");r.addEventListener("submit",async t=>{t.preventDefault();const n=s.value,e=(await o.makeRequest({password:n},{})).flatten();e.isErr()?i.innerText=e.error.info:window.location.href="/"});

View File

@ -0,0 +1 @@
import{passwordSetupApi as o}from"./shared.bundle.js";const r=document.getElementById("passwordSetupForm"),a=document.getElementById("passwordInput"),p=document.getElementById("passwordRepeatInput"),d=document.getElementById("errDiv");r.addEventListener("submit",async t=>{t.preventDefault();const n=a.value,s=p.value,e=(await o.makeRequest({password:n,passwordRepeat:s},{})).flatten();e.isErr()?d.innerText=e.error.info:window.location.href="/login"});

File diff suppressed because one or more lines are too long

View File

@ -16,14 +16,8 @@ form.addEventListener("submit", async (e) => {
const res = (await loginApi.makeRequest({ password }, {})).flatten(); const res = (await loginApi.makeRequest({ password }, {})).flatten();
if (res.isErr()) { if (res.isErr()) {
if (res.error.type === "RequestValidationError") { errDiv.innerText = res.error.info;
errDiv.innerText = res.error.msg;
}
} else { } else {
if (!res.value.isMatch) { window.location.href = "/";
errDiv.innerText = "invalid password";
} else {
window.location.href = "/";
}
} }
}); });

29
server/src/js/setup.ts Normal file
View File

@ -0,0 +1,29 @@
/// <reference lib="dom" />
import { passwordSetupApi } from "./shared.bundle.ts";
const form = document.getElementById("passwordSetupForm") as HTMLFormElement;
const passwordInput = document.getElementById(
"passwordInput",
) as HTMLInputElement;
const passwordRepeatInput = document.getElementById(
"passwordRepeatInput",
) as HTMLInputElement;
const errDiv = document.getElementById("errDiv") as HTMLDivElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const password = passwordInput.value;
const passwordRepeat = passwordRepeatInput.value;
const res =
(await passwordSetupApi.makeRequest({ password, passwordRepeat }, {}))
.flatten();
if (res.isErr()) {
errDiv.innerText = res.error.info;
} else {
window.location.href = "/login";
}
});

View File

@ -1,7 +1,11 @@
import { Option, some } from "@shared/utils/option.ts"; import { Option, some } from "@shared/utils/option.ts";
import db from "@lib/db/index.ts"; import db from "@lib/db/index.ts";
import { ok, Result } from "@shared/utils/result.ts"; import { ok, Result } from "@shared/utils/result.ts";
import { AdminPasswordNotSetError, QueryExecutionError } from "@lib/errors.ts"; import {
AdminPasswordNotSetError,
adminPasswordNotSetError,
QueryExecutionError,
} from "@lib/errors.ts";
import { AdminRaw, AdminSessionRaw } from "@lib/db/types/index.ts"; import { AdminRaw, AdminSessionRaw } from "@lib/db/types/index.ts";
import { generateRandomString, passwd } from "@lib/utils.ts"; import { generateRandomString, passwd } from "@lib/utils.ts";
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts"; import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
@ -52,7 +56,7 @@ class Admin {
const result = this.getPasswordHash().flattenOption( const result = this.getPasswordHash().flattenOption(
() => { () => {
log.warn("Tried to verify password when it is not set"); log.warn("Tried to verify password when it is not set");
return new AdminPasswordNotSetError( return adminPasswordNotSetError(
"Admin password is not set", "Admin password is not set",
); );
}, },

View File

@ -19,7 +19,7 @@ export type ExtractRouteParams<T extends string> = T extends string
: never; : never;
type ApiError = type ApiError =
| SchemaValidationError | RequestValidationError
| ResponseValidationError; | ResponseValidationError;
export class Api< export class Api<
@ -57,7 +57,7 @@ export class Api<
return this.schema.req return this.schema.req
.parse(reqBody) .parse(reqBody)
.toAsync() .toAsync()
.mapErr((e) => requestValidationError(e.msg)) .mapErr((e) => requestValidationError(e.info))
.andThenAsync(async (data) => { .andThenAsync(async (data) => {
const pathSplitted = this.pathSplitted; const pathSplitted = this.pathSplitted;
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
@ -71,6 +71,7 @@ export class Api<
method: this.method, method: this.method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json; charset=utf-8",
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}, },
@ -80,7 +81,7 @@ export class Api<
return this.schema.res.parse(resBody).toAsync() return this.schema.res.parse(resBody).toAsync()
.map((v) => v as InferSchemaType<ResSchema>) .map((v) => v as InferSchemaType<ResSchema>)
.mapErr((e) => responseValidationError(e.msg)); .mapErr((e) => responseValidationError(e.info));
}); });
} }

View File

@ -15,6 +15,8 @@ import {
FailedToParseRequestAsJSONError, FailedToParseRequestAsJSONError,
failedToParseRequestAsJSONError, failedToParseRequestAsJSONError,
} from "@src/lib/errors.ts"; } 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 // https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
const SECURITY_HEADERS: Headers = new Headers({ const SECURITY_HEADERS: Headers = new Headers({
@ -86,7 +88,7 @@ export class Context<
} }
public setParams( public setParams(
params: Params, params: Params<string>,
): Context<S, ReqSchema, ResSchema> { ): Context<S, ReqSchema, ResSchema> {
const ctx = new Context<S, ReqSchema, ResSchema>( const ctx = new Context<S, ReqSchema, ResSchema>(
this.req, this.req,
@ -98,12 +100,13 @@ export class Context<
ctx._port = this._port; ctx._port = this._port;
ctx._cookies = this._cookies; ctx._cookies = this._cookies;
ctx.res = this.res; ctx.res = this.res;
ctx.schema = this.schema;
return ctx as Context<S, ReqSchema, ResSchema>; return ctx as Context<S, ReqSchema, ResSchema>;
} }
public parseBody(): ResultAsync< public parseBody(): ResultAsync<
InferSchemaType<ReqSchema>, InferSchemaType<ReqSchema>,
SchemaValidationError | FailedToParseRequestAsJSONError RequestValidationError | FailedToParseRequestAsJSONError
> { > {
return ResultAsync return ResultAsync
.fromPromise( .fromPromise(
@ -115,7 +118,9 @@ export class Context<
return ok(data); return ok(data);
} }
return this.schema?.req.parse(data); return this.schema?.req.parse(data).mapErr((e) =>
requestValidationError(e.info)
);
}); });
} }
@ -138,6 +143,21 @@ export class Context<
return none; 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> { get hostname(): Option<string> {
if (this._hostname) return some(this._hostname); if (this._hostname) return some(this._hostname);
const remoteAddr = this.info.remoteAddr; const remoteAddr = this.info.remoteAddr;

View File

@ -1,49 +1,89 @@
import usbip from "@src/lib/usbip.ts"; import usbip, {
import { CommandExecutionError,
type CommandExecutionError,
DeviceDetailed, DeviceDetailed,
type UsbipUnknownError, DeviceDoesNotExistError,
} from "@shared/utils/usbip.ts"; deviceDoesNotExistError,
import { none } from "@shared/utils/option.ts"; UsbipUnknownError,
} from "@src/lib/usbip.ts";
import { none, Option, some } from "@shared/utils/option.ts";
import { InferSchemaType, z } from "@shared/utils/validator.ts"; import { InferSchemaType, z } from "@shared/utils/validator.ts";
import log from "@shared/utils/logger.ts"; import log from "@shared/utils/logger.ts";
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts"; import { ResultAsync } from "@shared/utils/resultasync.ts";
import { err, Ok, ok, Result } from "@shared/utils/result.ts";
type FailedToAccessDevices = CommandExecutionError | UsbipUnknownError;
class Devices { class Devices {
private readonly devices: Map<string, Device> = new Map(); private devices: Result<
Map<string, Device>,
updateDevices(): ResultAsync<
void,
CommandExecutionError | UsbipUnknownError CommandExecutionError | UsbipUnknownError
> { > = ok(new Map());
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); public update(
const disconnected = old.difference(current); busid: string,
update: Partial<DeviceMutables>,
): Result<void, DeviceDoesNotExistError | FailedToAccessDevices> {
return this.devices.andThen((devices) => {
const device = devices.get(busid);
for (const device of devices) { if (device === undefined) {
if (connected.has(device.busid)) { return err(
this.devices.set( deviceDoesNotExistError(
device.busid, `Device with busid ${busid} does not exist`,
this.deviceFromDetailed(device), ),
); );
} }
}
for (const device of disconnected) { for (const key of Object.keys(update)) {
this.devices.delete(device); device[key as keyof typeof update] =
} update[key as keyof typeof update] || none;
}, }
);
return ok();
});
} }
deviceFromDetailed(d: DeviceDetailed): Device { public updateDevices(): ResultAsync<
void,
FailedToAccessDevices
> {
return usbip.getDevicesDetailed()
.mapErr((e) => {
log.error("Failed to update devices!");
this.devices = err(e);
return e;
})
.map((d) => d.unwrapOr([] as DeviceDetailed[]))
.map(
(devices) => {
const current = new Set(devices.map((d) => d.busid));
const old = new Set(
this.devices.unwrapOrElse(() => {
this.devices = ok(new Map());
return this.devices.unwrap();
}).keys(),
);
const connected = current.difference(old);
const disconnected = old.difference(current);
for (const device of devices) {
if (connected.has(device.busid)) {
this.devices.unwrap().set(
device.busid,
this.deviceFromDetailed(device),
);
}
}
for (const device of disconnected) {
this.devices.unwrap().delete(device);
}
},
);
}
private deviceFromDetailed(d: DeviceDetailed): Device {
return { return {
busid: d.busid, busid: d.busid,
usbid: d.usbid, usbid: d.usbid,
@ -54,6 +94,12 @@ class Devices {
connectedAt: new Date(), connectedAt: new Date(),
}; };
} }
public list(): Result<Option<Device[]>, FailedToAccessDevices> {
return this.devices.map((devices) => devices.values().toArray()).map((
devices,
) => devices.length > 0 ? some(devices) : none);
}
} }
export const deviceSchema = z.obj({ export const deviceSchema = z.obj({
@ -64,20 +110,19 @@ export const deviceSchema = z.obj({
displayName: z.option(z.string()), displayName: z.option(z.string()),
description: z.option(z.string()), description: z.option(z.string()),
connectedAt: z.date(), connectedAt: z.date(),
}).strict();
export const deviceMutablesSchema = deviceSchema.pick({
displayName: true,
description: true,
}); });
const test = new Devices(); export type DeviceMutables = InferSchemaType<typeof deviceMutablesSchema>;
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>; export type Device = InferSchemaType<typeof deviceSchema>;
const devices = new Devices();
devices.updateDevices();
export default devices;

View File

@ -1,4 +1,4 @@
import { InferSchemaType } from "@shared/utils/validator.ts"; import { InferSchemaType, z } from "@shared/utils/validator.ts";
import { createErrorFactory, defineError } from "@shared/utils/errors.ts"; import { createErrorFactory, defineError } from "@shared/utils/errors.ts";
export const queryExecutionErrorSchema = defineError( export const queryExecutionErrorSchema = defineError(
@ -72,3 +72,47 @@ export const failedToParseRequestAsJSONError = createErrorFactory(
export type FailedToParseRequestAsJSONError = InferSchemaType< export type FailedToParseRequestAsJSONError = InferSchemaType<
typeof failedToParseRequestAsJSONErrorSchema typeof failedToParseRequestAsJSONErrorSchema
>; >;
export const tooManyRequestsErrorSchema = defineError(
"TooManyRequestsError",
);
export const tooManyRequestsError = createErrorFactory(
tooManyRequestsErrorSchema,
);
export type TooManyRequestsError = InferSchemaType<
typeof tooManyRequestsErrorSchema
>;
export const unauthorizedErrorSchema = defineError(
"UnauthorizedError",
);
export const unauthorizedError = createErrorFactory(unauthorizedErrorSchema);
export type UnauthorizedError = InferSchemaType<typeof unauthorizedErrorSchema>;
export const invalidPasswordErrorSchema = defineError("InvalidPasswordError");
export const invalidPasswordError = createErrorFactory(
invalidPasswordErrorSchema,
);
export type InvalidPasswordError = InferSchemaType<
typeof invalidPasswordErrorSchema
>;
export const adminPasswordAlreadySetErrorSchema = defineError(
"AdminPasswordAlreadySetError",
);
export const adminPasswordAlreadySetError = createErrorFactory(
adminPasswordAlreadySetErrorSchema,
);
export type AdminPasswordAlreadySetError = InferSchemaType<
typeof adminPasswordAlreadySetErrorSchema
>;
export const passwordsMustMatchErrorSchema = defineError(
"PasswordsMustMatchError",
);
export const passwordsMustMatchError = createErrorFactory(
passwordsMustMatchErrorSchema,
);
export type PasswordsMustMatchError = InferSchemaType<
typeof passwordsMustMatchErrorSchema
>;

View File

@ -51,32 +51,33 @@ class HttpRouter {
return this; return this;
} }
public add<S extends string>( public add<
S extends string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
>(
path: S, path: S,
method: string, method: string,
handler: RequestHandler<S>, handler: RequestHandler<S, ReqSchema, ResSchema>,
schema?: { schema?: { req: ReqSchema; res: ResSchema },
res: Schema<any>;
req: Schema<any>;
},
): HttpRouter; ): HttpRouter;
public add<S extends string>(
public add<
S extends string,
ReqSchema extends Schema<any> = Schema<unknown>,
ResSchema extends Schema<any> = Schema<unknown>,
>(
path: S[], path: S[],
method: string, method: string,
handler: RequestHandler<string>, handler: RequestHandler<string, ReqSchema, ResSchema>,
schema?: { schema?: { req: ReqSchema; res: ResSchema },
res: Schema<any>;
req: Schema<any>;
},
): HttpRouter; ): HttpRouter;
public add( public add(
path: string | string[], path: string | string[],
method: string, method: string,
handler: RequestHandler<string>, handler: RequestHandler<string>,
schema?: { schema?: { req: Schema<any>; res: Schema<any> },
res: Schema<any>;
req: Schema<any>;
},
): HttpRouter { ): HttpRouter {
const paths = Array.isArray(path) ? path : [path]; const paths = Array.isArray(path) ? path : [path];

View File

@ -1,4 +1,291 @@
import UsbipManager from "@shared/utils/usbip.ts"; import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts";
import { err, getMessageFromError, ok } from "@shared/utils/result.ts";
import { errAsync } from "@shared/utils/index.ts";
import log from "@shared/utils/logger.ts";
import {
fromNullableVal,
none,
type Option,
some,
} from "@shared/utils/option.ts";
import { createErrorFactory, defineError } from "@shared/utils/errors.ts";
import { InferSchemaType } from "@shared/utils/validator.ts";
export const commandExecutionErrorSchema = defineError("CommandExecutionError");
export const commandExecutionError = createErrorFactory(
commandExecutionErrorSchema,
);
export type CommandExecutionError = InferSchemaType<
typeof commandExecutionErrorSchema
>;
export const deviceDoesNotExistErrorSchema = defineError(
"DeviceDoesNotExistError",
);
export const deviceDoesNotExistError = createErrorFactory(
deviceDoesNotExistErrorSchema,
);
export type DeviceDoesNotExistError = InferSchemaType<
typeof deviceDoesNotExistErrorSchema
>;
export const deviceAlreadyBoundErrorSchema = defineError(
"DeviceAlreadyBoundError",
);
export const deviceAlreadyBoundError = createErrorFactory(
deviceAlreadyBoundErrorSchema,
);
export type DeviceAlreadyBoundError = InferSchemaType<
typeof deviceAlreadyBoundErrorSchema
>;
export const deviceNotBoundErrorSchema = defineError("DeviceNotBoundError");
export const deviceNotBoundError = createErrorFactory(
deviceNotBoundErrorSchema,
);
export type DeviceNotBoundError = InferSchemaType<
typeof deviceNotBoundErrorSchema
>;
export const usbipUnknownErrorSchema = defineError("UsbipUnknownError");
export const usbipUnknownError = createErrorFactory(usbipUnknownErrorSchema);
export type UsbipUnknownError = InferSchemaType<typeof usbipUnknownErrorSchema>;
type UsbipCommonError = DeviceDoesNotExistError | UsbipUnknownError;
class UsbipManager {
private readonly listDeatiledCmd = new Deno.Command("usbip", {
args: ["list", "-l"],
});
private readonly listParsableCmd = new Deno.Command("usbip", {
args: ["list", "-pl"],
});
private readonly decoder = new TextDecoder();
private readonly usbidRegex = /[0-9abcdef]{4}:[0-9abcdef]{4}/;
private readonly busidRegex =
/(?:[0-9]+(?:\.[0-9]+)*-)*[0-9]+(?:\.[0-9]+)*/;
private executeCommand(
cmd: Deno.Command,
): ResultAsync<CommandOutput, CommandExecutionError> {
const promise = cmd.output();
return ResultAsync.fromPromise(
promise,
(e) => commandExecutionError(getMessageFromError(e)),
)
.map(({ stdout, stderr, code }) =>
new CommandOutput(
this.decoder.decode(stdout).trim(),
this.decoder.decode(stderr).trim(),
code,
)
);
}
private handleCommonErrors(stderr: string): UsbipCommonError {
if (
stderr.includes("device with the specified bus ID does not exist")
) {
return deviceDoesNotExistError(stderr);
}
return usbipUnknownError(stderr);
}
private parseDetailedList(stdout: string): Option<DeviceDetailed[]> {
const devices: DeviceDetailed[] = [];
const deviceEntries = stdout.trim().split("\n\n");
for (const deviceEntry of deviceEntries) {
const busid = deviceEntry.match(this.busidRegex)?.shift();
if (!busid) {
log.error(
`Failed to parse busid of a device:\n ${deviceEntry}`,
);
continue;
}
const usbid = fromNullableVal(
deviceEntry.match(this.usbidRegex)?.shift(),
);
const [_, line2] = deviceEntry.split("\n");
const [vendorVal, nameVal] = line2
? line2.split(" : ").map((s) => s.trim())
: [undefined, undefined];
const vendor = fromNullableVal(vendorVal);
const name = nameVal
? some(
nameVal.replace(
usbid.isSome() ? usbid.value : this.usbidRegex,
"",
).replace("()", "")
.trim(),
)
: none;
[["usbid", usbid], ["vendor", vendor], ["name", name]].filter((v) =>
(v[1] as Option<string>).isNone()
).map((v) => log.warn(`Failed to parse ${v[0]}:\n ${deviceEntry}`));
devices.push({
busid,
usbid,
vendor,
name,
});
}
return devices.length > 0 ? some(devices) : none;
}
public getDevicesDetailed(): ResultAsync<
Option<DeviceDetailed[]>,
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listDeatiledCmd).andThen(
({ stdout, stderr, success }) => {
if (success) {
if (stderr) {
log.warn(
`usbip list -l succeeded but encountered an error: ${stderr}`,
);
}
return ok(this.parseDetailedList(stdout));
}
return err(usbipUnknownError(stderr));
},
);
}
private parseParsableList(stdout: string): Option<Device[]> {
const devices: Device[] = [];
const devicesEntries = stdout.trim().split("\n");
for (const deviceEntry of devicesEntries) {
const [busid, usbid] = deviceEntry
.slice(0, -1)
.split("#")
.map((v) => v.split("=")[1].trim() || undefined);
if (!busid) {
log.error(
`Failed to parse busid of a device:\n ${deviceEntry}`,
);
continue;
}
if (!usbid) {
log.warn(
`Failed to parse usbid of a device:\n ${deviceEntry}`,
);
}
devices.push({
busid,
usbid: fromNullableVal(usbid),
});
}
return devices.length > 0 ? some(devices) : none;
}
public getDevices(): ResultAsync<
Option<Device[]>,
CommandExecutionError | UsbipUnknownError
> {
return this.executeCommand(this.listParsableCmd).andThenAsync(
({ stdout, stderr, success }) => {
if (success) {
if (stderr) {
log.warn(
`usbip list -lp succeeded but encountered an error: ${stderr}`,
);
}
return okAsync(this.parseParsableList(stdout));
}
return errAsync(usbipUnknownError(stderr));
},
);
}
public bindDevice(
busid: string,
): ResultAsync<
string,
UsbipCommonError | DeviceAlreadyBoundError | CommandExecutionError
> {
const cmd = new Deno.Command("usbip", { args: ["bind", "-b", busid] });
return this.executeCommand(cmd).andThen(
({ stderr, success }) => {
if (success) {
return ok(stderr.trim() || "Device bound successfully");
}
if (stderr.includes("is already bound to usbip-host")) {
return err(deviceAlreadyBoundError(stderr));
}
return err(this.handleCommonErrors(stderr));
},
);
}
public unbindDevice(
busid: string,
): ResultAsync<
string,
CommandExecutionError | DeviceNotBoundError | UsbipCommonError
> {
const cmd = new Deno.Command("usbip", {
args: ["unbind", "-b", busid],
});
return this.executeCommand(cmd).andThen(({ stderr, success }) => {
if (success) {
return ok(stderr.trim() || "Device unbound successfully");
}
if (stderr.includes("device is not bound to usbip-host driver")) {
return err(deviceNotBoundError(stderr));
}
return err(this.handleCommonErrors(stderr));
});
}
}
class CommandOutput {
constructor(
public readonly stdout: string,
public readonly stderr: string,
public readonly code: number,
) {}
get success(): boolean {
return this.code === 0;
}
}
export interface DeviceDetailed {
busid: string;
usbid: Option<string>;
vendor: Option<string>;
name: Option<string>;
}
interface Device {
busid: string;
usbid: Option<string>;
}
const usbip = new UsbipManager(); const usbip = new UsbipManager();

View File

@ -1,24 +1,60 @@
import { Middleware } from "@lib/router.ts"; import { Middleware } from "@lib/router.ts";
import admin from "@lib/admin.ts"; import admin from "@lib/admin.ts";
import {
queryExecutionError,
tooManyRequestsError,
unauthorizedError,
} from "@src/lib/errors.ts";
import { err, ok } from "@shared/utils/result.ts";
import { eta } from "../../main.ts";
const LOGIN_PATH = "/login"; const LOGIN_PATH = "/login";
const SETUP_PATH = "/setup";
const authMiddleware: Middleware = async (c, next) => { const authMiddleware: Middleware = async (c, next) => {
const token = c.cookies.get("token"); const token = c.cookies.get("token");
const isValid = token const isValid = token
.map((token) => admin.sessions.verifyToken(token)) .map((token) => admin.sessions.verifyToken(token)).match(
.toBoolean(); (r) => r,
() => ok(false),
);
if (isValid.isErr()) {
return c.matchPreferredType(
() => c.html(eta.render("./internal_error.html", {})),
() =>
c.json(
err(queryExecutionError("Server failed to execute query")),
),
() => new Response("500 Internal server error", { status: 500 }),
);
}
const path = c.path; const path = c.path;
if (path.startsWith("/public")) { if (
await next(); !isValid.value && !path.startsWith("/public") && path !== LOGIN_PATH &&
} else { path !== SETUP_PATH
if (path !== LOGIN_PATH && !isValid) { ) {
return c.redirect("/login"); if (!isValid.value) {
c.cookies.delete("token");
} }
await next(); if (c.preferredType.isNone()) {
return new Response("401 unautorized", { status: 401 });
}
switch (c.preferredType.value) {
case "json":
return c.json(
err(unauthorizedError("Unauthorized")),
{ status: 401 },
);
case "html":
return c.redirect("/login");
}
} }
await next();
}; };
export default authMiddleware; export default authMiddleware;

View File

@ -1,5 +1,7 @@
import { Middleware } from "@lib/router.ts"; import { Middleware } from "@lib/router.ts";
import log from "@shared/utils/logger.ts"; import log from "@shared/utils/logger.ts";
import { err } from "@shared/utils/result.ts";
import { tooManyRequestsError } from "@src/lib/errors.ts";
const requestCounts: Partial< const requestCounts: Partial<
Record<string, { count: number; lastReset: number }> Record<string, { count: number; lastReset: number }>
@ -31,9 +33,7 @@ const rateLimitMiddleware: Middleware = async (c, next) => {
} }
case "json": { case "json": {
return c.json( return c.json(
{ err(tooManyRequestsError("Too many request")),
err: "429 Too Many Requests",
},
{ {
status: 429, status: 429,
}, },

View File

@ -0,0 +1,3 @@
<% layout("./layouts/layout.html") %>
Internal error occurred

View File

@ -0,0 +1,10 @@
<% layout("./layouts/basic.html") %>
<main>
<form id=passwordSetupForm method=POST>
<p>password</p><input id=passwordInput name=password type=password>
<p>password repeat</p><input id=passwordRepeatInput name=passwordRepeat type=password><input value="sign in"
type=submit>
<div id="errDiv"></div>
</form>
</main>
<script defer src=/public/js/setup.js type=module></script>

Binary file not shown.

View File

@ -0,0 +1 @@
<% layout("./layouts/layout.html") %> Internal error occurred

1
server/views/setup.html Normal file
View File

@ -0,0 +1 @@
<% layout("./layouts/basic.html") %> <main><form id=passwordSetupForm method=POST><p>password</p><input id=passwordInput name=password type=password><p>password repeat</p><input id=passwordRepeatInput name=passwordRepeat type=password><input value="sign in" type=submit><div id=errDiv></div></form></main><script defer src=/public/js/setup.js type=module></script>

38
shared/utils/errors.ts Normal file
View File

@ -0,0 +1,38 @@
import {
InferSchemaType,
LiteralSchema,
ObjectSchema,
Schema,
StringSchema,
z,
} from "@shared/utils/validator.ts";
type ErrorDefinition<T extends string, I extends Schema<any>> = ObjectSchema<
{ type: LiteralSchema<T>; info: I }
>;
export function defineError<
T extends string,
I extends Schema<any> = StringSchema,
>(
type: T,
info?: I,
): ErrorDefinition<T, I> {
return z.obj({
type: z.literal(type),
info: (info ?? z.string()) as I,
});
}
export function createErrorFactory<
T extends string,
I extends Schema<any>,
>(
errorDefinition: ErrorDefinition<T, I>,
): (info: InferSchemaType<I>) => InferSchemaType<ErrorDefinition<T, I>> {
return (info: InferSchemaType<I>) => {
return {
type: errorDefinition.shape.type.literal,
info,
};
};
}

View File

@ -367,11 +367,15 @@ export function flattenResult<R extends Result<any, any>>(
): FlattenResult<R> { ): FlattenResult<R> {
let currentResult = nestedResult; let currentResult = nestedResult;
while (currentResult instanceof Ok) { while (
currentResult = currentResult.value; currentResult instanceof Ok &&
(currentResult.value instanceof Ok ||
currentResult.value instanceof Err)
) {
currentResult = currentResult.value as R;
} }
return ok(currentResult) as FlattenResult<R>; return currentResult as FlattenResult<R>;
} }
export type UnwrapOption<T> = T extends Option<infer V> ? V : T; export type UnwrapOption<T> = T extends Option<infer V> ? V : T;

View File

@ -54,7 +54,7 @@ export class SchemaValidationError extends Error {
}; };
} }
get msg(): string { get info(): string {
return SchemaValidationError.getBestMsg(this.detail); return SchemaValidationError.getBestMsg(this.detail);
} }
@ -136,14 +136,14 @@ export class SchemaValidationError extends Error {
case "missingProperties": case "missingProperties":
return `Missing required properties: ${detail.keys.join(", ")}`; return `Missing required properties: ${detail.keys.join(", ")}`;
case "unionValidation": case "unionValidation":
return `Input did not match any union member`; return `Input did not match any union member`;
default: default:
return "Unknown error"; return "Unknown error";
} }
} }
} }
function createValidationError( export function createValidationError(
input: unknown, input: unknown,
error: ValidationErrorDetail, error: ValidationErrorDetail,
) { ) {
@ -285,7 +285,7 @@ export class StringSchema extends BaseSchema<string> {
} }
} }
class LiteralSchema<L extends string> extends BaseSchema<L> { export class LiteralSchema<L extends string> extends BaseSchema<L> {
constructor( constructor(
public readonly literal: L, public readonly literal: L,
msg?: string, msg?: string,
@ -645,7 +645,7 @@ class NeverSchema extends BaseSchema<never> {
} }
} }
class ObjectSchema<S extends Record<string, Schema<any>>> export class ObjectSchema<S extends Record<string, Schema<any>>>
extends BaseSchema<{ [K in keyof S]: InferSchemaType<S[K]> }> { extends BaseSchema<{ [K in keyof S]: InferSchemaType<S[K]> }> {
private strictMode: boolean = false; private strictMode: boolean = false;
private objectMsg?; private objectMsg?;
@ -768,12 +768,41 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
}); });
} }
strict(): this { public strict(): this {
this.strictMode = true; this.strictMode = true;
return this; return this;
} }
public pick<
P extends Partial<
Record<keyof InferSchemaType<this>, boolean>
>,
>(
keys: P,
): ObjectPick<this, P> {
const o: Record<string, Schema<any>> = {};
for (const key of Object.keys(keys)) {
if (keys[key as keyof P]) {
o[key] = this.shape[key];
}
}
return z.obj(o) as unknown as ObjectPick<this, P>;
}
} }
type PickedKeys<T> = {
[K in keyof T]: T[K] extends true ? K : never;
}[keyof T];
type ObjectPick<
O extends ObjectSchema<any>,
P extends Partial<Record<keyof InferSchemaType<O>, boolean>>,
> = O extends ObjectSchema<infer T>
? ObjectSchema<{ [K in PickedKeys<P> & keyof T]: T[K] }>
: never;
type InferUnionSchemaType<U extends Schema<any>[]> = U[number] extends type InferUnionSchemaType<U extends Schema<any>[]> = U[number] extends
Schema<infer T> ? T : never; Schema<infer T> ? T : never;