main.mx raw
1 package main
2
3 import (
4 "bytes"
5 "fmt"
6 "net/http"
7 "os"
8 "path"
9 "strings"
10 "syscall"
11 "unsafe"
12 )
13
14 var (
15 repoRoot = "/home/git"
16 gitBin = "/usr/bin/git"
17 addr = "127.0.0.1:3000"
18 host = "git.mleku.dev"
19 gitBackend = "/usr/lib/git-core/git-http-backend"
20 )
21
22 // ── exec ────────────────────────────────────────────────────────
23
24 // run forks, execs bin with args, captures stdout via pipe.
25 // Uses raw clone syscall to bypass moxie's missing runtime fork hooks.
26 func run(bin string, args ...string) ([]byte, error) {
27 var p [2]int
28 if err := syscall.Pipe(p[:]); err != nil {
29 return nil, err
30 }
31
32 argv := make([]string, 0, len(args)+1)
33 argv = append(argv, bin)
34 argv = append(argv, args...)
35
36 binp, _ := syscall.BytePtrFromString(bin)
37 argvp, _ := syscall.SlicePtrFromStrings(argv)
38 envp, _ := syscall.SlicePtrFromStrings([]string{"PATH=/usr/bin:/bin"})
39
40 r1, _, e := syscall.RawSyscall(syscall.SYS_CLONE, uintptr(syscall.SIGCHLD), 0, 0)
41 if e != 0 {
42 syscall.Close(p[0])
43 syscall.Close(p[1])
44 return nil, e
45 }
46
47 if r1 == 0 {
48 // child
49 syscall.Dup2(p[1], 1)
50 syscall.Close(p[0])
51 syscall.Close(p[1])
52 devnull, _ := syscall.Open("/dev/null", syscall.O_RDWR, 0)
53 syscall.Dup2(devnull, 0)
54 syscall.Dup2(devnull, 2)
55 syscall.Close(devnull)
56 syscall.RawSyscall(syscall.SYS_EXECVE,
57 uintptr(unsafe.Pointer(binp)),
58 uintptr(unsafe.Pointer(&argvp[0])),
59 uintptr(unsafe.Pointer(&envp[0])))
60 syscall.Exit(127)
61 }
62
63 pid := int(r1)
64 syscall.Close(p[1])
65
66 var buf bytes.Buffer
67 tmp := make([]byte, 8192)
68 for {
69 n, _ := syscall.Read(p[0], tmp)
70 if n <= 0 {
71 break
72 }
73 buf.Write(tmp[:n])
74 }
75 syscall.Close(p[0])
76
77 var ws syscall.WaitStatus
78 syscall.Wait4(pid, &ws, 0, nil)
79
80 if ws.Exited() && ws.ExitStatus() != 0 {
81 return buf.Bytes(), fmt.Errorf("exit %d", ws.ExitStatus())
82 }
83 return buf.Bytes(), nil
84 }
85
86 func gitCmd(repoDir string, args ...string) ([]byte, error) {
87 full := make([]string, 0, len(args)+2)
88 full = append(full, "--git-dir="+repoDir)
89 full = append(full, args...)
90 return run(gitBin, full...)
91 }
92
93 // runIO forks, execs bin with args using given env, optionally piping stdin.
94 func runIO(env []string, stdin []byte, bin string, args ...string) ([]byte, error) {
95 var pout [2]int
96 if err := syscall.Pipe(pout[:]); err != nil {
97 return nil, err
98 }
99
100 hasIn := stdin != nil
101 var pin [2]int
102 if hasIn {
103 if err := syscall.Pipe(pin[:]); err != nil {
104 syscall.Close(pout[0])
105 syscall.Close(pout[1])
106 return nil, err
107 }
108 }
109
110 argv := make([]string, 0, len(args)+1)
111 argv = append(argv, bin)
112 argv = append(argv, args...)
113
114 binp, _ := syscall.BytePtrFromString(bin)
115 argvp, _ := syscall.SlicePtrFromStrings(argv)
116 envp, _ := syscall.SlicePtrFromStrings(env)
117
118 r1, _, e := syscall.RawSyscall(syscall.SYS_CLONE, uintptr(syscall.SIGCHLD), 0, 0)
119 if e != 0 {
120 syscall.Close(pout[0])
121 syscall.Close(pout[1])
122 if hasIn {
123 syscall.Close(pin[0])
124 syscall.Close(pin[1])
125 }
126 return nil, e
127 }
128
129 if r1 == 0 {
130 syscall.Dup2(pout[1], 1)
131 syscall.Close(pout[0])
132 syscall.Close(pout[1])
133 if hasIn {
134 syscall.Dup2(pin[0], 0)
135 syscall.Close(pin[0])
136 syscall.Close(pin[1])
137 }
138 devnull, _ := syscall.Open("/dev/null", syscall.O_RDWR, 0)
139 if !hasIn {
140 syscall.Dup2(devnull, 0)
141 }
142 syscall.Dup2(devnull, 2)
143 syscall.Close(devnull)
144 syscall.RawSyscall(syscall.SYS_EXECVE,
145 uintptr(unsafe.Pointer(binp)),
146 uintptr(unsafe.Pointer(&argvp[0])),
147 uintptr(unsafe.Pointer(&envp[0])))
148 syscall.Exit(127)
149 }
150
151 pid := int(r1)
152 syscall.Close(pout[1])
153 if hasIn {
154 syscall.Close(pin[0])
155 for off := 0; off < len(stdin); {
156 n, err := syscall.Write(pin[1], stdin[off:])
157 if n <= 0 || err != nil {
158 break
159 }
160 off += n
161 }
162 syscall.Close(pin[1])
163 }
164
165 var buf bytes.Buffer
166 tmp := make([]byte, 32768)
167 for {
168 n, _ := syscall.Read(pout[0], tmp)
169 if n <= 0 {
170 break
171 }
172 buf.Write(tmp[:n])
173 }
174 syscall.Close(pout[0])
175
176 var ws syscall.WaitStatus
177 syscall.Wait4(pid, &ws, 0, nil)
178
179 if ws.Exited() && ws.ExitStatus() != 0 {
180 return buf.Bytes(), fmt.Errorf("exit %d", ws.ExitStatus())
181 }
182 return buf.Bytes(), nil
183 }
184
185 // ── html helpers ────────────────────────────────────────────────
186
187 func esc(s string) string {
188 s = strings.ReplaceAll(s, "&", "&")
189 s = strings.ReplaceAll(s, "<", "<")
190 s = strings.ReplaceAll(s, ">", ">")
191 s = strings.ReplaceAll(s, "\"", """)
192 return s
193 }
194
195 // ── markdown ────────────────────────────────────────────────────
196
197 // renderMD converts markdown source to HTML.
198 func renderMD(src, linkName string) string {
199 var b strings.Builder
200 lines := strings.Split(src, "\n")
201 n := len(lines)
202 i := 0
203 var closeTags []string // stack of tags to close
204
205 flush := func() {
206 for j := len(closeTags) - 1; j >= 0; j-- {
207 b.WriteString(closeTags[j])
208 }
209 closeTags = nil
210 }
211
212 inState := func(tag string) bool {
213 for _, t := range closeTags {
214 if t == tag {
215 return true
216 }
217 }
218 return false
219 }
220
221 lastWasHeading := false
222
223 for i < n {
224 line := lines[i]
225
226 // fenced code block
227 if strings.HasPrefix(line, "```") {
228 flush()
229 lastWasHeading = false
230 lang := strings.TrimSpace(line[3:])
231 b.WriteString(`<pre><code>`)
232 if lang != "" {
233 _ = lang // no syntax highlighting, just consume
234 }
235 i++
236 for i < n && !strings.HasPrefix(lines[i], "```") {
237 b.WriteString(esc(lines[i]))
238 b.WriteByte('\n')
239 i++
240 }
241 b.WriteString(`</code></pre>`)
242 i++ // skip closing ```
243 continue
244 }
245
246 // blank line — close open blocks
247 if strings.TrimSpace(line) == "" {
248 flush()
249 i++
250 continue
251 }
252
253 // heading
254 if line[0] == '#' {
255 flush()
256 lvl := 0
257 for lvl < len(line) && line[lvl] == '#' {
258 lvl++
259 }
260 if lvl <= 6 && lvl < len(line) && line[lvl] == ' ' {
261 text := strings.TrimSpace(line[lvl:])
262 text = strings.TrimRight(text, " #")
263 b.WriteString(fmt.Sprintf("<h%d>%s</h%d>\n", lvl, mdInline(text, linkName), lvl))
264 lastWasHeading = true
265 i++
266 continue
267 }
268 }
269
270 // horizontal rule — skip if immediately after a heading (already has border-bottom)
271 trimmed := strings.TrimSpace(line)
272 if len(trimmed) >= 3 && (allChar(trimmed, '-') || allChar(trimmed, '*') || allChar(trimmed, '_')) {
273 flush()
274 if !lastWasHeading {
275 b.WriteString("<hr>\n")
276 }
277 lastWasHeading = false
278 i++
279 continue
280 }
281
282 // table (line contains | and next line is separator)
283 if strings.Contains(line, "|") && i+1 < n && isTableSep(lines[i+1]) {
284 flush()
285 b.WriteString("<table>\n<thead><tr>")
286 for _, cell := range splitTableRow(line) {
287 b.WriteString("<th>" + mdInline(strings.TrimSpace(cell), linkName) + "</th>")
288 }
289 b.WriteString("</tr></thead>\n<tbody>\n")
290 i += 2 // skip header + separator
291 for i < n && strings.Contains(lines[i], "|") && strings.TrimSpace(lines[i]) != "" {
292 b.WriteString("<tr>")
293 for _, cell := range splitTableRow(lines[i]) {
294 b.WriteString("<td>" + mdInline(strings.TrimSpace(cell), linkName) + "</td>")
295 }
296 b.WriteString("</tr>\n")
297 i++
298 }
299 b.WriteString("</tbody></table>\n")
300 continue
301 }
302
303 // blockquote
304 if strings.HasPrefix(line, "> ") || line == ">" {
305 if !inState("</blockquote>\n") {
306 flush()
307 b.WriteString("<blockquote>")
308 closeTags = append(closeTags, "</blockquote>\n")
309 }
310 text := ""
311 if len(line) > 2 {
312 text = line[2:]
313 }
314 b.WriteString(mdInline(text, linkName) + "\n")
315 i++
316 continue
317 }
318
319 // unordered list
320 if (strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "+ ")) && len(line) > 2 {
321 if !inState("</ul>\n") {
322 flush()
323 b.WriteString("<ul>\n")
324 closeTags = append(closeTags, "</ul>\n")
325 }
326 b.WriteString("<li>" + mdInline(line[2:], linkName) + "</li>\n")
327 i++
328 continue
329 }
330
331 // ordered list
332 if isOL(line) {
333 if !inState("</ol>\n") {
334 flush()
335 b.WriteString("<ol>\n")
336 closeTags = append(closeTags, "</ol>\n")
337 }
338 dot := strings.IndexByte(line, '.')
339 b.WriteString("<li>" + mdInline(strings.TrimSpace(line[dot+1:]), linkName) + "</li>\n")
340 i++
341 continue
342 }
343
344 // paragraph
345 if !inState("</p>\n") {
346 flush()
347 b.WriteString("<p>")
348 closeTags = append(closeTags, "</p>\n")
349 } else {
350 b.WriteByte('\n')
351 }
352 b.WriteString(mdInline(line, linkName))
353 i++
354 }
355
356 flush()
357 return b.String()
358 }
359
360 // mdInline processes inline markdown: code, bold, italic, links, images.
361 func mdInline(s, linkName string) string {
362 s = esc(s) // HTML-escape first; markdown punctuation (*,[,],`,!) is not affected
363 var b strings.Builder
364 i := 0
365 for i < len(s) {
366 // backtick code span
367 if s[i] == '`' {
368 end := strings.IndexByte(s[i+1:], '`')
369 if end >= 0 {
370 b.WriteString("<code>" + s[i+1:i+1+end] + "</code>")
371 i += end + 2
372 continue
373 }
374 }
375
376 // linked image [](link-url) — render as text link to link-url
377 if s[i] == '[' && i+1 < len(s) && s[i+1] == '!' && i+2 < len(s) && s[i+2] == '[' {
378 if innerText, _, innerAdv := parseLink(s[i+2:]); innerAdv > 0 {
379 pos := i + 2 + innerAdv
380 if pos+1 < len(s) && s[pos] == ']' && s[pos+1] == '(' {
381 end := strings.IndexByte(s[pos+2:], ')')
382 if end >= 0 {
383 url := s[pos+2 : pos+2+end]
384 b.WriteString(`<a href="` + url + `">` + innerText + `</a>`)
385 i = pos + 2 + end + 1
386 continue
387 }
388 }
389 }
390 }
391
392 // image  — inline if relative, link if external
393 if s[i] == '!' && i+1 < len(s) && s[i+1] == '[' {
394 if text, url, advance := parseLink(s[i+1:]); advance > 0 {
395 if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "//") {
396 b.WriteString(`<a href="` + url + `">` + text + `</a>`)
397 } else {
398 raw := "/" + linkName + "/raw/" + strings.TrimPrefix(url, "./")
399 b.WriteString(`<img src="` + raw + `" alt="` + text + `">`)
400 }
401 i += 1 + advance
402 continue
403 }
404 }
405
406 // link [text](url)
407 if s[i] == '[' {
408 if text, url, advance := parseLink(s[i:]); advance > 0 {
409 b.WriteString(`<a href="` + url + `">` + text + `</a>`)
410 i += advance
411 continue
412 }
413 }
414
415 // bold **text**
416 if i+1 < len(s) && s[i] == '*' && s[i+1] == '*' {
417 end := strings.Index(s[i+2:], "**")
418 if end >= 0 {
419 b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
420 i += end + 4
421 continue
422 }
423 }
424
425 // bold __text__
426 if i+1 < len(s) && s[i] == '_' && s[i+1] == '_' {
427 end := strings.Index(s[i+2:], "__")
428 if end >= 0 {
429 b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
430 i += end + 4
431 continue
432 }
433 }
434
435 // italic *text*
436 if s[i] == '*' && (i+1 < len(s) && s[i+1] != '*') {
437 end := strings.IndexByte(s[i+1:], '*')
438 if end > 0 {
439 b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
440 i += end + 2
441 continue
442 }
443 }
444
445 // italic _text_
446 if s[i] == '_' && (i+1 < len(s) && s[i+1] != '_') {
447 end := strings.IndexByte(s[i+1:], '_')
448 if end > 0 {
449 b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
450 i += end + 2
451 continue
452 }
453 }
454
455 b.WriteByte(s[i])
456 i++
457 }
458 return b.String()
459 }
460
461 // parseLink parses [text](url) starting at s[0]=='['.
462 // Returns text, url, and total bytes consumed. Returns 0 advance on failure.
463 func parseLink(s string) (string, string, int) {
464 if len(s) < 4 || s[0] != '[' {
465 return "", "", 0
466 }
467 closeBracket := strings.IndexByte(s[1:], ']')
468 if closeBracket < 0 {
469 return "", "", 0
470 }
471 closeBracket++ // adjust for offset
472 if closeBracket+1 >= len(s) || s[closeBracket+1] != '(' {
473 return "", "", 0
474 }
475 closeParen := strings.IndexByte(s[closeBracket+2:], ')')
476 if closeParen < 0 {
477 return "", "", 0
478 }
479 text := s[1:closeBracket]
480 url := s[closeBracket+2 : closeBracket+2+closeParen]
481 return text, url, closeBracket + 2 + closeParen + 1
482 }
483
484 func allChar(s string, c byte) bool {
485 for i := 0; i < len(s); i++ {
486 if s[i] != c && s[i] != ' ' {
487 return false
488 }
489 }
490 return true
491 }
492
493 func isOL(line string) bool {
494 i := 0
495 for i < len(line) && line[i] >= '0' && line[i] <= '9' {
496 i++
497 }
498 return i > 0 && i < len(line)-1 && line[i] == '.' && line[i+1] == ' '
499 }
500
501 func isTableSep(line string) bool {
502 t := strings.TrimSpace(line)
503 if !strings.Contains(t, "|") {
504 return false
505 }
506 for _, c := range t {
507 if c != '|' && c != '-' && c != ':' && c != ' ' {
508 return false
509 }
510 }
511 return true
512 }
513
514 func splitTableRow(line string) []string {
515 line = strings.TrimSpace(line)
516 line = strings.Trim(line, "|")
517 return strings.Split(line, "|")
518 }
519
520 const css = `
521 *{box-sizing:border-box}
522 body{background:#000;color:#bbb;font:14px/1.6 monospace;margin:0;padding:20px 40px;max-width:960px}
523 a{color:#7ab;text-decoration:none}
524 a:hover{text-decoration:underline}
525 h1{color:#ddd;font-size:20px;border-bottom:1px solid #333;padding-bottom:6px}
526 h2{color:#ccc;font-size:16px;margin-top:24px}
527 pre{background:#0a0a0a;color:#ccc;padding:14px;border-radius:3px;overflow-x:auto;border:1px solid #222}
528 .ls{list-style:none;padding:0;margin:0}
529 .ls li{padding:3px 0;border-bottom:1px solid #1a1a1a}
530 .ls .d a{color:#8bf}
531 .ls .d a::before{content:"d ";color:#555}
532 .ls .f a::before{content:" ";color:#555}
533 nav{margin-bottom:16px;color:#666}
534 nav a{margin-right:4px}
535 .desc{color:#666;margin-left:12px;font-size:12px}
536 .info{color:#666;font-size:12px;margin-top:4px}
537 .md{line-height:1.7}
538 .md h1{font-size:22px;margin-top:28px}
539 .md h2{font-size:18px;margin-top:24px}
540 .md h3{font-size:15px;color:#ccc;margin-top:20px}
541 .md h4,.md h5,.md h6{font-size:14px;color:#aaa;margin-top:16px}
542 .md p{margin:10px 0}
543 .md code{background:#1a1a2a;padding:2px 5px;border-radius:3px;font-size:13px}
544 .md pre{margin:12px 0}
545 .md pre code{background:none;padding:0}
546 .md blockquote{border-left:3px solid #444;margin:10px 0;padding:4px 16px;color:#999}
547 .md ul,.md ol{padding-left:24px;margin:8px 0}
548 .md li{margin:3px 0}
549 .md hr{border:none;border-top:1px solid #333;margin:20px 0}
550 .md img{max-width:100%}
551 .md table{border-collapse:collapse;margin:12px 0}
552 .md th,.md td{border:1px solid #333;padding:6px 12px}
553 .md th{background:#1a1a2a}
554 .readme-box{position:relative}
555 .readme-box input{display:none}
556 .readme-box .readme-body{max-height:50vh;overflow:hidden}
557 .readme-box input:checked+.readme-body{max-height:none}
558 .readme-box .readme-body::after{content:"";position:absolute;bottom:28px;left:0;right:0;height:80px;background:linear-gradient(transparent,#000);pointer-events:none}
559 .readme-box input:checked+.readme-body::after{display:none}
560 .readme-box label{display:block;text-align:center;padding:6px;color:#7ab;cursor:pointer;border-top:1px solid #222;margin-top:4px;font-size:13px}
561 .readme-box label:hover{color:#9cd}
562 .readme-box input:checked~label .show{display:none}
563 .readme-box input:not(:checked)~label .hide{display:none}
564 `
565
566 func page(title, body string) []byte {
567 var b bytes.Buffer
568 b.WriteString(`<!doctype html><html><head><meta charset="utf-8">`)
569 b.WriteString(`<meta name="viewport" content="width=device-width">`)
570 b.WriteString(`<title>` + esc(title) + `</title>`)
571 b.WriteString(`<style>` + css + `</style>`)
572 b.WriteString(`</head><body>` + body + `</body></html>`)
573 return b.Bytes()
574 }
575
576 // ── repo discovery ──────────────────────────────────────────────
577
578 func findRepos() []string {
579 entries, err := os.ReadDir(repoRoot)
580 if err != nil {
581 return nil
582 }
583 var repos []string
584 for _, e := range entries {
585 if !e.IsDir() {
586 continue
587 }
588 if _, err := os.Stat(path.Join(repoRoot, e.Name(), "HEAD")); err == nil {
589 repos = append(repos, e.Name())
590 }
591 }
592 return repos
593 }
594
595 func cleanName(s string) string {
596 return strings.TrimSuffix(s, ".git")
597 }
598
599 // resolveRepo maps a URL name to the actual repo directory name.
600 // Accepts both "smesh" and "smesh.git".
601 func resolveRepo(name string) (string, string) {
602 // try exact match first
603 rp := path.Join(repoRoot, name)
604 if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
605 return name, rp
606 }
607 // try with .git suffix
608 gitName := name + ".git"
609 rp = path.Join(repoRoot, gitName)
610 if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
611 return gitName, rp
612 }
613 return "", ""
614 }
615
616 func repoDesc(repoDir string) string {
617 data, err := os.ReadFile(path.Join(repoDir, "description"))
618 if err != nil {
619 return ""
620 }
621 s := strings.TrimSpace(string(data))
622 if strings.HasPrefix(s, "Unnamed repository") {
623 return ""
624 }
625 return s
626 }
627
628 // ── ref resolution ──────────────────────────────────────────────
629
630 // defaultRef finds the actual default branch for a bare repo.
631 // HEAD may point to a ref that doesn't exist (e.g. refs/heads/master
632 // when the only branch is main).
633 func defaultRef(repoDir string) string {
634 // read HEAD to get the symbolic ref
635 data, err := os.ReadFile(path.Join(repoDir, "HEAD"))
636 if err != nil {
637 return "HEAD"
638 }
639 s := strings.TrimSpace(string(data))
640 if strings.HasPrefix(s, "ref: ") {
641 ref := s[5:]
642 // check if this ref exists as a loose ref file
643 if _, err := os.Stat(path.Join(repoDir, ref)); err == nil {
644 return ref
645 }
646 // check packed-refs
647 packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
648 if err == nil && strings.Contains(string(packed), ref) {
649 return ref
650 }
651 }
652
653 // HEAD ref is broken — find first available branch
654 refsDir := path.Join(repoDir, "refs", "heads")
655 entries, err := os.ReadDir(refsDir)
656 if err == nil {
657 for _, e := range entries {
658 if !e.IsDir() {
659 return "refs/heads/" + e.Name()
660 }
661 }
662 }
663
664 // try packed-refs as last resort
665 packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
666 if err == nil {
667 for _, line := range strings.Split(string(packed), "\n") {
668 line = strings.TrimSpace(line)
669 if line == "" || line[0] == '#' || line[0] == '^' {
670 continue
671 }
672 fields := strings.Fields(line)
673 if len(fields) >= 2 && strings.HasPrefix(fields[1], "refs/heads/") {
674 return fields[1]
675 }
676 }
677 }
678
679 return "HEAD"
680 }
681
682 // ── tree parsing ────────────────────────────────────────────────
683
684 type entry struct {
685 name string
686 typ string // "tree" or "blob"
687 }
688
689 func lsTree(repoDir, ref, treePath string) ([]entry, error) {
690 args := []string{"ls-tree", ref}
691 if treePath != "" {
692 args = append(args, treePath+"/")
693 }
694 out, err := gitCmd(repoDir, args...)
695 if err != nil {
696 return nil, err
697 }
698
699 prefix := ""
700 if treePath != "" {
701 prefix = treePath + "/"
702 }
703
704 var entries []entry
705 for _, line := range strings.Split(string(out), "\n") {
706 line = strings.TrimSpace(line)
707 if line == "" {
708 continue
709 }
710 // format: <mode> <type> <hash>\t<name>
711 tab := strings.IndexByte(line, '\t')
712 if tab < 0 {
713 continue
714 }
715 name := line[tab+1:]
716 fields := strings.Fields(line[:tab])
717 if len(fields) < 2 {
718 continue
719 }
720 // strip directory prefix
721 if prefix != "" && strings.HasPrefix(name, prefix) {
722 name = name[len(prefix):]
723 }
724 entries = append(entries, entry{name: name, typ: fields[1]})
725 }
726 return entries, nil
727 }
728
729 func mimeType(name string) string {
730 ext := name
731 if i := strings.LastIndexByte(name, '.'); i >= 0 {
732 ext = name[i:]
733 }
734 switch strings.ToLower(ext) {
735 case ".html", ".htm":
736 return "text/html; charset=utf-8"
737 case ".css":
738 return "text/css; charset=utf-8"
739 case ".js":
740 return "text/javascript; charset=utf-8"
741 case ".json":
742 return "application/json"
743 case ".png":
744 return "image/png"
745 case ".jpg", ".jpeg":
746 return "image/jpeg"
747 case ".gif":
748 return "image/gif"
749 case ".svg":
750 return "image/svg+xml"
751 case ".ico":
752 return "image/x-icon"
753 case ".woff2":
754 return "font/woff2"
755 case ".wasm":
756 return "application/wasm"
757 case ".pdf":
758 return "application/pdf"
759 case ".xml":
760 return "application/xml"
761 case ".txt", ".md", ".go", ".rs", ".py", ".sh", ".yml", ".yaml", ".toml",
762 ".c", ".h", ".cpp", ".java", ".rb", ".pl", ".lua", ".sql", ".diff",
763 ".patch", ".conf", ".cfg", ".ini", ".log", ".csv", ".tsx", ".ts",
764 ".jsx", ".vue", ".svelte", ".mx":
765 return "text/plain; charset=utf-8"
766 }
767 return "application/octet-stream"
768 }
769
770 func isGitProto(sub string) bool {
771 return sub == "info" || sub == "git-upload-pack" || sub == "git-receive-pack" ||
772 sub == "HEAD" || sub == "objects"
773 }
774
775 // ── handlers ────────────────────────────────────────────────────
776
777 func handler(w http.ResponseWriter, r *http.Request) {
778 p := strings.Trim(r.URL.Path, "/")
779
780 if p == "" {
781 serveIndex(w)
782 return
783 }
784
785 parts := strings.SplitN(p, "/", 3)
786 urlName := parts[0]
787
788 // git smart HTTP protocol
789 if len(parts) > 1 && isGitProto(parts[1]) {
790 serveGit(w, r)
791 return
792 }
793
794 repo, rp := resolveRepo(urlName)
795
796 if repo == "" {
797 http.NotFound(w, r)
798 return
799 }
800
801 // go-get meta tag support
802 if r.URL.Query().Get("go-get") == "1" {
803 serveGoGet(w, urlName, repo)
804 return
805 }
806
807 // use the clean URL name for links
808 linkName := cleanName(repo)
809
810 if len(parts) == 1 {
811 serveRepo(w, linkName, repo, rp)
812 return
813 }
814
815 sub := ""
816 if len(parts) > 2 {
817 sub = parts[2]
818 }
819
820 switch parts[1] {
821 case "tree":
822 serveTree(w, linkName, repo, rp, sub)
823 case "blob":
824 serveBlob(w, linkName, repo, rp, sub)
825 case "raw":
826 serveRaw(w, linkName, rp, sub)
827 default:
828 http.NotFound(w, r)
829 }
830 }
831
832 func serveGoGet(w http.ResponseWriter, urlName, repo string) {
833 mod := cleanName(urlName)
834 w.Header().Set("Content-Type", "text/html")
835 fmt.Fprintf(w, `<html><head><meta name="go-import" content="%s/%s git https://%s/%s"></head><body>go get</body></html>`,
836 esc(host), esc(mod), esc(host), esc(repo))
837 }
838
839 func serveIndex(w http.ResponseWriter) {
840 repos := findRepos()
841 var b strings.Builder
842 b.WriteString(`<h1>` + esc(host) + `</h1><ul class="ls">`)
843 for _, r := range repos {
844 name := cleanName(r)
845 desc := repoDesc(path.Join(repoRoot, r))
846 b.WriteString(`<li><a href="/` + esc(name) + `">` + esc(name) + `</a>`)
847 if desc != "" {
848 b.WriteString(`<span class="desc">` + esc(desc) + `</span>`)
849 }
850 b.WriteString(`</li>`)
851 }
852 b.WriteString(`</ul>`)
853 w.Header().Set("Content-Type", "text/html; charset=utf-8")
854 w.Write(page("repos", b.String()))
855 }
856
857 func serveRepo(w http.ResponseWriter, linkName, repo, repoDir string) {
858 ref := defaultRef(repoDir)
859 var b strings.Builder
860
861 b.WriteString(`<nav><a href="/">repos</a></nav>`)
862 b.WriteString(`<h1>` + esc(linkName) + `</h1>`)
863
864 // description
865 if desc := repoDesc(repoDir); desc != "" {
866 b.WriteString(`<p class="info">` + esc(desc) + `</p>`)
867 }
868
869 // clone urls
870 b.WriteString(`<p class="info">git clone https://` + esc(host) + `/` + esc(repo) + `</p>`)
871 b.WriteString(`<p class="info">git clone ssh://git@` + esc(host) + `:2222/~/` + esc(repo) + `</p>`)
872
873 // readme
874 for _, readme := range []string{"README.md", "README", "README.txt", "readme.md"} {
875 data, err := gitCmd(repoDir, "show", ref+":"+readme)
876 if err == nil && len(data) > 0 {
877 b.WriteString(`<div class="readme-box">`)
878 b.WriteString(`<input type="checkbox" id="readme-toggle">`)
879 b.WriteString(`<div class="readme-body">`)
880 if strings.HasSuffix(readme, ".md") {
881 b.WriteString(`<div class="md">` + renderMD(string(data), linkName) + `</div>`)
882 } else {
883 b.WriteString(`<h2>` + esc(readme) + `</h2>`)
884 b.WriteString(`<pre>` + esc(string(data)) + `</pre>`)
885 }
886 b.WriteString(`</div>`)
887 b.WriteString(`<label for="readme-toggle"><span class="show">show more</span><span class="hide">show less</span></label>`)
888 b.WriteString(`</div>`)
889 break
890 }
891 }
892
893 // file listing
894 entries, err := lsTree(repoDir, ref, "")
895 if err != nil {
896 b.WriteString(`<p class="info">empty repository — push to get started</p>`)
897 } else if len(entries) > 0 {
898 b.WriteString(`<h2>files</h2>`)
899 writeEntriesList(&b, linkName, entries, "")
900 }
901
902 w.Header().Set("Content-Type", "text/html; charset=utf-8")
903 w.Write(page(linkName, b.String()))
904 }
905
906 func writeEntries(b *strings.Builder, linkName, repoDir, ref, treePath string) {
907 entries, err := lsTree(repoDir, ref, treePath)
908 if err != nil {
909 b.WriteString(`<p class="info">` + esc(err.Error()) + `</p>`)
910 return
911 }
912 writeEntriesList(b, linkName, entries, treePath)
913 }
914
915 func writeEntriesList(b *strings.Builder, linkName string, entries []entry, treePath string) {
916 b.WriteString(`<ul class="ls">`)
917 for _, e := range entries {
918 if e.typ != "tree" {
919 continue
920 }
921 fp := e.name
922 if treePath != "" {
923 fp = treePath + "/" + e.name
924 }
925 b.WriteString(`<li class="d"><a href="/` + esc(linkName) + `/tree/` + esc(fp) + `">` + esc(e.name) + `/</a></li>`)
926 }
927 for _, e := range entries {
928 if e.typ == "tree" {
929 continue
930 }
931 fp := e.name
932 if treePath != "" {
933 fp = treePath + "/" + e.name
934 }
935 b.WriteString(`<li class="f"><a href="/` + esc(linkName) + `/blob/` + esc(fp) + `">` + esc(e.name) + `</a></li>`)
936 }
937 b.WriteString(`</ul>`)
938 }
939
940 func breadcrumb(linkName, treePath string) string {
941 var b strings.Builder
942 b.WriteString(`<nav><a href="/">repos</a> / <a href="/` + esc(linkName) + `">` + esc(linkName) + `</a>`)
943 if treePath != "" {
944 parts := strings.Split(treePath, "/")
945 for i, p := range parts {
946 prefix := strings.Join(parts[:i+1], "/")
947 if i < len(parts)-1 {
948 b.WriteString(` / <a href="/` + esc(linkName) + `/tree/` + esc(prefix) + `">` + esc(p) + `</a>`)
949 } else {
950 b.WriteString(` / ` + esc(p))
951 }
952 }
953 }
954 b.WriteString(`</nav>`)
955 return b.String()
956 }
957
958 func serveTree(w http.ResponseWriter, linkName, repo, repoDir, treePath string) {
959 ref := defaultRef(repoDir)
960 var b strings.Builder
961 b.WriteString(breadcrumb(linkName, treePath))
962 b.WriteString(`<h1>` + esc(treePath) + `</h1>`)
963 writeEntries(&b, linkName, repoDir, ref, treePath)
964
965 w.Header().Set("Content-Type", "text/html; charset=utf-8")
966 w.Write(page(linkName+" / "+treePath, b.String()))
967 }
968
969 func serveBlob(w http.ResponseWriter, linkName, repo, repoDir, blobPath string) {
970 ref := defaultRef(repoDir)
971 data, err := gitCmd(repoDir, "show", ref+":"+blobPath)
972 if err != nil {
973 http.NotFound(w, nil)
974 return
975 }
976
977 var b strings.Builder
978 b.WriteString(breadcrumb(linkName, blobPath))
979
980 fname := blobPath
981 if idx := strings.LastIndexByte(blobPath, '/'); idx >= 0 {
982 fname = blobPath[idx+1:]
983 }
984 b.WriteString(`<h1>` + esc(fname) + ` <a href="/` + esc(linkName) + `/raw/` + esc(blobPath) + `" style="font-size:12px;font-weight:normal">raw</a></h1>`)
985
986 // line numbers
987 lines := strings.Split(string(data), "\n")
988 b.WriteString(`<pre>`)
989 for i, line := range lines {
990 ln := fmt.Sprintf("%4d ", i+1)
991 b.WriteString(`<span style="color:#444">` + ln + `</span>` + esc(line) + "\n")
992 }
993 b.WriteString(`</pre>`)
994
995 w.Header().Set("Content-Type", "text/html; charset=utf-8")
996 w.Write(page(linkName+" / "+blobPath, b.String()))
997 }
998
999 func serveRaw(w http.ResponseWriter, linkName, repoDir, filePath string) {
1000 if filePath == "" {
1001 http.NotFound(w, nil)
1002 return
1003 }
1004 ref := defaultRef(repoDir)
1005 data, err := gitCmd(repoDir, "show", ref+":"+filePath)
1006 if err != nil {
1007 http.NotFound(w, nil)
1008 return
1009 }
1010 w.Header().Set("Content-Type", mimeType(filePath))
1011 w.Header().Set("Cache-Control", "max-age=300")
1012 w.Write(data)
1013 }
1014
1015 func serveGit(w http.ResponseWriter, r *http.Request) {
1016 env := []string{
1017 "PATH=/usr/bin:/bin",
1018 "GIT_PROJECT_ROOT=" + repoRoot,
1019 "GIT_HTTP_EXPORT_ALL=1",
1020 "REQUEST_METHOD=" + r.Method,
1021 "QUERY_STRING=" + r.URL.RawQuery,
1022 "PATH_INFO=" + r.URL.Path,
1023 "SERVER_PROTOCOL=HTTP/1.1",
1024 }
1025 if ct := r.Header.Get("Content-Type"); ct != "" {
1026 env = append(env, "CONTENT_TYPE="+ct)
1027 }
1028 if cl := r.Header.Get("Content-Length"); cl != "" {
1029 env = append(env, "CONTENT_LENGTH="+cl)
1030 }
1031 if proto := r.Header.Get("Git-Protocol"); proto != "" {
1032 env = append(env, "GIT_PROTOCOL="+proto)
1033 }
1034 ra := r.RemoteAddr
1035 if i := strings.LastIndexByte(ra, ':'); i >= 0 {
1036 ra = ra[:i]
1037 }
1038 env = append(env, "REMOTE_ADDR="+ra)
1039
1040 var stdin []byte
1041 if r.Method == "POST" && r.Body != nil {
1042 var body bytes.Buffer
1043 tmp := make([]byte, 8192)
1044 for {
1045 n, err := r.Body.Read(tmp)
1046 if n > 0 {
1047 body.Write(tmp[:n])
1048 }
1049 if err != nil {
1050 break
1051 }
1052 }
1053 if body.Len() > 0 {
1054 stdin = body.Bytes()
1055 }
1056 }
1057
1058 out, err := runIO(env, stdin, gitBackend)
1059 if err != nil && len(out) == 0 {
1060 http.Error(w, "git backend error", 500)
1061 return
1062 }
1063
1064 // parse CGI response: headers \n\n body
1065 sep := bytes.Index(out, []byte("\r\n\r\n"))
1066 skip := 4
1067 if sep < 0 {
1068 sep = bytes.Index(out, []byte("\n\n"))
1069 skip = 2
1070 }
1071 if sep < 0 {
1072 http.Error(w, "bad cgi response", 500)
1073 return
1074 }
1075
1076 code := 200
1077 for _, line := range strings.Split(string(out[:sep]), "\n") {
1078 line = strings.TrimRight(line, "\r")
1079 idx := strings.IndexByte(line, ':')
1080 if idx < 0 {
1081 continue
1082 }
1083 key := strings.TrimSpace(line[:idx])
1084 val := strings.TrimSpace(line[idx+1:])
1085 if strings.EqualFold(key, "Status") {
1086 if len(val) >= 3 {
1087 code = 0
1088 for j := 0; j < 3; j++ {
1089 if val[j] >= '0' && val[j] <= '9' {
1090 code = code*10 + int(val[j]-'0')
1091 }
1092 }
1093 }
1094 } else {
1095 w.Header().Set(key, val)
1096 }
1097 }
1098 w.WriteHeader(code)
1099 w.Write(out[sep+skip:])
1100 }
1101
1102 // ── main ────────────────────────────────────────────────────────
1103
1104 func main() {
1105 for i := 1; i < len(os.Args); i++ {
1106 switch os.Args[i] {
1107 case "-repos":
1108 i++
1109 if i < len(os.Args) {
1110 repoRoot = os.Args[i]
1111 }
1112 case "-listen":
1113 i++
1114 if i < len(os.Args) {
1115 addr = os.Args[i]
1116 }
1117 case "-git":
1118 i++
1119 if i < len(os.Args) {
1120 gitBin = os.Args[i]
1121 }
1122 case "-host":
1123 i++
1124 if i < len(os.Args) {
1125 host = os.Args[i]
1126 }
1127 case "-git-backend":
1128 i++
1129 if i < len(os.Args) {
1130 gitBackend = os.Args[i]
1131 }
1132 }
1133 }
1134
1135 // auto-detect git-http-backend path
1136 if _, err := os.Stat(gitBackend); err != nil {
1137 for _, p := range []string{"/usr/lib/git-core/git-http-backend", "/usr/libexec/git-core/git-http-backend"} {
1138 if _, err := os.Stat(p); err == nil {
1139 gitBackend = p
1140 break
1141 }
1142 }
1143 }
1144
1145 fmt.Printf("gitweb %s repos=%s\n", addr, repoRoot)
1146 http.HandleFunc("/", handler)
1147 if err := http.ListenAndServe(addr, nil); err != nil {
1148 fmt.Fprintf(os.Stderr, "%v\n", err)
1149 os.Exit(1)
1150 }
1151 }
1152