scripts/
static/
templates/
.gitignore
30 B
.gitsigners
112 B
AGENTS.md
7.5 KiB
LICENSE
89 B
README.md
1.9 KiB
deploy
723 B
discuss.go
16.7 KiB
git.go
3.5 KiB
git_cli.go
16.0 KiB
git_http.go
1.9 KiB
go.mod
572 B
go.sum
1.9 KiB
handler.go
11.3 KiB
handler_test.go
69.0 KiB
main.go
5.2 KiB
template.go
8.9 KiB
watch
272 B
main.go
raw
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "flag" |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "net/http" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "sort" |
| 12 | "strings" |
| 13 | ) |
| 14 | |
| 15 | type server struct { |
| 16 | repos map[string]*RepoInfo |
| 17 | sorted []string |
| 18 | tmpl *templateSet |
| 19 | db *sql.DB |
| 20 | title string |
| 21 | description string |
| 22 | baseURL string |
| 23 | scanPath string |
| 24 | username string |
| 25 | password string |
| 26 | dev bool |
| 27 | discussions bool |
| 28 | } |
| 29 | |
| 30 | func main() { |
| 31 | listen := flag.String("listen", ":8080", "listen address") |
| 32 | scanPath := flag.String("scan-path", ".", "path to scan for git repos") |
| 33 | title := flag.String("title", "radiant code repositories", "site title") |
| 34 | description := flag.String("description", "", "site description shown on the index page") |
| 35 | baseURL := flag.String("base-url", "", "base URL prefix (e.g. /git)") |
| 36 | nonBare := flag.Bool("non-bare", false, "also scan for non-bare repos (dirs containing .git)") |
| 37 | username := flag.String("username", "", "HTTP basic auth username (requires -password)") |
| 38 | password := flag.String("password", "", "HTTP basic auth password (requires -username)") |
| 39 | dbPath := flag.String("db-path", "./forge.db", "path to SQLite database for discussions") |
| 40 | discussions := flag.Bool("discussions", false, "enable discussions feature") |
| 41 | dev := flag.Bool("dev", false, "enable dev mode (auto sign-in link)") |
| 42 | flag.Parse() |
| 43 | |
| 44 | if (*username == "") != (*password == "") { |
| 45 | log.Fatal("-username and -password must both be set, or both be omitted") |
| 46 | } |
| 47 | |
| 48 | abs, err := filepath.Abs(*scanPath) |
| 49 | if err != nil { |
| 50 | log.Fatalf("invalid scan path: %v", err) |
| 51 | } |
| 52 | |
| 53 | repos, err := scanRepositories(abs, *nonBare) |
| 54 | if err != nil { |
| 55 | log.Fatalf("scan repositories: %v", err) |
| 56 | } |
| 57 | |
| 58 | sorted := make([]string, 0, len(repos)) |
| 59 | for name := range repos { |
| 60 | sorted = append(sorted, name) |
| 61 | } |
| 62 | sort.Slice(sorted, func(i, j int) bool { |
| 63 | return repos[sorted[i]].LastUpdated.After(repos[sorted[j]].LastUpdated) |
| 64 | }) |
| 65 | |
| 66 | tmpl, err := loadTemplates() |
| 67 | if err != nil { |
| 68 | log.Fatalf("load templates: %v", err) |
| 69 | } |
| 70 | |
| 71 | var db *sql.DB |
| 72 | if *discussions { |
| 73 | db, err = openDB(*dbPath) |
| 74 | if err != nil { |
| 75 | log.Fatalf("open database: %v", err) |
| 76 | } |
| 77 | defer db.Close() |
| 78 | } |
| 79 | |
| 80 | srv := &server{ |
| 81 | repos: repos, |
| 82 | sorted: sorted, |
| 83 | tmpl: tmpl, |
| 84 | db: db, |
| 85 | title: *title, |
| 86 | description: *description, |
| 87 | baseURL: strings.TrimRight(*baseURL, "/"), |
| 88 | scanPath: abs, |
| 89 | username: *username, |
| 90 | password: *password, |
| 91 | dev: *dev, |
| 92 | discussions: *discussions, |
| 93 | } |
| 94 | |
| 95 | mux := http.NewServeMux() |
| 96 | mux.HandleFunc("/style.css", srv.serveCSS) |
| 97 | mux.HandleFunc("/radiant.svg", srv.serveLogo) |
| 98 | mux.HandleFunc("/js/", srv.serveJS) |
| 99 | mux.HandleFunc("/fonts/", srv.serveFont) |
| 100 | mux.HandleFunc("/avatars/", srv.serveAvatar) |
| 101 | mux.HandleFunc("/", srv.route) |
| 102 | |
| 103 | var handler http.Handler = mux |
| 104 | if srv.username != "" { |
| 105 | handler = srv.basicAuth(mux) |
| 106 | } |
| 107 | |
| 108 | log.Printf("listening on %s (scanning %s, %d repos)", *listen, abs, len(repos)) |
| 109 | if err := http.ListenAndServe(*listen, handler); err != nil { |
| 110 | log.Fatal(err) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func (s *server) basicAuth(next http.Handler) http.Handler { |
| 115 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 116 | u, p, ok := r.BasicAuth() |
| 117 | if !ok || u != s.username || p != s.password { |
| 118 | w.Header().Set("WWW-Authenticate", `Basic realm="source-browser"`) |
| 119 | http.Error(w, "Unauthorized", http.StatusUnauthorized) |
| 120 | return |
| 121 | } |
| 122 | next.ServeHTTP(w, r) |
| 123 | }) |
| 124 | } |
| 125 | |
| 126 | func scanRepositories(root string, nonBare bool) (map[string]*RepoInfo, error) { |
| 127 | repos := make(map[string]*RepoInfo) |
| 128 | |
| 129 | entries, err := os.ReadDir(root) |
| 130 | if err != nil { |
| 131 | return nil, fmt.Errorf("read dir %s: %w", root, err) |
| 132 | } |
| 133 | |
| 134 | for _, entry := range entries { |
| 135 | if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { |
| 136 | continue |
| 137 | } |
| 138 | dirPath := filepath.Join(root, entry.Name()) |
| 139 | |
| 140 | var gitDir string |
| 141 | |
| 142 | if isBareRepo(dirPath) { |
| 143 | gitDir = dirPath |
| 144 | } else if nonBare && isWorkTreeRepo(dirPath) { |
| 145 | gitDir = filepath.Join(dirPath, ".git") |
| 146 | } else { |
| 147 | continue |
| 148 | } |
| 149 | |
| 150 | // Repos are private by default; skip unless a 'public' file exists. |
| 151 | if _, err := os.Stat(filepath.Join(gitDir, "public")); err != nil { |
| 152 | continue |
| 153 | } |
| 154 | |
| 155 | name := strings.TrimSuffix(entry.Name(), ".git") |
| 156 | |
| 157 | desc := "" |
| 158 | descBytes, err := os.ReadFile(filepath.Join(gitDir, "description")) |
| 159 | if err == nil { |
| 160 | d := strings.TrimSpace(string(descBytes)) |
| 161 | if d != "Unnamed repository; edit this file 'description' to name the repository." { |
| 162 | desc = d |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | owner := "" |
| 167 | ownerBytes, err := os.ReadFile(filepath.Join(gitDir, "owner")) |
| 168 | if err == nil { |
| 169 | owner = strings.TrimSpace(string(ownerBytes)) |
| 170 | } |
| 171 | |
| 172 | git := newGitCLIBackend(gitDir) |
| 173 | |
| 174 | info := &RepoInfo{ |
| 175 | Name: name, |
| 176 | Path: dirPath, |
| 177 | GitDir: gitDir, |
| 178 | Description: desc, |
| 179 | Owner: owner, |
| 180 | Git: git, |
| 181 | } |
| 182 | |
| 183 | defaultBranch := git.getDefaultBranch() |
| 184 | commit, err := git.getCommit(defaultBranch) |
| 185 | if err == nil { |
| 186 | info.LastUpdated = commit.AuthorDate |
| 187 | } |
| 188 | |
| 189 | repos[name] = info |
| 190 | } |
| 191 | |
| 192 | return repos, nil |
| 193 | } |
| 194 | |
| 195 | func isWorkTreeRepo(path string) bool { |
| 196 | info, err := os.Stat(filepath.Join(path, ".git")) |
| 197 | if err != nil { |
| 198 | return false |
| 199 | } |
| 200 | return info.IsDir() |
| 201 | } |
| 202 | |
| 203 | func isBareRepo(path string) bool { |
| 204 | for _, f := range []string{"HEAD", "objects", "refs"} { |
| 205 | if _, err := os.Stat(filepath.Join(path, f)); err != nil { |
| 206 | return false |
| 207 | } |
| 208 | } |
| 209 | return true |
| 210 | } |