package main import ( "fmt" "io" "net/http" "sort" "strconv" "strings" ) type pageData struct { SiteTitle string SiteDescription string BaseURL string Repo string Description string Section string Ref string CommitHash string Handle string Avatar string DevMode bool Discussions bool Data any } func (s *server) newPageData(r *http.Request, repo *RepoInfo, section, ref string) pageData { var handle, avatar string if s.discussions { handle = getSessionHandle(r) if handle != "" { avatar = getAvatar(s.db, handle) } } pd := pageData{ SiteTitle: s.title, SiteDescription: s.description, BaseURL: s.baseURL, Section: section, Ref: ref, Handle: handle, Avatar: avatar, DevMode: s.dev, Discussions: s.discussions, } if repo != nil { pd.Repo = repo.Name pd.Description = repo.Description } return pd } func (s *server) serveCSS(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(cssContent) } func (s *server) serveLogo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(logoContent) } func (s *server) serveJS(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/js/") var data []byte switch name { case "hirad.js": data = hiradJS case "hiril.js": data = hirilJS default: http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(data) } func (s *server) serveFont(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/fonts/") data, err := fontsFS.ReadFile("static/fonts/" + name) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "font/ttf") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(data) } func (s *server) serveAvatar(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/avatars/") data, err := avatarsFS.ReadFile("static/avatars/" + name) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(data) } func (s *server) route(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, s.baseURL) path = strings.Trim(path, "/") if path == "" { s.handleIndex(w, r) return } if path == "login" && s.discussions { s.handleLogin(w, r) return } if path == "logout" && s.discussions { s.handleLogout(w, r) return } if path == "dev/login" && s.dev && s.discussions { s.handleDevLogin(w, r) return } // Site-level forum (discussions not scoped to a repo). if path == "discussions" || strings.HasPrefix(path, "discussions/") { if !s.discussions { s.renderError(w, r, http.StatusNotFound, "Page not found") return } rest := strings.TrimPrefix(path, "discussions") rest = strings.TrimPrefix(rest, "/") s.routeSiteDiscussions(w, r, rest) return } segments := strings.SplitN(path, "/", 3) repoName := strings.TrimSuffix(segments[0], ".git") repo, ok := s.repos[repoName] if !ok { s.renderError(w, r, http.StatusNotFound, "Repository not found") return } // Handle Git smart HTTP protocol requests (git clone over HTTPS). if isGitHTTPRequest(r) { s.handleGitHTTP(w, r, repo) return } if len(segments) == 1 { s.handleSummary(w, r, repo) return } action := segments[1] rest := "" if len(segments) > 2 { rest = segments[2] } switch action { case "refs": s.handleRefs(w, r, repo) case "log": s.handleLog(w, r, repo, rest) case "tree": s.handleTree(w, r, repo, rest) case "commit": s.handleCommit(w, r, repo, rest) case "raw": s.handleRaw(w, r, repo, rest) case "discussions": if !s.discussions { s.renderError(w, r, http.StatusNotFound, "Page not found") return } s.routeDiscussions(w, r, repo, rest) default: s.renderError(w, r, http.StatusNotFound, "Page not found") } } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { type indexData struct { Repos []*RepoInfo IsEmpty bool } // Refresh LastUpdated for each repo from its latest commit. for _, repo := range s.repos { defaultBranch := repo.Git.getDefaultBranch() commit, err := repo.Git.getCommit(defaultBranch) if err == nil { repo.LastUpdated = commit.AuthorDate } } // Re-sort repos by last updated (most recent first). sort.Slice(s.sorted, func(i, j int) bool { return s.repos[s.sorted[i]].LastUpdated.After(s.repos[s.sorted[j]].LastUpdated) }) repos := make([]*RepoInfo, 0, len(s.sorted)) for _, name := range s.sorted { repos = append(repos, s.repos[name]) } pd := s.newPageData(r, nil, "repositories", "") pd.Data = indexData{Repos: repos, IsEmpty: len(repos) == 0} s.tmpl.render(w, "index", pd) } type homeData struct { DefaultRef string Branches []RefInfo Tree []TreeNode Readme *BlobInfo LastCommit *CommitInfo ActiveBlob *BlobInfo ActivePath string IsEmpty bool CloneSSH string CloneHTTPS string } func (s *server) handleSummary(w http.ResponseWriter, r *http.Request, repo *RepoInfo) { s.renderHome(w, r, repo, "", nil, "") } func (s *server) renderHome(w http.ResponseWriter, r *http.Request, repo *RepoInfo, ref string, blob *BlobInfo, activePath string) { git := repo.Git if ref == "" { ref = git.getDefaultBranch() } hash, err := git.resolveRef(ref) if err != nil { pd := s.newPageData(r, repo, "home", ref) pd.Data = homeData{ DefaultRef: ref, IsEmpty: true, } s.tmpl.render(w, "home", pd) return } tree := git.buildTreeNodes(hash, activePath) lastCommit, _ := git.getCommit(hash) branches, _ := git.getBranches() // Show README for the active directory (or root if no active path / file selected) readmeDir := "" if activePath != "" && blob == nil { readmeDir = activePath } readme := git.getReadme(hash, readmeDir) pd := s.newPageData(r, repo, "home", ref) scheme := "https" if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" { scheme = "http" } if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { scheme = proto } cloneHTTPS := scheme + "://" + r.Host + s.baseURL + "/" + repo.Name + ".git" pd.Data = homeData{ DefaultRef: ref, Branches: branches, Tree: tree, Readme: readme, LastCommit: lastCommit, ActiveBlob: blob, ActivePath: activePath, CloneSSH: "git@radiant.computer:radiant/" + repo.Name + ".git", CloneHTTPS: cloneHTTPS, } s.tmpl.render(w, "home", pd) } func (s *server) handleLog(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { type logData struct { Branches []RefInfo LastCommit *CommitInfo Commits []CommitInfo Page int HasPrev bool HasNext bool PrevPage int NextPage int Ref string IsEmpty bool } page := 0 if p := r.URL.Query().Get("page"); p != "" { if n, err := strconv.Atoi(p); err == nil && n >= 0 { page = n } } git := repo.Git refStr := rest if refStr == "" { refStr = git.getDefaultBranch() } hash, err := git.resolveRef(refStr) if err != nil { pd := s.newPageData(r, repo, "log", refStr) pd.Data = logData{IsEmpty: true, Ref: refStr} s.tmpl.render(w, "log", pd) return } commits, hasMore, err := git.getLog(hash, page, 50) if err != nil { s.renderError(w, r, http.StatusInternalServerError, err.Error()) return } branches, _ := git.getBranches() lastCommit, _ := git.getCommit(hash) pd := s.newPageData(r, repo, "log", refStr) pd.Data = logData{ Branches: branches, LastCommit: lastCommit, Commits: commits, Page: page, HasPrev: page > 0, HasNext: hasMore, PrevPage: page - 1, NextPage: page + 1, Ref: refStr, IsEmpty: len(commits) == 0, } s.tmpl.render(w, "log", pd) } func (s *server) handleTree(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { git := repo.Git if rest == "" { rest = git.getDefaultBranch() } segments := strings.Split(rest, "/") hash, ref, path, err := git.resolveRefAndPath(segments) if err != nil { s.renderError(w, r, http.StatusNotFound, "Ref not found") return } // File: show blob in content view if path != "" && !git.isTreePath(hash, path) { blob, err := git.getBlob(hash, path) if err != nil { s.renderError(w, r, http.StatusNotFound, "File not found") return } s.renderHome(w, r, repo, ref, blob, path) return } // Directory: expand tree to this path s.renderHome(w, r, repo, ref, nil, path) } func (s *server) handleCommit(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { type commitData struct { Commit *CommitInfo Files []DiffFile TruncatedFiles int } git := repo.Git commit, err := git.getCommit(rest) if err != nil { s.renderError(w, r, http.StatusNotFound, "Commit not found") return } files, err := git.getDiff(rest) if err != nil { s.renderError(w, r, http.StatusInternalServerError, err.Error()) return } var truncatedFiles int if len(files) > maxDiffFiles { truncatedFiles = len(files) - maxDiffFiles files = files[:maxDiffFiles] } pd := s.newPageData(r, repo, "commit", "") pd.CommitHash = commit.Hash pd.Data = commitData{ Commit: commit, Files: files, TruncatedFiles: truncatedFiles, } s.tmpl.render(w, "commit", pd) } func (s *server) handleRefs(w http.ResponseWriter, r *http.Request, repo *RepoInfo) { type refsData struct { Branches []RefInfo Tags []RefInfo } git := repo.Git branches, _ := git.getBranches() tags, _ := git.getTags() pd := s.newPageData(r, repo, "refs", "") pd.Data = refsData{Branches: branches, Tags: tags} s.tmpl.render(w, "refs", pd) } func (s *server) handleRaw(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { if rest == "" { s.renderError(w, r, http.StatusNotFound, "No path specified") return } git := repo.Git segments := strings.Split(rest, "/") hash, _, path, err := git.resolveRefAndPath(segments) if err != nil { s.renderError(w, r, http.StatusNotFound, "Ref not found") return } if path == "" { s.renderError(w, r, http.StatusNotFound, "No file path") return } reader, contentType, size, err := git.getRawBlob(hash, path) if err != nil { s.renderError(w, r, http.StatusNotFound, "File not found") return } defer reader.Close() w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) io.Copy(w, reader) } func (s *server) routeDiscussions(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) { switch { case rest == "" || rest == "/": s.handleDiscussions(w, r, repo) case rest == "new": s.handleNewDiscussion(w, r, repo) default: s.handleDiscussion(w, r, repo, rest) } } func (s *server) renderError(w http.ResponseWriter, r *http.Request, code int, message string) { type errorData struct { Code int Message string Path string } w.WriteHeader(code) pd := s.newPageData(r, nil, "", "") pd.Data = errorData{Code: code, Message: message, Path: r.URL.Path} s.tmpl.render(w, "error", pd) }