scripts/
static/
templates/
.gitignore
30 B
.gitsigners
112 B
AGENTS.md
7.4 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.2 KiB
git_http.go
2.5 KiB
go.mod
572 B
go.sum
1.9 KiB
handler.go
10.8 KiB
handler_test.go
71.7 KiB
main.go
5.7 KiB
template.go
8.8 KiB
watch
272 B
handler.go
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 | } |