larc r1

484 lines ยท 11.4 KB Raw
1 package server
2
3 import (
4 "sort"
5 "strconv"
6 "strings"
7 "time"
8
9 "github.com/bytedance/sonic"
10 "github.com/gofiber/fiber/v2"
11
12 "github.com/lain/larc/internal/core"
13 "github.com/lain/larc/internal/repo"
14 )
15
16 /* HTTP handlers for larc server.
17 * Browser routes return HTML, API routes return JSON. */
18
19 /* ========== Browser Handlers (HTML) ========== */
20
21 // handleRepoIndex renders README.md as HTML
22 func (s *Server) handleRepoIndex(c *fiber.Ctx) error {
23 r, repoCfg, err := s.getRepo(c)
24 if err != nil {
25 return err
26 }
27
28 /* get latest revision */
29 latestRev, err := r.Meta.GetLatestRevision()
30 if err != nil {
31 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
32 }
33
34 if latestRev == 0 {
35 /* empty repo */
36 html, err := RenderPage(&PageData{
37 Title: repoCfg.Name,
38 RepoName: repoCfg.Name,
39 Content: "<p>Empty repository. No commits yet.</p>",
40 })
41 if err != nil {
42 return err
43 }
44 c.Set("Content-Type", "text/html; charset=utf-8")
45 return c.Send(html)
46 }
47
48 /* get tree for latest revision */
49 rev, err := r.Meta.GetRevision(latestRev)
50 if err != nil {
51 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
52 }
53
54 tree, err := r.GetTree(rev.TreeHash)
55 if err != nil {
56 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
57 }
58
59 /* find README.md */
60 var readmeHash string
61 for _, entry := range tree.Entries {
62 lower := strings.ToLower(entry.Path)
63 if lower == "readme.md" || lower == "readme" || lower == "readme.txt" {
64 readmeHash = entry.BlobHash
65 break
66 }
67 }
68
69 if readmeHash == "" {
70 /* no README */
71 html, err := RenderPage(&PageData{
72 Title: repoCfg.Name,
73 RepoName: repoCfg.Name,
74 Revision: latestRev,
75 Content: "<p>No README.md found in this repository.</p>",
76 })
77 if err != nil {
78 return err
79 }
80 c.Set("Content-Type", "text/html; charset=utf-8")
81 return c.Send(html)
82 }
83
84 /* read README content */
85 readmeContent, err := r.Blobs.Read(readmeHash)
86 if err != nil {
87 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read README")
88 }
89
90 /* render HTML */
91 html, err := RenderReadme(repoCfg.Name, latestRev, readmeContent)
92 if err != nil {
93 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render README")
94 }
95
96 c.Set("Content-Type", "text/html; charset=utf-8")
97 return c.Send(html)
98 }
99
100 // handleTree renders directory listing
101 func (s *Server) handleTree(c *fiber.Ctx) error {
102 r, repoCfg, err := s.getRepo(c)
103 if err != nil {
104 return err
105 }
106
107 revParam := c.Params("rev")
108 revNum, err := strconv.ParseInt(revParam, 10, 64)
109 if err != nil {
110 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
111 }
112
113 rev, err := r.Meta.GetRevision(revNum)
114 if err != nil {
115 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
116 }
117
118 tree, err := r.GetTree(rev.TreeHash)
119 if err != nil {
120 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
121 }
122
123 /* current path in tree */
124 currentPath := c.Params("*")
125 currentPath = strings.TrimSuffix(currentPath, "/")
126
127 /* build breadcrumbs */
128 var breadcrumbs []Breadcrumb
129 if currentPath != "" {
130 parts := strings.Split(currentPath, "/")
131 for i, part := range parts {
132 path := strings.Join(parts[:i+1], "/")
133 breadcrumbs = append(breadcrumbs, Breadcrumb{
134 Name: part,
135 URL: "/" + repoCfg.Name + "/tree/" + revParam + "/" + path,
136 })
137 }
138 }
139
140 /* filter entries for current directory */
141 dirsMap := make(map[string]bool)
142 var files []TreeEntryView
143
144 for _, entry := range tree.Entries {
145 /* check if entry is in current path */
146 entryPath := entry.Path
147
148 if currentPath != "" {
149 if !strings.HasPrefix(entryPath, currentPath+"/") {
150 continue
151 }
152 entryPath = strings.TrimPrefix(entryPath, currentPath+"/")
153 }
154
155 /* check if it's a direct child or in subdirectory */
156 parts := strings.SplitN(entryPath, "/", 2)
157 if len(parts) > 1 {
158 /* it's in a subdirectory */
159 dirName := parts[0]
160 if !dirsMap[dirName] {
161 dirsMap[dirName] = true
162 }
163 } else {
164 /* direct file */
165 files = append(files, TreeEntryView{
166 Name: parts[0],
167 Path: entry.Path,
168 Size: entry.Size,
169 SizeStr: formatSize(entry.Size),
170 IsDir: false,
171 })
172 }
173 }
174
175 /* convert dirs map to slice */
176 var dirs []TreeEntryView
177 for dirName := range dirsMap {
178 dirPath := dirName
179 if currentPath != "" {
180 dirPath = currentPath + "/" + dirName
181 }
182 dirs = append(dirs, TreeEntryView{
183 Name: dirName,
184 Path: dirPath,
185 IsDir: true,
186 })
187 }
188
189 /* sort dirs and files */
190 sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name })
191 sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
192
193 data := &TreeData{
194 RepoName: repoCfg.Name,
195 Revision: revNum,
196 Path: currentPath,
197 Breadcrumbs: breadcrumbs,
198 Entries: len(dirs) > 0 || len(files) > 0,
199 Dirs: dirs,
200 Files: files,
201 }
202
203 html, err := RenderTree(data)
204 if err != nil {
205 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render tree")
206 }
207
208 c.Set("Content-Type", "text/html; charset=utf-8")
209 return c.Send(html)
210 }
211
212 // handleBlob returns file content
213 func (s *Server) handleBlob(c *fiber.Ctx) error {
214 r, _, err := s.getRepo(c)
215 if err != nil {
216 return err
217 }
218
219 revParam := c.Params("rev")
220 revNum, err := strconv.ParseInt(revParam, 10, 64)
221 if err != nil {
222 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
223 }
224
225 path := c.Params("*")
226
227 rev, err := r.Meta.GetRevision(revNum)
228 if err != nil {
229 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
230 }
231
232 tree, err := r.GetTree(rev.TreeHash)
233 if err != nil {
234 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
235 }
236
237 /* find file in tree */
238 var blobHash string
239 for _, entry := range tree.Entries {
240 if entry.Path == path {
241 blobHash = entry.BlobHash
242 break
243 }
244 }
245
246 if blobHash == "" {
247 return fiber.NewError(fiber.StatusNotFound, "File not found")
248 }
249
250 content, err := r.Blobs.Read(blobHash)
251 if err != nil {
252 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read file")
253 }
254
255 /* TODO(kroot): detect content type */
256 c.Set("Content-Type", "text/plain; charset=utf-8")
257 return c.Send(content)
258 }
259
260 // handleLog renders commit history
261 func (s *Server) handleLog(c *fiber.Ctx) error {
262 r, repoCfg, err := s.getRepo(c)
263 if err != nil {
264 return err
265 }
266
267 limit := c.QueryInt("limit", 30)
268 offset := c.QueryInt("offset", 0)
269 branch := c.Query("branch", "")
270
271 revs, err := r.Meta.ListRevisions(branch, limit+1, offset) // +1 to check if more exist
272 if err != nil {
273 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list revisions")
274 }
275
276 latestRev, _ := r.Meta.GetLatestRevision()
277
278 /* check pagination */
279 hasNext := len(revs) > limit
280 if hasNext {
281 revs = revs[:limit]
282 }
283 hasPrev := offset > 0
284
285 /* convert to view */
286 var commits []CommitView
287 for _, rev := range revs {
288 commits = append(commits, CommitView{
289 Number: rev.Number,
290 Branch: rev.Branch,
291 Author: rev.Author,
292 Message: rev.Message,
293 DateStr: time.Unix(rev.Timestamp, 0).Format("Jan 2, 2006 15:04"),
294 })
295 }
296
297 data := &LogData{
298 RepoName: repoCfg.Name,
299 LatestRev: latestRev,
300 Commits: commits,
301 HasPrev: hasPrev,
302 HasNext: hasNext,
303 PrevOffset: offset - limit,
304 NextOffset: offset + limit,
305 }
306
307 if data.PrevOffset < 0 {
308 data.PrevOffset = 0
309 }
310
311 html, err := RenderLog(data)
312 if err != nil {
313 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render log")
314 }
315
316 c.Set("Content-Type", "text/html; charset=utf-8")
317 return c.Send(html)
318 }
319
320 /* ========== API Handlers (JSON) ========== */
321
322 // handleAPIInfo returns repository info
323 func (s *Server) handleAPIInfo(c *fiber.Ctx) error {
324 r, repoCfg, err := s.getRepo(c)
325 if err != nil {
326 return err
327 }
328
329 latestRev, _ := r.Meta.GetLatestRevision()
330 branches, _ := r.Meta.ListBranches()
331
332 c.Set("Content-Type", "application/json")
333 return c.JSON(fiber.Map{
334 "name": repoCfg.Name,
335 "latest_rev": latestRev,
336 "branch_count": len(branches),
337 "default_branch": core.DefaultBranchName,
338 })
339 }
340
341 // handleAPILatestRev returns the latest revision number
342 func (s *Server) handleAPILatestRev(c *fiber.Ctx) error {
343 r, _, err := s.getRepo(c)
344 if err != nil {
345 return err
346 }
347
348 latestRev, err := r.Meta.GetLatestRevision()
349 if err != nil {
350 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
351 }
352
353 c.Set("Content-Type", "application/json")
354 return c.JSON(fiber.Map{"revision": latestRev})
355 }
356
357 // handleAPIRevision returns revision details
358 func (s *Server) handleAPIRevision(c *fiber.Ctx) error {
359 r, _, err := s.getRepo(c)
360 if err != nil {
361 return err
362 }
363
364 numParam := c.Params("num")
365 revNum, err := strconv.ParseInt(numParam, 10, 64)
366 if err != nil {
367 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
368 }
369
370 rev, err := r.Meta.GetRevision(revNum)
371 if err != nil {
372 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
373 }
374
375 c.Set("Content-Type", "application/json")
376 return c.JSON(rev)
377 }
378
379 // handleAPIBranches returns all branches
380 func (s *Server) handleAPIBranches(c *fiber.Ctx) error {
381 r, _, err := s.getRepo(c)
382 if err != nil {
383 return err
384 }
385
386 branches, err := r.Meta.ListBranches()
387 if err != nil {
388 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list branches")
389 }
390
391 c.Set("Content-Type", "application/json")
392 return c.JSON(branches)
393 }
394
395 // handleAPIBlob returns blob content
396 func (s *Server) handleAPIBlob(c *fiber.Ctx) error {
397 r, _, err := s.getRepo(c)
398 if err != nil {
399 return err
400 }
401
402 hash := c.Params("hash")
403 if len(hash) != 16 {
404 return fiber.NewError(fiber.StatusBadRequest, "Invalid blob hash")
405 }
406
407 content, err := r.Blobs.Read(hash)
408 if err != nil {
409 return fiber.NewError(fiber.StatusNotFound, "Blob not found")
410 }
411
412 c.Set("Content-Type", "application/octet-stream")
413 return c.Send(content)
414 }
415
416 // handleAPIUploadBlob uploads a new blob
417 func (s *Server) handleAPIUploadBlob(c *fiber.Ctx) error {
418 r, _, err := s.getRepo(c)
419 if err != nil {
420 return err
421 }
422
423 body := c.Body()
424 if len(body) == 0 {
425 return fiber.NewError(fiber.StatusBadRequest, "Empty body")
426 }
427
428 hash, err := r.Blobs.Write(body)
429 if err != nil {
430 return fiber.NewError(fiber.StatusInternalServerError, "Failed to store blob")
431 }
432
433 c.Set("Content-Type", "application/json")
434 return c.JSON(fiber.Map{"hash": hash, "size": len(body)})
435 }
436
437 // CommitRequest is the request body for creating a commit
438 type CommitRequest struct {
439 Branch string `json:"branch"`
440 Message string `json:"message"`
441 Author string `json:"author"`
442 Entries []core.TreeEntry `json:"entries"`
443 }
444
445 // handleAPICommit creates a new revision
446 func (s *Server) handleAPICommit(c *fiber.Ctx) error {
447 r, _, err := s.getRepo(c)
448 if err != nil {
449 return err
450 }
451
452 var req CommitRequest
453 if err := sonic.Unmarshal(c.Body(), &req); err != nil {
454 return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
455 }
456
457 if req.Message == "" {
458 return fiber.NewError(fiber.StatusBadRequest, "Message required")
459 }
460 if len(req.Entries) == 0 {
461 return fiber.NewError(fiber.StatusBadRequest, "Entries required")
462 }
463
464 /* use authenticated user as author if not specified */
465 author := req.Author
466 if author == "" {
467 author, _ = c.Locals("username").(string)
468 }
469 if author == "" {
470 author = "anonymous"
471 }
472
473 rev, err := r.Commit(author, req.Message, req.Entries)
474 if err != nil {
475 return fiber.NewError(fiber.StatusInternalServerError, "Failed to create commit")
476 }
477
478 c.Set("Content-Type", "application/json")
479 return c.JSON(rev)
480 }
481
482 /* NOTE(kroot): suppress unused import */
483 var _ = repo.LarcDir
484