Real-time collaboration within space (#411)

This commit is contained in:
Zef Hemel
2023-06-13 20:47:05 +02:00
committed by GitHub
parent 063a8e4767
commit 8e0a7cf177
54 changed files with 1358 additions and 187 deletions
+120
View File
@@ -0,0 +1,120 @@
import { KVStore } from "../plugos/lib/kv_store.ts";
export type User = {
username: string;
passwordHash: string; // hashed password
salt: string;
groups: string[]; // special "admin"
};
async function createUser(
username: string,
password: string,
groups: string[],
salt = generateSalt(16),
): Promise<User> {
return {
username,
passwordHash: await hashSHA256(`${salt}${password}`),
salt,
groups,
};
}
const userPrefix = `u:`;
export class Authenticator {
constructor(private store: KVStore) {
}
async register(
username: string,
password: string,
groups: string[],
salt?: string,
): Promise<void> {
await this.store.set(
`${userPrefix}${username}`,
await createUser(username, password, groups, salt),
);
}
async authenticateHashed(
username: string,
hashedPassword: string,
): Promise<boolean> {
const user = await this.store.get(`${userPrefix}${username}`) as User;
if (!user) {
return false;
}
return user.passwordHash === hashedPassword;
}
async authenticate(
username: string,
password: string,
): Promise<string | undefined> {
const user = await this.store.get(`${userPrefix}${username}`) as User;
if (!user) {
return undefined;
}
const hashedPassword = await hashSHA256(`${user.salt}${password}`);
return user.passwordHash === hashedPassword ? hashedPassword : undefined;
}
async getAllUsers(): Promise<User[]> {
return (await this.store.queryPrefix(userPrefix)).map((item) => item.value);
}
getUser(username: string): Promise<User | undefined> {
return this.store.get(`${userPrefix}${username}`);
}
async setPassword(username: string, password: string): Promise<void> {
const user = await this.getUser(username);
if (!user) {
throw new Error(`User does not exist`);
}
user.passwordHash = await hashSHA256(`${user.salt}${password}`);
await this.store.set(`${userPrefix}${username}`, user);
}
async deleteUser(username: string): Promise<void> {
const user = await this.getUser(username);
if (!user) {
throw new Error(`User does not exist`);
}
await this.store.del(`${userPrefix}${username}`);
}
async setGroups(username: string, groups: string[]): Promise<void> {
const user = await this.getUser(username);
if (!user) {
throw new Error(`User does not exist`);
}
user.groups = groups;
await this.store.set(`${userPrefix}${username}`, user);
}
}
async function hashSHA256(message: string): Promise<string> {
// Transform the string into an ArrayBuffer
const encoder = new TextEncoder();
const data = encoder.encode(message);
// Generate the hash
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
// Transform the hash into a hex string
return Array.from(new Uint8Array(hashBuffer)).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
}
function generateSalt(length: number): string {
const array = new Uint8Array(length / 2); // because two characters represent one byte in hex
crypto.getRandomValues(array);
return Array.from(array, (byte) => ("00" + byte.toString(16)).slice(-2)).join(
"",
);
}
+46
View File
@@ -0,0 +1,46 @@
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);
console.log("CLient 1 leaves page 1");
assertEquals(collabServer.updatePresence("client1", undefined, "page1"), {});
assertEquals(collabServer.pages.size, 0);
assertEquals(collabServer.updatePresence("client1", "page1"), {});
console.log("Client 1 joins page 2");
assertEquals(collabServer.updatePresence("client1", "page2", "page1"), {});
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", "page2"), {});
assertEquals(collabServer.updatePresence("client1", "page2", "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", "page1");
console.log("Going to sleep 20ms");
await sleep(20);
console.log("Then client 1 pings, but client 2 does not");
collabServer.updatePresence("client1", "page2", "page2");
await sleep(20);
console.log("Going to cleanup, which should clean client 2");
collabServer.cleanup(35);
assertEquals(collabServer.pages.get("page2")!.collabId, undefined);
console.log(collabServer);
});
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+231
View File
@@ -0,0 +1,231 @@
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; lastPing: number }> = new Map();
pages: Map<string, CollabPage> = new Map();
yCollabServer?: Hocuspocus;
constructor(private spacePrimitives: SpacePrimitives) {
}
start() {
setInterval(() => {
this.cleanup(3 * collabPingInterval);
}, collabPingInterval);
}
updatePresence(
clientId: string,
currentPage?: string,
previousPage?: string,
): { collabId?: string } {
if (previousPage && currentPage !== previousPage) {
// Client switched pages
// Update last page record
const lastCollabPage = this.pages.get(previousPage);
if (lastCollabPage) {
lastCollabPage.clients.delete(clientId);
if (lastCollabPage.clients.size === 1) {
delete lastCollabPage.collabId;
}
if (lastCollabPage.clients.size === 0) {
this.pages.delete(previousPage);
} else {
// Elect a new master client
if (lastCollabPage.masterClientId === clientId) {
// Any is fine, really
lastCollabPage.masterClientId =
[...lastCollabPage.clients.keys()][0];
}
}
}
}
if (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 {};
}
} 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);
}
}
}
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}`;
}
+2
View File
@@ -1,3 +1,5 @@
export * from "../common/deps.ts";
export { Application, 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";
+63 -13
View File
@@ -7,13 +7,15 @@ 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";
export type ServerOptions = {
hostname: string;
port: number;
pagesPath: string;
clientAssetBundle: AssetBundle;
user?: string;
authenticator: Authenticator;
pass?: string;
certFile?: string;
keyFile?: string;
@@ -24,11 +26,12 @@ export class HttpServer {
app: Application;
private hostname: string;
private port: number;
user?: string;
abortController?: AbortController;
clientAssetBundle: AssetBundle;
settings?: BuiltinSettings;
spacePrimitives: SpacePrimitives;
collab: CollabServer;
authenticator: Authenticator;
constructor(
spacePrimitives: SpacePrimitives,
@@ -37,7 +40,7 @@ export class HttpServer {
this.hostname = options.hostname;
this.port = options.port;
this.app = new Application();
this.user = options.user;
this.authenticator = options.authenticator;
this.clientAssetBundle = options.clientAssetBundle;
let fileFilterFn: (s: string) => boolean = () => true;
@@ -62,6 +65,8 @@ 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
@@ -123,7 +128,8 @@ export class HttpServer {
this.app.use(({ request, response }, next) => {
if (
!request.url.pathname.startsWith("/.fs") &&
request.url.pathname !== "/.auth"
request.url.pathname !== "/.auth" &&
!request.url.pathname.startsWith("/.ws")
) {
response.headers.set("Content-type", "text/html");
response.body = this.renderIndexHtml();
@@ -134,10 +140,12 @@ export class HttpServer {
// Pages API
const fsRouter = this.buildFsRouter(this.spacePrimitives);
this.addPasswordAuth(this.app);
await this.addPasswordAuth(this.app);
this.app.use(fsRouter.routes());
this.app.use(fsRouter.allowedMethods());
this.collab.route(this.app);
this.abortController = new AbortController();
const listenOptions: any = {
hostname: this.hostname,
@@ -168,25 +176,40 @@ export class HttpServer {
this.settings = await ensureSettingsAndIndex(this.spacePrimitives);
}
private addPasswordAuth(app: Application) {
private async addPasswordAuth(app: Application) {
const excludedPaths = [
"/manifest.json",
"/favicon.png",
"/logo.png",
"/.auth",
];
if (this.user) {
const b64User = btoa(this.user);
if ((await this.authenticator.getAllUsers()).length > 0) {
app.use(async ({ request, response, cookies }, next) => {
if (!excludedPaths.includes(request.url.pathname)) {
const authCookie = await cookies.get("auth");
if (!authCookie || authCookie !== b64User) {
if (!authCookie) {
response.status = 401;
response.body = "Unauthorized, please authenticate";
return;
}
const [username, hashedPassword] = authCookie.split(":");
if (
!await this.authenticator.authenticateHashed(
username,
hashedPassword,
)
) {
response.status = 401;
response.body = "Invalid username/password, please reauthenticate";
return;
}
}
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(
@@ -195,11 +218,15 @@ export class HttpServer {
return;
} else if (request.method === "POST") {
const values = await request.body({ type: "form" }).value;
const username = values.get("username"),
password = values.get("password"),
const username = values.get("username")!,
password = values.get("password")!,
refer = values.get("refer");
if (this.user === `${username}:${password}`) {
await cookies.set("auth", b64User, {
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",
});
@@ -273,6 +300,19 @@ export class HttpServer {
});
return;
}
case "presence": {
// RPC to check (for collab purposes) which client has what page open
response.headers.set("Content-Type", "application/json");
console.log("Got presence update", body);
response.body = JSON.stringify(
this.collab.updatePresence(
body.clientId,
body.currentPage,
body.previousPage,
),
);
return;
}
default:
response.headers.set("Content-Type", "text/plain");
response.status = 400;
@@ -290,6 +330,11 @@ export class HttpServer {
.get("\/(.+)", 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,
@@ -322,6 +367,11 @@ export class HttpServer {
.put("\/(.+)", 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;
}
let body: Uint8Array;
if (