No More Collab. Fixes #449

* Fully removes real-time collaboration
* URL scheme rewrite
This commit is contained in:
Zef Hemel
2023-07-06 16:47:50 +02:00
committed by GitHub
parent 021bf7e9a4
commit fb75ea1a65
45 changed files with 380 additions and 1356 deletions
+167 -182
View File
@@ -1,15 +1,13 @@
import { Application, Router } from "./deps.ts";
import { Application, Context, Next, oakCors, Router } from "./deps.ts";
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts";
import { base64Decode } from "../plugos/asset_bundle/base64.ts";
import { ensureSettingsAndIndex } from "../common/util.ts";
import { performLocalFetch } from "../common/proxy_fetch.ts";
import { BuiltinSettings } from "../web/types.ts";
import { gitIgnoreCompiler } from "./deps.ts";
import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts";
import { CollabServer } from "./collab.ts";
import { Authenticator } from "./auth.ts";
import { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts";
import { FileMeta } from "../common/types.ts";
export type ServerOptions = {
hostname: string;
@@ -31,7 +29,6 @@ export class HttpServer {
clientAssetBundle: AssetBundle;
settings?: BuiltinSettings;
spacePrimitives: SpacePrimitives;
collab: CollabServer;
authenticator: Authenticator;
constructor(
@@ -66,8 +63,6 @@ export class HttpServer {
}
},
);
this.collab = new CollabServer(this.spacePrimitives);
this.collab.start();
}
// Replaces some template variables in index.html in a rather ad-hoc manner, but YOLO
@@ -76,76 +71,25 @@ export class HttpServer {
.replaceAll(
"{{SPACE_PATH}}",
this.options.pagesPath.replaceAll("\\", "\\\\"),
).replaceAll(
"{{SYNC_ENDPOINT}}",
"/.fs",
);
}
async start() {
await this.reloadSettings();
// Serve static files (javascript, css, html)
this.app.use(async ({ request, response }, next) => {
if (request.url.pathname === "/") {
// 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)),
);
this.app.use(this.serveStatic.bind(this));
if (request.method === "GET") {
response.body = data;
}
} catch {
await next();
}
});
// Fallback, serve index.html
this.app.use(({ request, response }, next) => {
if (
!request.url.pathname.startsWith("/.fs") &&
request.url.pathname !== "/.auth" &&
!request.url.pathname.startsWith("/.ws")
) {
response.headers.set("Content-type", "text/html");
response.body = this.renderIndexHtml();
} else {
return next();
}
});
// Pages API
const fsRouter = this.buildFsRouter(this.spacePrimitives);
await this.addPasswordAuth(this.app);
const fsRouter = this.addFsRoutes(this.spacePrimitives);
this.app.use(fsRouter.routes());
this.app.use(fsRouter.allowedMethods());
this.collab.route(this.app);
// Fallback, serve the UI index.html
this.app.use(({ response }) => {
response.headers.set("Content-type", "text/html");
response.body = this.renderIndexHtml();
});
this.abortController = new AbortController();
const listenOptions: any = {
@@ -172,6 +116,52 @@ export class HttpServer {
);
}
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();
}
}
async reloadSettings() {
// TODO: Throttle this?
this.settings = await ensureSettingsAndIndex(this.spacePrimitives);
@@ -185,6 +175,7 @@ export class HttpServer {
"/.auth",
];
// Middleware handling the /.auth page and flow
app.use(async ({ request, response, cookies }, next) => {
if (request.url.pathname === "/.auth") {
if (request.url.search === "?logout") {
@@ -227,6 +218,7 @@ export class HttpServer {
});
if ((await this.authenticator.getAllUsers()).length > 0) {
// Users defined, so enabling auth
app.use(async ({ request, response, cookies }, next) => {
if (!excludedPaths.includes(request.url.pathname)) {
const authCookie = await cookies.get("auth");
@@ -250,23 +242,37 @@ export class HttpServer {
}
}
private buildFsRouter(spacePrimitives: SpacePrimitives): Router {
private addFsRoutes(spacePrimitives: SpacePrimitives): Router {
const fsRouter = new Router();
const corsMiddleware = oakCors({
allowedHeaders: "*",
exposedHeaders: "*",
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
});
// File list
fsRouter.get("/", corsMiddleware, async ({ response }) => {
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);
methods: ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"],
});
fsRouter.use(corsMiddleware);
// File list
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("/");
}
},
);
// RPC
fsRouter.post("/", corsMiddleware, async ({ request, response }) => {
fsRouter.post("/.rpc", async ({ request, response }) => {
const body = await request.body({ type: "json" }).value;
try {
switch (body.operation) {
@@ -288,6 +294,7 @@ export class HttpServer {
});
return;
}
console.log("Running shell command:", body.cmd, body.args);
const p = new Deno.Command(body.cmd, {
args: body.args,
cwd: this.options.pagesPath,
@@ -304,6 +311,9 @@ export class HttpServer {
stderr,
code: output.code,
});
if (output.code !== 0) {
console.error("Error running shell command", stdout, stderr);
}
return;
}
default:
@@ -319,129 +329,104 @@ export class HttpServer {
}
});
const filePathRegex = "\/(.+\\.[a-zA-Z]+)";
fsRouter
.get("\/(.+)", corsMiddleware, async ({ params, response, request }) => {
const name = params[0];
console.log("Loading file", name);
if (name.startsWith(".")) {
// Don't expose hidden files
response.status = 404;
return;
}
try {
const attachmentData = await spacePrimitives.readFile(
name,
);
const lastModifiedHeader = new Date(attachmentData.meta.lastModified)
.toUTCString();
if (request.headers.get("If-Modified-Since") === lastModifiedHeader) {
response.status = 304;
.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";
return;
}
response.status = 200;
response.headers.set(
"X-Last-Modified",
"" + attachmentData.meta.lastModified,
);
response.headers.set("Cache-Control", "no-cache");
response.headers.set("X-Permission", attachmentData.meta.perm);
response.headers.set(
"Last-Modified",
lastModifiedHeader,
);
response.headers.set("Content-Type", attachmentData.meta.contentType);
response.body = attachmentData.data;
} catch {
// console.error("Error in main router", e);
response.status = 404;
response.body = "";
}
})
.put("\/(.+)", corsMiddleware, async ({ request, response, params }) => {
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);
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;
}
const body = await request.body({ type: "bytes" }).value;
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";
}
},
)
.delete(filePathRegex, async ({ response, params }) => {
const name = params[0];
console.log("Saving file", name);
console.log("Deleting file", name);
if (name.startsWith(".")) {
// Don't expose hidden files
response.status = 403;
return;
}
let body: Uint8Array;
if (
request.headers.get("X-Content-Base64")
) {
const content = await request.body({ type: "text" }).value;
body = base64Decode(content);
} else {
body = await request.body({ type: "bytes" }).value;
}
try {
const meta = await spacePrimitives.writeFile(
name,
body,
);
response.status = 200;
response.headers.set("Content-Type", meta.contentType);
response.headers.set("X-Last-Modified", "" + meta.lastModified);
response.headers.set("X-Content-Length", "" + meta.size);
response.headers.set("X-Permission", meta.perm);
response.body = "OK";
} catch (err) {
response.status = 500;
response.body = "Write failed";
console.error("Pipeline failed", err);
}
})
.options("\/(.+)", async ({ request, response, params }) => {
const name = params[0];
// Manually set CORS headers
response.headers.set("access-control-allow-headers", "*");
response.headers.set(
"access-control-allow-methods",
"GET,POST,PUT,DELETE,OPTIONS",
);
response.headers.set("access-control-allow-origin", "*");
response.headers.set("access-control-expose-headers", "*");
try {
const meta = await spacePrimitives.getFileMeta(name);
response.status = 200;
response.headers.set("Content-Type", meta.contentType);
response.headers.set("X-Last-Modified", "" + meta.lastModified);
response.headers.set("X-Content-Length", "" + meta.size);
response.headers.set("X-Permission", meta.perm);
const clientId = request.headers.get("X-Client-Id");
if (name.endsWith(".md") && clientId) {
const pageName = name.substring(0, name.length - ".md".length);
console.log(`Got presence update from ${clientId}: ${pageName}`);
const { collabId } = this.collab.updatePresence(clientId, pageName);
if (collabId) {
response.headers.set("X-Collab-Id", collabId);
}
}
} catch {
// Have to do this because of CORS
response.status = 200;
response.headers.set("X-Status", "404");
response.body = "Not found";
// console.error("Options failed", err);
}
})
.delete("\/(.+)", corsMiddleware, async ({ response, params }) => {
const name = params[0];
console.log("Deleting file", name);
try {
await spacePrimitives.deleteFile(name);
response.status = 200;
response.body = "OK";
} catch (e: any) {
console.error("Error deleting attachment", e);
response.status = 200;
response.status = 500;
response.body = e.message;
}
});
return new Router().use("/.fs", fsRouter.routes());
})
.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);
}
stop() {