1package server
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/http"
  7	"net/url"
  8	"os"
  9	"path/filepath"
 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/dustin/go-humanize"
 17	"goji.io"
 18	"goji.io/pat"
 19	"goji.io/pattern"
 20)
 21
 22// logWriter is a wrapper around http.ResponseWriter that allows us to capture
 23// the HTTP status code and bytes written to the response.
 24type logWriter struct {
 25	http.ResponseWriter
 26	code, bytes int
 27}
 28
 29func (r *logWriter) Write(p []byte) (int, error) {
 30	written, err := r.ResponseWriter.Write(p)
 31	r.bytes += written
 32	return written, err
 33}
 34
 35// Note this is generally only called when sending an HTTP error, so it's
 36// important to set the `code` value to 200 as a default
 37func (r *logWriter) WriteHeader(code int) {
 38	r.code = code
 39	r.ResponseWriter.WriteHeader(code)
 40}
 41
 42func loggingMiddleware(next http.Handler) http.Handler {
 43	logger := logger.WithPrefix("server.http")
 44	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 45		start := time.Now()
 46		writer := &logWriter{code: http.StatusOK, ResponseWriter: w}
 47		logger.Debug("request",
 48			"method", r.Method,
 49			"uri", r.RequestURI,
 50			"addr", r.RemoteAddr)
 51		next.ServeHTTP(writer, r)
 52		elapsed := time.Since(start)
 53		logger.Debug("response",
 54			"status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)),
 55			"bytes", humanize.Bytes(uint64(writer.bytes)),
 56			"time", elapsed)
 57	})
 58}
 59
 60// HTTPServer is an http server.
 61type HTTPServer struct {
 62	cfg        *config.Config
 63	server     *http.Server
 64	dirHandler http.Handler
 65}
 66
 67func NewHTTPServer(cfg *config.Config) (*HTTPServer, error) {
 68	mux := goji.NewMux()
 69	s := &HTTPServer{
 70		cfg:        cfg,
 71		dirHandler: http.FileServer(http.Dir(cfg.Backend.RepositoryStorePath())),
 72		server: &http.Server{
 73			Addr:              cfg.HTTP.ListenAddr,
 74			Handler:           mux,
 75			ReadHeaderTimeout: time.Second * 10,
 76			ReadTimeout:       time.Second * 10,
 77			WriteTimeout:      time.Second * 10,
 78			MaxHeaderBytes:    http.DefaultMaxHeaderBytes,
 79		},
 80	}
 81
 82	mux.Use(loggingMiddleware)
 83	mux.HandleFunc(pat.Get("/:repo"), s.repoIndexHandler)
 84	mux.HandleFunc(pat.Get("/:repo/*"), s.dumbGitHandler)
 85	return s, nil
 86}
 87
 88// Close closes the HTTP server.
 89func (s *HTTPServer) Close() error {
 90	return s.server.Close()
 91}
 92
 93// ListenAndServe starts the HTTP server.
 94func (s *HTTPServer) ListenAndServe() error {
 95	return s.server.ListenAndServe()
 96}
 97
 98// Shutdown gracefully shuts down the HTTP server.
 99func (s *HTTPServer) Shutdown(ctx context.Context) error {
100	return s.server.Shutdown(ctx)
101}
102
103var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html>
104<html lang="en">
105<head>
106	<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
107	<meta http-equiv="refresh" content="0; url=https://godoc.org/{{.ImportRoot}}/{{.Repo}}"
108 	<meta name="go-import" content="{{.ImportRoot}}/{{.Repo}} git {{.Config.SSH.PublicURL}}/{{.Repo}}">
109</head>
110<body>
111Redirecting to docs at <a href="https://godoc.org/{{.ImportRoot}}/{{.Repo}}">godoc.org/{{.ImportRoot}}/{{.Repo}}</a>...
112</body>
113</html>`))
114
115func (s *HTTPServer) repoIndexHandler(w http.ResponseWriter, r *http.Request) {
116	repo := pat.Param(r, "repo")
117	repo = sanitizeRepoName(repo)
118
119	// Only respond to go-get requests
120	if r.URL.Query().Get("go-get") != "1" {
121		http.NotFound(w, r)
122		return
123	}
124
125	access := s.cfg.Access.AccessLevel(repo, nil)
126	if access < backend.ReadOnlyAccess {
127		http.NotFound(w, r)
128		return
129	}
130
131	importRoot, err := url.Parse(s.cfg.HTTP.PublicURL)
132	if err != nil {
133		http.Error(w, err.Error(), http.StatusInternalServerError)
134	}
135
136	if err := repoIndexHTMLTpl.Execute(w, struct {
137		Repo       string
138		Config     *config.Config
139		ImportRoot string
140	}{
141		Repo:       repo,
142		Config:     s.cfg,
143		ImportRoot: importRoot.Host,
144	}); err != nil {
145		http.Error(w, err.Error(), http.StatusInternalServerError)
146		return
147	}
148}
149
150func (s *HTTPServer) dumbGitHandler(w http.ResponseWriter, r *http.Request) {
151	repo := pat.Param(r, "repo")
152	repo = sanitizeRepoName(repo) + ".git"
153
154	access := s.cfg.Access.AccessLevel(repo, nil)
155	if access < backend.ReadOnlyAccess || !s.cfg.Backend.AllowKeyless() {
156		httpStatusError(w, http.StatusUnauthorized)
157		return
158	}
159
160	path := pattern.Path(r.Context())
161	stat, err := os.Stat(filepath.Join(s.cfg.Backend.RepositoryStorePath(), repo, path))
162	// Restrict access to files
163	if err != nil || stat.IsDir() {
164		http.NotFound(w, r)
165		return
166	}
167
168	// Don't allow access to non-git clients
169	ua := r.Header.Get("User-Agent")
170	if !strings.HasPrefix(strings.ToLower(ua), "git") {
171		httpStatusError(w, http.StatusBadRequest)
172		return
173	}
174
175	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
176	w.Header().Set("X-Content-Type-Options", "nosniff")
177	r.URL.Path = fmt.Sprintf("/%s/%s", repo, path)
178	s.dirHandler.ServeHTTP(w, r)
179}
180
181func httpStatusError(w http.ResponseWriter, status int) {
182	http.Error(w, fmt.Sprintf("%d %s", status, http.StatusText(status)), status)
183}