| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 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 |
|
| 26 |
readonly repo: string; |
| 27 |
readonly branch: string; |
| 28 |
|
| 29 |
readonly folder: string; |
| 30 |
readonly owner_id: string; |
| 31 |
readonly owner_name: string; |
| 32 |
readonly join_code: string | null; |
| 33 |
|
| 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 |
|
| 50 |
export const spaceVault = (id: number): string => `space:${id}`; |
| 51 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 99 |
if (space === undefined || !isMember(db, id, who)) return fail("not_found", "No such shared folder."); |
| 100 |
return ok(space); |
| 101 |
} |
| 102 |
|
| 103 |
|
| 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 |
|
| 115 |
const REPO = /^[\w.-]+\/[\w.-]+$/; |
| 116 |
const BRANCH = /^(?!-)(?!.*\.\.)[\w./-]{1,100}$/; |
| 117 |
|
| 118 |
|
| 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 |
|
| 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 |
|
| 143 |
export const fileOf = (space: Pick<Space, "folder">, folder: string, title: string): string => |
| 144 |
join(space.folder, folder, `${title}.md`); |
| 145 |
|
| 146 |
|
| 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 |
|
| 154 |
export const titleOfPath = (path: string): string => |
| 155 |
path.slice(path.lastIndexOf("/") + 1).replace(/\.md$/i, ""); |
| 156 |
|
| 157 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 256 |
|
| 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); |