main.go 5.2 KiB 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
}