Skip to content
307 linesCodeBlameRaw
1import { data } from "react-router";
2
3import {
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
19import type { Route } from "./+types/api";
20import { workspacePeople } from "../../../lib/chat.server";
21import { listQuery } from "../../../lib/folios";
22import { docs, folios, identity, repos, work } from "../../../lib/services.server";
23import { 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 */
33export 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
72type 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
96export 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. */
164async 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
170async 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 */
185async 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: { id: f.id, kind: f.kind, title: f.title, icon: f.icon, path: f.path, space: f.space?.name ?? null, private: f.private, excerpt: f.excerpt, edited_at: f.edited_at, edited_by: f.edited_by?.display_name ?? null },
194 };
195}
196
197/** What chat's card for an artifact link shows to someone who can read it. */
198export type FolioUnfurl = Pick<Folio, "id" | "kind" | "title" | "icon" | "path" | "private" | "excerpt" | "edited_at"> & { space: string | null; edited_by: string | null };
199
200/**
201 * The card for a g1t address embedded in a doc, as this reader may see
202 * it: an issue or pull request with its state, a channel, a project, an
203 * artifact. Anything they can't read is not found, exactly as one that
204 * does not exist.
205 */
206async function embed(slug: string, address: string, viewer: User, origin: string): Promise<Result<unknown>> {
207 let path: string;
208 try {
209 const url = new URL(address, origin);
210 if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } };
211 path = url.pathname;
212 } catch {
213 return { ok: false, error: { code: "invalid", message: "That isn't an address." } };
214 }
215 const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
216 const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } };
217 // /<owner>/-/artifacts/<slug>-<id>
218 if (parts[1] === "-" && parts[2] === "artifacts" && parts[3]) {
219 if (parts[0]!.toLowerCase() !== slug) return missing;
220 const id = folioIdFrom(parts[3]);
221 if (!id) return missing;
222 const found = await folios.folio(slug, viewer, id, { peek: true });
223 if (!found.ok) return missing;
224 const f = found.value;
225 return { ok: true, value: { kind: "page", title: `${f.icon ? `${f.icon} ` : ""}${f.title || "Untitled"}`, subtitle: f.space?.name ?? "Private", state: null, href: f.path } };
226 }
227 // /<owner>/-/chat/<channel>
228 if (parts[1] === "-" && parts[2] === "chat" && parts[3]) {
229 return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } };
230 }
231 if (parts.length < 2 || parts[1] === "-") return missing;
232 const repo = { namespace: parts[0]!, name: parts[1]! };
233 // /<owner>/<repo>/issues/<n> and /pull/<n>
234 const number = Number(parts[3]);
235 if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) {
236 if (parts[2] === "issues") {
237 const found = await work.getIssue(repo, number, viewer);
238 if (!found.ok) return missing;
239 return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } };
240 }
241 const found = await work.getPull(repo, number, viewer);
242 if (!found.ok) return missing;
243 const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null };
244 const state = pull.mergedAt ? "merged" : String(pull.status ?? "open");
245 return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } };
246 }
247 const found = await repos.get(repo, viewer);
248 if (!found.ok) return missing;
249 return { ok: true, value: { kind: "project", title: `${repo.namespace}/${repo.name}`, subtitle: (found.value as { description?: string | null }).description ?? "Project", state: null, href: `/${repo.namespace}/${repo.name}` } };
250}
251
252/** The workspace's repositories the viewer can read, newest first: `owner/name` and its default branch. */
253async function repositories(slug: string, viewer: User): Promise<Result<{ repo: string; default_branch: string; private: boolean }[]>> {
254 const found = await repos.list(viewer, { namespace: slug });
255 return {
256 ok: true,
257 value: found
258 .filter((r) => !r.forkOf)
259 .slice(0, 300)
260 .map((r) => ({ repo: `${r.namespace}/${r.name}`.toLowerCase(), default_branch: r.defaultBranch, private: r.isPrivate })),
261 };
262}
263
264/**
265 * A citation for `path` in `repo`, as the viewer can read it: pinned to
266 * the default branch's head commit, once the file or folder is there (a
267 * glob is taken as written).
268 */
269async function cite(viewer: User, repo: string, rawPath: string): Promise<Result<{ repo: string; path: string; ref: string | null; kind: "file" | "folder" | "glob" }>> {
270 const [namespace, name, ...rest] = repo.trim().toLowerCase().split("/");
271 const missing = { ok: false as const, error: { code: "not_found" as const, message: "No such repository, or you can't read it." } };
272 if (!namespace || !name || rest.length) return missing;
273 const path = rawPath
274 .trim()
275 .replace(/\\/g, "/")
276 .split("/")
277 .filter((p) => p && p !== ".")
278 .join("/");
279 if (!path || path.split("/").includes("..")) return { ok: false, error: { code: "invalid", message: "Name a file, a folder or a pattern in the repository." } };
280 const where = { namespace, name };
281 const root = await repos.tree(where, viewer, null, "");
282 if (!root.ok) return missing;
283 const head = root.value.head?.hash ?? null;
284 if (/[*?]/.test(path)) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "glob" } };
285 if (!head) return { ok: false, error: { code: "not_found", message: "That repository has no commits yet." } };
286 const [blob, tree] = await Promise.all([repos.blob(where, viewer, head, path).catch(() => null), repos.tree(where, viewer, head, path).catch(() => null)]);
287 if (blob?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "file" } };
288 if (tree?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "folder" } };
289 return { ok: false, error: { code: "not_found", message: `There is no ${path} on ${root.value.repo.defaultBranch}.` } };
290}
291
292/** Names and faces for member keys, as the workspace knows them. */
293async function who(slug: string, viewer: User, keys: string[]): Promise<Result<unknown>> {
294 const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5));
295 const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6)));
296 const [names, { people, agents }] = await Promise.all([userIds.length ? identity.usernames(userIds).catch(() => ({}) as Record<string, string>) : Promise.resolve({} as Record<string, string>), workspacePeople(slug, viewer)]);
297 const out = keys.map((key) => {
298 if (key.startsWith("agent:")) {
299 const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined;
300 return { key, kind: "agent", id: key.slice(6), name: agent?.handle ?? "agent", display_name: agent?.display_name ?? "Former agent", avatar: agent?.avatar ?? null, avatar_seed: agent?.avatar_seed ?? null, look: agent?.look ?? null };
301 }
302 const username = names[key.slice(5)]?.toLowerCase();
303 const person = username ? people.find((p) => p.name === username) : undefined;
304 return { key, kind: "user", id: key.slice(5), name: username ?? "ghost", display_name: person?.display_name ?? username ?? "Former member", avatar: person?.avatar ?? null, avatar_seed: null };
305 });
306 return { ok: true, value: out };
307}