larc r8

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