handler.go 10.8 KiB raw
1
package main
2
3
import (
4
	"fmt"
5
	"io"
6
	"net/http"
7
	"sort"
8
	"strconv"
9
	"strings"
10
)
11
12
type pageData struct {
13
	SiteTitle       string
14
	SiteDescription string
15
	BaseURL         string
16
	Repo            string
17
	Description     string
18
	Section         string
19
	Ref             string
20
	CommitHash      string
21
	Handle          string
22
	Avatar          string
23
	DevMode         bool
24
	Discussions     bool
25
	Data            any
26
}
27
28
func (s *server) newPageData(r *http.Request, repo *RepoInfo, section, ref string) pageData {
29
	var handle, avatar string
30
	if s.discussions {
31
		handle = getSessionHandle(r)
32
		if handle != "" {
33
			avatar = getAvatar(s.db, handle)
34
		}
35
	}
36
	pd := pageData{
37
		SiteTitle:       s.title,
38
		SiteDescription: s.description,
39
		BaseURL:         s.baseURL,
40
		Section:         section,
41
		Ref:             ref,
42
		Handle:          handle,
43
		Avatar:          avatar,
44
		DevMode:         s.dev,
45
		Discussions:     s.discussions,
46
	}
47
	if repo != nil {
48
		pd.Repo = repo.Name
49
		pd.Description = repo.Description
50
	}
51
	return pd
52
}
53
54
func (s *server) serveCSS(w http.ResponseWriter, r *http.Request) {
55
	w.Header().Set("Content-Type", "text/css; charset=utf-8")
56
	w.Header().Set("Cache-Control", "public, max-age=3600")
57
	w.Write(cssContent)
58
}
59
60
func (s *server) serveLogo(w http.ResponseWriter, r *http.Request) {
61
	w.Header().Set("Content-Type", "image/svg+xml")
62
	w.Header().Set("Cache-Control", "public, max-age=86400")
63
	w.Write(logoContent)
64
}
65
66
func (s *server) serveFont(w http.ResponseWriter, r *http.Request) {
67
	name := strings.TrimPrefix(r.URL.Path, "/fonts/")
68
	data, err := fontsFS.ReadFile("static/fonts/" + name)
69
	if err != nil {
70
		http.NotFound(w, r)
71
		return
72
	}
73
	w.Header().Set("Content-Type", "font/ttf")
74
	w.Header().Set("Cache-Control", "public, max-age=86400")
75
	w.Write(data)
76
}
77
78
func (s *server) serveAvatar(w http.ResponseWriter, r *http.Request) {
79
	name := strings.TrimPrefix(r.URL.Path, "/avatars/")
80
	data, err := avatarsFS.ReadFile("static/avatars/" + name)
81
	if err != nil {
82
		http.NotFound(w, r)
83
		return
84
	}
85
	w.Header().Set("Content-Type", "image/svg+xml")
86
	w.Header().Set("Cache-Control", "public, max-age=86400")
87
	w.Write(data)
88
}
89
90
func (s *server) route(w http.ResponseWriter, r *http.Request) {
91
	path := strings.TrimPrefix(r.URL.Path, s.baseURL)
92
	path = strings.Trim(path, "/")
93
94
	if path == "" {
95
		s.handleIndex(w, r)
96
		return
97
	}
98
99
	if path == "login" && s.discussions {
100
		s.handleLogin(w, r)
101
		return
102
	}
103
	if path == "logout" && s.discussions {
104
		s.handleLogout(w, r)
105
		return
106
	}
107
108
	if path == "dev/login" && s.dev && s.discussions {
109
		s.handleDevLogin(w, r)
110
		return
111
	}
112
113
	// Site-level forum (discussions not scoped to a repo).
114
	if path == "discussions" || strings.HasPrefix(path, "discussions/") {
115
		if !s.discussions {
116
			s.renderError(w, r, http.StatusNotFound, "Page not found")
117
			return
118
		}
119
		rest := strings.TrimPrefix(path, "discussions")
120
		rest = strings.TrimPrefix(rest, "/")
121
		s.routeSiteDiscussions(w, r, rest)
122
		return
123
	}
124
125
	segments := strings.SplitN(path, "/", 3)
126
	repoName := strings.TrimSuffix(segments[0], ".git")
127
128
	repo, ok := s.repos[repoName]
129
	if !ok {
130
		s.renderError(w, r, http.StatusNotFound, "Repository not found")
131
		return
132
	}
133
134
	// Handle Git smart HTTP protocol requests (git clone over HTTPS).
135
	if isGitHTTPRequest(r) {
136
		s.handleGitHTTP(w, r, repo)
137
		return
138
	}
139
140
	if len(segments) == 1 {
141
		s.handleSummary(w, r, repo)
142
		return
143
	}
144
145
	action := segments[1]
146
	rest := ""
147
	if len(segments) > 2 {
148
		rest = segments[2]
149
	}
150
151
	switch action {
152
	case "refs":
153
		s.handleRefs(w, r, repo)
154
	case "log":
155
		s.handleLog(w, r, repo, rest)
156
	case "tree":
157
		s.handleTree(w, r, repo, rest)
158
	case "commit":
159
		s.handleCommit(w, r, repo, rest)
160
	case "raw":
161
		s.handleRaw(w, r, repo, rest)
162
	case "discussions":
163
		if !s.discussions {
164
			s.renderError(w, r, http.StatusNotFound, "Page not found")
165
			return
166
		}
167
		s.routeDiscussions(w, r, repo, rest)
168
	default:
169
		s.renderError(w, r, http.StatusNotFound, "Page not found")
170
	}
171
}
172
173
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
174
	type indexData struct {
175
		Repos   []*RepoInfo
176
		IsEmpty bool
177
	}
