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