2023-07-06 14:47:50 +00:00
|
|
|
import { Application, Context, Next, oakCors, Router } from "./deps.ts";
|
2022-10-10 12:50:21 +00:00
|
|
|
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
|
2022-10-12 09:47:13 +00:00
|
|
|
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts";
|
2023-05-23 18:53:53 +00:00
|
|
|
import { ensureSettingsAndIndex } from "../common/util.ts";
|
|
|
|
import { performLocalFetch } from "../common/proxy_fetch.ts";
|
2023-05-29 07:53:49 +00:00
|
|
|
import { BuiltinSettings } from "../web/types.ts";
|
|
|
|
import { gitIgnoreCompiler } from "./deps.ts";
|
|
|
|
import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts";
|
2023-06-13 18:47:05 +00:00
|
|
|
import { Authenticator } from "./auth.ts";
|
2023-07-06 14:47:50 +00:00
|
|
|
import { FileMeta } from "../common/types.ts";
|
2022-10-10 12:50:21 +00:00
|
|
|
|
|
|
|
export type ServerOptions = {
|
2022-12-04 05:24:06 +00:00
|
|
|
hostname: string;
|
2022-10-10 12:50:21 +00:00
|
|
|
port: number;
|
|
|
|
pagesPath: string;
|
2023-05-23 18:53:53 +00:00
|
|
|
clientAssetBundle: AssetBundle;
|
2023-06-13 18:47:05 +00:00
|
|
|
authenticator: Authenticator;
|
2022-12-05 11:14:21 +00:00
|
|
|
pass?: string;
|
2023-05-23 18:53:53 +00:00
|
|
|
certFile?: string;
|
|
|
|
keyFile?: string;
|
|
|
|
maxFileSizeMB?: number;
|
2022-10-10 12:50:21 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
export class HttpServer {
|
|
|
|
app: Application;
|
2022-12-04 05:24:06 +00:00
|
|
|
private hostname: string;
|
2022-10-10 12:50:21 +00:00
|
|
|
private port: number;
|
|
|
|
abortController?: AbortController;
|
2023-05-23 18:53:53 +00:00
|
|
|
clientAssetBundle: AssetBundle;
|
2023-05-29 07:53:49 +00:00
|
|
|
settings?: BuiltinSettings;
|
|
|
|
spacePrimitives: SpacePrimitives;
|
2023-06-13 18:47:05 +00:00
|
|
|
authenticator: Authenticator;
|
2022-10-10 12:50:21 +00:00
|
|
|
|
2023-05-23 18:53:53 +00:00
|
|
|
constructor(
|
2023-05-29 07:53:49 +00:00
|
|
|
spacePrimitives: SpacePrimitives,
|
2023-05-23 18:53:53 +00:00
|
|
|
private options: ServerOptions,
|
|
|
|
) {
|
2022-12-04 05:24:06 +00:00
|
|
|
this.hostname = options.hostname;
|
2022-10-10 12:50:21 +00:00
|
|
|
this.port = options.port;
|
2023-05-23 18:53:53 +00:00
|
|
|
this.app = new Application();
|
2023-06-13 18:47:05 +00:00
|
|
|
this.authenticator = options.authenticator;
|
2023-05-23 18:53:53 +00:00
|
|
|
this.clientAssetBundle = options.clientAssetBundle;
|
2023-05-29 07:53:49 +00:00
|
|
|
|
|
|
|
let fileFilterFn: (s: string) => boolean = () => true;
|
|
|
|
this.spacePrimitives = new FilteredSpacePrimitives(
|
|
|
|
spacePrimitives,
|
|
|
|
(meta) => {
|
|
|
|
// Don't list file exceeding the maximum file size
|
|
|
|
if (
|
|
|
|
options.maxFileSizeMB &&
|
|
|
|
meta.size / (1024 * 1024) > options.maxFileSizeMB
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return fileFilterFn(meta.name);
|
|
|
|
},
|
|
|
|
async () => {
|
|
|
|
await this.reloadSettings();
|
|
|
|
if (typeof this.settings?.spaceIgnore === "string") {
|
|
|
|
fileFilterFn = gitIgnoreCompiler(this.settings.spaceIgnore).accepts;
|
|
|
|
} else {
|
|
|
|
fileFilterFn = () => true;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
2023-05-23 18:53:53 +00:00
|
|
|
}
|
2022-10-10 12:50:21 +00:00
|
|
|
|
2023-05-23 18:53:53 +00:00
|
|
|
// Replaces some template variables in index.html in a rather ad-hoc manner, but YOLO
|
|
|
|
renderIndexHtml() {
|
|
|
|
return this.clientAssetBundle.readTextFileSync(".client/index.html")
|
|
|
|
.replaceAll(
|
|
|
|
"{{SPACE_PATH}}",
|
|
|
|
this.options.pagesPath.replaceAll("\\", "\\\\"),
|
|
|
|
);
|
2022-10-10 12:50:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async start() {
|
2023-05-29 07:53:49 +00:00
|
|
|
await this.reloadSettings();
|
2022-10-12 09:47:13 +00:00
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
// Serve static files (javascript, css, html)
|
|
|
|
this.app.use(this.serveStatic.bind(this));
|
2023-05-23 18:53:53 +00:00
|
|
|
|
2023-06-13 18:47:05 +00:00
|
|
|
await this.addPasswordAuth(this.app);
|
2023-07-06 14:47:50 +00:00
|
|
|
const fsRouter = this.addFsRoutes(this.spacePrimitives);
|
2022-10-10 12:50:21 +00:00
|
|
|
this.app.use(fsRouter.routes());
|
|
|
|
this.app.use(fsRouter.allowedMethods());
|
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
// Fallback, serve the UI index.html
|
|
|
|
this.app.use(({ response }) => {
|
|
|
|
response.headers.set("Content-type", "text/html");
|
|
|
|
response.body = this.renderIndexHtml();
|
|
|
|
});
|
2023-06-13 18:47:05 +00:00
|
|
|
|
2022-10-10 12:50:21 +00:00
|
|
|
this.abortController = new AbortController();
|
2023-05-23 18:53:53 +00:00
|
|
|
const listenOptions: any = {
|
2022-12-14 19:32:26 +00:00
|
|
|
hostname: this.hostname,
|
|
|
|
port: this.port,
|
|
|
|
signal: this.abortController.signal,
|
2023-05-23 18:53:53 +00:00
|
|
|
};
|
|
|
|
if (this.options.keyFile) {
|
|
|
|
listenOptions.key = Deno.readTextFileSync(this.options.keyFile);
|
|
|
|
}
|
|
|
|
if (this.options.certFile) {
|
|
|
|
listenOptions.cert = Deno.readTextFileSync(this.options.certFile);
|
|
|
|
}
|
|
|
|
this.app.listen(listenOptions)
|
2022-11-25 12:08:59 +00:00
|
|
|
.catch((e: any) => {
|
|
|
|
console.log("Server listen error:", e.message);
|
|
|
|
Deno.exit(1);
|
|
|
|
});
|
2022-12-14 19:32:26 +00:00
|
|
|
const visibleHostname = this.hostname === "0.0.0.0"
|
|
|
|
? "localhost"
|
|
|
|
: this.hostname;
|
2022-10-10 12:50:21 +00:00
|
|
|
console.log(
|
2023-01-16 15:45:55 +00:00
|
|
|
`SilverBullet is now running: http://${visibleHostname}:${this.port}`,
|
2022-10-10 12:50:21 +00:00
|
|
|
);
|
2022-11-26 13:15:38 +00:00
|
|
|
}
|
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
serveStatic(
|
|
|
|
{ request, response }: Context<Record<string, any>, Record<string, any>>,
|
|
|
|
next: Next,
|
|
|
|
) {
|
|
|
|
if (
|
|
|
|
request.url.pathname === "/"
|
|
|
|
) {
|
|
|
|
// Serve the UI (index.html)
|
|
|
|
// Note: we're explicitly not setting Last-Modified and If-Modified-Since header here because this page is dynamic
|
|
|
|
response.headers.set("Content-type", "text/html");
|
|
|
|
response.body = this.renderIndexHtml();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
const assetName = request.url.pathname.slice(1);
|
|
|
|
if (
|
|
|
|
this.clientAssetBundle.has(assetName) &&
|
|
|
|
request.headers.get("If-Modified-Since") ===
|
|
|
|
utcDateString(this.clientAssetBundle.getMtime(assetName))
|
|
|
|
) {
|
|
|
|
response.status = 304;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
response.status = 200;
|
|
|
|
response.headers.set(
|
|
|
|
"Content-type",
|
|
|
|
this.clientAssetBundle.getMimeType(assetName),
|
|
|
|
);
|
|
|
|
const data = this.clientAssetBundle.readFileSync(
|
|
|
|
assetName,
|
|
|
|
);
|
|
|
|
response.headers.set("Cache-Control", "no-cache");
|
|
|
|
response.headers.set("Content-length", "" + data.length);
|
|
|
|
response.headers.set(
|
|
|
|
"Last-Modified",
|
|
|
|
utcDateString(this.clientAssetBundle.getMtime(assetName)),
|
|
|
|
);
|
|
|
|
|
|
|
|
if (request.method === "GET") {
|
|
|
|
response.body = data;
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
return next();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-29 07:53:49 +00:00
|
|
|
async reloadSettings() {
|
|
|
|
// TODO: Throttle this?
|
|
|
|
this.settings = await ensureSettingsAndIndex(this.spacePrimitives);
|
|
|
|
}
|
|
|
|
|
2023-06-13 18:47:05 +00:00
|
|
|
private async addPasswordAuth(app: Application) {
|
2022-12-22 10:21:12 +00:00
|
|
|
const excludedPaths = [
|
|
|
|
"/manifest.json",
|
|
|
|
"/favicon.png",
|
|
|
|
"/logo.png",
|
|
|
|
"/.auth",
|
|
|
|
];
|
2023-07-02 09:25:32 +00:00
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
// Middleware handling the /.auth page and flow
|
2023-07-02 09:25:32 +00:00
|
|
|
app.use(async ({ request, response, cookies }, next) => {
|
|
|
|
if (request.url.pathname === "/.auth") {
|
|
|
|
if (request.url.search === "?logout") {
|
|
|
|
await cookies.delete("auth");
|
|
|
|
// Implicit fallthrough to login page
|
|
|
|
}
|
|
|
|
if (request.method === "GET") {
|
|
|
|
response.headers.set("Content-type", "text/html");
|
|
|
|
response.body = this.clientAssetBundle.readTextFileSync(
|
|
|
|
".client/auth.html",
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
} else if (request.method === "POST") {
|
|
|
|
const values = await request.body({ type: "form" }).value;
|
|
|
|
const username = values.get("username")!,
|
|
|
|
password = values.get("password")!,
|
|
|
|
refer = values.get("refer");
|
|
|
|
const hashedPassword = await this.authenticator.authenticate(
|
|
|
|
username,
|
|
|
|
password,
|
|
|
|
);
|
|
|
|
if (hashedPassword) {
|
|
|
|
await cookies.set("auth", `${username}:${hashedPassword}`, {
|
|
|
|
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // in a week
|
|
|
|
sameSite: "strict",
|
|
|
|
});
|
|
|
|
response.redirect(refer || "/");
|
|
|
|
// console.log("All headers", request.headers);
|
|
|
|
} else {
|
|
|
|
response.redirect("/.auth?error=1");
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
} else {
|
2023-07-04 14:53:39 +00:00
|
|
|
response.redirect("/.auth");
|
2023-07-02 09:25:32 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
await next();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2023-06-13 18:47:05 +00:00
|
|
|
if ((await this.authenticator.getAllUsers()).length > 0) {
|
2023-07-06 14:47:50 +00:00
|
|
|
// Users defined, so enabling auth
|
2022-12-22 10:21:12 +00:00
|
|
|
app.use(async ({ request, response, cookies }, next) => {
|
2022-12-15 11:59:31 +00:00
|
|
|
if (!excludedPaths.includes(request.url.pathname)) {
|
2022-12-22 10:21:12 +00:00
|
|
|
const authCookie = await cookies.get("auth");
|
2023-06-13 18:47:05 +00:00
|
|
|
if (!authCookie) {
|
2023-07-04 14:53:39 +00:00
|
|
|
response.redirect("/.auth");
|
2022-12-22 10:21:12 +00:00
|
|
|
return;
|
|
|
|
}
|
2023-06-13 18:47:05 +00:00
|
|
|
const [username, hashedPassword] = authCookie.split(":");
|
|
|
|
if (
|
|
|
|
!await this.authenticator.authenticateHashed(
|
|
|
|
username,
|
|
|
|
hashedPassword,
|
|
|
|
)
|
|
|
|
) {
|
2023-07-04 14:53:39 +00:00
|
|
|
response.redirect("/.auth");
|
2023-06-13 18:47:05 +00:00
|
|
|
return;
|
|
|
|
}
|
2022-12-22 10:21:12 +00:00
|
|
|
}
|
2023-07-02 09:25:32 +00:00
|
|
|
await next();
|
2022-10-17 18:35:38 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
private addFsRoutes(spacePrimitives: SpacePrimitives): Router {
|
2022-10-17 18:35:38 +00:00
|
|
|
const fsRouter = new Router();
|
2023-07-02 09:25:32 +00:00
|
|
|
const corsMiddleware = oakCors({
|
|
|
|
allowedHeaders: "*",
|
|
|
|
exposedHeaders: "*",
|
2023-07-06 14:47:50 +00:00
|
|
|
methods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"],
|
2023-07-02 09:25:32 +00:00
|
|
|
});
|
2023-07-06 14:47:50 +00:00
|
|
|
|
|
|
|
fsRouter.use(corsMiddleware);
|
|
|
|
|
2022-10-17 18:35:38 +00:00
|
|
|
// File list
|
2023-07-06 14:47:50 +00:00
|
|
|
fsRouter.get(
|
|
|
|
"/index.json",
|
|
|
|
// corsMiddleware,
|
|
|
|
async ({ request, response }) => {
|
|
|
|
if (request.headers.get("Accept") === "application/json") {
|
|
|
|
// Only handle direct requests for a JSON representation of the file list
|
|
|
|
response.headers.set("Content-type", "application/json");
|
|
|
|
response.headers.set("X-Space-Path", this.options.pagesPath);
|
|
|
|
const files = await spacePrimitives.fetchFileList();
|
|
|
|
response.body = JSON.stringify(files);
|
|
|
|
} else {
|
|
|
|
// Otherwise, redirect to the UI
|
|
|
|
// The reason to do this is to handle authentication systems like Authelia nicely
|
|
|
|
response.redirect("/");
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
2022-10-17 18:35:38 +00:00
|
|
|
|
2023-05-23 18:53:53 +00:00
|
|
|
// RPC
|
2023-07-06 14:47:50 +00:00
|
|
|
fsRouter.post("/.rpc", async ({ request, response }) => {
|
2023-05-23 18:53:53 +00:00
|
|
|
const body = await request.body({ type: "json" }).value;
|
|
|
|
try {
|
|
|
|
switch (body.operation) {
|
|
|
|
case "fetch": {
|
|
|
|
const result = await performLocalFetch(body.url, body.options);
|
2023-05-24 04:47:39 +00:00
|
|
|
console.log("Proxying fetch request to", body.url);
|
2023-05-23 18:53:53 +00:00
|
|
|
response.headers.set("Content-Type", "application/json");
|
|
|
|
response.body = JSON.stringify(result);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
case "shell": {
|
|
|
|
// TODO: Have a nicer way to do this
|
|
|
|
if (this.options.pagesPath.startsWith("s3://")) {
|
|
|
|
response.status = 500;
|
|
|
|
response.body = JSON.stringify({
|
|
|
|
stdout: "",
|
|
|
|
stderr: "Cannot run shell commands with S3 backend",
|
|
|
|
code: 500,
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
2023-07-06 14:47:50 +00:00
|
|
|
console.log("Running shell command:", body.cmd, body.args);
|
2023-05-23 18:53:53 +00:00
|
|
|
const p = new Deno.Command(body.cmd, {
|
|
|
|
args: body.args,
|
|
|
|
cwd: this.options.pagesPath,
|
|
|
|
stdout: "piped",
|
|
|
|
stderr: "piped",
|
|
|
|
});
|
|
|
|
const output = await p.output();
|
|
|
|
const stdout = new TextDecoder().decode(output.stdout);
|
|
|
|
const stderr = new TextDecoder().decode(output.stderr);
|
|
|
|
|
|
|
|
response.headers.set("Content-Type", "application/json");
|
|
|
|
response.body = JSON.stringify({
|
|
|
|
stdout,
|
|
|
|
stderr,
|
|
|
|
code: output.code,
|
|
|
|
});
|
2023-07-06 14:47:50 +00:00
|
|
|
if (output.code !== 0) {
|
|
|
|
console.error("Error running shell command", stdout, stderr);
|
|
|
|
}
|
2023-05-23 18:53:53 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
response.headers.set("Content-Type", "text/plain");
|
|
|
|
response.status = 400;
|
|
|
|
response.body = "Unknown operation";
|
|
|
|
}
|
|
|
|
} catch (e: any) {
|
|
|
|
console.log("Error", e);
|
|
|
|
response.status = 500;
|
|
|
|
response.body = e.message;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
const filePathRegex = "\/(.+\\.[a-zA-Z]+)";
|
|
|
|
|
2022-10-17 18:35:38 +00:00
|
|
|
fsRouter
|
2023-07-06 14:47:50 +00:00
|
|
|
.get(
|
|
|
|
filePathRegex,
|
|
|
|
// corsMiddleware,
|
|
|
|
async ({ params, response, request }) => {
|
|
|
|
const name = params[0];
|
|
|
|
console.log("Requested file", name);
|
|
|
|
if (name.startsWith(".")) {
|
|
|
|
// Don't expose hidden files
|
|
|
|
response.status = 404;
|
|
|
|
response.body = "Not exposed";
|
2022-10-21 14:56:46 +00:00
|
|
|
return;
|
|
|
|
}
|
2023-07-29 15:06:32 +00:00
|
|
|
// Handle federated links through a simple redirect, only used for attachments loads with service workers disabled
|
|
|
|
if (name.startsWith("!")) {
|
|
|
|
let url = name.slice(1);
|
|
|
|
if (url.startsWith("localhost")) {
|
|
|
|
url = `http://${url}`;
|
|
|
|
} else {
|
|
|
|
url = `https://${url}`;
|
|
|
|
}
|
|
|
|
response.redirect(url);
|
|
|
|
return;
|
|
|
|
}
|
2023-07-06 14:47:50 +00:00
|
|
|
try {
|
|
|
|
const fileData = await spacePrimitives.readFile(
|
|
|
|
name,
|
|
|
|
);
|
|
|
|
const lastModifiedHeader = new Date(fileData.meta.lastModified)
|
|
|
|
.toUTCString();
|
|
|
|
if (
|
|
|
|
request.headers.get("If-Modified-Since") === lastModifiedHeader
|
|
|
|
) {
|
|
|
|
response.status = 304;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
response.status = 200;
|
|
|
|
this.fileMetaToHeaders(response.headers, fileData.meta);
|
|
|
|
response.headers.set("Last-Modified", lastModifiedHeader);
|
2022-10-17 18:35:38 +00:00
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
response.body = fileData.data;
|
|
|
|
} catch {
|
|
|
|
// console.error("Error GETting of file", name, e);
|
|
|
|
response.status = 404;
|
|
|
|
response.body = "Not found";
|
|
|
|
}
|
|
|
|
},
|
|
|
|
)
|
|
|
|
.put(
|
|
|
|
filePathRegex,
|
|
|
|
// corsMiddleware,
|
|
|
|
async ({ request, response, params }) => {
|
|
|
|
const name = params[0];
|
|
|
|
console.log("Saving file", name);
|
|
|
|
if (name.startsWith(".")) {
|
|
|
|
// Don't expose hidden files
|
|
|
|
response.status = 403;
|
|
|
|
return;
|
|
|
|
}
|
2023-01-13 14:41:29 +00:00
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
const body = await request.body({ type: "bytes" }).value;
|
2023-07-02 09:25:32 +00:00
|
|
|
|
2023-07-06 14:47:50 +00:00
|
|
|
try {
|
|
|
|
const meta = await spacePrimitives.writeFile(
|
|
|
|
name,
|
|
|
|
body,
|
|
|
|
);
|
|
|
|
response.status = 200;
|
|
|
|
this.fileMetaToHeaders(response.headers, meta);
|
|
|
|
response.body = "OK";
|
|
|
|
} catch (err) {
|
|
|
|
console.error("Write failed", err);
|
|
|
|
response.status = 500;
|
|
|
|
response.body = "Write failed";
|
2023-07-02 09:25:32 +00:00
|
|
|
}
|
2023-07-06 14:47:50 +00:00
|
|
|
},
|
|
|
|
)
|
|
|
|
.delete(filePathRegex, async ({ response, params }) => {
|
2022-10-17 18:35:38 +00:00
|
|
|
const name = params[0];
|
2023-05-25 15:21:51 +00:00
|
|
|
console.log("Deleting file", name);
|
2023-07-06 14:47:50 +00:00
|
|
|
if (name.startsWith(".")) {
|
|
|
|
// Don't expose hidden files
|
|
|
|
response.status = 403;
|
|
|
|
return;
|
|
|
|
}
|
2022-10-17 18:35:38 +00:00
|
|
|
try {
|
|
|
|
await spacePrimitives.deleteFile(name);
|
|
|
|
response.status = 200;
|
|
|
|
response.body = "OK";
|
|
|
|
} catch (e: any) {
|
|
|
|
console.error("Error deleting attachment", e);
|
2023-07-06 14:47:50 +00:00
|
|
|
response.status = 500;
|
2022-10-17 18:35:38 +00:00
|
|
|
response.body = e.message;
|
|
|
|
}
|
2023-07-06 14:47:50 +00:00
|
|
|
})
|
|
|
|
.options(filePathRegex, corsMiddleware);
|
|
|
|
return fsRouter;
|
|
|
|
}
|
|
|
|
|
|
|
|
private fileMetaToHeaders(headers: Headers, fileMeta: FileMeta) {
|
|
|
|
headers.set("Content-Type", fileMeta.contentType);
|
|
|
|
headers.set(
|
|
|
|
"X-Last-Modified",
|
|
|
|
"" + fileMeta.lastModified,
|
|
|
|
);
|
|
|
|
headers.set("Cache-Control", "no-cache");
|
|
|
|
headers.set("X-Permission", fileMeta.perm);
|
2022-10-10 12:50:21 +00:00
|
|
|
}
|
|
|
|
|
2023-05-23 18:53:53 +00:00
|
|
|
stop() {
|
2022-10-10 12:50:21 +00:00
|
|
|
if (this.abortController) {
|
|
|
|
this.abortController.abort();
|
|
|
|
console.log("stopped server");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-05-23 18:53:53 +00:00
|
|
|
|
|
|
|
function utcDateString(mtime: number): string {
|
|
|
|
return new Date(mtime).toUTCString();
|
|
|
|
}
|