| 1 | import { data } from "react-router"; |
| 2 | |
| 3 | import { |
| 4 | folioIdFrom, |
| 5 | isFolioKind, |
| 6 | type DocRole, |
| 7 | type DocSpaceChange, |
| 8 | type Folio, |
| 9 | type FolioAccessChange, |
| 10 | type FolioChange, |
| 11 | type FolioListQuery, |
| 12 | type FolioMove, |
| 13 | type NewDocSpace, |
| 14 | type NewFolio, |
| 15 | type Result, |
| 16 | type User, |
| 17 | } from "@g1t/contracts"; |
| 18 | |
| 19 | import type { Route } from "./+types/api"; |
| 20 | import { workspacePeople } from "../../../lib/chat.server"; |
| 21 | import { listQuery } from "../../../lib/folios"; |
| 22 | import { docs, folios, identity, repos, work } from "../../../lib/services.server"; |
| 23 | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; |
| 24 | |
| 25 | /** |
| 26 | * What Artifacts' pages ask for as they run, as JSON: a page of the home |
| 27 | * list, link search, an artifact's sharing, history and one version, |
| 28 | * templates, cards for embedded g1t things and chat's link cards; and |
| 29 | * everything they change: artifacts, sharing, spaces, members, |
| 30 | * suggestions, templates. Every answer is `{ ok, value }` or |
| 31 | * `{ ok: false, error }`, the services' own shape. |
| 32 | */ |
| 33 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 34 | const viewer = requireUser(context, request); |
| 35 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 36 | const slug = params.owner.toLowerCase(); |
| 37 | const url = new URL(request.url); |
| 38 | const q = url.searchParams; |
| 39 | const answer = await safely<unknown>(async () => { |
| 40 | // A page of the home list ("Show more"): the same filters as the page's address. |
| 41 | if (q.has("list")) return folios.list(slug, viewer, await withOwner(listQuery(q))); |
| 42 | // Words only: what `[[` and the move dialog offer as you type. |
| 43 | if (q.has("search")) return folios.search(slug, viewer, { q: q.get("search") ?? "", mode: "words", limit: Number(q.get("limit")) || 10 }); |
| 44 | if (q.get("access")) return folios.access(slug, viewer, q.get("access")!); |
| 45 | if (q.get("versions")) return folios.versions(slug, viewer, q.get("versions")!); |
| 46 | if (q.get("version") && q.get("folio")) return folios.version(slug, viewer, q.get("folio")!, q.get("version")!); |
| 47 | if (q.has("templates")) return folios.templates(slug, viewer, isFolioKind(q.get("templates")) ? (q.get("templates") as NewFolio["kind"]) : null); |
| 48 | if (q.get("embed")) return embed(slug, q.get("embed")!, viewer, url.origin); |
| 49 | // Chat's card for an artifact link, as this viewer may see it. |
| 50 | if (q.get("unfurl")) return unfurl(slug, q.get("unfurl")!, viewer); |
| 51 | // Repositories to cite or show the docs of: the workspace's that the viewer can read. |
| 52 | if (q.has("repos")) return repositories(slug, viewer); |
| 53 | // A citation's commit: the default branch's head, once the path is there. |
| 54 | if (q.get("cite")) return cite(viewer, q.get("cite")!, q.get("path") ?? ""); |
| 55 | // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on an artifact. |
| 56 | if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100)); |
| 57 | // A member's key (`user:<id>`) by username, for sharing with them or adding them to a space. |
| 58 | if (q.get("person")) { |
| 59 | const user = await identity.userByUsername(q.get("person")!.toLowerCase()); |
| 60 | return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } }; |
| 61 | } |
| 62 | // The workspace's teams, to share with. |
| 63 | if (q.has("teams")) { |
| 64 | const teams = await identity.listTeams(viewer, slug).catch(() => null); |
| 65 | return { ok: true, value: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; |
| 66 | } |
| 67 | return folios.sidebar(slug, viewer); |
| 68 | }); |
| 69 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 70 | } |
| 71 | |
| 72 | type Sent = { |
| 73 | intent?: string; |
| 74 | folio_id?: string; |
| 75 | space_id?: string; |
| 76 | suggestion_id?: string; |
| 77 | version_id?: string; |
| 78 | template_id?: string; |
| 79 | member?: string; |
| 80 | role?: DocRole | null; |
| 81 | decision?: "accept" | "reject"; |
| 82 | on?: boolean; |
| 83 | folio?: NewFolio; |
| 84 | change?: FolioChange; |
| 85 | move?: FolioMove; |
| 86 | access?: FolioAccessChange; |
| 87 | message?: string | null; |
| 88 | space?: NewDocSpace; |
| 89 | space_change?: DocSpaceChange; |
| 90 | name?: string; |
| 91 | description?: string | null; |
| 92 | repo?: string; |
| 93 | id?: string; |
| 94 | }; |
| 95 | |
| 96 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 97 | assertSameOrigin(request); |
| 98 | const viewer = requireUser(context, request); |
| 99 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 100 | const slug = params.owner.toLowerCase(); |
| 101 | const sent = (await request.json().catch(() => ({}))) as Sent; |
| 102 | const id = sent.folio_id ?? ""; |
| 103 | const answer = await safely(async (): Promise<Result<unknown>> => { |
| 104 | switch (sent.intent) { |
| 105 | case "create": |
| 106 | return folios.create(slug, viewer, sent.folio ?? { kind: "doc" }); |
| 107 | case "update": |
| 108 | return folios.update(slug, viewer, id, sent.change ?? {}); |
| 109 | case "move": |
| 110 | return folios.move(slug, viewer, id, sent.move ?? { space_id: null, parent_id: null }); |
| 111 | case "duplicate": |
| 112 | return folios.duplicate(slug, viewer, id); |
| 113 | case "trash": |
| 114 | return folios.trash(slug, viewer, id); |
| 115 | case "restore": |
| 116 | return folios.restore(slug, viewer, id); |
| 117 | case "delete": |
| 118 | return folios.delete(slug, viewer, id); |
| 119 | case "empty_trash": |
| 120 | return folios.emptyTrash(slug, viewer); |
| 121 | case "favorite": |
| 122 | return folios.favorite(slug, viewer, id, !!sent.on); |
| 123 | case "access": |
| 124 | if (!sent.access) return { ok: false, error: { code: "invalid", message: "What should change?" } }; |
| 125 | return folios.changeAccess(slug, viewer, id, sent.access); |
| 126 | case "request_access": |
| 127 | return folios.requestAccess(slug, viewer, id, sent.message ?? null); |
| 128 | case "join_space": |
| 129 | return folios.joinSpace(slug, viewer, sent.space_id ?? ""); |
| 130 | case "leave_space": |
| 131 | return folios.leaveSpace(slug, viewer, sent.space_id ?? ""); |
| 132 | case "restore_version": |
| 133 | return folios.restoreVersion(slug, viewer, id, sent.version_id ?? ""); |
| 134 | case "decide": |
| 135 | return folios.decideSuggestion(slug, viewer, sent.suggestion_id ?? "", sent.decision === "accept" ? "accept" : "reject"); |
| 136 | case "save_template": |
| 137 | return folios.saveTemplate(slug, viewer, { folio_id: id, name: sent.name ?? "", description: sent.description ?? null }); |
| 138 | case "delete_template": |
| 139 | return folios.deleteTemplate(slug, viewer, sent.template_id ?? ""); |
| 140 | case "mark_current": |
| 141 | return folios.markCurrent(slug, viewer, id); |
| 142 | // Spaces are the artifacts service's own, shared with Artifacts. |
| 143 | case "create_space": |
| 144 | return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace)); |
| 145 | case "update_space": |
| 146 | return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {}); |
| 147 | // Deleting one trashes its artifacts, which is the folio code's. |
| 148 | case "delete_space": |
| 149 | return folios.deleteSpace(slug, viewer, sent.space_id ?? ""); |
| 150 | case "set_member": |
| 151 | return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null); |
| 152 | case "add_repo_space": |
| 153 | return docs.addRepoSpace(slug, viewer, sent.repo ?? ""); |
| 154 | case "remove_repo_space": |
| 155 | return docs.removeRepoSpace(slug, viewer, sent.id ?? ""); |
| 156 | default: |
| 157 | return { ok: false, error: { code: "invalid", message: "Unknown request." } }; |
| 158 | } |
| 159 | }); |
| 160 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 161 | } |
| 162 | |
| 163 | /** The owner filter names a person by username; the service keys people by id. */ |
| 164 | async function withOwner(query: FolioListQuery): Promise<FolioListQuery> { |
| 165 | if (!query.owner) return query; |
| 166 | const user = await identity.userByUsername(query.owner.toLowerCase()).catch(() => null); |
| 167 | return { ...query, owner: user ? `user:${user.id}` : "user:nobody" }; |
| 168 | } |
| 169 | |
| 170 | async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> { |
| 171 | try { |
| 172 | return await run(); |
| 173 | } catch (error) { |
| 174 | console.error("artifacts api:", error); |
| 175 | return { ok: false, error: { code: "conflict", message: "Artifacts didn't answer. Try again in a moment." } }; |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Chat's card for an artifact link, for this viewer only (plan section |
| 181 | * 4.3 rule 3): its kind, title, space and last edit when they can read it; |
| 182 | * `null` (no title, nothing else) when they can't or it doesn't exist. |
| 183 | * Looking never records a visit: a card is not opening the link. |
| 184 | */ |
| 185 | async function unfurl(slug: string, address: string, viewer: User): Promise<Result<FolioUnfurl | null>> { |
| 186 | const id = folioIdFrom(address.split(/[?#]/)[0]!.replace(/\/+$/, "")); |
| 187 | if (!id) return { ok: false, error: { code: "invalid", message: "That isn't an artifact's address." } }; |
| 188 | const found = await folios.folio(slug, viewer, id, { peek: true }); |
| 189 | if (!found.ok) return { ok: true, value: null }; |
| 190 | const f = found.value; |
| 191 | return { |
| 192 | ok: true, |
| 193 | value: { |
| 194 | id: f.id, |
| 195 | kind: f.kind, |
| 196 | title: f.title, |
| 197 | icon: f.icon, |
| 198 | path: f.path, |
| 199 | space: f.space?.name ?? null, |
| 200 | private: f.private, |
| 201 | excerpt: f.excerpt, |
| 202 | edited_at: f.edited_at, |
| 203 | edited_by: f.edited_by?.display_name ?? null, |
| 204 | }, |
| 205 | }; |
| 206 | } |
| 207 | |
| 208 | /** What chat's card for an artifact link shows to someone who can read it. */ |
| 209 | export type FolioUnfurl = Pick<Folio, "id" | "kind" | "title" | "icon" | "path" | "private" | "excerpt" | "edited_at"> & { |
| 210 | space: string | null; |
| 211 | edited_by: string | null; |
| 212 | }; |
| 213 | |
| 214 | /** |
| 215 | * The card for a g1t address embedded in a doc, as this reader may see |
| 216 | * it: a work item or pull request with its state, a channel, a project, an |
| 217 | * artifact. Anything they can't read is not found, exactly as one that |
| 218 | * does not exist. |
| 219 | */ |
| 220 | async function embed(slug: string, address: string, viewer: User, origin: string): Promise<Result<unknown>> { |
| 221 | let path: string; |
| 222 | try { |
| 223 | const url = new URL(address, origin); |
| 224 | if (url.origin !== origin) |
| 225 | return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } }; |
| 226 | path = url.pathname; |
| 227 | } catch { |
| 228 | return { ok: false, error: { code: "invalid", message: "That isn't an address." } }; |
| 229 | } |
| 230 | const parts = path.split("/").filter(Boolean).map(decodeURIComponent); |
| 231 | const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } }; |
| 232 | // /<owner>/-/artifacts/<slug>-<id> |
| 233 | if (parts[1] === "-" && parts[2] === "artifacts" && parts[3]) { |
| 234 | if (parts[0]!.toLowerCase() !== slug) return missing; |
| 235 | const id = folioIdFrom(parts[3]); |
| 236 | if (!id) return missing; |
| 237 | const found = await folios.folio(slug, viewer, id, { peek: true }); |
| 238 | if (!found.ok) return missing; |
| 239 | const f = found.value; |
| 240 | return { |
| 241 | ok: true, |
| 242 | value: { kind: "page", title: `${f.icon ? `${f.icon} ` : ""}${f.title || "Untitled"}`, subtitle: f.space?.name ?? "Private", state: null, href: f.path }, |
| 243 | }; |
| 244 | } |
| 245 | // /<owner>/-/chat/<channel> |
| 246 | if (parts[1] === "-" && parts[2] === "chat" && parts[3]) { |
| 247 | return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } }; |
| 248 | } |
| 249 | // /<workspace>/-/work/<n>, and an old issue's /<owner>/<repo>/issues/<n>, which reads as `#n` there. |
| 250 | const item = |
| 251 | parts[1] === "-" && parts[2] === "work" && /^\d+$/.test(parts[3] ?? "") ? { workspace: parts[0]!.toLowerCase(), number: Number(parts[3]) } : null; |
| 252 | const legacy = parts[1] !== "-" && parts[2] === "issues" && /^\d+$/.test(parts[3] ?? "") ? Number(parts[3]) : null; |
| 253 | if (item || legacy) { |
| 254 | const target = item |
| 255 | ? { kind: "work_item" as const, ...item } |
| 256 | : await work.resolveRef(viewer, { repo: { namespace: parts[0]!.toLowerCase(), name: parts[1]! } }, legacy!).catch(() => null); |
| 257 | if (target?.kind === "work_item") { |
| 258 | const found = await work.getWorkItem(target.workspace, target.number, viewer); |
| 259 | if (!found.ok) return missing; |
| 260 | const { item: shown } = found.value; |
| 261 | return { |
| 262 | ok: true, |
| 263 | value: { |
| 264 | kind: "item", |
| 265 | title: `${shown.title} #${shown.number}`, |
| 266 | subtitle: shown.workspace, |
| 267 | state: shown.state, |
| 268 | href: `/${shown.workspace}/-/work/${shown.number}`, |
| 269 | }, |
| 270 | }; |
| 271 | } |
| 272 | if (target?.kind !== "pull") return missing; |
| 273 | path = `/${target.repo}/pull/${target.number}`; |
| 274 | parts.splice(0, parts.length, ...path.split("/").filter(Boolean)); |
| 275 | } |
| 276 | if (parts.length < 2 || parts[1] === "-") return missing; |
| 277 | const repo = { namespace: parts[0]!, name: parts[1]! }; |
| 278 | // /<owner>/<repo>/pull/<n> |
| 279 | const number = Number(parts[3]); |
| 280 | if ((parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) { |
| 281 | const found = await work.getPull(repo, number, viewer); |
| 282 | if (!found.ok) return missing; |
| 283 | const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null }; |
| 284 | const state = pull.mergedAt ? "merged" : String(pull.status ?? "open"); |
| 285 | return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } }; |
| 286 | } |
| 287 | const found = await repos.get(repo, viewer); |
| 288 | if (!found.ok) return missing; |
| 289 | return { |
| 290 | ok: true, |
| 291 | value: { |
| 292 | kind: "project", |
| 293 | title: `${repo.namespace}/${repo.name}`, |
| 294 | subtitle: (found.value as { description?: string | null }).description ?? "Project", |
| 295 | state: null, |
| 296 | href: `/${repo.namespace}/${repo.name}`, |
| 297 | }, |
| 298 | }; |
| 299 | } |
| 300 | |
| 301 | /** The workspace's repositories the viewer can read, newest first: `owner/name` and its default branch. */ |
| 302 | async function repositories(slug: string, viewer: User): Promise<Result<{ repo: string; default_branch: string; private: boolean }[]>> { |
| 303 | const found = await repos.list(viewer, { namespace: slug }); |
| 304 | return { |
| 305 | ok: true, |
| 306 | value: found |
| 307 | .filter((r) => !r.forkOf) |
| 308 | .slice(0, 300) |
| 309 | .map((r) => ({ repo: `${r.namespace}/${r.name}`.toLowerCase(), default_branch: r.defaultBranch, private: r.isPrivate })), |
| 310 | }; |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * A citation for `path` in `repo`, as the viewer can read it: pinned to |
| 315 | * the default branch's head commit, once the file or folder is there (a |
| 316 | * glob is taken as written). |
| 317 | */ |
| 318 | async function cite( |
| 319 | viewer: User, |
| 320 | repo: string, |
| 321 | rawPath: string, |
| 322 | ): Promise<Result<{ repo: string; path: string; ref: string | null; kind: "file" | "folder" | "glob" }>> { |
| 323 | const [namespace, name, ...rest] = repo.trim().toLowerCase().split("/"); |
| 324 | const missing = { ok: false as const, error: { code: "not_found" as const, message: "No such repository, or you can't read it." } }; |
| 325 | if (!namespace || !name || rest.length) return missing; |
| 326 | const path = rawPath |
| 327 | .trim() |
| 328 | .replace(/\\/g, "/") |
| 329 | .split("/") |
| 330 | .filter((p) => p && p !== ".") |
| 331 | .join("/"); |
| 332 | if (!path || path.split("/").includes("..")) |
| 333 | return { ok: false, error: { code: "invalid", message: "Name a file, a folder or a pattern in the repository." } }; |
| 334 | const where = { namespace, name }; |
| 335 | const root = await repos.tree(where, viewer, null, ""); |
| 336 | if (!root.ok) return missing; |
| 337 | const head = root.value.head?.hash ?? null; |
| 338 | if (/[*?]/.test(path)) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "glob" } }; |
| 339 | if (!head) return { ok: false, error: { code: "not_found", message: "That repository has no commits yet." } }; |
| 340 | const [blob, tree] = await Promise.all([repos.blob(where, viewer, head, path).catch(() => null), repos.tree(where, viewer, head, path).catch(() => null)]); |
| 341 | if (blob?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "file" } }; |
| 342 | if (tree?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "folder" } }; |
| 343 | return { ok: false, error: { code: "not_found", message: `There is no ${path} on ${root.value.repo.defaultBranch}.` } }; |
| 344 | } |
| 345 | |
| 346 | /** Names and faces for member keys, as the workspace knows them. */ |
| 347 | async function who(slug: string, viewer: User, keys: string[]): Promise<Result<unknown>> { |
| 348 | const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5)); |
| 349 | const [names, people] = await Promise.all([ |
| 350 | userIds.length ? identity.usernames(userIds).catch(() => ({}) as Record<string, string>) : Promise.resolve({} as Record<string, string>), |
| 351 | workspacePeople(slug, viewer), |
| 352 | ]); |
| 353 | const out = keys.map((key) => { |
| 354 | // An old agent's (agents no longer exist): its comments and edits stay, under a plain name. |
| 355 | if (key.startsWith("agent:")) |
| 356 | return { key, kind: "agent", id: key.slice(6), name: "agent", display_name: "A deleted agent", avatar: null, avatar_seed: null }; |
| 357 | const username = names[key.slice(5)]?.toLowerCase(); |
| 358 | const person = username ? people.find((p) => p.name === username) : undefined; |
| 359 | return { |
| 360 | key, |
| 361 | kind: "user", |
| 362 | id: key.slice(5), |
| 363 | name: username ?? "ghost", |
| 364 | display_name: person?.display_name ?? username ?? "Former member", |
| 365 | avatar: person?.avatar ?? null, |
| 366 | avatar_seed: null, |
| 367 | }; |
| 368 | }); |
| 369 | return { ok: true, value: out }; |
| 370 | } |