working admin auth
This commit is contained in:
@ -1,16 +1,68 @@
|
||||
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({
|
||||
password: z.string(),
|
||||
}),
|
||||
res: z.result(
|
||||
z.obj({
|
||||
isMatch: z.boolean(),
|
||||
}),
|
||||
z.any(),
|
||||
z.void(),
|
||||
z.union([
|
||||
adminPasswordNotSetErrorSchema,
|
||||
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": {
|
||||
"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": {
|
||||
"@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 loggerMiddleware from "@src/middleware/logger.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 admin from "@src/lib/admin.ts";
|
||||
import {
|
||||
FailedToParseRequestAsJSON,
|
||||
QueryExecutionError,
|
||||
} from "@src/lib/errors.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 router = new HttpRouter();
|
||||
|
||||
const views = Deno.cwd() + "/views/";
|
||||
const eta = new Eta({ views });
|
||||
export const eta = new Eta({ views });
|
||||
|
||||
router.use(loggerMiddleware);
|
||||
router.use(rateLimitMiddleware);
|
||||
@ -45,9 +50,21 @@ router.get("/public/*", async (c) => {
|
||||
|
||||
router
|
||||
.get(["", "/index.html"], (c) => {
|
||||
console.log(devices.list());
|
||||
|
||||
return c.html(eta.render("./index.html", {}));
|
||||
})
|
||||
.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) =>
|
||||
admin.sessions.verifyToken(token)
|
||||
)
|
||||
@ -56,10 +73,21 @@ router
|
||||
console.log(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) => {
|
||||
const r = await c
|
||||
.parseBody()
|
||||
@ -72,7 +100,7 @@ router.api(loginApi, async (c) => {
|
||||
return c.json400(
|
||||
err({
|
||||
type: r.error.type,
|
||||
msg: r.error.message,
|
||||
info: r.error.info,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -89,42 +117,56 @@ router.api(loginApi, async (c) => {
|
||||
value,
|
||||
expires,
|
||||
});
|
||||
return ok({ isMatch: true });
|
||||
return ok();
|
||||
}).match(
|
||||
(v) => c.json(v),
|
||||
(e) => handleCommonErrors(c, e),
|
||||
);
|
||||
} else {
|
||||
return c.json(ok({
|
||||
isMatch: false,
|
||||
}));
|
||||
return c.json(err(invalidPasswordError("Invalid login or password")));
|
||||
}
|
||||
});
|
||||
|
||||
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(
|
||||
c: Context<any, any, any>,
|
||||
error:
|
||||
| QueryExecutionError
|
||||
| FailedToParseRequestAsJSON
|
||||
| SchemaValidationError,
|
||||
| FailedToParseRequestAsJSONError
|
||||
| RequestValidationError,
|
||||
): Response {
|
||||
switch (error.type) {
|
||||
case "QueryExecutionError":
|
||||
return c.json(
|
||||
err(new QueryExecutionError("Server failed to execute query")),
|
||||
err(queryExecutionError("Server failed to execute query")),
|
||||
{ status: 500 },
|
||||
);
|
||||
case "FailedToParseRequestAsJSON":
|
||||
case "FailedToParseRequestAsJSONError":
|
||||
return c.json(
|
||||
err(error),
|
||||
{ status: 400 },
|
||||
);
|
||||
case "SchemaValiationError":
|
||||
case "RequestValidationError":
|
||||
return c.json(
|
||||
err({
|
||||
type: "ValidationError",
|
||||
msg: error.msg,
|
||||
}),
|
||||
err(error),
|
||||
{ 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();
|
||||
|
||||
if (res.isErr()) {
|
||||
if (res.error.type === "RequestValidationError") {
|
||||
errDiv.innerText = res.error.msg;
|
||||
}
|
||||
} else {
|
||||
if (!res.value.isMatch) {
|
||||
errDiv.innerText = "invalid password";
|
||||
errDiv.innerText = res.error.info;
|
||||
} 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 db from "@lib/db/index.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 { generateRandomString, passwd } from "@lib/utils.ts";
|
||||
import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts";
|
||||
@ -52,7 +56,7 @@ class Admin {
|
||||
const result = this.getPasswordHash().flattenOption(
|
||||
() => {
|
||||
log.warn("Tried to verify password when it is not set");
|
||||
return new AdminPasswordNotSetError(
|
||||
return adminPasswordNotSetError(
|
||||
"Admin password is not set",
|
||||
);
|
||||
},
|
||||
|
||||
@ -19,7 +19,7 @@ export type ExtractRouteParams<T extends string> = T extends string
|
||||
: never;
|
||||
|
||||
type ApiError =
|
||||
| SchemaValidationError
|
||||
| RequestValidationError
|
||||
| ResponseValidationError;
|
||||
|
||||
export class Api<
|
||||
@ -57,7 +57,7 @@ export class Api<
|
||||
return this.schema.req
|
||||
.parse(reqBody)
|
||||
.toAsync()
|
||||
.mapErr((e) => requestValidationError(e.msg))
|
||||
.mapErr((e) => requestValidationError(e.info))
|
||||
.andThenAsync(async (data) => {
|
||||
const pathSplitted = this.pathSplitted;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@ -71,6 +71,7 @@ export class Api<
|
||||
method: this.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
@ -80,7 +81,7 @@ export class Api<
|
||||
|
||||
return this.schema.res.parse(resBody).toAsync()
|
||||
.map((v) => v as InferSchemaType<ResSchema>)
|
||||
.mapErr((e) => responseValidationError(e.msg));
|
||||
.mapErr((e) => responseValidationError(e.info));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ import {
|
||||
FailedToParseRequestAsJSONError,
|
||||
failedToParseRequestAsJSONError,
|
||||
} from "@src/lib/errors.ts";
|
||||
import { RequestValidationError } from "@src/lib/errors.ts";
|
||||
import { requestValidationError } from "@src/lib/errors.ts";
|
||||
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html
|
||||
const SECURITY_HEADERS: Headers = new Headers({
|
||||
@ -86,7 +88,7 @@ export class Context<
|
||||
}
|
||||
|
||||
public setParams(
|
||||
params: Params,
|
||||
params: Params<string>,
|
||||
): Context<S, ReqSchema, ResSchema> {
|
||||
const ctx = new Context<S, ReqSchema, ResSchema>(
|
||||
this.req,
|
||||
@ -98,12 +100,13 @@ export class Context<
|
||||
ctx._port = this._port;
|
||||
ctx._cookies = this._cookies;
|
||||
ctx.res = this.res;
|
||||
ctx.schema = this.schema;
|
||||
return ctx as Context<S, ReqSchema, ResSchema>;
|
||||
}
|
||||
|
||||
public parseBody(): ResultAsync<
|
||||
InferSchemaType<ReqSchema>,
|
||||
SchemaValidationError | FailedToParseRequestAsJSONError
|
||||
RequestValidationError | FailedToParseRequestAsJSONError
|
||||
> {
|
||||
return ResultAsync
|
||||
.fromPromise(
|
||||
@ -115,7 +118,9 @@ export class Context<
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
if (this._hostname) return some(this._hostname);
|
||||
const remoteAddr = this.info.remoteAddr;
|
||||
|
||||
@ -1,35 +1,75 @@
|
||||
import usbip from "@src/lib/usbip.ts";
|
||||
import {
|
||||
type CommandExecutionError,
|
||||
import usbip, {
|
||||
CommandExecutionError,
|
||||
DeviceDetailed,
|
||||
type UsbipUnknownError,
|
||||
} from "@shared/utils/usbip.ts";
|
||||
import { none } from "@shared/utils/option.ts";
|
||||
DeviceDoesNotExistError,
|
||||
deviceDoesNotExistError,
|
||||
UsbipUnknownError,
|
||||
} from "@src/lib/usbip.ts";
|
||||
import { none, Option, some } 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";
|
||||
import { ResultAsync } from "@shared/utils/resultasync.ts";
|
||||
import { err, Ok, ok, Result } from "@shared/utils/result.ts";
|
||||
|
||||
type FailedToAccessDevices = CommandExecutionError | UsbipUnknownError;
|
||||
|
||||
class Devices {
|
||||
private readonly devices: Map<string, Device> = new Map();
|
||||
|
||||
updateDevices(): ResultAsync<
|
||||
void,
|
||||
private devices: Result<
|
||||
Map<string, Device>,
|
||||
CommandExecutionError | UsbipUnknownError
|
||||
> = ok(new Map());
|
||||
|
||||
public update(
|
||||
busid: string,
|
||||
update: Partial<DeviceMutables>,
|
||||
): Result<void, DeviceDoesNotExistError | FailedToAccessDevices> {
|
||||
return this.devices.andThen((devices) => {
|
||||
const device = devices.get(busid);
|
||||
|
||||
if (device === undefined) {
|
||||
return err(
|
||||
deviceDoesNotExistError(
|
||||
`Device with busid ${busid} does not exist`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(update)) {
|
||||
device[key as keyof typeof update] =
|
||||
update[key as keyof typeof update] || none;
|
||||
}
|
||||
|
||||
return ok();
|
||||
});
|
||||
}
|
||||
|
||||
public updateDevices(): ResultAsync<
|
||||
void,
|
||||
FailedToAccessDevices
|
||||
> {
|
||||
return usbip.getDevicesDetailed().mapErr((e) => {
|
||||
return usbip.getDevicesDetailed()
|
||||
.mapErr((e) => {
|
||||
log.error("Failed to update devices!");
|
||||
this.devices = err(e);
|
||||
return e;
|
||||
}).map((d) => d.unwrapOr([])).map(
|
||||
})
|
||||
.map((d) => d.unwrapOr([] as DeviceDetailed[]))
|
||||
.map(
|
||||
(devices) => {
|
||||
const current = new Set(devices.map((d) => d.busid));
|
||||
const old = new Set(this.devices.keys());
|
||||
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.set(
|
||||
this.devices.unwrap().set(
|
||||
device.busid,
|
||||
this.deviceFromDetailed(device),
|
||||
);
|
||||
@ -37,13 +77,13 @@ class Devices {
|
||||
}
|
||||
|
||||
for (const device of disconnected) {
|
||||
this.devices.delete(device);
|
||||
this.devices.unwrap().delete(device);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
deviceFromDetailed(d: DeviceDetailed): Device {
|
||||
private deviceFromDetailed(d: DeviceDetailed): Device {
|
||||
return {
|
||||
busid: d.busid,
|
||||
usbid: d.usbid,
|
||||
@ -54,6 +94,12 @@ class Devices {
|
||||
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({
|
||||
@ -64,20 +110,19 @@ export const deviceSchema = z.obj({
|
||||
displayName: z.option(z.string()),
|
||||
description: z.option(z.string()),
|
||||
connectedAt: z.date(),
|
||||
}).strict();
|
||||
|
||||
export const deviceMutablesSchema = deviceSchema.pick({
|
||||
displayName: true,
|
||||
description: true,
|
||||
});
|
||||
|
||||
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 DeviceMutables = InferSchemaType<typeof deviceMutablesSchema>;
|
||||
|
||||
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";
|
||||
|
||||
export const queryExecutionErrorSchema = defineError(
|
||||
@ -72,3 +72,47 @@ export const failedToParseRequestAsJSONError = createErrorFactory(
|
||||
export type FailedToParseRequestAsJSONError = InferSchemaType<
|
||||
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;
|
||||
}
|
||||
|
||||
public add<S extends string>(
|
||||
public add<
|
||||
S extends string,
|
||||
ReqSchema extends Schema<any> = Schema<unknown>,
|
||||
ResSchema extends Schema<any> = Schema<unknown>,
|
||||
>(
|
||||
path: S,
|
||||
method: string,
|
||||
handler: RequestHandler<S>,
|
||||
schema?: {
|
||||
res: Schema<any>;
|
||||
req: Schema<any>;
|
||||
},
|
||||
handler: RequestHandler<S, ReqSchema, ResSchema>,
|
||||
schema?: { req: ReqSchema; res: ResSchema },
|
||||
): 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[],
|
||||
method: string,
|
||||
handler: RequestHandler<string>,
|
||||
schema?: {
|
||||
res: Schema<any>;
|
||||
req: Schema<any>;
|
||||
},
|
||||
handler: RequestHandler<string, ReqSchema, ResSchema>,
|
||||
schema?: { req: ReqSchema; res: ResSchema },
|
||||
): HttpRouter;
|
||||
|
||||
public add(
|
||||
path: string | string[],
|
||||
method: string,
|
||||
handler: RequestHandler<string>,
|
||||
schema?: {
|
||||
res: Schema<any>;
|
||||
req: Schema<any>;
|
||||
},
|
||||
schema?: { req: Schema<any>; res: Schema<any> },
|
||||
): HttpRouter {
|
||||
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();
|
||||
|
||||
|
||||
@ -1,24 +1,60 @@
|
||||
import { Middleware } from "@lib/router.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 SETUP_PATH = "/setup";
|
||||
|
||||
const authMiddleware: Middleware = async (c, next) => {
|
||||
const token = c.cookies.get("token");
|
||||
const isValid = token
|
||||
.map((token) => admin.sessions.verifyToken(token))
|
||||
.toBoolean();
|
||||
.map((token) => admin.sessions.verifyToken(token)).match(
|
||||
(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;
|
||||
|
||||
if (path.startsWith("/public")) {
|
||||
await next();
|
||||
} else {
|
||||
if (path !== LOGIN_PATH && !isValid) {
|
||||
return c.redirect("/login");
|
||||
if (
|
||||
!isValid.value && !path.startsWith("/public") && path !== LOGIN_PATH &&
|
||||
path !== SETUP_PATH
|
||||
) {
|
||||
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;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Middleware } from "@lib/router.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<
|
||||
Record<string, { count: number; lastReset: number }>
|
||||
@ -31,9 +33,7 @@ const rateLimitMiddleware: Middleware = async (c, next) => {
|
||||
}
|
||||
case "json": {
|
||||
return c.json(
|
||||
{
|
||||
err: "429 Too Many Requests",
|
||||
},
|
||||
err(tooManyRequestsError("Too many request")),
|
||||
{
|
||||
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> {
|
||||
let currentResult = nestedResult;
|
||||
|
||||
while (currentResult instanceof Ok) {
|
||||
currentResult = currentResult.value;
|
||||
while (
|
||||
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;
|
||||
|
||||
@ -54,7 +54,7 @@ export class SchemaValidationError extends Error {
|
||||
};
|
||||
}
|
||||
|
||||
get msg(): string {
|
||||
get info(): string {
|
||||
return SchemaValidationError.getBestMsg(this.detail);
|
||||
}
|
||||
|
||||
@ -143,7 +143,7 @@ export class SchemaValidationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function createValidationError(
|
||||
export function createValidationError(
|
||||
input: unknown,
|
||||
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(
|
||||
public readonly literal: L,
|
||||
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]> }> {
|
||||
private strictMode: boolean = false;
|
||||
private objectMsg?;
|
||||
@ -768,12 +768,41 @@ class ObjectSchema<S extends Record<string, Schema<any>>>
|
||||
});
|
||||
}
|
||||
|
||||
strict(): this {
|
||||
public strict(): this {
|
||||
this.strictMode = true;
|
||||
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
|
||||
Schema<infer T> ? T : never;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user