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