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 server *http.Server
85 dirHandler http.Handler
86 logger *log.Logger
87}
88
89func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
90 cfg := config.FromContext(ctx)
91 mux := goji.NewMux()
92 s := &HTTPServer{
93 ctx: ctx,
94 cfg: cfg,
95 logger: log.FromContext(ctx).WithPrefix("http"),
96 dirHandler: http.FileServer(http.Dir(filepath.Join(cfg.DataPath, "repos"))),
97 server: &http.Server{
98 Addr: cfg.HTTP.ListenAddr,
99 Handler: mux,
100 ReadHeaderTimeout: time.Second * 10,
101 ReadTimeout: time.Second * 10,
102 WriteTimeout: time.Second * 10,
103 MaxHeaderBytes: http.DefaultMaxHeaderBytes,
104 },
105 }
106
107 mux.Use(s.loggingMiddleware)
108 for _, m := range []Matcher{
109 getInfoRefs,
110 getHead,
111 getAlternates,
112 getHTTPAlternates,
113 getInfoPacks,
114 getInfoFile,
115 getLooseObject,
116 getPackFile,
117 getIdxFile,
118 } {
119 mux.HandleFunc(NewPattern(m), s.handleGit)
120 }
121 mux.HandleFunc(pat.Get("/*"), s.handleIndex)
122 return s, nil
123}
124
125// Close closes the HTTP server.
126func (s *HTTPServer) Close() error {
127 return s.server.Close()
128}
129
130// ListenAndServe starts the HTTP server.
131func (s *HTTPServer) ListenAndServe() error {
132 if s.cfg.HTTP.TLSKeyPath != "" && s.cfg.HTTP.TLSCertPath != "" {
133 return s.server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath)
134 }
135 return s.server.ListenAndServe()
136}
137
138// Shutdown gracefully shuts down the HTTP server.
139func (s *HTTPServer) Shutdown(ctx context.Context) error {
140 return s.server.Shutdown(ctx)
141}
142
143// Pattern is a pattern for matching a URL.
144// It matches against GET requests.
145type Pattern struct {
146 match func(*url.URL) *match
147}
148
149// NewPattern returns a new Pattern with the given matcher.
150func NewPattern(m Matcher) *Pattern {
151 return &Pattern{
152 match: m,
153 }
154}
155
156// Match is a match for a URL.
157//
158// It implements goji.Pattern.
159func (p *Pattern) Match(r *http.Request) *http.Request {
160 if r.Method != "GET" {
161 return nil
162 }
163
164 if m := p.match(r.URL); m != nil {
165 ctx := context.WithValue(r.Context(), pattern.Variable("repo"), m.RepoPath)
166 ctx = context.WithValue(ctx, pattern.Variable("file"), m.FilePath)
167 return r.WithContext(ctx)
168 }
169 return nil
170}
171
172// Matcher finds a match in a *url.URL.
173type Matcher = func(*url.URL) *match
174
175var (
176 getInfoRefs = func(u *url.URL) *match {
177 return matchSuffix(u.Path, "/info/refs")
178 }
179
180 getHead = func(u *url.URL) *match {
181 return matchSuffix(u.Path, "/HEAD")
182 }
183
184 getAlternates = func(u *url.URL) *match {
185 return matchSuffix(u.Path, "/objects/info/alternates")
186 }
187
188 getHTTPAlternates = func(u *url.URL) *match {
189 return matchSuffix(u.Path, "/objects/info/http-alternates")
190 }
191
192 getInfoPacks = func(u *url.URL) *match {
193 return matchSuffix(u.Path, "/objects/info/packs")
194 }
195
196 getInfoFileRegexp = regexp.MustCompile(".*?(/objects/info/[^/]*)$")
197 getInfoFile = func(u *url.URL) *match {
198 return findStringSubmatch(u.Path, getInfoFileRegexp)
199 }
200
201 getLooseObjectRegexp = regexp.MustCompile(".*?(/objects/[0-9a-f]{2}/[0-9a-f]{38})$")
202 getLooseObject = func(u *url.URL) *match {
203 return findStringSubmatch(u.Path, getLooseObjectRegexp)
204 }
205
206 getPackFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.pack)$`)
207 getPackFile = func(u *url.URL) *match {
208 return findStringSubmatch(u.Path, getPackFileRegexp)
209 }
210
211 getIdxFileRegexp = regexp.MustCompile(`.*?(/objects/pack/pack-[0-9a-f]{40}\.idx)$`)
212 getIdxFile = func(u *url.URL) *match {
213 return findStringSubmatch(u.Path, getIdxFileRegexp)
214 }
215)
216
217// match represents a match for a URL.
218type match struct {
219 RepoPath, FilePath string
220}
221
222func matchSuffix(path, suffix string) *match {
223 if !strings.HasSuffix(path, suffix) {
224 return nil
225 }
226 repoPath := strings.Replace(path, suffix, "", 1)
227 filePath := strings.Replace(path, repoPath+"/", "", 1)
228 return &match{repoPath, filePath}
229}
230
231func findStringSubmatch(path string, prefix *regexp.Regexp) *match {
232 m := prefix.FindStringSubmatch(path)
233 if m == nil {
234 return nil
235 }
236 suffix := m[1]
237 repoPath := strings.Replace(path, suffix, "", 1)
238 filePath := strings.Replace(path, repoPath+"/", "", 1)
239 return &match{repoPath, filePath}
240}
241
242var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
243<html lang="en">
244<head>
245 <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
246 <meta http-equiv="refresh" content="0; url=https://godoc.org/{{ .ImportRoot }}/{{.Repo}}">
247 <meta name="go-import" content="{{ .ImportRoot }}/{{ .Repo }} git {{ .Config.HTTP.PublicURL }}/{{ .Repo }}">
248</head>
249<body>
250Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...
251</body>
252</html>`))
253
254func (s *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) {
255 repo := pattern.Path(r.Context())
256 repo = utils.SanitizeRepo(repo)
257
258 // Handle go get requests.
259 //
260 // Always return a 200 status code, even if the repo doesn't exist.
261 //
262 // https://golang.org/cmd/go/#hdr-Remote_import_paths
263 // https://go.dev/ref/mod#vcs-branch
264 if r.URL.Query().Get("go-get") == "1" {
265 repo := repo
266 importRoot, err := url.Parse(s.cfg.HTTP.PublicURL)
267 if err != nil {
268 http.Error(w, err.Error(), http.StatusInternalServerError)
269 return
270 }
271
272 // find the repo
273 for {
274 if _, err := s.cfg.Backend.Repository(repo); err == nil {
275 break
276 }
277
278 if repo == "" || repo == "." || repo == "/" {
279 return
280 }
281
282 repo = path.Dir(repo)
283 }
284
285 if err := repoIndexHTMLTpl.Execute(w, struct {
286 Repo string
287 Config *config.Config
288 ImportRoot string
289 }{
290 Repo: url.PathEscape(repo),
291 Config: s.cfg,
292 ImportRoot: importRoot.Host,
293 }); err != nil {
294 http.Error(w, err.Error(), http.StatusInternalServerError)
295 return
296 }
297
298 goGetCounter.WithLabelValues(repo).Inc()
299 return
300 }
301
302 http.NotFound(w, r)
303}
304
305func (s *HTTPServer) handleGit(w http.ResponseWriter, r *http.Request) {
306 repo := pat.Param(r, "repo")
307 repo = utils.SanitizeRepo(repo) + ".git"
308 if _, err := s.cfg.Backend.Repository(repo); err != nil {
309 s.logger.Debug("repository not found", "repo", repo, "err", err)
310 http.NotFound(w, r)
311 return
312 }
313
314 if !s.cfg.Backend.AllowKeyless() {
315 http.Error(w, "Forbidden", http.StatusForbidden)
316 return
317 }
318
319 access := s.cfg.Backend.AccessLevel(repo, "")
320 if access < backend.ReadOnlyAccess {
321 http.Error(w, "Unauthorized", http.StatusUnauthorized)
322 return
323 }
324
325 file := pat.Param(r, "file")
326 gitHttpCounter.WithLabelValues(repo, file).Inc()
327 r.URL.Path = fmt.Sprintf("/%s/%s", repo, file)
328 s.dirHandler.ServeHTTP(w, r)
329}