benjivers/notes Public Public: anyone can read this; nobody can change it.

Files main
261 lines · 10.9 KB · ~3.2k tok Raw History
1
/**
2
 * Shared folders. A space is a folder of a git repository on the forge —
3
 * `docs/` of `ben/brainstorm`, say — that its members write in together.
4
 * Its notes are ordinary notes whose vault is the space (author_id
5
 * `space:<id>`), so links, backlinks, tags, search and the graph work in it
6
 * exactly as they do in a personal vault, and never reach across: a
7
 * personal vault and a space share nothing. Each note keeps its file's
8
 * path, and `src/sync.ts` keeps the folder and the repository in step.
9
 *
10
 * Who is in: the person who made it (an app admin — the repository token
11
 * is theirs, so nobody else may point it at a repository), and whoever
12
 * opened its join link while it was on. The owner can turn the link off,
13
 * make a new one, and remove members; a member can leave.
14
 */
15
16
import type { Database } from "bun:sqlite";
17
import { randomBytes } from "node:crypto";
18
import { emit } from "./live.ts";
19
import type { Failure, Result } from "./actions.ts";
20
import type { Who } from "./who.ts";
21
22
export interface Space {
23
  readonly id: number;
24
  readonly name: string;
25
  /** `owner/name` on the forge. */
26
  readonly repo: string;
27
  readonly branch: string;
28
  /** The folder in the repository the notes live in, `""` for its root. */
29
  readonly folder: string;
30
  readonly owner_id: string;
31
  readonly owner_name: string;
32
  readonly join_code: string | null;
33
  /** The commit the notes were last read from. */
34
  readonly head: string | null;
35
  readonly synced_at: string | null;
36
  readonly sync_error: string | null;
37
  readonly created_at: string;
38
}
39
40
export interface Member {
41
  readonly who_id: string;
42
  readonly who_name: string;
43
  readonly joined_at: string;
44
}
45
46
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
47
const fail = <T>(error: Failure, message: string): Result<T> => ({ ok: false, error, message });
48
49
/** The vault a space's notes are kept in: what their author_id says. */
50
export const spaceVault = (id: number): string => `space:${id}`;
51
/** The space a vault is, when it is one. */
52
export const spaceOf = (vault: string): number | undefined => {
53
  const m = /^space:(\d+)$/.exec(vault);
54
  return m === null ? undefined : Number(m[1]);
55
};
56
/** A space's address in the app. */
57
export const spaceHref = (id: number): string => `/s/${id}`;
58
59
export const findSpace = (db: Database, id: number): Space | undefined =>
60
  Number.isInteger(id)
61
    ? (db.query<Space, [number]>("SELECT * FROM space WHERE id = ?1").get(id) ?? undefined)
62
    : undefined;
63
64
export const isMember = (db: Database, id: number, who: Who): boolean =>
65
  who.role !== "anon" &&
66
  db
67
    .query<{ n: number }, [number, string]>(
68
      "SELECT 1 AS n FROM space_member WHERE space_id = ?1 AND who_id = ?2",
69
    )
70
    .get(id, who.id) !== null;
71
72
export const members = (db: Database, id: number): Member[] =>
73
  db
74
    .query<Member, [number]>(
75
      "SELECT who_id, who_name, joined_at FROM space_member WHERE space_id = ?1 ORDER BY joined_at, who_name",
76
    )
77
    .all(id);
78
79
/** Tell every member of a space what changed in it. */
80
export function tellSpace(db: Database, id: number, type: string, data: unknown): void {
81
  for (const m of members(db, id)) emit(type, data, m.who_id);
82
}
83
84
/** The spaces `who` is in, by name. */
85
export const mySpaces = (db: Database, who: Who): Space[] =>
86
  who.role === "anon"
87
    ? []
88
    : db
89
        .query<Space, [string]>(
90
          "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",
91
        )
92
        .all(who.id);