178
179
	// Refresh LastUpdated for each repo from its latest commit.
180
	for _, repo := range s.repos {
181
		defaultBranch := repo.Git.getDefaultBranch()
182
		commit, err := repo.Git.getCommit(defaultBranch)
183
		if err == nil {
184
			repo.LastUpdated = commit.AuthorDate
185
		}
186
	}
187
188
	// Re-sort repos by last updated (most recent first).
189
	sort.Slice(s.sorted, func(i, j int) bool {
190
		return s.repos[s.sorted[i]].LastUpdated.After(s.repos[s.sorted[j]].LastUpdated)
191
	})
192
193
	repos := make([]*RepoInfo, 0, len(s.sorted))
194
	for _, name := range s.sorted {
195
		repos = append(repos, s.repos[name])
196
	}
197
	pd := s.newPageData(r, nil, "repositories", "")
198
	pd.Data = indexData{Repos: repos, IsEmpty: len(repos) == 0}
199
	s.tmpl.render(w, "index", pd)
200
}
201
202
type homeData struct {
203
	DefaultRef string
204
	Branches   []RefInfo
205
	Tree       []TreeNode
206
	Readme     *BlobInfo
207
	LastCommit *CommitInfo
208
	ActiveBlob *BlobInfo
209
	ActivePath string
210
	IsEmpty    bool
211
	CloneSSH   string
212
	CloneHTTPS string
213
}
214
215
func (s *server) handleSummary(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
216
	s.renderHome(w, r, repo, "", nil, "")
217
}
218
219
func (s *server) renderHome(w http.ResponseWriter, r *http.Request, repo *RepoInfo, ref string, blob *BlobInfo, activePath string) {
220
	git := repo.Git
221
222
	if ref == "" {
223
		ref = git.getDefaultBranch()
224
	}
225
226
	hash, err := git.resolveRef(ref)
227
	if err != nil {
228
		pd := s.newPageData(r, repo, "home", ref)
229
		pd.Data = homeData{
230
			DefaultRef: ref,
231
			IsEmpty:    true,
232
		}
233
		s.tmpl.render(w, "home", pd)
234
		return
235
	}
236
237
	tree := git.buildTreeNodes(hash, activePath)
238
	lastCommit, _ := git.getCommit(hash)
239
	branches, _ := git.getBranches()
240
241
	// Show README for the active directory (or root if no active path / file selected)
242
	readmeDir := ""
243
	if activePath != "" && blob == nil {
244
		readmeDir = activePath
245
	}
246
	readme := git.getReadme(hash, readmeDir)
247
248
	pd := s.newPageData(r, repo, "home", ref)
249
	scheme := "https"
250
	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" {
251
		scheme = "http"
252
	}
253
	if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
254
		scheme = proto
255
	}
256
	cloneHTTPS := scheme + "://" + r.Host + s.baseURL + "/" + repo.Name + ".git"
257
258
	pd.Data = homeData{
259
		DefaultRef: ref,
260
		Branches:   branches,
261
		Tree:       tree,
262
		Readme:     readme,
263
		LastCommit: lastCommit,
264
		ActiveBlob: blob,
265
		ActivePath: activePath,
266
		CloneSSH:   s.sshClonePrefix + repo.Name + ".git",
267
		CloneHTTPS: cloneHTTPS,
268
	}
269
	s.tmpl.render(w, "home", pd)
