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