working admin auth
This commit is contained in:
@ -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,
|
||||||
|
);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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="/"});
|
||||||
|
|||||||
1
server/public/js/setup.js
Normal file
1
server/public/js/setup.js
Normal 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
@ -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
29
server/src/js/setup.ts
Normal 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";
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
>;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
3
server/src/views/internal_error.html
Normal file
3
server/src/views/internal_error.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<% layout("./layouts/layout.html") %>
|
||||||
|
|
||||||
|
Internal error occurred
|
||||||
10
server/src/views/setup.html
Normal file
10
server/src/views/setup.html
Normal 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>
|
||||||
BIN
server/test.db
BIN
server/test.db
Binary file not shown.
1
server/views/internal_error.html
Normal file
1
server/views/internal_error.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<% layout("./layouts/layout.html") %> Internal error occurred
|
||||||
1
server/views/setup.html
Normal file
1
server/views/setup.html
Normal 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
38
shared/utils/errors.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user