93
94
/** A space, for one of its members. */
95
export function getSpace(db: Database, who: Who, id: number): Result<Space> {
96
  if (who.role === "anon") return fail("signin_required", "Sign in to open this folder.");
97
  const space = findSpace(db, id);
98
  // Whoever is not in it learns nothing about whether it exists.
99
  if (space === undefined || !isMember(db, id, who)) return fail("not_found", "No such shared folder.");
100
  return ok(space);
101
}
102
103
/** A space, for its owner. */
104
function owned(db: Database, who: Who, id: number): Result<Space> {
105
  const space = getSpace(db, who, id);
106
  if (!space.ok) return space;
107
  if (who.role === "anon" || space.value.owner_id !== who.id)
108
    return fail("forbidden", "Only the folder's owner can do that.");
109
  return space;
110
}
111
112
const code = (): string => randomBytes(18).toString("base64url");
113
114
// What a repository, branch and folder may be called: the forge's own rules, roughly.
115
const REPO = /^[\w.-]+\/[\w.-]+$/;
116
const BRANCH = /^(?!-)(?!.*\.\.)[\w./-]{1,100}$/;
117
118
/** A file or folder name a note can have: one segment, nothing a filesystem or a link would trip on. */
119
export function cleanSegment(raw: string): Result<string> {
120
  const s = raw.replace(/\s+/g, " ").trim();
121
  if (s === "" || s.length > 120) return fail("invalid", "A name is 1 to 120 characters.");
122
  if (s.startsWith(".")) return fail("invalid", "A name cannot start with a dot.");
123
  if (/[\/\\:*?"<>|\[\]#^\u0000-\u001f]/.test(s))
124
    return fail("invalid", "A name cannot hold / \\ : * ? \" < > | [ ] # or ^.");
125
  return ok(s);
126
}
127
128
/** A folder path, `a/b`: each segment clean, no empty ones. `""` is the top. */
129
export function cleanFolder(raw: string): Result<string> {
130
  const parts = raw.split("/").map((p) => p.trim()).filter((p) => p !== "");
131
  const out: string[] = [];
132
  for (const p of parts) {
133
    const clean = cleanSegment(p);
134
    if (!clean.ok) return clean;
135
    out.push(clean.value);
136
  }
137
  return ok(out.join("/"));
138
}
139
140
const join = (...parts: string[]): string => parts.filter((p) => p !== "").join("/");
141
142
/** The file a note titled `title` in `folder` (relative to the space's own) is kept in. */
143
export const fileOf = (space: Pick<Space, "folder">, folder: string, title: string): string =>
144
  join(space.folder, folder, `${title}.md`);
145
146
/** The folder a file is in, relative to the space's own. */
147
export function folderOf(space: Pick<Space, "folder">, path: string): string {
148
  const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
149
  if (space.folder === "") return dir;
150
  return dir === space.folder ? "" : dir.slice(space.folder.length + 1);
151
}
152
153
/** A file's note title: its name, without `.md`. */
154
export const titleOfPath = (path: string): string =>
155
  path.slice(path.lastIndexOf("/") + 1).replace(/\.md$/i, "");
156
157
/** Make a space: an app admin points it at a repository folder, and is its first member. */
158
export function createSpace(
159
  db: Database,
160
  who: Who,
161
  input: { name: string; repo: string; branch?: string; folder?: string },
162
): Result<Space> {
163
  if (who.role === "anon") return fail("signin_required", "Sign in to make a shared folder.");
164
  if (who.role !== "admin")
165
    return fail("forbidden", "Only an app admin can connect a repository — it is written to with their token.");
166
  const name = input.name.replace(/\s+/g, " ").trim();
167
  if (name === "" || name.length > 80) return fail("invalid", "A name is 1 to 80 characters.");
168
  const repo = input.repo.trim().replace(/^\/+|\/+$/g, "");
169
  if (!REPO.test(repo)) return fail("invalid", "The repository is owner/name, as on the forge.");
170
  const branch = (input.branch ?? "").trim() || "main";
171
  if (!BRANCH.test(branch)) return fail("invalid", "That is not a branch name.");
172
  const folder = cleanFolder(input.folder ?? "");
173
  if (!folder.ok) return folder;
174
  const taken = db
175
    .query<{ id: number }, [string, string, string]>(
176
      "SELECT id FROM space WHERE repo = ?1 AND branch = ?2 AND folder = ?3",
177
    )
178
    .get(repo, branch, folder.value);
179
  if (taken !== null) return fail("invalid", "That folder is already shared here.");
180
  const space = db.transaction(() => {
181
    const made = db
182
      .query<Space, [string, string, string, string, string, string, string]>(
183
        "INSERT INTO space (name, repo, branch, folder, owner_id, owner_name, join_code) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING *",
184
      )
185
      .get(name, repo, branch, folder.value, who.id, who.name, code())!;
186
    db.query("INSERT INTO space_member (space_id, who_id, who_name) VALUES (?1, ?2, ?3)").run(
187
      made.id,
188
      who.id,
189
      who.name,
190
    );
191
    return made;
192
  })();
193
  return ok(space);
194
}
195
196
/** Open a join link: whoever is signed in becomes a member. */
197
export function joinSpace(db: Database, who: Who, input: { code: string }): Result<Space> {
198
  if (who.role === "anon") return fail("signin_required", "Sign in, then open the link again.");
199
  const space =
200
    input.code === ""
201
      ? null
202
      : db.query<Space, [string]>("SELECT * FROM space WHERE join_code = ?1").get(input.code);
203
  if (space === null) return fail("not_found", "That link has been turned off, or was never one.");
204
  db.query(
205
    "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",
206
  ).run(space.id, who.id, who.name);
207
  tellSpace(db, space.id, "space.changed", { id: space.id });
208
  return ok(space);
209
}
210
211
/** Turn the join link off, or on with a new address (the old one stops working). */
212
export function setLink(db: Database, who: Who, input: { id: number; on: boolean }): Result<Space> {
213
  const space = owned(db, who, input.id);
214
  if (!space.ok) return space;
215
  return ok(
216
    db
217
      .query<Space, [string | null, number]>("UPDATE space SET join_code = ?1 WHERE id = ?2 RETURNING *")
218
      .get(input.on ? code() : null, input.id)!,
219
  );
220
}
221
222
/** The owner removes a member, or a member leaves. The owner stays. */
223
export function removeMember(
224
  db: Database,
225
  who: Who,
226
  input: { id: number; member: string },
227
): Result<{ id: number }> {
228
  const space = getSpace(db, who, input.id);
229
  if (!space.ok) return space;
230
  if (who.role === "anon") return fail("signin_required", "Sign in first.");
231
  const self = input.member === who.id;
232
  if (!self && space.value.owner_id !== who.id)
233
    return fail("forbidden", "Only the folder's owner can remove someone.");
234
  if (input.member === space.value.owner_id)
235
    return fail("invalid", "The owner can't leave their own folder — delete it instead.");
236
  db.query("DELETE FROM space_member WHERE space_id = ?1 AND who_id = ?2").run(input.id, input.member);
237
  emit("space.changed", { id: input.id }, input.member);
238
  tellSpace(db, input.id, "space.changed", { id: input.id });
239
  return ok({ id: input.id });
240
}
241
242
/** Stop sharing: the space and its copy of the notes go; the repository keeps every file. */
243
export function deleteSpace(db: Database, who: Who, input: { id: number }): Result<{ id: number }> {
244
  const space = owned(db, who, input.id);
245
  if (!space.ok) return space;
246
  const everyone = members(db, input.id);
247
  db.transaction(() => {
248
    db.query("DELETE FROM note WHERE author_id = ?1").run(spaceVault(input.id));
249
    db.query("DELETE FROM space WHERE id = ?1").run(input.id);
250
  })();
251
  for (const m of everyone) emit("space.changed", { id: input.id }, m.who_id);
252
  return ok({ id: input.id });
253
}
254
255
// A change to a space's notes is a commit owed: `src/sync.ts` listens here
256
// (and actions call `edited`) so the two never import each other.
257
let onEdit: (id: number) => void = () => {};
258
export const whenEdited = (listen: (id: number) => void): void => {
259
  onEdit = listen;
260
};
261
export const edited = (id: number): void => onEdit(id);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts