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