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