main.mx raw
1 package main
2
3 import (
4 "bytes"
5 "fmt"
6 "net"
7 "net/http"
8 "os"
9 "path"
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.smesh.lol"
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 := []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 := []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 := []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 := []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 := []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 = bytes.ReplaceAll(s, "&", "&")
189 s = bytes.ReplaceAll(s, "<", "<")
190 s = bytes.ReplaceAll(s, ">", ">")
191 s = bytes.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 bytes.Buffer
200 lines := bytes.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 — count opening backticks for GFM nesting
227 if bytes.HasPrefix(line, "```") {
228 flush()
229 lastWasHeading = false
230 fenceLen := 0
231 for fenceLen < len(line) && line[fenceLen] == '`' {
232 fenceLen++
233 }
234 lang := bytes.TrimSpace(line[fenceLen:])
235 b.WriteString(`<pre><code>`)
236 if lang != "" {
237 _ = lang // no syntax highlighting, just consume
238 }
239 i++
240 for i < n {
241 cl := bytes.TrimSpace(lines[i])
242 // closing fence: >= same backtick count, nothing else
243 ticks := 0
244 for ticks < len(cl) && cl[ticks] == '`' {
245 ticks++
246 }
247 if ticks >= fenceLen && allChar(cl, '`') {
248 break
249 }
250 b.WriteString(esc(lines[i]))
251 b.WriteByte('\n')
252 i++
253 }
254 b.WriteString(`</code></pre>`)
255 i++ // skip closing ```
256 continue
257 }
258
259 // blank line — close open blocks
260 if bytes.TrimSpace(line) == "" {
261 flush()
262 i++
263 continue
264 }
265
266 // heading
267 if line[0] == '#' {
268 flush()
269 lvl := 0
270 for lvl < len(line) && line[lvl] == '#' {
271 lvl++
272 }
273 if lvl <= 6 && lvl < len(line) && line[lvl] == ' ' {
274 text := bytes.TrimSpace(line[lvl:])
275 text = bytes.TrimRight(text, " #")
276 b.WriteString(fmt.Sprintf("<h%d>%s</h%d>\n", lvl, mdInline(text, linkName), lvl))
277 lastWasHeading = true
278 i++
279 continue
280 }
281 }
282
283 // horizontal rule — skip if immediately after a heading (already has border-bottom)
284 trimmed := bytes.TrimSpace(line)
285 if len(trimmed) >= 3 && (allChar(trimmed, '-') || allChar(trimmed, '*') || allChar(trimmed, '_')) {
286 flush()
287 if !lastWasHeading {
288 b.WriteString("<hr>\n")
289 }
290 lastWasHeading = false
291 i++
292 continue
293 }
294
295 // table (line contains unescaped | and next line is separator)
296 if hasUnescapedPipe(line) && i+1 < n && isTableSep(lines[i+1]) {
297 flush()
298 b.WriteString("<table>\n<thead><tr>")
299 for _, cell := range splitTableRow(line) {
300 b.WriteString("<th>" + mdInline(bytes.TrimSpace(cell), linkName) + "</th>")
301 }
302 b.WriteString("</tr></thead>\n<tbody>\n")
303 i += 2 // skip header + separator
304 for i < n && hasUnescapedPipe(lines[i]) && bytes.TrimSpace(lines[i]) != "" {
305 b.WriteString("<tr>")
306 for _, cell := range splitTableRow(lines[i]) {
307 b.WriteString("<td>" + mdInline(bytes.TrimSpace(cell), linkName) + "</td>")
308 }
309 b.WriteString("</tr>\n")
310 i++
311 }
312 b.WriteString("</tbody></table>\n")
313 continue
314 }
315
316 // blockquote
317 if bytes.HasPrefix(line, "> ") || line == ">" {
318 if !inState("</blockquote>\n") {
319 flush()
320 b.WriteString("<blockquote>")
321 closeTags = append(closeTags, "</blockquote>\n")
322 }
323 text := ""
324 if len(line) > 2 {
325 text = line[2:]
326 }
327 b.WriteString(mdInline(text, linkName) + "\n")
328 i++
329 continue
330 }
331
332 // unordered list
333 if (bytes.HasPrefix(line, "- ") || bytes.HasPrefix(line, "* ") || bytes.HasPrefix(line, "+ ")) && len(line) > 2 {
334 if !inState("</ul>\n") {
335 flush()
336 b.WriteString("<ul>\n")
337 closeTags = append(closeTags, "</ul>\n")
338 }
339 b.WriteString("<li>" + mdInline(line[2:], linkName) + "</li>\n")
340 i++
341 continue
342 }
343
344 // ordered list
345 if isOL(line) {
346 if !inState("</ol>\n") {
347 flush()
348 b.WriteString("<ol>\n")
349 closeTags = append(closeTags, "</ol>\n")
350 }
351 dot := bytes.IndexByte(line, '.')
352 b.WriteString("<li>" + mdInline(bytes.TrimSpace(line[dot+1:]), linkName) + "</li>\n")
353 i++
354 continue
355 }
356
357 // paragraph
358 if !inState("</p>\n") {
359 flush()
360 b.WriteString("<p>")
361 closeTags = append(closeTags, "</p>\n")
362 } else {
363 b.WriteByte('\n')
364 }
365 b.WriteString(mdInline(line, linkName))
366 i++
367 }
368
369 flush()
370 return b.String()
371 }
372
373 // mdInline processes inline markdown: code, bold, italic, links, images.
374 func mdInline(s, linkName string) string {
375 s = esc(s) // HTML-escape first; markdown punctuation (*,[,],`,!) is not affected
376 var b bytes.Buffer
377 i := 0
378 for i < len(s) {
379 // backtick code span
380 if s[i] == '`' {
381 end := bytes.IndexByte(s[i+1:], '`')
382 if end >= 0 {
383 b.WriteString("<code>" + s[i+1:i+1+end] + "</code>")
384 i += end + 2
385 continue
386 }
387 }
388
389 // linked image [](link-url) — render as text link to link-url
390 if s[i] == '[' && i+1 < len(s) && s[i+1] == '!' && i+2 < len(s) && s[i+2] == '[' {
391 if innerText, _, innerAdv := parseLink(s[i+2:]); innerAdv > 0 {
392 pos := i + 2 + innerAdv
393 if pos+1 < len(s) && s[pos] == ']' && s[pos+1] == '(' {
394 end := bytes.IndexByte(s[pos+2:], ')')
395 if end >= 0 {
396 url := s[pos+2 : pos+2+end]
397 b.WriteString(`<a href="` + url + `">` + innerText + `</a>`)
398 i = pos + 2 + end + 1
399 continue
400 }
401 }
402 }
403 }
404
405 // image  — inline if relative, link if external
406 if s[i] == '!' && i+1 < len(s) && s[i+1] == '[' {
407 if text, url, advance := parseLink(s[i+1:]); advance > 0 {
408 if bytes.HasPrefix(url, "http://") || bytes.HasPrefix(url, "https://") || bytes.HasPrefix(url, "//") {
409 b.WriteString(`<a href="` + url + `">` + text + `</a>`)
410 } else {
411 raw := "/" + linkName + "/raw/" + bytes.TrimPrefix(url, "./")
412 b.WriteString(`<img src="` + raw + `" alt="` + text + `">`)
413 }
414 i += 1 + advance
415 continue
416 }
417 }
418
419 // link [text](url) — rewrite relative paths to gitweb blob URLs
420 if s[i] == '[' {
421 if text, url, advance := parseLink(s[i:]); advance > 0 {
422 href := url
423 if linkName != "" && !bytes.HasPrefix(url, "http://") && !bytes.HasPrefix(url, "https://") && !bytes.HasPrefix(url, "//") && !bytes.HasPrefix(url, "#") && !bytes.HasPrefix(url, "/") {
424 href = "/" + linkName + "/blob/" + bytes.TrimPrefix(url, "./")
425 }
426 b.WriteString(`<a href="` + href + `">` + text + `</a>`)
427 i += advance
428 continue
429 }
430 }
431
432 // bold **text**
433 if i+1 < len(s) && s[i] == '*' && s[i+1] == '*' {
434 end := bytes.Index(s[i+2:], "**")
435 if end >= 0 {
436 b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
437 i += end + 4
438 continue
439 }
440 }
441
442 // bold __text__
443 if i+1 < len(s) && s[i] == '_' && s[i+1] == '_' {
444 end := bytes.Index(s[i+2:], "__")
445 if end >= 0 {
446 b.WriteString("<strong>" + s[i+2:i+2+end] + "</strong>")
447 i += end + 4
448 continue
449 }
450 }
451
452 // italic *text*
453 if s[i] == '*' && (i+1 < len(s) && s[i+1] != '*') {
454 end := bytes.IndexByte(s[i+1:], '*')
455 if end > 0 {
456 b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
457 i += end + 2
458 continue
459 }
460 }
461
462 // italic _text_
463 if s[i] == '_' && (i+1 < len(s) && s[i+1] != '_') {
464 end := bytes.IndexByte(s[i+1:], '_')
465 if end > 0 {
466 b.WriteString("<em>" + s[i+1:i+1+end] + "</em>")
467 i += end + 2
468 continue
469 }
470 }
471
472 // backslash escape — \| \* \_ \` \[ \] \\ render as literal
473 if s[i] == '\\' && i+1 < len(s) {
474 next := s[i+1]
475 if next == '|' || next == '*' || next == '_' || next == '`' || next == '[' || next == ']' || next == '\\' {
476 b.WriteByte(next)
477 i += 2
478 continue
479 }
480 }
481
482 b.WriteByte(s[i])
483 i++
484 }
485 return b.String()
486 }
487
488 // parseLink parses [text](url) starting at s[0]=='['.
489 // Returns text, url, and total bytes consumed. Returns 0 advance on failure.
490 func parseLink(s string) (string, string, int) {
491 if len(s) < 4 || s[0] != '[' {
492 return "", "", 0
493 }
494 closeBracket := bytes.IndexByte(s[1:], ']')
495 if closeBracket < 0 {
496 return "", "", 0
497 }
498 closeBracket++ // adjust for offset
499 if closeBracket+1 >= len(s) || s[closeBracket+1] != '(' {
500 return "", "", 0
501 }
502 closeParen := bytes.IndexByte(s[closeBracket+2:], ')')
503 if closeParen < 0 {
504 return "", "", 0
505 }
506 text := s[1:closeBracket]
507 url := s[closeBracket+2 : closeBracket+2+closeParen]
508 return text, url, closeBracket + 2 + closeParen + 1
509 }
510
511 func allChar(s string, c byte) bool {
512 for i := 0; i < len(s); i++ {
513 if s[i] != c && s[i] != ' ' {
514 return false
515 }
516 }
517 return true
518 }
519
520 func isOL(line string) bool {
521 i := 0
522 for i < len(line) && line[i] >= '0' && line[i] <= '9' {
523 i++
524 }
525 return i > 0 && i < len(line)-1 && line[i] == '.' && line[i+1] == ' '
526 }
527
528 // hasUnescapedPipe returns true if the line contains | that isn't
529 // preceded by \ and isn't inside a backtick code span.
530 func hasUnescapedPipe(line string) bool {
531 inCode := false
532 for i := 0; i < len(line); i++ {
533 if line[i] == '`' {
534 inCode = !inCode
535 } else if line[i] == '\\' && i+1 < len(line) && line[i+1] == '|' {
536 i++ // skip escaped pipe
537 } else if line[i] == '|' && !inCode {
538 return true
539 }
540 }
541 return false
542 }
543
544 func isTableSep(line string) bool {
545 t := bytes.TrimSpace(line)
546 if !bytes.Contains(t, "|") {
547 return false
548 }
549 for _, c := range t {
550 if c != '|' && c != '-' && c != ':' && c != ' ' {
551 return false
552 }
553 }
554 return true
555 }
556
557 func splitTableRow(line string) []string {
558 line = bytes.TrimSpace(line)
559 line = bytes.Trim(line, "|")
560 // Split on | but not \| (escaped pipe) or | inside backtick spans.
561 // Uses index slicing into the original line — avoids bytes.Buffer
562 // aliasing where string=[]byte causes Reset() to overwrite prior cells.
563 var cells []string
564 start := 0
565 inCode := false
566 for i := 0; i < len(line); i++ {
567 if line[i] == '`' {
568 inCode = !inCode
569 } else if line[i] == '\\' && i+1 < len(line) && line[i+1] == '|' {
570 i++ // skip escaped pipe — mdInline handles \| → |
571 } else if line[i] == '|' && !inCode {
572 cells = append(cells, line[start:i])
573 start = i + 1
574 }
575 }
576 cells = append(cells, line[start:])
577 return cells
578 }
579
580 const css = `
581 :root{--bg:#fff;--bg2:#eaeaea;--fg:#111;--accent:#7b3fe4;--muted:#666;--border:#bbb}
582 .dark{--bg:#000;--bg2:#111;--fg:#e0e0e0;--accent:#f59e0b;--muted:#666;--border:#333}
583 *{box-sizing:border-box}
584 body{background:var(--bg);color:var(--fg);font:14px/1.6 'Fira Code',monospace;margin:0;padding:20px 40px;max-width:960px}
585 a{color:var(--accent);text-decoration:none}
586 a:hover{text-decoration:underline}
587 h1{color:var(--fg);font-size:20px;border-bottom:1px solid var(--border);padding-bottom:6px}
588 h2{color:var(--fg);font-size:16px;margin-top:24px}
589 pre{background:var(--bg2);color:var(--fg);padding:14px;border-radius:3px;overflow-x:auto;border:1px solid var(--border)}
590 .ls{list-style:none;padding:0;margin:0}
591 .ls li{padding:3px 0;border-bottom:1px solid var(--border)}
592 .ls .d a{color:var(--accent)}
593 .ls .d a::before{content:"d ";color:var(--muted)}
594 .ls .f a::before{content:" ";color:var(--muted)}
595 nav{margin-bottom:16px;color:var(--muted)}
596 nav a{margin-right:4px}
597 .desc{color:var(--muted);margin-left:12px;font-size:12px}
598 .info{color:var(--muted);font-size:12px;margin-top:4px}
599 .md{line-height:1.7}
600 .md h1{font-size:22px;margin-top:28px}
601 .md h2{font-size:18px;margin-top:24px}
602 .md h3{font-size:15px;margin-top:20px}
603 .md h4,.md h5,.md h6{font-size:14px;color:var(--muted);margin-top:16px}
604 .md p{margin:10px 0}
605 .md code{background:var(--bg2);padding:2px 5px;border-radius:3px;font-size:13px}
606 .md pre{margin:12px 0}
607 .md pre code{background:none;padding:0}
608 .md blockquote{border-left:3px solid var(--border);margin:10px 0;padding:4px 16px;color:var(--muted)}
609 .md ul,.md ol{padding-left:24px;margin:8px 0}
610 .md li{margin:3px 0}
611 .md hr{border:none;border-top:1px solid var(--border);margin:20px 0}
612 .md img{max-width:100%}
613 .md table{border-collapse:collapse;margin:12px 0}
614 .md th,.md td{border:1px solid var(--border);padding:6px 12px}
615 .md th{background:var(--bg2)}
616 .readme-box{position:relative}
617 .readme-box input{display:none}
618 .readme-box .readme-body{max-height:50vh;overflow:hidden}
619 .readme-box input:checked+.readme-body{max-height:none}
620 .readme-box .readme-body::after{content:"";position:absolute;bottom:28px;left:0;right:0;height:80px;background:linear-gradient(transparent,var(--bg));pointer-events:none}
621 .readme-box input:checked+.readme-body::after{display:none}
622 .readme-box label{display:block;text-align:center;padding:6px;color:var(--accent);cursor:pointer;border-top:1px solid var(--border);margin-top:4px;font-size:13px}
623 .readme-box label:hover{opacity:0.8}
624 .readme-box input:checked~label .show{display:none}
625 .readme-box input:not(:checked)~label .hide{display:none}
626 .hdr{display:flex;align-items:center;gap:12px;margin-bottom:16px}
627 .hdr svg{width:56px;height:56px;flex-shrink:0}
628 .hdr h1{border:none;padding:0;margin:0}
629 .theme-btn{position:fixed;top:12px;right:12px;background:none;border:none;color:var(--fg);cursor:pointer;padding:8px;opacity:0.6}
630 .theme-btn:hover{opacity:1}
631 .theme-btn svg{width:22px;height:22px}
632 .when-dark{display:none}
633 .dark .when-light{display:none}
634 .dark .when-dark{display:inline}
635 .ln{color:var(--muted)}
636 ::-webkit-scrollbar{width:14px}
637 ::-webkit-scrollbar-track{background:var(--bg)}
638 ::-webkit-scrollbar-thumb{background:var(--border);border-radius:7px;border:3px solid var(--bg)}
639 ::-webkit-scrollbar-thumb:hover{background:var(--muted)}
640 *{scrollbar-width:auto;scrollbar-color:var(--border) var(--bg)}
641 `
642
643 const logo = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 680"><defs><clipPath id="clip"><circle cx="340" cy="340" r="290"/></clipPath></defs><g clip-path="url(#clip)"><line x1="340" y1="340" x2="340" y2="282" stroke="currentColor" stroke-width="64" stroke-linecap="round"/><line x1="340" y1="282" x2="282" y2="239.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="282" y1="239.5" x2="217" y2="217" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="217" y1="217" x2="155.9" y2="198.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="155.9" y1="198.8" x2="98.9" y2="178.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="155.9" y1="198.8" x2="122" y2="148.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="217" y1="217" x2="198.8" y2="155.9" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="198.8" y1="155.9" x2="148.8" y2="122" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="198.8" y1="155.9" x2="178.9" y2="98.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="282" y1="239.5" x2="295" y2="171.9" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="295" y1="171.9" x2="251.2" y2="125.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="251.2" y1="125.7" x2="211.7" y2="79.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="251.2" y1="125.7" x2="246.8" y2="65.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="295" y1="171.9" x2="309.7" y2="110" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="309.7" y1="110" x2="283.4" y2="55.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="309.7" y1="110" x2="321" y2="50.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="340" y1="282" x2="398" y2="239.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="398" y1="239.5" x2="385" y2="171.9" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="385" y1="171.9" x2="370.3" y2="110" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="370.3" y1="110" x2="359" y2="50.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="370.3" y1="110" x2="396.6" y2="55.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="385" y1="171.9" x2="428.8" y2="125.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="428.8" y1="125.7" x2="433.2" y2="65.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="428.8" y1="125.7" x2="468.3" y2="79.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="398" y1="239.5" x2="463" y2="217" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="463" y1="217" x2="481.2" y2="155.9" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="481.2" y1="155.9" x2="501.1" y2="98.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="481.2" y1="155.9" x2="531.2" y2="122" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="463" y1="217" x2="524.1" y2="198.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="524.1" y1="198.8" x2="558" y2="148.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="524.1" y1="198.8" x2="581.1" y2="178.9" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="340" y1="340" x2="390.2" y2="369" stroke="currentColor" stroke-width="64" stroke-linecap="round"/><line x1="390.2" y1="369" x2="456" y2="340" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="456" y1="340" x2="508.1" y2="295" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="508.1" y1="295" x2="554.3" y2="251.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="554.3" y1="251.2" x2="600.1" y2="211.7" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="554.3" y1="251.2" x2="614.6" y2="246.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="508.1" y1="295" x2="570" y2="309.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="570" y1="309.7" x2="624.4" y2="283.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="570" y1="309.7" x2="629.4" y2="321" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="456" y1="340" x2="508.1" y2="385" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="508.1" y1="385" x2="570" y2="370.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="570" y1="370.3" x2="629.4" y2="359" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="570" y1="370.3" x2="624.4" y2="396.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="508.1" y1="385" x2="554.3" y2="428.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="554.3" y1="428.8" x2="614.6" y2="433.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="554.3" y1="428.8" x2="600.1" y2="468.3" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="390.2" y1="369" x2="398" y2="440.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="398" y1="440.5" x2="463" y2="463" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="463" y1="463" x2="524.1" y2="481.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="524.1" y1="481.2" x2="581.1" y2="501.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="524.1" y1="481.2" x2="558" y2="531.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="463" y1="463" x2="481.2" y2="524.1" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="481.2" y1="524.1" x2="531.2" y2="558" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="481.2" y1="524.1" x2="501.1" y2="581.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="398" y1="440.5" x2="385" y2="508.1" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="385" y1="508.1" x2="428.8" y2="554.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="428.8" y1="554.3" x2="468.3" y2="600.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="428.8" y1="554.3" x2="433.2" y2="614.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="385" y1="508.1" x2="370.3" y2="570" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="370.3" y1="570" x2="396.6" y2="624.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="370.3" y1="570" x2="359" y2="629.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="340" y1="340" x2="289.8" y2="369" stroke="currentColor" stroke-width="64" stroke-linecap="round"/><line x1="289.8" y1="369" x2="282" y2="440.5" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="282" y1="440.5" x2="295" y2="508.1" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="295" y1="508.1" x2="309.7" y2="570" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="309.7" y1="570" x2="321" y2="629.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="309.7" y1="570" x2="283.4" y2="624.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="295" y1="508.1" x2="251.2" y2="554.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="251.2" y1="554.3" x2="246.8" y2="614.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="251.2" y1="554.3" x2="211.7" y2="600.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="282" y1="440.5" x2="217" y2="463" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="217" y1="463" x2="198.8" y2="524.1" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="198.8" y1="524.1" x2="178.9" y2="581.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="198.8" y1="524.1" x2="148.8" y2="558" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="217" y1="463" x2="155.9" y2="481.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="155.9" y1="481.2" x2="122" y2="531.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="155.9" y1="481.2" x2="98.9" y2="501.1" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="289.8" y1="369" x2="224" y2="340" stroke="currentColor" stroke-width="48" stroke-linecap="round"/><line x1="224" y1="340" x2="171.9" y2="385" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="171.9" y1="385" x2="125.7" y2="428.8" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="125.7" y1="428.8" x2="79.9" y2="468.3" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="125.7" y1="428.8" x2="65.4" y2="433.2" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="171.9" y1="385" x2="110" y2="370.3" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="110" y1="370.3" x2="55.6" y2="396.6" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="110" y1="370.3" x2="50.6" y2="359" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="224" y1="340" x2="171.9" y2="295" stroke="currentColor" stroke-width="36" stroke-linecap="round"/><line x1="171.9" y1="295" x2="110" y2="309.7" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="110" y1="309.7" x2="50.6" y2="321" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="110" y1="309.7" x2="55.6" y2="283.4" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="171.9" y1="295" x2="125.7" y2="251.2" stroke="currentColor" stroke-width="27" stroke-linecap="round"/><line x1="125.7" y1="251.2" x2="65.4" y2="246.8" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/><line x1="125.7" y1="251.2" x2="79.9" y2="211.7" stroke="currentColor" stroke-width="20.25" stroke-linecap="round"/></g><circle cx="340" cy="340" r="290" fill="none" stroke="currentColor" stroke-width="12"/></svg>`
644
645 const themeJS = `<script>(function(){var t=localStorage.getItem("smesh-theme");if(!t)t=matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";if(t==="dark")document.documentElement.classList.add("dark")})()</script>`
646
647 const sunIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`
648 const moonIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>`
649
650 const toggleJS = `<script>document.getElementById("theme-toggle").onclick=function(){var d=document.documentElement.classList.toggle("dark");localStorage.setItem("smesh-theme",d?"dark":"light")}</script>`
651
652 func page(title, body string) []byte {
653 var b bytes.Buffer
654 b.WriteString(`<!doctype html><html><head><meta charset="utf-8">`)
655 b.WriteString(`<meta name="viewport" content="width=device-width">`)
656 b.WriteString(`<title>` + esc(title) + `</title>`)
657 b.WriteString(`<style>` + css + `</style>`)
658 b.WriteString(themeJS)
659 b.WriteString(`</head><body>`)
660 b.WriteString(`<button class="theme-btn" id="theme-toggle"><span class="when-light">` + moonIcon + `</span><span class="when-dark">` + sunIcon + `</span></button>`)
661 b.WriteString(body)
662 b.WriteString(toggleJS)
663 b.WriteString(`</body></html>`)
664 return b.Bytes()
665 }
666
667 // ── repo discovery ──────────────────────────────────────────────
668
669 func findRepos() []string {
670 entries, err := os.ReadDir(repoRoot)
671 if err != nil {
672 return nil
673 }
674 var repos []string
675 for _, e := range entries {
676 if !e.IsDir() {
677 continue
678 }
679 if _, err := os.Stat(path.Join(repoRoot, e.Name(), "HEAD")); err == nil {
680 repos = append(repos, e.Name())
681 }
682 }
683 return repos
684 }
685
686 func cleanName(s string) string {
687 return bytes.TrimSuffix(s, ".git")
688 }
689
690 // resolveRepo maps a URL name to the actual repo directory name.
691 // Accepts both "smesh" and "smesh.git".
692 func resolveRepo(name string) (string, string) {
693 // try exact match first
694 rp := path.Join(repoRoot, name)
695 if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
696 return name, rp
697 }
698 // try with .git suffix
699 gitName := name + ".git"
700 rp = path.Join(repoRoot, gitName)
701 if _, err := os.Stat(path.Join(rp, "HEAD")); err == nil {
702 return gitName, rp
703 }
704 return "", ""
705 }
706
707 func repoDesc(repoDir string) string {
708 data, err := os.ReadFile(path.Join(repoDir, "description"))
709 if err != nil {
710 return ""
711 }
712 s := bytes.TrimSpace(string(data))
713 if bytes.HasPrefix(s, "Unnamed repository") {
714 return ""
715 }
716 return s
717 }
718
719 // ── ref resolution ──────────────────────────────────────────────
720
721 // defaultRef finds the actual default branch for a bare repo.
722 // HEAD may point to a ref that doesn't exist (e.g. refs/heads/master
723 // when the only branch is main).
724 func defaultRef(repoDir string) string {
725 // read HEAD to get the symbolic ref
726 data, err := os.ReadFile(path.Join(repoDir, "HEAD"))
727 if err != nil {
728 return "HEAD"
729 }
730 s := bytes.TrimSpace(string(data))
731 if bytes.HasPrefix(s, "ref: ") {
732 ref := s[5:]
733 // check if this ref exists as a loose ref file
734 if _, err := os.Stat(path.Join(repoDir, ref)); err == nil {
735 return ref
736 }
737 // check packed-refs
738 packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
739 if err == nil && bytes.Contains(string(packed), ref) {
740 return ref
741 }
742 }
743
744 // HEAD ref is broken — find first available branch
745 refsDir := path.Join(repoDir, "refs", "heads")
746 entries, err := os.ReadDir(refsDir)
747 if err == nil {
748 for _, e := range entries {
749 if !e.IsDir() {
750 return "refs/heads/" + e.Name()
751 }
752 }
753 }
754
755 // try packed-refs as last resort
756 packed, err := os.ReadFile(path.Join(repoDir, "packed-refs"))
757 if err == nil {
758 for _, line := range bytes.Split(string(packed), "\n") {
759 line = bytes.TrimSpace(line)
760 if line == "" || line[0] == '#' || line[0] == '^' {
761 continue
762 }
763 fields := bytes.Fields(line)
764 if len(fields) >= 2 && bytes.HasPrefix(fields[1], "refs/heads/") {
765 return fields[1]
766 }
767 }
768 }
769
770 return "HEAD"
771 }
772
773 // ── tree parsing ────────────────────────────────────────────────
774
775 type entry struct {
776 name string
777 typ string // "tree" or "blob"
778 }
779
780 func lsTree(repoDir, ref, treePath string) ([]entry, error) {
781 args := []string{"ls-tree", ref}
782 if treePath != "" {
783 args = append(args, treePath+"/")
784 }
785 out, err := gitCmd(repoDir, args...)
786 if err != nil {
787 return nil, err
788 }
789
790 prefix := ""
791 if treePath != "" {
792 prefix = treePath + "/"
793 }
794
795 var entries []entry
796 for _, line := range bytes.Split(string(out), "\n") {
797 line = bytes.TrimSpace(line)
798 if line == "" {
799 continue
800 }
801 // format: <mode> <type> <hash>\t<name>
802 tab := bytes.IndexByte(line, '\t')
803 if tab < 0 {
804 continue
805 }
806 name := line[tab+1:]
807 fields := bytes.Fields(line[:tab])
808 if len(fields) < 2 {
809 continue
810 }
811 // strip directory prefix
812 if prefix != "" && bytes.HasPrefix(name, prefix) {
813 name = name[len(prefix):]
814 }
815 entries = append(entries, entry{name: name, typ: fields[1]})
816 }
817 return entries, nil
818 }
819
820 func mimeType(name string) string {
821 ext := name
822 if i := bytes.LastIndexByte(name, '.'); i >= 0 {
823 ext = name[i:]
824 }
825 switch bytes.ToLower(ext) {
826 case ".html", ".htm":
827 return "text/html; charset=utf-8"
828 case ".css":
829 return "text/css; charset=utf-8"
830 case ".js":
831 return "text/javascript; charset=utf-8"
832 case ".json":
833 return "application/json"
834 case ".png":
835 return "image/png"
836 case ".jpg", ".jpeg":
837 return "image/jpeg"
838 case ".gif":
839 return "image/gif"
840 case ".svg":
841 return "image/svg+xml"
842 case ".ico":
843 return "image/x-icon"
844 case ".woff2":
845 return "font/woff2"
846 case ".wasm":
847 return "application/wasm"
848 case ".pdf":
849 return "application/pdf"
850 case ".xml":
851 return "application/xml"
852 case ".txt", ".md", ".go", ".rs", ".py", ".sh", ".yml", ".yaml", ".toml",
853 ".c", ".h", ".cpp", ".java", ".rb", ".pl", ".lua", ".sql", ".diff",
854 ".patch", ".conf", ".cfg", ".ini", ".log", ".csv", ".tsx", ".ts",
855 ".jsx", ".vue", ".svelte", ".mx":
856 return "text/plain; charset=utf-8"
857 }
858 return "application/octet-stream"
859 }
860
861 func isGitProto(sub string) bool {
862 return sub == "info" || sub == "git-upload-pack" || sub == "git-receive-pack" ||
863 sub == "HEAD" || sub == "objects"
864 }
865
866 // ── handlers ────────────────────────────────────────────────────
867
868 func handler(w http.ResponseWriter, r *http.Request) {
869 p := bytes.Trim(r.URL.Path, "/")
870
871 if p == "" {
872 serveIndex(w)
873 return
874 }
875
876 parts := bytes.SplitN(p, "/", 3)
877 urlName := parts[0]
878
879 // git smart HTTP protocol
880 if len(parts) > 1 && isGitProto(parts[1]) {
881 serveGit(w, r)
882 return
883 }
884
885 repo, rp := resolveRepo(urlName)
886
887 if repo == "" {
888 http.NotFound(w, r)
889 return
890 }
891
892 // go-get meta tag support
893 if r.URL.Query().Get("go-get") == "1" {
894 serveGoGet(w, urlName, repo)
895 return
896 }
897
898 // use the clean URL name for links
899 linkName := cleanName(repo)
900
901 if len(parts) == 1 {
902 serveRepo(w, linkName, repo, rp)
903 return
904 }
905
906 sub := ""
907 if len(parts) > 2 {
908 sub = parts[2]
909 }
910
911 switch parts[1] {
912 case "tree":
913 serveTree(w, linkName, repo, rp, sub)
914 case "blob":
915 serveBlob(w, linkName, repo, rp, sub)
916 case "raw":
917 serveRaw(w, linkName, rp, sub)
918 default:
919 http.NotFound(w, r)
920 }
921 }
922
923 func serveGoGet(w http.ResponseWriter, urlName, repo string) {
924 mod := cleanName(urlName)
925 w.Header().Set("Content-Type", "text/html")
926 fmt.Fprintf(w, `<html><head><meta name="go-import" content="%s/%s git https://%s/%s"></head><body>go get</body></html>`,
927 esc(host), esc(mod), esc(host), esc(repo))
928 }
929
930 func serveIndex(w http.ResponseWriter) {
931 repos := findRepos()
932 var b bytes.Buffer
933 b.WriteString(`<div class="hdr"><a href="/" style="color:var(--accent)">` + logo + `</a><h1>` + esc(host) + `</h1></div><ul class="ls">`)
934 for _, r := range repos {
935 name := cleanName(r)
936 desc := repoDesc(path.Join(repoRoot, r))
937 b.WriteString(`<li><a href="/` + esc(name) + `">` + esc(name) + `</a>`)
938 if desc != "" {
939 b.WriteString(`<span class="desc">` + esc(desc) + `</span>`)
940 }
941 b.WriteString(`</li>`)
942 }
943 b.WriteString(`</ul>`)
944 w.Header().Set("Content-Type", "text/html; charset=utf-8")
945 w.Write(page("repos", b.String()))
946 }
947
948 func serveRepo(w http.ResponseWriter, linkName, repo, repoDir string) {
949 ref := defaultRef(repoDir)
950 var b bytes.Buffer
951
952 b.WriteString(`<nav><a href="/">repos</a></nav>`)
953 b.WriteString(`<h1>` + esc(linkName) + `</h1>`)
954
955 // description
956 if desc := repoDesc(repoDir); desc != "" {
957 b.WriteString(`<p class="info">` + esc(desc) + `</p>`)
958 }
959
960 // clone urls
961 b.WriteString(`<p class="info">git clone https://` + esc(host) + `/` + esc(repo) + `</p>`)
962 b.WriteString(`<p class="info">git clone ssh://git@` + esc(host) + `:2222/~/` + esc(repo) + `</p>`)
963
964 // readme
965 for _, readme := range []string{"README.md", "README", "README.txt", "readme.md"} {
966 data, err := gitCmd(repoDir, "show", ref+":"+readme)
967 if err == nil && len(data) > 0 {
968 b.WriteString(`<div class="readme-box">`)
969 b.WriteString(`<input type="checkbox" id="readme-toggle">`)
970 b.WriteString(`<div class="readme-body">`)
971 if bytes.HasSuffix(readme, ".md") {
972 b.WriteString(`<div class="md">` + renderMD(string(data), linkName) + `</div>`)
973 } else {
974 b.WriteString(`<h2>` + esc(readme) + `</h2>`)
975 b.WriteString(`<pre>` + esc(string(data)) + `</pre>`)
976 }
977 b.WriteString(`</div>`)
978 b.WriteString(`<label for="readme-toggle"><span class="show">show more</span><span class="hide">show less</span></label>`)
979 b.WriteString(`</div>`)
980 break
981 }
982 }
983
984 // file listing
985 entries, err := lsTree(repoDir, ref, "")
986 if err != nil {
987 b.WriteString(`<p class="info">empty repository — push to get started</p>`)
988 } else if len(entries) > 0 {
989 b.WriteString(`<h2>files</h2>`)
990 writeEntriesList(&b, linkName, entries, "")
991 }
992
993 w.Header().Set("Content-Type", "text/html; charset=utf-8")
994 w.Write(page(linkName, b.String()))
995 }
996
997 func writeEntries(b *bytes.Buffer, linkName, repoDir, ref, treePath string) {
998 entries, err := lsTree(repoDir, ref, treePath)
999 if err != nil {
1000 b.WriteString(`<p class="info">` + esc(err.Error()) + `</p>`)
1001 return
1002 }
1003 writeEntriesList(b, linkName, entries, treePath)
1004 }
1005
1006 func writeEntriesList(b *bytes.Buffer, linkName string, entries []entry, treePath string) {
1007 b.WriteString(`<ul class="ls">`)
1008 for _, e := range entries {
1009 if e.typ != "tree" {
1010 continue
1011 }
1012 fp := e.name
1013 if treePath != "" {
1014 fp = treePath + "/" + e.name
1015 }
1016 b.WriteString(`<li class="d"><a href="/` + esc(linkName) + `/tree/` + esc(fp) + `">` + esc(e.name) + `/</a></li>`)
1017 }
1018 for _, e := range entries {
1019 if e.typ == "tree" {
1020 continue
1021 }
1022 fp := e.name
1023 if treePath != "" {
1024 fp = treePath + "/" + e.name
1025 }
1026 b.WriteString(`<li class="f"><a href="/` + esc(linkName) + `/blob/` + esc(fp) + `">` + esc(e.name) + `</a></li>`)
1027 }
1028 b.WriteString(`</ul>`)
1029 }
1030
1031 func breadcrumb(linkName, treePath string) string {
1032 var b bytes.Buffer
1033 b.WriteString(`<nav><a href="/">repos</a> / <a href="/` + esc(linkName) + `">` + esc(linkName) + `</a>`)
1034 if treePath != "" {
1035 parts := bytes.Split(treePath, "/")
1036 for i, p := range parts {
1037 prefix := bytes.Join(parts[:i+1], "/")
1038 if i < len(parts)-1 {
1039 b.WriteString(` / <a href="/` + esc(linkName) + `/tree/` + esc(prefix) + `">` + esc(p) + `</a>`)
1040 } else {
1041 b.WriteString(` / ` + esc(p))
1042 }
1043 }
1044 }
1045 b.WriteString(`</nav>`)
1046 return b.String()
1047 }
1048
1049 func serveTree(w http.ResponseWriter, linkName, repo, repoDir, treePath string) {
1050 ref := defaultRef(repoDir)
1051 var b bytes.Buffer
1052 b.WriteString(breadcrumb(linkName, treePath))
1053 b.WriteString(`<h1>` + esc(treePath) + `</h1>`)
1054 writeEntries(&b, linkName, repoDir, ref, treePath)
1055
1056 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1057 w.Write(page(linkName+" / "+treePath, b.String()))
1058 }
1059
1060 func serveBlob(w http.ResponseWriter, linkName, repo, repoDir, blobPath string) {
1061 ref := defaultRef(repoDir)
1062 data, err := gitCmd(repoDir, "show", ref+":"+blobPath)
1063 if err != nil {
1064 http.NotFound(w, nil)
1065 return
1066 }
1067
1068 var b bytes.Buffer
1069 b.WriteString(breadcrumb(linkName, blobPath))
1070
1071 fname := blobPath
1072 if idx := bytes.LastIndexByte(blobPath, '/'); idx >= 0 {
1073 fname = blobPath[idx+1:]
1074 }
1075 b.WriteString(`<h1>` + esc(fname) + ` <a href="/` + esc(linkName) + `/raw/` + esc(blobPath) + `" style="font-size:12px;font-weight:normal">raw</a></h1>`)
1076
1077 if bytes.HasSuffix(blobPath, ".md") {
1078 b.WriteString(`<div class="md">` + renderMD(string(data), linkName) + `</div>`)
1079 } else {
1080 lines := bytes.Split(string(data), "\n")
1081 b.WriteString(`<pre>`)
1082 for i, line := range lines {
1083 ln := fmt.Sprintf("%4d ", i+1)
1084 b.WriteString(`<span class="ln">` + ln + `</span>` + esc(line) + "\n")
1085 }
1086 b.WriteString(`</pre>`)
1087 }
1088
1089 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1090 w.Write(page(linkName+" / "+blobPath, b.String()))
1091 }
1092
1093 func serveRaw(w http.ResponseWriter, linkName, repoDir, filePath string) {
1094 if filePath == "" {
1095 http.NotFound(w, nil)
1096 return
1097 }
1098 ref := defaultRef(repoDir)
1099 data, err := gitCmd(repoDir, "show", ref+":"+filePath)
1100 if err != nil {
1101 http.NotFound(w, nil)
1102 return
1103 }
1104 w.Header().Set("Content-Type", mimeType(filePath))
1105 w.Header().Set("Cache-Control", "max-age=300")
1106 w.Write(data)
1107 }
1108
1109 func serveGit(w http.ResponseWriter, r *http.Request) {
1110 env := []string{
1111 "PATH=/usr/bin:/bin",
1112 "GIT_PROJECT_ROOT=" + repoRoot,
1113 "GIT_HTTP_EXPORT_ALL=1",
1114 "REQUEST_METHOD=" + r.Method,
1115 "QUERY_STRING=" + r.URL.RawQuery,
1116 "PATH_INFO=" + r.URL.Path,
1117 "SERVER_PROTOCOL=HTTP/1.1",
1118 }
1119 if ct := r.Header.Get("Content-Type"); ct != "" {
1120 env = append(env, "CONTENT_TYPE="+ct)
1121 }
1122 if cl := r.Header.Get("Content-Length"); cl != "" {
1123 env = append(env, "CONTENT_LENGTH="+cl)
1124 }
1125 if proto := r.Header.Get("Git-Protocol"); proto != "" {
1126 env = append(env, "GIT_PROTOCOL="+proto)
1127 }
1128 ra := r.RemoteAddr
1129 if i := bytes.LastIndexByte(ra, ':'); i >= 0 {
1130 ra = ra[:i]
1131 }
1132 env = append(env, "REMOTE_ADDR="+ra)
1133
1134 var stdin []byte
1135 if r.Method == "POST" && r.Body != nil {
1136 var body bytes.Buffer
1137 tmp := []byte{:8192}
1138 for {
1139 n, err := r.Body.Read(tmp)
1140 if n > 0 {
1141 body.Write(tmp[:n])
1142 }
1143 if err != nil {
1144 break
1145 }
1146 }
1147 if body.Len() > 0 {
1148 stdin = body.Bytes()
1149 }
1150 }
1151
1152 out, err := runIO(env, stdin, gitBackend)
1153 if err != nil && len(out) == 0 {
1154 http.Error(w, "git backend error", 500)
1155 return
1156 }
1157
1158 // parse CGI response: headers \n\n body
1159 sep := bytes.Index(out, []byte("\r\n\r\n"))
1160 skip := 4
1161 if sep < 0 {
1162 sep = bytes.Index(out, []byte("\n\n"))
1163 skip = 2
1164 }
1165 if sep < 0 {
1166 http.Error(w, "bad cgi response", 500)
1167 return
1168 }
1169
1170 code := 200
1171 for _, line := range bytes.Split(string(out[:sep]), "\n") {
1172 line = bytes.TrimRight(line, "\r")
1173 idx := bytes.IndexByte(line, ':')
1174 if idx < 0 {
1175 continue
1176 }
1177 key := bytes.TrimSpace(line[:idx])
1178 val := bytes.TrimSpace(line[idx+1:])
1179 if bytes.EqualFold(key, "Status") {
1180 if len(val) >= 3 {
1181 code = 0
1182 for j := 0; j < 3; j++ {
1183 if val[j] >= '0' && val[j] <= '9' {
1184 code = code*10 + int(val[j]-'0')
1185 }
1186 }
1187 }
1188 } else {
1189 w.Header().Set(key, val)
1190 }
1191 }
1192 w.WriteHeader(code)
1193 w.Write(out[sep+skip:])
1194 }
1195
1196 // ── main ────────────────────────────────────────────────────────
1197
1198 func main() {
1199 for i := 1; i < len(os.Args); i++ {
1200 switch os.Args[i] {
1201 case "-repos":
1202 i++
1203 if i < len(os.Args) {
1204 repoRoot = os.Args[i]
1205 }
1206 case "-listen":
1207 i++
1208 if i < len(os.Args) {
1209 addr = os.Args[i]
1210 }
1211 case "-git":
1212 i++
1213 if i < len(os.Args) {
1214 gitBin = os.Args[i]
1215 }
1216 case "-host":
1217 i++
1218 if i < len(os.Args) {
1219 host = os.Args[i]
1220 }
1221 case "-git-backend":
1222 i++
1223 if i < len(os.Args) {
1224 gitBackend = os.Args[i]
1225 }
1226 }
1227 }
1228
1229 // auto-detect git-http-backend path
1230 if _, err := os.Stat(gitBackend); err != nil {
1231 for _, p := range []string{"/usr/lib/git-core/git-http-backend", "/usr/libexec/git-core/git-http-backend"} {
1232 if _, err := os.Stat(p); err == nil {
1233 gitBackend = p
1234 break
1235 }
1236 }
1237 }
1238
1239 fmt.Printf("gitweb %s repos=%s\n", addr, repoRoot)
1240 // Parse addr ourselves to bypass moxie SplitHostPort codegen bug.
1241 tcpAddr := &net.TCPAddr{Port: 3000}
1242 if i := bytes.LastIndexByte(addr, ':'); i >= 0 {
1243 h := addr[:i]
1244 p := addr[i+1:]
1245 if h != "" {
1246 tcpAddr.IP = net.ParseIP(h)
1247 }
1248 port := 0
1249 for _, c := range p {
1250 port = port*10 + int(c-'0')
1251 }
1252 tcpAddr.Port = port
1253 }
1254 ln, err := net.ListenTCP("tcp", tcpAddr)
1255 if err != nil {
1256 fmt.Fprintf(os.Stderr, "%v\n", err)
1257 os.Exit(1)
1258 }
1259 http.HandleFunc("/", handler)
1260 if err := http.Serve(ln, nil); err != nil {
1261 fmt.Fprintf(os.Stderr, "%v\n", err)
1262 os.Exit(1)
1263 }
1264 }
1265