package main import ( "database/sql" "flag" "fmt" "log" "net/http" "os" "path/filepath" "sort" "strings" ) type server struct { repos map[string]*RepoInfo sorted []string tmpl *templateSet db *sql.DB title string description string baseURL string scanPath string username string password string dev bool discussions bool } func main() { listen := flag.String("listen", ":8080", "listen address") scanPath := flag.String("scan-path", ".", "path to scan for git repos") title := flag.String("title", "radiant code repositories", "site title") description := flag.String("description", "", "site description shown on the index page") baseURL := flag.String("base-url", "", "base URL prefix (e.g. /git)") nonBare := flag.Bool("non-bare", false, "also scan for non-bare repos (dirs containing .git)") username := flag.String("username", "", "HTTP basic auth username (requires -password)") password := flag.String("password", "", "HTTP basic auth password (requires -username)") dbPath := flag.String("db-path", "./forge.db", "path to SQLite database for discussions") discussions := flag.Bool("discussions", false, "enable discussions feature") dev := flag.Bool("dev", false, "enable dev mode (auto sign-in link)") flag.Parse() if (*username == "") != (*password == "") { log.Fatal("-username and -password must both be set, or both be omitted") } abs, err := filepath.Abs(*scanPath) if err != nil { log.Fatalf("invalid scan path: %v", err) } repos, err := scanRepositories(abs, *nonBare) if err != nil { log.Fatalf("scan repositories: %v", err) } sorted := make([]string, 0, len(repos)) for name := range repos { sorted = append(sorted, name) } sort.Slice(sorted, func(i, j int) bool { return repos[sorted[i]].LastUpdated.After(repos[sorted[j]].LastUpdated) }) tmpl, err := loadTemplates() if err != nil { log.Fatalf("load templates: %v", err) } var db *sql.DB if *discussions { db, err = openDB(*dbPath) if err != nil { log.Fatalf("open database: %v", err) } defer db.Close() } srv := &server{ repos: repos, sorted: sorted, tmpl: tmpl, db: db, title: *title, description: *description, baseURL: strings.TrimRight(*baseURL, "/"), scanPath: abs, username: *username, password: *password, dev: *dev, discussions: *discussions, } mux := http.NewServeMux() mux.HandleFunc("/style.css", srv.serveCSS) mux.HandleFunc("/radiant.svg", srv.serveLogo) mux.HandleFunc("/js/", srv.serveJS) mux.HandleFunc("/fonts/", srv.serveFont) mux.HandleFunc("/avatars/", srv.serveAvatar) mux.HandleFunc("/", srv.route) var handler http.Handler = mux if srv.username != "" { handler = srv.basicAuth(mux) } log.Printf("listening on %s (scanning %s, %d repos)", *listen, abs, len(repos)) if err := http.ListenAndServe(*listen, handler); err != nil { log.Fatal(err) } } func (s *server) basicAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() if !ok || u != s.username || p != s.password { w.Header().Set("WWW-Authenticate", `Basic realm="source-browser"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } func scanRepositories(root string, nonBare bool) (map[string]*RepoInfo, error) { repos := make(map[string]*RepoInfo) entries, err := os.ReadDir(root) if err != nil { return nil, fmt.Errorf("read dir %s: %w", root, err) } for _, entry := range entries { if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { continue } dirPath := filepath.Join(root, entry.Name()) var gitDir string if isBareRepo(dirPath) { gitDir = dirPath } else if nonBare && isWorkTreeRepo(dirPath) { gitDir = filepath.Join(dirPath, ".git") } else { continue } // Repos are private by default; skip unless a 'public' file exists. if _, err := os.Stat(filepath.Join(gitDir, "public")); err != nil { continue } name := strings.TrimSuffix(entry.Name(), ".git") desc := "" descBytes, err := os.ReadFile(filepath.Join(gitDir, "description")) if err == nil { d := strings.TrimSpace(string(descBytes)) if d != "Unnamed repository; edit this file 'description' to name the repository." { desc = d } } owner := "" ownerBytes, err := os.ReadFile(filepath.Join(gitDir, "owner")) if err == nil { owner = strings.TrimSpace(string(ownerBytes)) } git := newGitCLIBackend(gitDir) info := &RepoInfo{ Name: name, Path: dirPath, GitDir: gitDir, Description: desc, Owner: owner, Git: git, } defaultBranch := git.getDefaultBranch() commit, err := git.getCommit(defaultBranch) if err == nil { info.LastUpdated = commit.AuthorDate } repos[name] = info } return repos, nil } func isWorkTreeRepo(path string) bool { info, err := os.Stat(filepath.Join(path, ".git")) if err != nil { return false } return info.IsDir() } func isBareRepo(path string) bool { for _, f := range []string{"HEAD", "objects", "refs"} { if _, err := os.Stat(filepath.Join(path, f)); err != nil { return false } } return true }