package server import ( "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" "larc.wejust.rest/larc/internal/core" "larc.wejust.rest/larc/internal/repo" ) /* HTTP handlers for larc server. * Browser routes return HTML, API routes return JSON. */ // handleIndex renders the server home page. // If README exists in storage.root, it will be rendered. // Otherwise, shows list of available repositories. func (s *Server) handleIndex(c *fiber.Ctx) error { /* check for README in storage.root (try multiple names) */ readmeNames := []string{"README.md", "README", "README.txt"} var content []byte for _, name := range readmeNames { readmePath := filepath.Join(s.config.Storage.Root, name) data, err := os.ReadFile(readmePath) if err == nil && len(data) > 0 { content = data break } } if len(content) > 0 { /* render custom README */ html, err := RenderHomePage(content, s.config.Repos) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to render README") } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(html) } /* no custom README, show repo list */ html, err := RenderRepoList(s.config.Repos) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to render page") } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(html) } // handleRepoIndex renders README.md as HTML func (s *Server) handleRepoIndex(c *fiber.Ctx) error { r, repoCfg, err := s.getRepo(c) if err != nil { return err } /* get latest revision */ latestRev, err := r.Meta.GetLatestRevision() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision") } if latestRev == 0 { /* empty repo */ html, err := RenderPage(&PageData{ Title: repoCfg.Name, RepoName: repoCfg.Name, Content: "

Empty repository. No commits yet.

", }) if err != nil { return err } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(html) } /* get tree for latest revision */ rev, err := r.Meta.GetRevision(latestRev) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision") } tree, err := r.GetTree(rev.TreeHash) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree") } /* find README.md */ var readmeHash string for _, entry := range tree.Entries { lower := strings.ToLower(entry.Path) if lower == "readme.md" || lower == "readme" || lower == "readme.txt" { readmeHash = entry.BlobHash break } } if readmeHash == "" { /* no README */ html, err := RenderPage(&PageData{ Title: repoCfg.Name, RepoName: repoCfg.Name, Revision: latestRev, Content: "

No README.md found in this repository.

