File
Blob: src/worker/routes/ui/helpers.ts
| 1 | import type { CacheContext } from "@/worker/cache"; |
| 2 | import type { DebugPackState, DebugStateSnapshot } from "@/worker/do/repo/debug"; |
| 3 | import type { HeadInfo, Ref } from "@/worker/git"; |
| 4 | import type { PackRefIndexStatus } from "@/shared/git/types"; |
| 5 | import type { Viewer } from "@/client/server/viewer"; |
| 6 | import { getHeadAndRefs } from "@/worker/git"; |
| 7 | import { shortRefName } from "@/shared/git/ref-display"; |
| 8 | import { formatSize, HttpError, isValidOwnerRepo } from "@/shared/web"; |
| 9 | import { handleError } from "@/client/server/error"; |
| 10 | import { buildCacheKeyFrom, cacheOrLoadJSONForRequest } from "@/worker/cache"; |
| 11 | import { isRequestPrivate, markRequestPrivate } from "@/worker/cache/policy"; |
| 12 | import { loadSessionMembership } from "@/worker/auth/sessionMembership"; |
| 13 | import { loadViewer } from "@/worker/auth/session"; |
| 14 | import { getRepoActivity, type RepoActivity } from "@/worker/common"; |
| 15 | import { createLogger } from "@/worker/common/logger"; |
| 16 | import { countSubrequest, getLimiter, type Limiter } from "@/worker/git/operations/limits"; |
| 17 | import { packRefsKey } from "@/worker/keys"; |
| 18 | import { resolveRepositoryRoute, type RepositoryRoute } from "@/worker/repositories/route"; |
| 19 | import type { AppContext } from "@/worker/routes/hono"; |
| 20 | import { renderUiDocumentResponse } from "@/worker/routes/uiResponse"; |
| 21 | |
| 22 | export type AdminPackState = DebugPackState & { |
| 23 | refIndexStatus?: PackRefIndexStatus; |
| 24 | refIndexSize?: number; |
| 25 | }; |
| 26 | export type DebugState = Omit< |
| 27 | DebugStateSnapshot, |
| 28 | "packStats" | "activePacks" | "supersededPacks" |
| 29 | > & { |
| 30 | packStats?: AdminPackState[]; |
| 31 | activePacks: AdminPackState[]; |
| 32 | supersededPacks: AdminPackState[]; |
| 33 | }; |
| 34 | export type CompactionData = DebugState["compaction"]; |
| 35 | |
| 36 | type PackRefIndexMetadata = { |
| 37 | status: PackRefIndexStatus; |
| 38 | size?: number; |
| 39 | }; |
| 40 | |
| 41 | export 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 | |
| 50 | export 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`. |
| 65 | export { isRequestPrivate, markRequestPrivate }; |
| 66 | |
| 67 | export 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. |
| 93 | export 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. |
| 109 | export 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). |
| 127 | export 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 | |
| 137 | export type AdminRepoAccess = |
| 138 | | { kind: "ok"; route: RepositoryRoute; cacheCtx: CacheContext; viewer: Viewer; limiter: Limiter } |
| 139 | | { kind: "response"; response: Response }; |
| 140 | |
| 141 | export type UiRepoAccessOptions = { |
| 142 | // For data-API endpoints that return JSON (not HTML) on failure. |
| 143 | responseShape?: "html" | "json"; |
| 144 | }; |
| 145 | |
| 146 | export 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 | |
| 216 | export 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 | |
| 224 | function 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. |
| 238 | export 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 | |
| 283 | function 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. |
| 296 | export 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 | |
| 330 | export function getDefaultBranchFromHead(head: HeadInfo | undefined): string { |
| 331 | return head?.target ? shortRefName(head.target) : "main"; |
| 332 | } |
| 333 | |
| 334 | function 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 | |
| 342 | function 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 | |
| 362 | async 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 | |
| 425 | export 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 | |
| 440 | export 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 | |
| 461 | export 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 | } |