No More Collab. Fixes #449
* Fully removes real-time collaboration * URL scheme rewrite
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
import { assert, assertEquals } from "../test_deps.ts";
|
||||
import { CollabServer } from "./collab.ts";
|
||||
|
||||
Deno.test("Collab server", async () => {
|
||||
const collabServer = new CollabServer(null as any);
|
||||
console.log("Client 1 joins page 1");
|
||||
assertEquals(collabServer.updatePresence("client1", "page1"), {});
|
||||
assertEquals(collabServer.pages.size, 1);
|
||||
assertEquals(collabServer.updatePresence("client1", "page2"), {});
|
||||
assertEquals(collabServer.pages.size, 1);
|
||||
console.log("Client 2 joins to page 2, collab id created, but not exposed");
|
||||
assertEquals(
|
||||
collabServer.updatePresence("client2", "page2").collabId,
|
||||
undefined,
|
||||
);
|
||||
assert(
|
||||
collabServer.updatePresence("client1", "page2").collabId !== undefined,
|
||||
);
|
||||
console.log("Client 2 moves to page 1, collab id destroyed");
|
||||
assertEquals(collabServer.updatePresence("client2", "page1"), {});
|
||||
assertEquals(collabServer.updatePresence("client1", "page2"), {});
|
||||
assertEquals(collabServer.pages.get("page2")!.collabId, undefined);
|
||||
assertEquals(collabServer.pages.get("page1")!.collabId, undefined);
|
||||
console.log("Going to cleanup, which should have no effect");
|
||||
collabServer.cleanup(50);
|
||||
assertEquals(collabServer.pages.size, 2);
|
||||
collabServer.updatePresence("client2", "page2");
|
||||
console.log("Going to sleep 20ms");
|
||||
await sleep(20);
|
||||
console.log("Then client 1 pings, but client 2 does not");
|
||||
collabServer.updatePresence("client1", "page2");
|
||||
await sleep(20);
|
||||
console.log("Going to cleanup, which should clean client 2");
|
||||
collabServer.cleanup(35);
|
||||
assertEquals(collabServer.pages.get("page2")!.collabId, undefined);
|
||||
assertEquals(collabServer.clients.size, 1);
|
||||
console.log(collabServer);
|
||||
});
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { getAvailablePortSync } from "https://deno.land/x/port@1.0.0/mod.ts";
|
||||
import { nanoid } from "https://esm.sh/nanoid@4.0.0";
|
||||
import { race, timeout } from "../common/async_util.ts";
|
||||
import { Application } from "./deps.ts";
|
||||
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
|
||||
import { collabPingInterval } from "../plugs/collab/constants.ts";
|
||||
import { Hocuspocus } from "./deps.ts";
|
||||
|
||||
type CollabPage = {
|
||||
clients: Map<string, number>; // clientId -> lastPing
|
||||
collabId?: string;
|
||||
// The currently elected provider of the initial document
|
||||
masterClientId: string;
|
||||
};
|
||||
|
||||
export class CollabServer {
|
||||
clients: Map<string, { openPage: string; lastUpdate: number }> = new Map(); // clientId -> openPage
|
||||
pages: Map<string, CollabPage> = new Map();
|
||||
yCollabServer?: Hocuspocus;
|
||||
|
||||
constructor(private spacePrimitives: SpacePrimitives) {
|
||||
}
|
||||
|
||||
start() {
|
||||
setInterval(() => {
|
||||
this.cleanup(3 * collabPingInterval);
|
||||
}, collabPingInterval);
|
||||
}
|
||||
|
||||
updatePresence(
|
||||
clientId: string,
|
||||
currentPage: string,
|
||||
): { collabId?: string } {
|
||||
let client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
client = { openPage: "", lastUpdate: 0 };
|
||||
this.clients.set(clientId, client);
|
||||
}
|
||||
client.lastUpdate = Date.now();
|
||||
if (currentPage !== client.openPage) {
|
||||
// Client switched pages
|
||||
// Update last page record
|
||||
const lastCollabPage = this.pages.get(client.openPage);
|
||||
if (lastCollabPage) {
|
||||
lastCollabPage.clients.delete(clientId);
|
||||
if (lastCollabPage.clients.size === 1) {
|
||||
delete lastCollabPage.collabId;
|
||||
}
|
||||
|
||||
if (lastCollabPage.clients.size === 0) {
|
||||
this.pages.delete(client.openPage);
|
||||
} else {
|
||||
// Elect a new master client
|
||||
if (lastCollabPage.masterClientId === clientId) {
|
||||
// Any is fine, really
|
||||
lastCollabPage.masterClientId =
|
||||
[...lastCollabPage.clients.keys()][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ok, let's update our records now
|
||||
client.openPage = currentPage;
|
||||
}
|
||||
|
||||
// Update new page
|
||||
let nextCollabPage = this.pages.get(currentPage);
|
||||
if (!nextCollabPage) {
|
||||
// Newly opened page (no other clients on this page right now)
|
||||
nextCollabPage = {
|
||||
clients: new Map(),
|
||||
masterClientId: clientId,
|
||||
};
|
||||
this.pages.set(currentPage, nextCollabPage);
|
||||
}
|
||||
// Register last ping from us
|
||||
nextCollabPage.clients.set(clientId, Date.now());
|
||||
|
||||
if (nextCollabPage.clients.size > 1 && !nextCollabPage.collabId) {
|
||||
// Create a new collabId
|
||||
nextCollabPage.collabId = nanoid();
|
||||
}
|
||||
// console.log("State", this.pages);
|
||||
if (nextCollabPage.collabId) {
|
||||
// We will now expose this collabId, except when we're just starting this session
|
||||
// in which case we'll wait for the original client to publish the document
|
||||
const existingyCollabSession = this.yCollabServer?.documents.get(
|
||||
buildCollabId(nextCollabPage.collabId, `${currentPage}.md`),
|
||||
);
|
||||
if (existingyCollabSession) {
|
||||
// console.log("Found an existing collab session already, let's join!");
|
||||
return { collabId: nextCollabPage.collabId };
|
||||
} else if (clientId === nextCollabPage.masterClientId) {
|
||||
// console.log("We're the master, so we should connect");
|
||||
return { collabId: nextCollabPage.collabId };
|
||||
} else {
|
||||
// We're not the first client, so we need to wait for the first client to connect
|
||||
// console.log("We're not the master, so we should wait");
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(timeout: number) {
|
||||
// Clean up pages and their clients that haven't pinged for some time
|
||||
for (const [pageName, page] of this.pages) {
|
||||
for (const [clientId, lastPing] of page.clients) {
|
||||
if (Date.now() - lastPing > timeout) {
|
||||
// Eject client
|
||||
page.clients.delete(clientId);
|
||||
// Elect a new master client
|
||||
if (page.masterClientId === clientId && page.clients.size > 0) {
|
||||
page.masterClientId = [...page.clients.keys()][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page.clients.size === 1) {
|
||||
// If there's only one client left, we don't need to keep the collabId around anymore
|
||||
delete page.collabId;
|
||||
}
|
||||
if (page.clients.size === 0) {
|
||||
// And if we have no clients left, well...
|
||||
this.pages.delete(pageName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [clientId, { lastUpdate }] of this.clients) {
|
||||
if (Date.now() - lastUpdate > timeout) {
|
||||
// Eject client
|
||||
this.clients.delete(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route(app: Application) {
|
||||
// The way this works is that we spin up a separate WS server locally and then proxy requests to it
|
||||
// This is the only way I could get Hocuspocus to work with Deno
|
||||
const internalPort = getAvailablePortSync();
|
||||
this.yCollabServer = new Hocuspocus({
|
||||
port: internalPort,
|
||||
address: "127.0.0.1",
|
||||
quiet: true,
|
||||
onStoreDocument: async (data) => {
|
||||
const [_, path] = splitCollabId(data.documentName);
|
||||
const text = data.document.getText("codemirror").toString();
|
||||
console.log(
|
||||
"[Hocuspocus]",
|
||||
"Persisting",
|
||||
path,
|
||||
"to space on server",
|
||||
);
|
||||
const meta = await this.spacePrimitives.writeFile(
|
||||
path,
|
||||
new TextEncoder().encode(text),
|
||||
);
|
||||
// Broadcast new persisted lastModified date
|
||||
data.document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: "persisted",
|
||||
path,
|
||||
lastModified: meta.lastModified,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
},
|
||||
onDisconnect: (client) => {
|
||||
console.log("[Hocuspocus]", "Client disconnected", client.clientsCount);
|
||||
if (client.clientsCount === 0) {
|
||||
console.log(
|
||||
"[Hocuspocus]",
|
||||
"Last client disconnected from",
|
||||
client.documentName,
|
||||
"purging from memory",
|
||||
);
|
||||
this.yCollabServer!.documents.delete(client.documentName);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
this.yCollabServer.listen();
|
||||
|
||||
app.use((ctx) => {
|
||||
// if (ctx.request.url.pathname === "/.ws") {
|
||||
// const sock = ctx.upgrade();
|
||||
// sock.onmessage = (e) => {
|
||||
// console.log("WS: Got message", e.data);
|
||||
// };
|
||||
// }
|
||||
// Websocket proxy to hocuspocus
|
||||
if (ctx.request.url.pathname === "/.ws-collab") {
|
||||
const sock = ctx.upgrade();
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${internalPort}`);
|
||||
const wsReady = race([
|
||||
new Promise<void>((resolve) => {
|
||||
ws.onopen = () => {
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
timeout(1000),
|
||||
]).catch(() => {
|
||||
console.error("Timeout waiting for collab to open websocket");
|
||||
sock.close();
|
||||
});
|
||||
sock.onmessage = (e) => {
|
||||
// console.log("Got message", e);
|
||||
wsReady.then(() => ws.send(e.data)).catch(console.error);
|
||||
};
|
||||
sock.onclose = () => {
|
||||
if (ws.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
if (sock.OPEN) {
|
||||
sock.send(e.data);
|
||||
} else {
|
||||
console.error("Got message from websocket but socket is not open");
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (sock.OPEN) {
|
||||
sock.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function splitCollabId(documentName: string): [string, string] {
|
||||
const [collabId, ...pathPieces] = documentName.split("/");
|
||||
const path = pathPieces.join("/");
|
||||
return [collabId, path];
|
||||
}
|
||||
|
||||
function buildCollabId(collabId: string, path: string): string {
|
||||
return `${collabId}/${path}`;
|
||||
}
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
export * from "../common/deps.ts";
|
||||
export { Application, Router } from "https://deno.land/x/oak@v12.4.0/mod.ts";
|
||||
export type { Next } from "https://deno.land/x/oak@v12.4.0/mod.ts";
|
||||
export {
|
||||
Application,
|
||||
Context,
|
||||
Router,
|
||||
} from "https://deno.land/x/oak@v12.4.0/mod.ts";
|
||||
export * as etag from "https://deno.land/x/oak@v12.4.0/etag.ts";
|
||||
|
||||
export { Hocuspocus } from "npm:@hocuspocus/server@2.1.0";
|
||||
export { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts";
|
||||
|
||||
+167
-182
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user