", }) if err != nil { return err } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(html) } /* read README content */ readmeContent, err := r.Blobs.Read(readmeHash) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to read README") } /* render HTML */ html, err := RenderReadme(repoCfg.Name, latestRev, readmeContent) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to render README") } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(html) } // handleTree renders directory listing func (s *Server) handleTree(c *fiber.Ctx) error { r, repoCfg, err := s.getRepo(c) if err != nil { return err } revParam := c.Params("rev") revNum, err := strconv.ParseInt(revParam, 10, 64) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number") } rev, err := r.Meta.GetRevision(revNum) if err != nil { return fiber.NewError(fiber.StatusNotFound, "Revision not found") } tree, err := r.GetTree(rev.TreeHash) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree") } /* current path in tree */ currentPath := c.Params("*") currentPath = strings.TrimSuffix(currentPath, "/") /* build breadcrumbs */ var breadcrumbs []Breadcrumb if currentPath != "" { parts := strings.Split(currentPath, "/") for i, part := range parts { path := strings.Join(parts[:i+1], "/") breadcrumbs = append(breadcrumbs, Breadcrumb{ Name: part, URL: "/" + repoCfg.Name + "/tree/" + revParam + "/" + path, }) } } /* filter entries for current directory */ dirsMap := make(map[string]bool) var files []TreeEntryView for _, entry := range tree.Entries { /* check if entry is in current path */ entryPath := entry.Path if currentPath != "" { if !strings.HasPrefix(entryPath, currentPath+"/") { continue } entryPath = strings.TrimPrefix(entryPath, currentPath+"/") } /* check if it's a direct child or in subdirectory */ parts := strings.SplitN(entryPath, "/", 2) if len(parts) > 1 { /* it's in a subdirectory */ dirName := parts[0] if !dirsMap[dirName] { dirsMap[dirName] = true } } else { /* direct file */ files = append(files, TreeEntryView{ Name: parts[0], Path: entry.Path, Size: entry.Size, SizeStr: formatSize(entry.Size), IsDir: false, }) } } /* convert dirs map to slice */ var dirs []TreeEntryView for dirName := range dirsMap { dirPath := dirName if currentPath != "" { dirPath = currentPath + "/" + dirName } dirs = append(dirs, TreeEntryView{ Name: dirName, Path: dirPath, IsDir: true, }) } /* sort dirs and files */ sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name }) sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) data := &TreeData{ RepoName: repoCfg.Name, Revision: revNum, Path: currentPath, Breadcrumbs: breadcrumbs, Entries: len(dirs) > 0 || len(files) > 0, Dirs: dirs, Files: files, } html, err := RenderTree(data) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to render tree") } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(html) } // handleBlob returns file content func (s *Server) handleBlob(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } revParam := c.Params("rev") revNum, err := strconv.ParseInt(revParam, 10, 64) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number") } path := c.Params("*") rev, err := r.Meta.GetRevision(revNum) if err != nil { return fiber.NewError(fiber.StatusNotFound, "Revision not found") } tree, err := r.GetTree(rev.TreeHash) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree") } /* find file in tree */ var blobHash string for _, entry := range tree.Entries { if entry.Path == path { blobHash = entry.BlobHash break } } if blobHash == "" { return fiber.NewError(fiber.StatusNotFound, "File not found") } content, err := r.Blobs.Read(blobHash) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to read file") } /* TODO(kroot): detect content type */ c.Set("Content-Type", "text/plain; charset=utf-8") return c.Send(content) } // handleLog renders commit history func (s *Server) handleLog(c *fiber.Ctx) error { r, repoCfg, err := s.getRepo(c) if err != nil { return err } limit := c.QueryInt("limit", 30) offset := c.QueryInt("offset", 0) branch := c.Query("branch", "") revs, err := r.Meta.ListRevisions(branch, limit+1, offset) // +1 to check if more exist if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to list revisions") } latestRev, _ := r.Meta.GetLatestRevision() /* check pagination */ hasNext := len(revs) > limit if hasNext { revs = revs[:limit] } hasPrev := offset > 0 /* convert to view */ var commits []CommitView for _, rev := range revs { commits = append(commits, CommitView{ Number: rev.Number, Branch: rev.Branch, Author: rev.Author, Message: rev.Message, DateStr: time.Unix(rev.Timestamp, 0).Format("Jan 2, 2006 15:04"), }) } data := &LogData{ RepoName: repoCfg.Name, LatestRev: latestRev, Commits: commits, HasPrev: hasPrev, HasNext: hasNext, PrevOffset: offset - limit, NextOffset: offset + limit, } if data.PrevOffset < 0 { data.PrevOffset = 0 } html, err := RenderLog(data) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to render log") } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(html) } // handleAPIInfo returns repository info func (s *Server) handleAPIInfo(c *fiber.Ctx) error { r, repoCfg, err := s.getRepo(c) if err != nil { return err } latestRev, _ := r.Meta.GetLatestRevision() branches, _ := r.Meta.ListBranches() c.Set("Content-Type", "application/json") return c.JSON(fiber.Map{ "name": repoCfg.Name, "latest_rev": latestRev, "branch_count": len(branches), "default_branch": core.DefaultBranchName, }) } // handleAPILatestRev returns the latest revision number func (s *Server) handleAPILatestRev(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } latestRev, err := r.Meta.GetLatestRevision() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision") } c.Set("Content-Type", "application/json") return c.JSON(fiber.Map{"revision": latestRev}) } // handleAPIRevision returns revision details func (s *Server) handleAPIRevision(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } numParam := c.Params("num") revNum, err := strconv.ParseInt(numParam, 10, 64) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number") } rev, err := r.Meta.GetRevision(revNum) if err != nil { return fiber.NewError(fiber.StatusNotFound, "Revision not found") } c.Set("Content-Type", "application/json") return c.JSON(rev) } // handleAPITree returns tree for a revision func (s *Server) handleAPITree(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } numParam := c.Params("num") revNum, err := strconv.ParseInt(numParam, 10, 64) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number") } rev, err := r.Meta.GetRevision(revNum) if err != nil { return fiber.NewError(fiber.StatusNotFound, "Revision not found") } tree, err := r.GetTree(rev.TreeHash) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree") } c.Set("Content-Type", "application/json") return c.JSON(tree) } // handleAPIBranches returns all branches func (s *Server) handleAPIBranches(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } branches, err := r.Meta.ListBranches() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to list branches") } c.Set("Content-Type", "application/json") return c.JSON(branches) } // handleAPIBlob returns blob content func (s *Server) handleAPIBlob(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } hash := c.Params("hash") if len(hash) != 16 { return fiber.NewError(fiber.StatusBadRequest, "Invalid blob hash") } content, err := r.Blobs.Read(hash) if err != nil { return fiber.NewError(fiber.StatusNotFound, "Blob not found") } c.Set("Content-Type", "application/octet-stream") return c.Send(content) } // handleAPIUploadBlob uploads a new blob func (s *Server) handleAPIUploadBlob(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } body := c.Body() if len(body) == 0 { return fiber.NewError(fiber.StatusBadRequest, "Empty body") } hash, err := r.Blobs.Write(body) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to store blob") } c.Set("Content-Type", "application/json") return c.JSON(fiber.Map{"hash": hash, "size": len(body)}) } // CommitRequest is the request body for creating a commit type CommitRequest struct { Branch string `json:"branch"` Message string `json:"message"` Author string `json:"author"` Entries []core.TreeEntry `json:"entries"` } // handleAPICommit creates a new revision func (s *Server) handleAPICommit(c *fiber.Ctx) error { r, _, err := s.getRepo(c) if err != nil { return err } var req CommitRequest if err := sonic.Unmarshal(c.Body(), &req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } if req.Message == "" { return fiber.NewError(fiber.StatusBadRequest, "Message required") } if len(req.Entries) == 0 { return fiber.NewError(fiber.StatusBadRequest, "Entries required") } /* use authenticated user as author if not specified */ author := req.Author if author == "" { author, _ = c.Locals("username").(string) } if author == "" { author = "anonymous" } rev, err := r.Commit(author, req.Message, req.Entries) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create commit") } c.Set("Content-Type", "application/json") return c.JSON(rev) } /* NOTE(kroot): suppress unused import */ var _ = repo.LarcDir