Skip to content
File

Blob: src/worker/routes/ui/helpers.ts

typescript479 lines
1import type { CacheContext } from "@/worker/cache";
2import type { DebugPackState, DebugStateSnapshot } from "@/worker/do/repo/debug";
3import type { HeadInfo, Ref } from "@/worker/git";
4import type { PackRefIndexStatus } from "@/shared/git/types";
5import type { Viewer } from "@/client/server/viewer";
6import { getHeadAndRefs } from "@/worker/git";
7import { shortRefName } from "@/shared/git/ref-display";
8import { formatSize, HttpError, isValidOwnerRepo } from "@/shared/web";
9import { handleError } from "@/client/server/error";
10import { buildCacheKeyFrom, cacheOrLoadJSONForRequest } from "@/worker/cache";
11import { isRequestPrivate, markRequestPrivate } from "@/worker/cache/policy";
12import { loadSessionMembership } from "@/worker/auth/sessionMembership";
13import { loadViewer } from "@/worker/auth/session";
14import { getRepoActivity, type RepoActivity } from "@/worker/common";
15import { createLogger } from "@/worker/common/logger";
16import { countSubrequest, getLimiter, type Limiter } from "@/worker/git/operations/limits";
17import { packRefsKey } from "@/worker/keys";
18import { resolveRepositoryRoute, type RepositoryRoute } from "@/worker/repositories/route";
19import type { AppContext } from "@/worker/routes/hono";
20import { renderUiDocumentResponse } from "@/worker/routes/uiResponse";
21 
22export type AdminPackState = DebugPackState & {
23 refIndexStatus?: PackRefIndexStatus;
24 refIndexSize?: number;
25};
26export type DebugState = Omit<
27 DebugStateSnapshot,
28 "packStats" | "activePacks" | "supersededPacks"
29> & {
30 packStats?: AdminPackState[];
31 activePacks: AdminPackState[];
32 supersededPacks: AdminPackState[];
33};
34export type CompactionData = DebugState["compaction"];
35 
36type PackRefIndexMetadata = {
37 status: PackRefIndexStatus;
38 size?: number;
39};
40 
41export async function badRequest(
42 env: Env,
43 title: string,
44 message: string,
45 extra?: { owner?: string; repo?: string; refEnc?: string; path?: string }
46): Promise<Response> {
47 return handleError(env, new HttpError(400, message, { expose: true }), title, extra);
48}
49 
50export function formatFromNowShort(deltaMs: number): string {
51 const s = Math.round(deltaMs / 1000);
52 if (s <= 0) return "soon";
53 const m = Math.floor(s / 60);
54 const h = Math.floor(m / 60);
55 const d = Math.floor(h / 24);
56 if (d > 0) return `in ${d}d ${h % 24}h`;
57 if (h > 0) return `in ${h}h ${m % 60}m`;
58 if (m > 0) return `in ${m}m`;
59 return `in ${s}s`;
60}
61 
62// Re-export the cache-policy predicates so existing UI handlers don't need
63// to know they live in the cache layer. The lower Git/protocol modules
64// import directly from `@/worker/cache/policy`.
65export { isRequestPrivate, markRequestPrivate };
66 
67export async function loadHeadAndRefsCached(
68 env: Env,
69 cacheCtx: CacheContext,
70 repoId: string
71): Promise<{ head: HeadInfo | undefined; refs: Ref[] } | null> {
72 const loader = async (): Promise<{ head: HeadInfo | undefined; refs: Ref[] } | null> => {
73 try {
74 const res = await getHeadAndRefs(env, repoId, cacheCtx);
75 return { head: res.head, refs: res.refs };
76 } catch {
77 return null;
78 }
79 };
80 const cacheKeyRefs = buildCacheKeyFrom(cacheCtx.req, "/_cache/refs", { repo: repoId });
81 return cacheOrLoadJSONForRequest<{ head: HeadInfo | undefined; refs: Ref[] }>(
82 cacheCtx,
83 cacheKeyRefs,
84 loader,
85 60
86 );
87}
88 
89// Shared 404 response for repo-serving handlers. Centralizes the SSR shell +
90// viewer load so handlers don't reach into `index.ts` internals. Callers
91// that have already resolved a viewer can pass it through to avoid a
92// second D1 round trip.
93export async function notFound(
94 c: AppContext,
95 title?: string,
96 viewer?: Viewer | null
97): Promise<Response> {
98 const resolvedViewer = viewer === undefined ? await loadViewer(c) : viewer;
99 return renderUiDocumentResponse(c.env, "404", title ? { title } : {}, {
100 status: 404,
101 failureBody: "Not found\n",
102 failureStatus: 404,
103 viewer: resolvedViewer,
104 });
105}
106 
107// Same as `notFound` but returns a JSON 404 — used by data API endpoints
108// (`/api/refs`) where SSR shell would be wasted bytes.
109export function notFoundJson(): Response {
110 return new Response(JSON.stringify({ error: "Not found" }), {
111 status: 404,
112 headers: {
113 "Content-Type": "application/json; charset=utf-8",
114 "Cache-Control": "no-store",
115 },
116 });
117}
118 
119// Bundle for the resolve+session+visibility decision shared by every UI
120// repo-serving handler. Returns either:
121// - { kind: "ok", route, cacheCtx, viewer, showActivityBanner }: caller
122// proceeds to render. The `cacheCtx` is already marked private when
123// private repository data is rendered.
124// - { kind: "response", response }: caller returns the response as-is.
125// Centralizes the non-disclosure rule (private + non-member -> 404,
126// identical to private + anonymous).
127export type UiRepoAccess =
128 | {
129 kind: "ok";
130 route: RepositoryRoute;
131 cacheCtx: CacheContext;
132 viewer: Viewer | null;
133 showActivityBanner: boolean;
134 }
135 | { kind: "response"; response: Response };
136 
137export type AdminRepoAccess =
138 | { kind: "ok"; route: RepositoryRoute; cacheCtx: CacheContext; viewer: Viewer; limiter: Limiter }
139 | { kind: "response"; response: Response };
140 
141export type UiRepoAccessOptions = {
142 // For data-API endpoints that return JSON (not HTML) on failure.
143 responseShape?: "html" | "json";
144};
145 
146export async function resolveUiRepoAccess(
147 c: AppContext,
148 owner: string,
149 repo: string,
150 options: UiRepoAccessOptions = {}
151): Promise<UiRepoAccess> {
152 const cacheCtx = c.var.cacheCtx;
153 if (!isValidOwnerRepo(owner) || !isValidOwnerRepo(repo)) {
154 return {
155 kind: "response",
156 response:
157 options.responseShape === "json" ? notFoundJson() : await notFound(c, "Invalid owner/repo"),
158 };
159 }
160 const viewer = await loadViewer(c);
161 const route = await resolveRepositoryRoute(c.env, owner, repo, {
162 mode: viewer ? "allow-d1-fallback" : "route-cache-only",
163 db: c.var.db,
164 log: c.var.logFor({ service: "RepoRoute" }),
165 });
166 if (!route) {
167 return {
168 kind: "response",
169 response: options.responseShape === "json" ? notFoundJson() : await notFound(c),
170 };
171 }
172 if (route.visibility === "public") {
173 if (!viewer) {
174 return { kind: "ok", route, cacheCtx, viewer, showActivityBanner: false };
175 }
176 const membership = await loadSessionMembership(c, route.namespaceId);
177 const showActivityBanner = membership.kind === "member";
178 return {
179 kind: "ok",
180 route,
181 cacheCtx,
182 viewer: membership.kind === "anonymous" ? viewer : membership.viewer,
183 showActivityBanner,
184 };
185 }
186 if (!viewer) {
187 const log = c.var.logFor({ service: "UiAcl", repoId: route.doName });
188 log.debug("ui-acl:private-non-member-404", { kind: "anonymous" });
189 return {
190 kind: "response",
191 response: options.responseShape === "json" ? notFoundJson() : await notFound(c),
192 };
193 }
194 // Private: gate on session membership. PAT credentials are never honored
195 // for UI/data routes (PATs are git-only).
196 const membership = await loadSessionMembership(c, route.namespaceId);
197 const log = c.var.logFor({ service: "UiAcl", repoId: route.doName });
198 if (membership.kind !== "member") {
199 log.debug("ui-acl:private-non-member-404", { kind: membership.kind });
200 // `loadSessionMembership` returns the viewer for signed-in-non-member
201 // results; reuse it so the 404 page renders the correct shell without a
202 // second D1 round-trip.
203 const passthroughViewer = membership.kind === "signed-in-non-member" ? membership.viewer : null;
204 return {
205 kind: "response",
206 response:
207 options.responseShape === "json"
208 ? notFoundJson()
209 : await notFound(c, undefined, passthroughViewer),
210 };
211 }
212 markRequestPrivate(cacheCtx);
213 return { kind: "ok", route, cacheCtx, viewer: membership.viewer, showActivityBanner: true };
214}
215 
216export async function loadUiRepoActivity(
217 env: Env,
218 access: Extract<UiRepoAccess, { kind: "ok" }>
219): Promise<RepoActivity | null> {
220 if (!access.showActivityBanner) return null;
221 return await getRepoActivity(env, access.route.doName, access.cacheCtx);
222}
223 
224function adminForbidden(): Response {
225 return new Response("Forbidden\n", {
226 status: 403,
227 headers: {
228 "Content-Type": "text/plain; charset=utf-8",
229 "Cache-Control": "no-store",
230 },
231 });
232}
233 
234// Browser admin access is session-only. Public repositories may disclose
235// their existence, but private repositories return 404 to anonymous users
236// and signed-in non-members so the admin surface does not become a
237// membership oracle.
238export async function resolveAdminPageRepoAccess(
239 c: AppContext,
240 owner: string,
241 repo: string
242): Promise<AdminRepoAccess> {
243 if (!isValidOwnerRepo(owner) || !isValidOwnerRepo(repo)) {
244 return {
245 kind: "response",
246 response: await badRequest(c.env, "Invalid owner/repo", "Owner or repo invalid", {
247 owner,
248 repo,
249 }),
250 };
251 }
252 
253 const viewerForResolution = await loadViewer(c);
254 const route = await resolveRepositoryRoute(c.env, owner, repo, {
255 mode: viewerForResolution ? "allow-d1-fallback" : "route-cache-only",
256 db: c.var.db,
257 log: c.var.logFor({ service: "RepoRoute" }),
258 });
259 if (!route) return { kind: "response", response: await notFound(c) };
260 
261 const membership = await loadSessionMembership(c, route.namespaceId);
262 if (membership.kind === "anonymous") {
263 if (route.visibility === "private") {
264 return { kind: "response", response: await notFound(c) };
265 }
266 return {
267 kind: "response",
268 response: c.redirect(`/auth?next=${encodeURIComponent(`/${owner}/${repo}/admin`)}`, 302),
269 };
270 }
271 if (membership.kind === "signed-in-non-member") {
272 if (route.visibility === "private") {
273 return { kind: "response", response: await notFound(c) };
274 }
275 return { kind: "response", response: adminForbidden() };
276 }
277 
278 const cacheCtx = c.var.cacheCtx;
279 markRequestPrivate(cacheCtx);
280 return { kind: "ok", route, cacheCtx, viewer: membership.viewer, limiter: c.var.limiter };
281}
282 
283function adminJsonError(message: string, status: number): Response {
284 return new Response(JSON.stringify({ error: message }), {
285 status,
286 headers: {
287 "Content-Type": "application/json; charset=utf-8",
288 "Cache-Control": "no-store",
289 },
290 });
291}
292 
293// JSON admin endpoints keep the same disclosure model as the admin page,
294// but public anonymous callers receive 401 instead of the sign-in redirect
295// and public signed-in non-members receive 403.
296export async function resolveAdminApiRepoAccess(c: AppContext): Promise<AdminRepoAccess> {
297 const owner = c.req.param("owner");
298 const repo = c.req.param("repo");
299 if (!owner || !repo || !isValidOwnerRepo(owner) || !isValidOwnerRepo(repo)) {
300 return { kind: "response", response: adminJsonError("Not found", 404) };
301 }
302 
303 const viewerForResolution = await loadViewer(c);
304 const route = await resolveRepositoryRoute(c.env, owner, repo, {
305 mode: viewerForResolution ? "allow-d1-fallback" : "route-cache-only",
306 db: c.var.db,
307 log: c.var.logFor({ service: "RepoRoute" }),
308 });
309 if (!route) return { kind: "response", response: adminJsonError("Not found", 404) };
310 
311 const membership = await loadSessionMembership(c, route.namespaceId);
312 if (membership.kind === "anonymous") {
313 if (route.visibility === "private") {
314 return { kind: "response", response: adminJsonError("Not found", 404) };
315 }
316 return { kind: "response", response: adminJsonError("Unauthorized", 401) };
317 }
318 if (membership.kind === "signed-in-non-member") {
319 if (route.visibility === "private") {
320 return { kind: "response", response: adminJsonError("Not found", 404) };
321 }
322 return { kind: "response", response: adminJsonError("Forbidden", 403) };
323 }
324 
325 const cacheCtx = c.var.cacheCtx;
326 markRequestPrivate(cacheCtx);
327 return { kind: "ok", route, cacheCtx, viewer: membership.viewer, limiter: c.var.limiter };
328}
329 
330export function getDefaultBranchFromHead(head: HeadInfo | undefined): string {
331 return head?.target ? shortRefName(head.target) : "main";
332}
333 
334function collectPackKeys(state: Partial<DebugStateSnapshot>): string[] {
335 const keys = new Set<string>();
336 for (const pack of state.packStats ?? []) keys.add(pack.key);
337 for (const pack of state.activePacks ?? []) keys.add(pack.key);
338 for (const pack of state.supersededPacks ?? []) keys.add(pack.key);
339 return [...keys];
340}
341 
342function applyPackRefMetadata(
343 packs: DebugPackState[] | undefined,
344 metadataByPackKey: Map<string, PackRefIndexMetadata>
345): AdminPackState[] | undefined {
346 if (!packs) return undefined;
347 
348 return packs.map((pack) => {
349 const metadata = metadataByPackKey.get(pack.key);
350 if (!metadata) {
351 return { ...pack, refIndexStatus: "unknown" };
352 }
353 
354 return {
355 ...pack,
356 refIndexStatus: metadata.status,
357 refIndexSize: metadata.size,
358 };
359 });
360}
361 
362async function loadPackRefIndexMetadata(args: {
363 env: Env;
364 repoId: string;
365 state: Partial<DebugStateSnapshot>;
366 cacheCtx: CacheContext;
367}): Promise<Map<string, PackRefIndexMetadata>> {
368 const packKeys = collectPackKeys(args.state);
369 const metadataByPackKey = new Map<string, PackRefIndexMetadata>();
370 if (packKeys.length === 0) return metadataByPackKey;
371 
372 const limiter = getLimiter(args.cacheCtx);
373 const log = createLogger(args.env.LOG_LEVEL, { service: "AdminPage", repoId: args.repoId });
374 const entries = await Promise.all(
375 packKeys.map(async (packKey): Promise<[string, PackRefIndexMetadata]> => {
376 const refsKey = packRefsKey(packKey);
377 try {
378 const refsObject = await limiter.run("r2:head-pack-refs", async () => {
379 if (!countSubrequest(args.cacheCtx)) {
380 log.warn("admin:pack-ref-head-budget-exhausted", { packKey, refsKey });
381 }
382 return await args.env.REPO_BUCKET.head(refsKey);
383 });
384 
385 if (!refsObject) {
386 log.debug("admin:pack-ref-head-missing", { packKey, refsKey });
387 return [packKey, { status: "missing" }];
388 }
389 
390 log.debug("admin:pack-ref-head-present", {
391 packKey,
392 refsKey,
393 bytes: refsObject.size,
394 });
395 return [packKey, { status: "present", size: refsObject.size }];
396 } catch (error) {
397 log.warn("admin:pack-ref-head-failed", {
398 packKey,
399 refsKey,
400 error: String(error),
401 });
402 return [packKey, { status: "unknown" }];
403 }
404 })
405 );
406 
407 let present = 0;
408 let missing = 0;
409 let unknown = 0;
410 for (const [packKey, metadata] of entries) {
411 metadataByPackKey.set(packKey, metadata);
412 if (metadata.status === "present") present++;
413 else if (metadata.status === "missing") missing++;
414 else unknown++;
415 }
416 log.debug("admin:pack-ref-head-summary", {
417 packs: packKeys.length,
418 present,
419 missing,
420 unknown,
421 });
422 return metadataByPackKey;
423}
424 
425export async function loadAdminPackRefIndexState(args: {
426 env: Env;
427 repoId: string;
428 state: Partial<DebugStateSnapshot>;
429 cacheCtx: CacheContext;
430}): Promise<Partial<DebugState>> {
431 const metadataByPackKey = await loadPackRefIndexMetadata(args);
432 return {
433 ...args.state,
434 packStats: applyPackRefMetadata(args.state.packStats, metadataByPackKey),
435 activePacks: applyPackRefMetadata(args.state.activePacks, metadataByPackKey),
436 supersededPacks: applyPackRefMetadata(args.state.supersededPacks, metadataByPackKey),
437 };
438}
439 
440export function computeStorageMetrics(state: Partial<DebugState> | undefined): {
441 storageSize: string;
442 packCount: number;
443 packList: string[];
444 supersededPackCount: number;
445} {
446 let totalStorageBytes = 0;
447 const packStats = state?.packStats ?? [];
448 for (const pack of packStats) {
449 if (typeof pack.packSize === "number") totalStorageBytes += pack.packSize;
450 if (typeof pack.indexSize === "number") totalStorageBytes += pack.indexSize;
451 if (typeof pack.refIndexSize === "number") totalStorageBytes += pack.refIndexSize;
452 }
453 const storageSize = formatSize(totalStorageBytes);
454 const activePacks = state?.activePacks ?? [];
455 const packList = activePacks.map((pack) => pack.key);
456 const packCount = packList.length;
457 const supersededPackCount = state?.supersededPacks?.length ?? 0;
458 return { storageSize, packCount, packList, supersededPackCount };
459}
460 
461export function computeCompactionStatus(compactionData: CompactionData | undefined): {
462 compactionStatus: string;
463 compactionStartedAt: string | null;
464} {
465 let compactionStatus = "Idle";
466 let compactionStartedAt: string | null = null;
467 if (compactionData?.running) {
468 compactionStatus = "Running";
469 if (compactionData.startedAt) {
470 try {
471 compactionStartedAt = new Date(compactionData.startedAt).toLocaleString();
472 } catch {}
473 }
474 } else if (compactionData?.queued) {
475 compactionStatus = "Queued";
476 }
477 return { compactionStatus, compactionStartedAt };
478}