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}