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}