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