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 }