scripts/
static/
templates/
.gitignore
30 B
.gitsigners
112 B
AGENTS.md
7.5 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.0 KiB
git_http.go
1.9 KiB
go.mod
572 B
go.sum
1.9 KiB
handler.go
11.3 KiB
handler_test.go
69.0 KiB
main.go
5.2 KiB
template.go
8.9 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) 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 | } |