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	if s.cfg.HTTP.TLSKeyPath != "" && s.cfg.HTTP.TLSCertPath != "" {
109		return s.server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath)
110	}
111	return s.server.ListenAndServe()
112}
113
114// Shutdown gracefully shuts down the HTTP server.
115func (s *HTTPServer) Shutdown(ctx context.Context) error {
116	return s.server.Shutdown(ctx)
117}
118
119// Pattern is a pattern for matching a URL.
120// It matches against GET requests.
121type Pattern struct {
122	match func(*url.URL) *Match
123}
124
125// NewPattern returns a new Pattern with the given matcher.
126func NewPattern(m Matcher) *Pattern {
127	return &Pattern{
128		match: m,
129	}
130}
131
132// Match is a match for a URL.
133//
134// It implements goji.Pattern.
135func (p *Pattern) Match(r *http.Request) *http.Request {
136	if r.Method != "GET" {
137		return nil
138	}
139
140	if m := p.match(r.URL); m != nil {
141		ctx := context.WithValue(r.Context(), pattern.Variable("repo"), m.RepoPath)
142		ctx = context.WithValue(ctx, pattern.Variable("file"), m.FilePath)
143		return r.WithContext(ctx)
144	}
145	return nil
146}
147
148// Matcher finds a match in a *url.URL.
149type Matcher = func(*url.URL) *Match
150
151var (
152	getInfoRefs = func(u *url.URL) *Match {
153		return matchSuffix(u.Path, "/info/refs")
154	}
155
156	getHead = func(u *url.URL) *Match {
157		return matchSuffix(u.Path, "/HEAD")
158	}
159
160	getAlternates = func(u *url.URL) *Match {
161		return matchSuffix(u.Path, "/objects/info/alternates")
162	}
163
164	getHTTPAlternates = func(u *url.URL) *Match {
165		return matchSuffix(u.Path, "/objects/info/http-alternates")
166	}
167
168	getInfoPacks = func(u *url.URL) *Match {
169		return matchSuffix(u.Path, "/objects/info/packs")
170	}
171
172	getInfoFileRegexp = regexp.MustCompile(".*?(/objects/info/[^/]*)$")
173	getInfoFile       = func(u *url.URL) *Match {
174		return findStringSubmatch(u.Path, getInfoFileRegexp)
175	}
176
177	getLooseObjectRegexp = regexp.MustCompile(".*?(/objects/[0-9a-f]{2}/[0-9a-f]{38})$")
178	getLooseObject       = func(u *url.URL) *Match {
179		return findStringSubmatch(u.Path, getLooseObjectRegexp)
180	}
181
182	getPackFileRegexp = regexp.MustCompile(".*?(/objects/pack/pack-[0-9a-f]{40}\\.pack)$")
183	getPackFile       = func(u *url.URL) *Match {
184		return findStringSubmatch(u.Path, getPackFileRegexp)
185	}
186
187	getIdxFileRegexp = regexp.MustCompile(".*?(/objects/pack/pack-[0-9a-f]{40}\\.idx)$")
188	getIdxFile       = func(u *url.URL) *Match {
189		return findStringSubmatch(u.Path, getIdxFileRegexp)
190	}
191)
192
193type Match struct {
194	RepoPath, FilePath string
195}
196
197func matchSuffix(path, suffix string) *Match {
198	if !strings.HasSuffix(path, suffix) {
199		return nil
200	}
201	repoPath := strings.Replace(path, suffix, "", 1)
202	filePath := strings.Replace(path, repoPath+"/", "", 1)
203	return &Match{repoPath, filePath}
204}
205
206func findStringSubmatch(path string, prefix *regexp.Regexp) *Match {
207	m := prefix.FindStringSubmatch(path)
208	if m == nil {
209		return nil
210	}
211	suffix := m[1]
212	repoPath := strings.Replace(path, suffix, "", 1)
213	filePath := strings.Replace(path, repoPath+"/", "", 1)
214	return &Match{repoPath, filePath}
215}
216
217var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
218<html lang="en">
219<head>
220	<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
221	<meta http-equiv="refresh" content="0; url=https://godoc.org/{{.ImportRoot}}/{{.Repo}}"
222 	<meta name="go-import" content="{{.ImportRoot}}/{{.Repo}} git {{.Config.SSH.PublicURL}}/{{.Repo}}">
223</head>
224<body>
225Redirecting to docs at <a href="https://godoc.org/{{.ImportRoot}}/{{.Repo}}">godoc.org/{{.ImportRoot}}/{{.Repo}}</a>...
226</body>
227</html>`))
228
229func (s *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) {
230	repo := pattern.Path(r.Context())
231	repo = utils.SanitizeRepo(repo)
232	if _, err := s.cfg.Backend.Repository(repo); err != nil {
233		http.NotFound(w, r)
234		return
235	}
236
237	// Only respond to go-get requests
238	if r.URL.Query().Get("go-get") != "1" {
239		http.NotFound(w, r)
240		return
241	}
242
243	access := s.cfg.Backend.AccessLevel(repo, nil)
244	if access < backend.ReadOnlyAccess {
245		http.NotFound(w, r)
246		return
247	}
248
249	importRoot, err := url.Parse(s.cfg.HTTP.PublicURL)
250	if err != nil {
251		http.Error(w, err.Error(), http.StatusInternalServerError)
252		return
253	}
254
255	if err := repoIndexHTMLTpl.Execute(w, struct {
256		Repo       string
257		Config     *config.Config
258		ImportRoot string
259	}{
260		Repo:       repo,
261		Config:     s.cfg,
262		ImportRoot: importRoot.Host,
263	}); err != nil {
264		http.Error(w, err.Error(), http.StatusInternalServerError)
265		return
266	}
267}
268
269func (s *HTTPServer) handleGit(w http.ResponseWriter, r *http.Request) {
270	repo := pat.Param(r, "repo")
271	repo = utils.SanitizeRepo(repo) + ".git"
272	if _, err := s.cfg.Backend.Repository(repo); err != nil {
273		logger.Debug("repository not found", "repo", repo, "err", err)
274		http.NotFound(w, r)
275		return
276	}
277
278	if !s.cfg.Backend.AllowKeyless() {
279		http.Error(w, "Forbidden", http.StatusForbidden)
280		return
281	}
282
283	access := s.cfg.Backend.AccessLevel(repo, nil)
284	if access < backend.ReadOnlyAccess {
285		http.Error(w, "Unauthorized", http.StatusUnauthorized)
286		return
287	}
288
289	file := pat.Param(r, "file")
290	r.URL.Path = fmt.Sprintf("/%s/%s", repo, file)
291	s.dirHandler.ServeHTTP(w, r)
292}