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