Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
main.go
38fa4e6cd9bb9dbf0ae23ef02e50b1c75cd84ebb
· 5.6 KB · 187 lines
raw
| 1 | // Command gsg serves a modular git browser. |
| 2 | // |
| 3 | // gsg -root /srv/git -addr :8080 -name "rschanz.org" |
| 4 | // gsg -root /srv/git -templates ./templates # hot-reload the theme |
| 5 | // gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081 |
| 6 | // |
| 7 | // Per-repository settings live in each repo's own git config, cgit-style: |
| 8 | // |
| 9 | // git --git-dir=/srv/git/foo.git config gsg.title "foo" |
| 10 | // git --git-dir=/srv/git/foo.git config gsg.description "a thing" |
| 11 | // git --git-dir=/srv/git/foo.git config gsg.hide true |
| 12 | // git --git-dir=/srv/git/foo.git config gsg.gpgkeyring /srv/git/foo-keys.asc |
| 13 | package main |
| 14 | |
| 15 | import ( |
| 16 | "embed" |
| 17 | "flag" |
| 18 | "fmt" |
| 19 | "io/fs" |
| 20 | "log" |
| 21 | "log/slog" |
| 22 | "net" |
| 23 | "net/http" |
| 24 | "os" |
| 25 | "time" |
| 26 | |
| 27 | "gsg/internal/core" |
| 28 | _ "gsg/internal/modules" // registers the built-in modules |
| 29 | "gsg/internal/notices" |
| 30 | "gsg/internal/site" |
| 31 | ) |
| 32 | |
| 33 | //go:embed templates |
| 34 | var templateFS embed.FS |
| 35 | |
| 36 | //go:embed static |
| 37 | var staticFS embed.FS |
| 38 | |
| 39 | type options struct { |
| 40 | root, addr, name, base, tmplDir string |
| 41 | noticesPath, adminAddr string |
| 42 | adminAllowRemote bool |
| 43 | cacheSize int |
| 44 | timeout time.Duration |
| 45 | } |
| 46 | |
| 47 | func main() { |
| 48 | var o options |
| 49 | flag.StringVar(&o.root, "root", "/srv/git", "directory containing bare repositories") |
| 50 | flag.StringVar(&o.addr, "addr", ":8080", "listen address") |
| 51 | flag.StringVar(&o.name, "name", "git", "site name") |
| 52 | flag.StringVar(&o.base, "base", "", "base URL") |
| 53 | flag.StringVar(&o.tmplDir, "templates", "", "serve templates from this directory, re-parsed per request") |
| 54 | flag.StringVar(&o.noticesPath, "notices", "", "YAML file of site and per-repo notices (empty disables the feature)") |
| 55 | flag.StringVar(&o.adminAddr, "admin-addr", "", "listen address for the notices admin UI (empty disables it; use loopback unless behind a container network)") |
| 56 | flag.BoolVar(&o.adminAllowRemote, "admin-allow-remote", false, "permit -admin-addr to bind a non-loopback address (required in containers)") |
| 57 | flag.IntVar(&o.cacheSize, "cache", 4096, "module cache entries") |
| 58 | flag.DurationVar(&o.timeout, "timeout", 10*time.Second, "per-request git budget") |
| 59 | flag.Parse() |
| 60 | |
| 61 | if err := run(o); err != nil { |
| 62 | log.Fatalf("gsg: %v", err) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func run(o options) error { |
| 67 | siteCfg := &core.Site{Name: o.name, BaseURL: o.base, Root: o.root} |
| 68 | |
| 69 | var nstore *notices.Store |
| 70 | if o.noticesPath != "" { |
| 71 | var err error |
| 72 | nstore, err = notices.Open(o.noticesPath) |
| 73 | if err != nil { |
| 74 | return err |
| 75 | } |
| 76 | defer nstore.Close() |
| 77 | notices.SetDefault(nstore) |
| 78 | // The site-level analogue of RepoInfo.GPGKeyring: notices can |
| 79 | // change without a commit moving, so the revision has to be in |
| 80 | // every module cache key and ETag (see Ctx.contentIdentity). |
| 81 | siteCfg.RevFn = nstore.Revision |
| 82 | } |
| 83 | if o.adminAddr != "" && nstore == nil { |
| 84 | return fmt.Errorf("-admin-addr requires -notices") |
| 85 | } |
| 86 | |
| 87 | if err := core.Start(siteCfg); err != nil { |
| 88 | return err |
| 89 | } |
| 90 | |
| 91 | var tfs fs.FS |
| 92 | var err error |
| 93 | if o.tmplDir != "" { |
| 94 | tfs = os.DirFS(o.tmplDir) |
| 95 | } else if tfs, err = fs.Sub(templateFS, "templates"); err != nil { |
| 96 | return err |
| 97 | } |
| 98 | |
| 99 | srv, err := core.NewServer(siteCfg, site.Routes(), tfs, o.cacheSize) |
| 100 | if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | srv.TemplateDir = o.tmplDir |
| 104 | srv.Timeout = o.timeout |
| 105 | |
| 106 | static, err := fs.Sub(staticFS, "static") |
| 107 | if err != nil { |
| 108 | return err |
| 109 | } |
| 110 | |
| 111 | mux := http.NewServeMux() |
| 112 | mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) |
| 113 | mux.Handle("/", srv.Handler()) |
| 114 | |
| 115 | servers := []*http.Server{{ |
| 116 | Addr: o.addr, |
| 117 | Handler: mux, |
| 118 | ReadHeaderTimeout: 5 * time.Second, |
| 119 | WriteTimeout: o.timeout + 5*time.Second, |
| 120 | }} |
| 121 | |
| 122 | if o.adminAddr != "" { |
| 123 | // The public mux stays strictly read-only. The admin routes live on |
| 124 | // their own listener so a misconfigured Caddy forward_auth cannot |
| 125 | // expose a write path on the public port. |
| 126 | if !o.adminAllowRemote && !isLoopbackAddr(o.adminAddr) { |
| 127 | return fmt.Errorf("-admin-addr %q is not loopback; pass -admin-allow-remote to allow it", o.adminAddr) |
| 128 | } |
| 129 | repos := make([]string, 0) |
| 130 | for _, h := range siteCfg.RepoList() { |
| 131 | if !h.Info.Hidden { |
| 132 | repos = append(repos, h.Info.Slug) |
| 133 | } |
| 134 | } |
| 135 | adminHandler, err := notices.NewHandler(notices.HandlerConfig{ |
| 136 | Store: nstore, |
| 137 | Repos: repos, |
| 138 | SiteName: o.name, |
| 139 | UserHeader: "X-Authentik-Username", |
| 140 | Logger: slog.Default(), |
| 141 | }) |
| 142 | if err != nil { |
| 143 | return err |
| 144 | } |
| 145 | amux := http.NewServeMux() |
| 146 | // Serve /static/ on the admin listener too. Behind Caddy the admin page |
| 147 | // is same-origin and its /static/ requests go to the public listener, |
| 148 | // but hitting the admin port directly (dev, curl) would otherwise 404 |
| 149 | // every stylesheet. Static assets are public either way. |
| 150 | amux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) |
| 151 | amux.Handle("/admin/", adminHandler) |
| 152 | servers = append(servers, &http.Server{ |
| 153 | Addr: o.adminAddr, |
| 154 | Handler: amux, |
| 155 | ReadHeaderTimeout: 5 * time.Second, |
| 156 | ReadTimeout: 15 * time.Second, |
| 157 | WriteTimeout: 15 * time.Second, |
| 158 | }) |
| 159 | slog.Info("gsg admin listening", "addr", o.adminAddr) |
| 160 | } |
| 161 | |
| 162 | slog.Info("gsg listening", |
| 163 | "addr", o.addr, "repos", len(siteCfg.RepoList()), |
| 164 | "routes", len(site.Routes()), "modules", len(core.Names())) |
| 165 | |
| 166 | errc := make(chan error, len(servers)) |
| 167 | for _, s := range servers { |
| 168 | s := s |
| 169 | go func() { errc <- s.ListenAndServe() }() |
| 170 | } |
| 171 | return <-errc |
| 172 | } |
| 173 | |
| 174 | // isLoopbackAddr is a blunt guard, not a security boundary: it stops the |
| 175 | // admin listener from silently binding 0.0.0.0 outside a deliberate opt-in. |
| 176 | func isLoopbackAddr(addr string) bool { |
| 177 | host, _, err := net.SplitHostPort(addr) |
| 178 | if err != nil { |
| 179 | host = addr |
| 180 | } |
| 181 | switch host { |
| 182 | case "127.0.0.1", "::1", "localhost": |
| 183 | return true |
| 184 | default: |
| 185 | return false |
| 186 | } |
| 187 | } |