1package web
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "net/url"
8 "path"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "text/template"
13 "time"
14
15 "github.com/charmbracelet/log"
16 "github.com/charmbracelet/soft-serve/server/backend"
17 "github.com/charmbracelet/soft-serve/server/config"
18 "github.com/charmbracelet/soft-serve/server/utils"
19 "github.com/dustin/go-humanize"
20 "github.com/prometheus/client_golang/prometheus"
21 "github.com/prometheus/client_golang/prometheus/promauto"
22 "goji.io"
23 "goji.io/pat"
24 "goji.io/pattern"
25)
26
27var (
28 gitHttpCounter = promauto.NewCounterVec(prometheus.CounterOpts{
29 Namespace: "soft_serve",
30 Subsystem: "http",
31 Name: "git_fetch_pull_total",
32 Help: "The total number of git fetch/pull requests",
33 }, []string{"repo", "file"})
34
35 goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{
36 Namespace: "soft_serve",
37 Subsystem: "http",
38 Name: "go_get_total",
39 Help: "The total number of go get requests",
40 }, []string{"repo"})
41)
42
43// logWriter is a wrapper around http.ResponseWriter that allows us to capture
44// the HTTP status code and bytes written to the response.
45type logWriter struct {
46 http.ResponseWriter
47 code, bytes int
48}
49
50func (r *logWriter) Write(p []byte) (int, error) {
51 written, err := r.ResponseWriter.Write(p)
52 r.bytes += written
53 return written, err
54}
55
56// Note this is generally only called when sending an HTTP error, so it's
57// important to set the `code` value to 200 as a default
58func (r *logWriter) WriteHeader(code int) {
59 r.code = code
60 r.ResponseWriter.WriteHeader(code)
61}
62
63func (s *HTTPServer) loggingMiddleware(next http.Handler) http.Handler {
64 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65 start := time.Now()
66 writer := &logWriter{code: http.StatusOK, ResponseWriter: w}
67 s.logger.Debug("request",
68 "method", r.Method,
69 "uri", r.RequestURI,
70 "addr", r.RemoteAddr)
71 next.ServeHTTP(writer, r)
72 elapsed := time.Since(start)
73 s.logger.Debug("response",
74 "status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)),
75 "bytes", humanize.Bytes(uint64(writer.bytes)),
76 "time", elapsed)
77 })
78}
79
80// HTTPServer is an http server.
81type HTTPServer struct {
82 ctx context.Context
83 cfg *config.Config
84 be backend.Backend
85 server *http.Server
86 dirHandler http.Handler
87 logger *log.Logger
88}
89
90func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
91 cfg := config.FromContext(ctx)
92 mux := goji.NewMux()
93 s := &HTTPServer{
94 ctx: ctx,
95 cfg: cfg,
96 be: backend.FromContext(ctx),
97 logger: log.FromContext(ctx).WithPrefix("http"),
98 dirHandler: http.FileServer(http.Dir(filepath.Join(cfg.DataPath, "repos"))),
99 server: &http.Server{
100 Addr: cfg.HTTP.ListenAddr,
101 Handler: mux,
102 ReadHeaderTimeout: time.Second * 10,
103 ReadTimeout: time.Second * 10,
104 WriteTimeout: time.Second * 10,
105 MaxHeaderBytes: http.DefaultMaxHeaderBytes,
106 },
107 }
108
109 mux.Use(s.loggingMiddleware)
110 for _, m := range []Matcher{
111 getInfoRefs,
112 getHead,
113 getAlternates,
114 getHTTPAlternates,
115 getInfoPacks,
116 getInfoFile,
117 getLooseObject,
118 getPackFile,
119 getIdxFile,
120 } {
121 mux.HandleFunc(NewPattern(m), s.handleGit)
122 }
123 mux.HandleFunc(pat.Get("/*"), s.handleIndex)
124 return s, nil
125}
126
127// Close closes the HTTP server.
128func (s *HTTPServer) Close() error {
129 return s.server.Close()
130}
131
132// ListenAndServe starts the HTTP server.
133func (s *HTTPServer) ListenAndServe() error {
134 if s.cfg.HTTP.TLSKeyPath != "" && s.cfg.HTTP.TLSCertPath != "" {
135 return s.server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath)
136 }
137 return s.server.ListenAndServe()
138}
139
140// Shutdown gracefully shuts down the HTTP server.
141func (s *HTTPServer) Shutdown(ctx context.Context) error {
142 return s.server.Shutdown(ctx)
143}
144
145// Pattern is a pattern for matching a URL.
146// It matches against GET requests.
147type Pattern struct {
148 match func(*url.URL) *match
149}
150
151// NewPattern returns a new Pattern with the given matcher.
152func NewPattern(m Matcher) *Pattern {
153 return &Pattern{
154 match: m,
155 }
156}
157
158// Match is a match for a URL.
159//
160// It implements goji.Pattern.
161func (p *Pattern) Match(r *http.Request) *http.Request {
162 if r.Method != "GET" {
163 return nil
164 }
165
166 if m := p.match(r.URL); m != nil {
167 ctx := context.WithValue(r.Context(), pattern.Variable("repo"), m.RepoPath)
168 ctx = context.WithValue(ctx, pattern.Variable("file"), m.FilePath)
169 return r.WithContext(ctx)
170 }
171 return nil
172}
173
174// Matcher finds a match in a *url.URL.
175type Matcher = func(*url.URL) *match
176
177var (
178 getInfoRefs = func(u *url.URL) *match {
179 return matchSuffix(u.Path, "/info/refs")
180 }
181
182 getHead = func(u *url.URL) *match {
183 return matchSuffix(u.Path, "/HEAD")
184 }
185
186 getAlternates = func(u *url.URL) *match {
187 return matchSuffix(u.Path, "/objects/info/alternates")
188 }
189
190 getHTTPAlternates = func(u *url.URL) *match {
191 return matchSuffix(u.Path, "/objects/info/http-alternates")
192 }
193
194 getInfoPacks = func(u *url.URL) *match {
195 return matchSuffix(u.Path, "/objects/info/packs")
196 }
197
198 getInfoFileRegexp = regexp.MustCompile(".*?(/objects/info/[^/]*)$")
199 getInfoFile = func(u *url.URL) *match {
200 return findStringSubmatch(u.Path, getInfoFileRegexp)
201 }
202
203 getLooseObjectRegexp = regexp.MustCompile(".*?(/objects/[0-9a-f]{2}/[0-9a-f]{38})$")
204 getLooseObject = func(u *url.URL) *match {
205 return findStringSubmatch(u.Path, getLooseObjectRegexp)
206 }
207
208 getPackFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.pack)$`)
209 getPackFile = func(u *url.URL) *match {
210 return findStringSubmatch(u.Path, getPackFileRegexp)
211 }
212
213 getIdxFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.idx)$`)
214 getIdxFile = func(u *url.URL) *match {
215 return findStringSubmatch(u.Path, getIdxFileRegexp)
216 }
217)
218
219// match represents a match for a URL.
220type match struct {
221 RepoPath, FilePath string
222}
223
224func matchSuffix(path, suffix string) *match {
225 if !strings.HasSuffix(path, suffix) {
226 return nil
227 }
228 repoPath := strings.Replace(path, suffix, "", 1)
229 filePath := strings.Replace(path, repoPath+"/", "", 1)
230 return &match{repoPath, filePath}
231}
232
233func findStringSubmatch(path string, prefix *regexp.Regexp) *match {
234 m := prefix.FindStringSubmatch(path)
235 if m == nil {
236 return nil
237 }
238 suffix := m[1]
239 repoPath := strings.Replace(path, suffix, "", 1)
240 filePath := strings.Replace(path, repoPath+"/", "", 1)
241 return &match{repoPath, filePath}
242}
243
244var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
245<html lang="en">
246<head>
247 <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
248 <meta http-equiv="refresh" content="0; url=https://godoc.org/{{ .ImportRoot }}/{{.Repo}}">
249 <meta name="go-import" content="{{ .ImportRoot }}/{{ .Repo }} git {{ .Config.HTTP.PublicURL }}/{{ .Repo }}">
250</head>
251<body>
252Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...
253</body>
254</html>`))
255
256func (s *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) {
257 repo := pattern.Path(r.Context())
258 repo = utils.SanitizeRepo(repo)
259 be := s.be.WithContext(r.Context())
260
261 // Handle go get requests.
262 //
263 // Always return a 200 status code, even if the repo doesn't exist.
264 //
265 // https://golang.org/cmd/go/#hdr-Remote_import_paths
266 // https://go.dev/ref/mod#vcs-branch
267 if r.URL.Query().Get("go-get") == "1" {
268 repo := repo
269 importRoot, err := url.Parse(s.cfg.HTTP.PublicURL)
270 if err != nil {
271 http.Error(w, err.Error(), http.StatusInternalServerError)
272 return
273 }
274
275 // find the repo
276 for {
277 if _, err := be.Repository(repo); err == nil {
278 break
279 }
280
281 if repo == "" || repo == "." || repo == "/" {
282 return
283 }
284
285 repo = path.Dir(repo)
286 }
287
288 if err := repoIndexHTMLTpl.Execute(w, struct {
289 Repo string
290 Config *config.Config
291 ImportRoot string
292 }{
293 Repo: url.PathEscape(repo),
294 Config: s.cfg,
295 ImportRoot: importRoot.Host,
296 }); err != nil {
297 http.Error(w, err.Error(), http.StatusInternalServerError)
298 return
299 }
300
301 goGetCounter.WithLabelValues(repo).Inc()
302 return
303 }
304
305 http.NotFound(w, r)
306}
307
308func (s *HTTPServer) handleGit(w http.ResponseWriter, r *http.Request) {
309 repo := pat.Param(r, "repo")
310 repo = utils.SanitizeRepo(repo) + ".git"
311 be := s.be.WithContext(r.Context())
312 if _, err := be.Repository(repo); err != nil {
313 s.logger.Debug("repository not found", "repo", repo, "err", err)
314 http.NotFound(w, r)
315 return
316 }
317
318 if !s.cfg.Backend.AllowKeyless() {
319 http.Error(w, "Forbidden", http.StatusForbidden)
320 return
321 }
322
323 access := s.cfg.Backend.AccessLevel(repo, "")
324 if access < backend.ReadOnlyAccess {
325 http.Error(w, "Unauthorized", http.StatusUnauthorized)
326 return
327 }
328
329 file := pat.Param(r, "file")
330 gitHttpCounter.WithLabelValues(repo, file).Inc()
331 r.URL.Path = fmt.Sprintf("/%s/%s", repo, file)
332 s.dirHandler.ServeHTTP(w, r)
333}