/** * Shared folders. A space is a folder of a git repository on the forge — * `docs/` of `ben/brainstorm`, say — that its members write in together. * Its notes are ordinary notes whose vault is the space (author_id * `space:`), so links, backlinks, tags, search and the graph work in it * exactly as they do in a personal vault, and never reach across: a * personal vault and a space share nothing. Each note keeps its file's * path, and `src/sync.ts` keeps the folder and the repository in step. * * Who is in: the person who made it (an app admin — the repository token * is theirs, so nobody else may point it at a repository), and whoever * opened its join link while it was on. The owner can turn the link off, * make a new one, and remove members; a member can leave. */ import type { Database } from "bun:sqlite"; import { randomBytes } from "node:crypto"; import { emit } from "./live.ts"; import type { Failure, Result } from "./actions.ts"; import type { Who } from "./who.ts"; export interface Space { readonly id: number; readonly name: string; /** `owner/name` on the forge. */ readonly repo: string; readonly branch: string; /** The folder in the repository the notes live in, `""` for its root. */ readonly folder: string; readonly owner_id: string; readonly owner_name: string; readonly join_code: string | null; /** The commit the notes were last read from. */ readonly head: string | null; readonly synced_at: string | null; readonly sync_error: string | null; readonly created_at: string; } export interface Member { readonly who_id: string; readonly who_name: string; readonly joined_at: string; } const ok = (value: T): Result => ({ ok: true, value }); const fail = (error: Failure, message: string): Result => ({ ok: false, error, message }); /** The vault a space's notes are kept in: what their author_id says. */ export const spaceVault = (id: number): string => `space:${id}`; /** The space a vault is, when it is one. */ export const spaceOf = (vault: string): number | undefined => { const m = /^space:(\d+)$/.exec(vault); return m === null ? undefined : Number(m[1]); }; /** A space's address in the app. */ export const spaceHref = (id: number): string => `/s/${id}`; export const findSpace = (db: Database, id: number): Space | undefined => Number.isInteger(id) ? (db.query("SELECT * FROM space WHERE id = ?1").get(id) ?? undefined) : undefined; export const isMember = (db: Database, id: number, who: Who): boolean => who.role !== "anon" && db .query<{ n: number }, [number, string]>( "SELECT 1 AS n FROM space_member WHERE space_id = ?1 AND who_id = ?2", ) .get(id, who.id) !== null; export const members = (db: Database, id: number): Member[] => db .query( "SELECT who_id, who_name, joined_at FROM space_member WHERE space_id = ?1 ORDER BY joined_at, who_name", ) .all(id); /** Tell every member of a space what changed in it. */ export function tellSpace(db: Database, id: number, type: string, data: unknown): void { for (const m of members(db, id)) emit(type, data, m.who_id); } /** The spaces `who` is in, by name. */ export const mySpaces = (db: Database, who: Who): Space[] => who.role === "anon" ? [] : db .query( "SELECT s.* FROM space s JOIN space_member m ON m.space_id = s.id WHERE m.who_id = ?1 ORDER BY s.name COLLATE NOCASE", ) .all(who.id); /** A space, for one of its members. */ export function getSpace(db: Database, who: Who, id: number): Result { if (who.role === "anon") return fail("signin_required", "Sign in to open this folder."); const space = findSpace(db, id); // Whoever is not in it learns nothing about whether it exists. if (space === undefined || !isMember(db, id, who)) return fail("not_found", "No such shared folder."); return ok(space); } /** A space, for its owner. */ function owned(db: Database, who: Who, id: number): Result { const space = getSpace(db, who, id); if (!space.ok) return space; if (who.role === "anon" || space.value.owner_id !== who.id) return fail("forbidden", "Only the folder's owner can do that."); return space; } const code = (): string => randomBytes(18).toString("base64url"); // What a repository, branch and folder may be called: the forge's own rules, roughly. const REPO = /^[\w.-]+\/[\w.-]+$/; const BRANCH = /^(?!-)(?!.*\.\.)[\w./-]{1,100}$/; /** A file or folder name a note can have: one segment, nothing a filesystem or a link would trip on. */ export function cleanSegment(raw: string): Result { const s = raw.replace(/\s+/g, " ").trim(); if (s === "" || s.length > 120) return fail("invalid", "A name is 1 to 120 characters."); if (s.startsWith(".")) return fail("invalid", "A name cannot start with a dot."); if (/[\/\\:*?"<>|\[\]#^\u0000-\u001f]/.test(s)) return fail("invalid", "A name cannot hold / \\ : * ? \" < > | [ ] # or ^."); return ok(s); } /** A folder path, `a/b`: each segment clean, no empty ones. `""` is the top. */ export function cleanFolder(raw: string): Result { const parts = raw.split("/").map((p) => p.trim()).filter((p) => p !== ""); const out: string[] = []; for (const p of parts) { const clean = cleanSegment(p); if (!clean.ok) return clean; out.push(clean.value); } return ok(out.join("/")); } const join = (...parts: string[]): string => parts.filter((p) => p !== "").join("/"); /** The file a note titled `title` in `folder` (relative to the space's own) is kept in. */ export const fileOf = (space: Pick, folder: string, title: string): string => join(space.folder, folder, `${title}.md`); /** The folder a file is in, relative to the space's own. */ export function folderOf(space: Pick, path: string): string { const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; if (space.folder === "") return dir; return dir === space.folder ? "" : dir.slice(space.folder.length + 1); } /** A file's note title: its name, without `.md`. */ export const titleOfPath = (path: string): string => path.slice(path.lastIndexOf("/") + 1).replace(/\.md$/i, ""); /** Make a space: an app admin points it at a repository folder, and is its first member. */ export function createSpace( db: Database, who: Who, input: { name: string; repo: string; branch?: string; folder?: string }, ): Result { if (who.role === "anon") return fail("signin_required", "Sign in to make a shared folder."); if (who.role !== "admin") return fail("forbidden", "Only an app admin can connect a repository — it is written to with their token."); const name = input.name.replace(/\s+/g, " ").trim(); if (name === "" || name.length > 80) return fail("invalid", "A name is 1 to 80 characters."); const repo = input.repo.trim().replace(/^\/+|\/+$/g, ""); if (!REPO.test(repo)) return fail("invalid", "The repository is owner/name, as on the forge."); const branch = (input.branch ?? "").trim() || "main"; if (!BRANCH.test(branch)) return fail("invalid", "That is not a branch name."); const folder = cleanFolder(input.folder ?? ""); if (!folder.ok) return folder; const taken = db .query<{ id: number }, [string, string, string]>( "SELECT id FROM space WHERE repo = ?1 AND branch = ?2 AND folder = ?3", ) .get(repo, branch, folder.value); if (taken !== null) return fail("invalid", "That folder is already shared here."); const space = db.transaction(() => { const made = db .query( "INSERT INTO space (name, repo, branch, folder, owner_id, owner_name, join_code) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *", ) .get(name, repo, branch, folder.value, who.id, who.name, code())!; db.query("INSERT INTO space_member (space_id, who_id, who_name) VALUES (?1, ?2, ?3)").run( made.id, who.id, who.name, ); return made; })(); return ok(space); } /** Open a join link: whoever is signed in becomes a member. */ export function joinSpace(db: Database, who: Who, input: { code: string }): Result { if (who.role === "anon") return fail("signin_required", "Sign in, then open the link again."); const space = input.code === "" ? null : db.query("SELECT * FROM space WHERE join_code = ?1").get(input.code); if (space === null) return fail("not_found", "That link has been turned off, or was never one."); db.query( "INSERT INTO space_member (space_id, who_id, who_name) VALUES (?1, ?2, ?3) ON CONFLICT (space_id, who_id) DO UPDATE SET who_name = excluded.who_name", ).run(space.id, who.id, who.name); tellSpace(db, space.id, "space.changed", { id: space.id }); return ok(space); } /** Turn the join link off, or on with a new address (the old one stops working). */ export function setLink(db: Database, who: Who, input: { id: number; on: boolean }): Result { const space = owned(db, who, input.id); if (!space.ok) return space; return ok( db .query("UPDATE space SET join_code = ?1 WHERE id = ?2 RETURNING *") .get(input.on ? code() : null, input.id)!, ); } /** The owner removes a member, or a member leaves. The owner stays. */ export function removeMember( db: Database, who: Who, input: { id: number; member: string }, ): Result<{ id: number }> { const space = getSpace(db, who, input.id); if (!space.ok) return space; if (who.role === "anon") return fail("signin_required", "Sign in first."); const self = input.member === who.id; if (!self && space.value.owner_id !== who.id) return fail("forbidden", "Only the folder's owner can remove someone."); if (input.member === space.value.owner_id) return fail("invalid", "The owner can't leave their own folder — delete it instead."); db.query("DELETE FROM space_member WHERE space_id = ?1 AND who_id = ?2").run(input.id, input.member); emit("space.changed", { id: input.id }, input.member); tellSpace(db, input.id, "space.changed", { id: input.id }); return ok({ id: input.id }); } /** Stop sharing: the space and its copy of the notes go; the repository keeps every file. */ export function deleteSpace(db: Database, who: Who, input: { id: number }): Result<{ id: number }> { const space = owned(db, who, input.id); if (!space.ok) return space; const everyone = members(db, input.id); db.transaction(() => { db.query("DELETE FROM note WHERE author_id = ?1").run(spaceVault(input.id)); db.query("DELETE FROM space WHERE id = ?1").run(input.id); })(); for (const m of everyone) emit("space.changed", { id: input.id }, m.who_id); return ok({ id: input.id }); } // A change to a space's notes is a commit owed: `src/sync.ts` listens here // (and actions call `edited`) so the two never import each other. let onEdit: (id: number) => void = () => {}; export const whenEdited = (listen: (id: number) => void): void => { onEdit = listen; }; export const edited = (id: number): void => onEdit(id);