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}