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	logger = log.WithPrefix("server.web")
 29)
 30
 31var (
 32	gitHttpCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 33		Namespace: "soft_serve",
 34		Subsystem: "http",
 35		Name:      "git_fetch_pull_total",
 36		Help:      "The total number of git fetch/pull requests",
 37	}, []string{"repo", "file"})
 38
 39	goGetCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 40		Namespace: "soft_serve",
 41		Subsystem: "http",
 42		Name:      "go_get_total",
 43		Help:      "The total number of go get requests",
 44	}, []string{"repo"})
 45)
 46
 47// logWriter is a wrapper around http.ResponseWriter that allows us to capture
 48// the HTTP status code and bytes written to the response.
 49type logWriter struct {
 50	http.ResponseWriter
 51	code, bytes int
 52}
 53
 54func (r *logWriter) Write(p []byte) (int, error) {
 55	written, err := r.ResponseWriter.Write(p)
 56	r.bytes += written
 57	return written, err
 58}
 59
 60// Note this is generally only called when sending an HTTP error, so it's
 61// important to set the `code` value to 200 as a default
 62func (r *logWriter) WriteHeader(code int) {
 63	r.code = code
 64	r.ResponseWriter.WriteHeader(code)
 65}
 66
 67func loggingMiddleware(next http.Handler) http.Handler {
 68	logger := logger.WithPrefix("server.http")
 69	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 70		start := time.Now()
 71		writer := &logWriter{code: http.StatusOK, ResponseWriter: w}
 72		logger.Debug("request",
 73			"method", r.Method,
 74			"uri", r.RequestURI,
 75			"addr", r.RemoteAddr)
 76		next.ServeHTTP(writer, r)
 77		elapsed := time.Since(start)
 78		logger.Debug("response",
 79			"status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)),
 80			"bytes", humanize.Bytes(uint64(writer.bytes)),
 81			"time", elapsed)
 82	})
 83}
 84
 85// HTTPServer is an http server.
 86type HTTPServer struct {
 87	cfg        *config.Config
 88	server     *http.Server
 89	dirHandler http.Handler
 90}
 91
 92func NewHTTPServer(cfg *config.Config) (*HTTPServer, error) {
 93	mux := goji.NewMux()
 94	s := &HTTPServer{
 95		cfg:        cfg,
 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(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		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}