270
}
271
272
func (s *server) handleLog(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
273
	type logData struct {
274
		Branches   []RefInfo
275
		LastCommit *CommitInfo
276
		Commits    []CommitInfo
277
		Page       int
278
		HasPrev    bool
279
		HasNext    bool
280
		PrevPage   int
281
		NextPage   int
282
		Ref        string
283
		IsEmpty    bool
284
	}
285
286
	page := 0
287
	if p := r.URL.Query().Get("page"); p != "" {
288
		if n, err := strconv.Atoi(p); err == nil && n >= 0 {
289
			page = n
290
		}
291
	}
292
293
	git := repo.Git
294
	refStr := rest
295
	if refStr == "" {
296
		refStr = git.getDefaultBranch()
297
	}
298
299
	hash, err := git.resolveRef(refStr)
300
	if err != nil {
301
		pd := s.newPageData(r, repo, "log", refStr)
302
		pd.Data = logData{IsEmpty: true, Ref: refStr}
303
		s.tmpl.render(w, "log", pd)
304
		return
305
	}
306
307
	commits, hasMore, err := git.getLog(hash, page, 50)
308
	if err != nil {
309
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
310
		return
311
	}
312
313
	branches, _ := git.getBranches()
314
	lastCommit, _ := git.getCommit(hash)
315
316
	pd := s.newPageData(r, repo, "log", refStr)
317
	pd.Data = logData{
318
		Branches:   branches,
319
		LastCommit: lastCommit,
320
		Commits:    commits,
321
		Page:       page,
322
		HasPrev:    page > 0,
323
		HasNext:    hasMore,
324
		PrevPage:   page - 1,
325
		NextPage:   page + 1,
326
		Ref:        refStr,
327
		IsEmpty:    len(commits) == 0,
328
	}
329
	s.tmpl.render(w, "log", pd)
330
}
331
332
func (s *server) handleTree(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
333
	git := repo.Git
334
335
	if rest == "" {
336
		rest = git.getDefaultBranch()
337
	}
338
339
	segments := strings.Split(rest, "/")
340
	hash, ref, path, err := git.resolveRefAndPath(segments)
341
	if err != nil {
342
		s.renderError(w, r, http.StatusNotFound, "Ref not found")
343
		return
344
	}
345
346
	// File: show blob in content view
347
	if path != "" && !git.isTreePath(hash, path) {
348
		blob, err := git.getBlob(hash, path)
349
		if err != nil {
350
			s.renderError(w, r, http.StatusNotFound, "File not found")
351
			return
352
		}
353
		s.renderHome(w, r, repo, ref, blob, path)
354
		return
355
	}
356
357
	// Directory: expand tree to this path
358
	s.renderHome(w, r, repo, ref, nil, path)
359
}
360
361
func (s *server) handleCommit(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
362
	type commitData struct {
363
		Commit         *CommitInfo
364
		Files          []DiffFile
365
		TruncatedFiles int
366
	}
367
368
	git := repo.Git
369
	commit, err := git.getCommit(rest)
370
	if err != nil {
371
		s.renderError(w, r, http.StatusNotFound, "Commit not found")
372
		return
373
	}
374
375
	files, err := git.getDiff(rest)
376
	if err != nil {
377
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
378
		return
379
	}
380
381
	var truncatedFiles int
382
	if len(files) > maxDiffFiles {
383
		truncatedFiles = len(files) - maxDiffFiles
384
		files = files[:maxDiffFiles]
385
	}
386
387
	pd := s.newPageData(r, repo, "commit", "")
388
	pd.CommitHash = commit.Hash
389
	pd.Data = commitData{
390
		Commit:         commit,
391
		Files:          files,
392
		TruncatedFiles: truncatedFiles,
393
	}
394
	s.tmpl.render(w, "commit", pd)
395
}
396
397
func (s *server) handleRefs(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
398
	type refsData struct {
399
		Branches []RefInfo
400
		Tags     []RefInfo
401
	}
402
403
	git := repo.Git
404
	branches, _ := git.getBranches()
405
	tags, _ := git.getTags()
406
407
	pd := s.newPageData(r, repo, "refs", "")
408
	pd.Data = refsData{Branches: branches, Tags: tags}
409
	s.tmpl.render(w, "refs", pd)
410
}
411
412
func (s *server) handleRaw(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
413
	if rest == "" {
414
		s.renderError(w, r, http.StatusNotFound, "No path specified")
415
		return
416
	}
417
418
	git := repo.Git
419
	segments := strings.Split(rest, "/")
420
	hash, _, path, err := git.resolveRefAndPath(segments)
421
	if err != nil {
422
		s.renderError(w, r, http.StatusNotFound, "Ref not found")
423
		return
424
	}
425
	if path == "" {
426
		s.renderError(w, r, http.StatusNotFound, "No file path")
427
		return
428
	}
429
430
	reader, contentType, size, err := git.getRawBlob(hash, path)
431
	if err != nil {
432
		s.renderError(w, r, http.StatusNotFound, "File not found")
433
		return
434
	}
435
	defer reader.Close()
436
437
	w.Header().Set("Content-Type", contentType)
438
	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
439
	io.Copy(w, reader)
440
}
441
442
func (s *server) routeDiscussions(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
443
	switch {
444
	case rest == "" || rest == "/":
445
		s.handleDiscussions(w, r, repo)
446
	case rest == "new":
447
		s.handleNewDiscussion(w, r, repo)
448
	default:
449
		s.handleDiscussion(w, r, repo, rest)
450
	}
451
}
452
453
func (s *server) renderError(w http.ResponseWriter, r *http.Request, code int, message string) {
454
	type errorData struct {
455
		Code    int
456
		Message string
457
		Path    string
458
	}
459
	w.WriteHeader(code)
460
	pd := s.newPageData(r, nil, "", "")
461
	pd.Data = errorData{Code: code, Message: message, Path: r.URL.Path}
462
	s.tmpl.render(w, "error", pd)
463
}