200 lines · 6.4 KB
1 /**
2 * REST API: Repository browsing endpoints.
3 * Tree listing, blob content, commit log.
4 */
5
6 import { authenticate } from '../../middleware/auth.js';
7 import { RepoService } from '../../services/repo-service.js';
8 import { ObjectStore } from '../../services/object-store.js';
9 import { RefStore } from '../../services/ref-store.js';
10 import { BrowseService } from '../../services/browse-service.js';
11 import type { HandlerContext } from '../../router.js';
12
13 const decoder = new TextDecoder();
14
15 /** Helper to set up browse context for a repo. */
16 async function getBrowseContext(request: Request, hctx: HandlerContext) {
17 const { owner, repo: repoName } = hctx.params;
18 const repoService = new RepoService(hctx.env.DB);
19 const { repo } = await repoService.getByOwnerAndName(owner, repoName);
20
21 const userId = await authenticate(request, hctx.env.DB);
22 const hasAccess = await repoService.hasReadAccess(repo, userId);
23 if (!hasAccess) {
24 return null;
25 }
26
27 const objectStore = new ObjectStore(hctx.env.OBJECTS, owner, repoName);
28 const refStore = new RefStore(hctx.env.DB, repo.id);
29 const browseService = new BrowseService(objectStore, refStore);
30
31 return { repo, browseService, refStore };
32 }
33
34 /**
35 * Resolve a combined "ref/path" string into separate ref and path parts.
36 * Branch names can contain slashes (e.g. "feat/initial-implementation"),
37 * so we try matching against known branches from longest to shortest.
38 */
39 async function resolveRefAndPath(
40 refStore: RefStore,
41 refAndPath: string,
42 defaultBranch: string
43 ): Promise<{ ref: string; path: string }> {
44 if (!refAndPath) {
45 return { ref: defaultBranch, path: '' };
46 }
47
48 const segments = refAndPath.split('/');
49
50 // Try progressively longer prefixes as the ref name
51 // Start from longest (most specific) to shortest
52 const allRefs = await refStore.getAll();
53 const branchNames = allRefs
54 .filter((r) => r.name.startsWith('refs/heads/'))
55 .map((r) => r.name.replace('refs/heads/', ''));
56 const tagNames = allRefs
57 .filter((r) => r.name.startsWith('refs/tags/'))
58 .map((r) => r.name.replace('refs/tags/', ''));
59 const allNames = new Set([...branchNames, ...tagNames]);
60
61 // Try from longest possible ref to shortest
62 for (let i = segments.length; i >= 1; i--) {
63 const candidate = segments.slice(0, i).join('/');
64 if (allNames.has(candidate)) {
65 return { ref: candidate, path: segments.slice(i).join('/') };
66 }
67 }
68
69 // Also check if it's a raw SHA
70 if (/^[0-9a-f]{40}$/.test(segments[0])) {
71 return { ref: segments[0], path: segments.slice(1).join('/') };
72 }
73
74 // Fall back to first segment as ref
75 return { ref: segments[0], path: segments.slice(1).join('/') };
76 }
77
78 /** GET /api/repos/:owner/:repo/tree/:refAndPath* */
79 export async function handleApiTree(
80 request: Request,
81 hctx: HandlerContext
82 ): Promise<Response> {
83 const ctx = await getBrowseContext(request, hctx);
84 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
85
86 const { ref, path } = await resolveRefAndPath(
87 ctx.refStore, hctx.params.refAndPath || '', ctx.repo.default_branch
88 );
89 const commitSha = await ctx.browseService.resolveRef(ref);
90 const entries = await ctx.browseService.listTree(commitSha, path);
91
92 return Response.json({ ref, commit: commitSha, path, entries });
93 }
94
95 /** GET /api/repos/:owner/:repo/blob/:refAndPath* */
96 export async function handleApiBlob(
97 request: Request,
98 hctx: HandlerContext
99 ): Promise<Response> {
100 const ctx = await getBrowseContext(request, hctx);
101 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
102
103 const { ref, path } = await resolveRefAndPath(
104 ctx.refStore, hctx.params.refAndPath || '', ctx.repo.default_branch
105 );
106 if (!path) return Response.json({ error: 'Path required' }, { status: 400 });
107
108 const commitSha = await ctx.browseService.resolveRef(ref);
109 const { content, sha } = await ctx.browseService.getBlob(commitSha, path);
110
111 // Check if content is likely text
112 const isText = isTextContent(content);
113
114 if (isText) {
115 const text = decoder.decode(content);
116 return Response.json({
117 ref,
118 commit: commitSha,
119 path,
120 sha,
121 size: content.length,
122 content: text,
123 encoding: 'utf-8',
124 });
125 } else {
126 // Binary file — return base64
127 const base64 = btoa(String.fromCharCode(...content));
128 return Response.json({
129 ref,
130 commit: commitSha,
131 path,
132 sha,
133 size: content.length,
134 content: base64,
135 encoding: 'base64',
136 });
137 }
138 }
139
140 /** GET /api/repos/:owner/:repo/commits/:ref (ref may contain slashes) */
141 export async function handleApiCommits(
142 request: Request,
143 hctx: HandlerContext
144 ): Promise<Response> {
145 const ctx = await getBrowseContext(request, hctx);
146 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
147
148 const refName = hctx.params.ref || ctx.repo.default_branch;
149 const url = new URL(request.url);
150 const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
151
152 const commitSha = await ctx.browseService.resolveRef(refName);
153 const commits = await ctx.browseService.getCommitLog(commitSha, limit);
154
155 return Response.json({ ref: refName, commits });
156 }
157
158 /** GET /api/repos/:owner/:repo/commit/:sha */
159 export async function handleApiCommitDetail(
160 request: Request,
161 hctx: HandlerContext
162 ): Promise<Response> {
163 const ctx = await getBrowseContext(request, hctx);
164 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
165
166 const { sha } = hctx.params;
167 const commit = await ctx.browseService.getCommit(sha);
168
169 return Response.json(commit);
170 }
171
172 /** GET /api/repos/:owner/:repo/branches */
173 export async function handleApiBranches(
174 request: Request,
175 hctx: HandlerContext
176 ): Promise<Response> {
177 const ctx = await getBrowseContext(request, hctx);
178 if (!ctx) return Response.json({ error: 'Not found' }, { status: 404 });
179
180 const allRefs = await ctx.refStore.getAll();
181 const branches = allRefs
182 .filter((r) => r.name.startsWith('refs/heads/'))
183 .map((r) => ({
184 name: r.name.replace('refs/heads/', ''),
185 sha: r.sha,
186 isDefault: r.name === `refs/heads/${ctx.repo.default_branch}`,
187 }));
188
189 return Response.json({ branches, default_branch: ctx.repo.default_branch });
190 }
191
192 /** Heuristic: check if content is text by scanning for NUL bytes. */
193 function isTextContent(content: Uint8Array): boolean {
194 const checkLen = Math.min(content.length, 8000);
195 for (let i = 0; i < checkLen; i++) {
196 if (content[i] === 0) return false;
197 }
198 return true;
199 }
200