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