1package web
2
3import (
4 "bytes"
5 "compress/gzip"
6 "context"
7 "fmt"
8 "io"
9 "net/http"
10 "os"
11 "path/filepath"
12 "regexp"
13 "strings"
14 "time"
15
16 "github.com/charmbracelet/log"
17 gitb "github.com/charmbracelet/soft-serve/git"
18 "github.com/charmbracelet/soft-serve/server/backend"
19 "github.com/charmbracelet/soft-serve/server/config"
20 "github.com/charmbracelet/soft-serve/server/git"
21 "github.com/charmbracelet/soft-serve/server/utils"
22 "github.com/prometheus/client_golang/prometheus"
23 "github.com/prometheus/client_golang/prometheus/promauto"
24 "goji.io/pat"
25 "goji.io/pattern"
26)
27
28// GitRoute is a route for git services.
29type GitRoute struct {
30 method string
31 pattern *regexp.Regexp
32 handler http.HandlerFunc
33
34 cfg *config.Config
35 be backend.Backend
36 logger *log.Logger
37}
38
39var _ Route = GitRoute{}
40
41// Match implements goji.Pattern.
42func (g GitRoute) Match(r *http.Request) *http.Request {
43 if g.method != r.Method {
44 return nil
45 }
46
47 re := g.pattern
48 ctx := r.Context()
49 if m := re.FindStringSubmatch(r.URL.Path); m != nil {
50 file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
51 repo := utils.SanitizeRepo(m[1]) + ".git"
52
53 var service git.Service
54 switch {
55 case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
56 service = git.UploadPackService
57 case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
58 service = git.ReceivePackService
59 }
60
61 ctx = context.WithValue(ctx, pattern.Variable("service"), service.String())
62 ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(g.cfg.DataPath, "repos", repo))
63 ctx = context.WithValue(ctx, pattern.Variable("repo"), repo)
64 ctx = context.WithValue(ctx, pattern.Variable("file"), file)
65
66 if g.cfg != nil {
67 ctx = config.WithContext(ctx, g.cfg)
68 }
69
70 if g.be != nil {
71 ctx = backend.WithContext(ctx, g.be.WithContext(ctx))
72 }
73
74 if g.logger != nil {
75 ctx = log.WithContext(ctx, g.logger)
76 }
77
78 return r.WithContext(ctx)
79 }
80
81 return nil
82}
83
84// ServeHTTP implements http.Handler.
85func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
86 g.handler(w, r)
87}
88
89var (
90 gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
91 Namespace: "soft_serve",
92 Subsystem: "http",
93 Name: "git_receive_pack_total",
94 Help: "The total number of git push requests",
95 }, []string{"repo"})
96
97 gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{
98 Namespace: "soft_serve",
99 Subsystem: "http",
100 Name: "git_upload_pack_total",
101 Help: "The total number of git fetch/pull requests",
102 }, []string{"repo", "file"})
103)
104
105func gitRoutes(ctx context.Context, logger *log.Logger) []Route {
106 routes := make([]Route, 0)
107 cfg := config.FromContext(ctx)
108 be := backend.FromContext(ctx)
109
110 // Git services
111 // These routes don't handle authentication/authorization.
112 // This is handled through wrapping the handlers for each route.
113 // See below (withAccess).
114 // TODO: add lfs support
115 for _, route := range []GitRoute{
116 {
117 pattern: regexp.MustCompile("(.*?)/git-upload-pack$"),
118 method: http.MethodPost,
119 handler: serviceRpc,
120 },
121 {
122 pattern: regexp.MustCompile("(.*?)/git-receive-pack$"),
123 method: http.MethodPost,
124 handler: serviceRpc,
125 },
126 {
127 pattern: regexp.MustCompile("(.*?)/info/refs$"),
128 method: http.MethodGet,
129 handler: getInfoRefs,
130 },
131 {
132 pattern: regexp.MustCompile("(.*?)/HEAD$"),
133 method: http.MethodGet,
134 handler: getTextFile,
135 },
136 {
137 pattern: regexp.MustCompile("(.*?)/objects/info/alternates$"),
138 method: http.MethodGet,
139 handler: getTextFile,
140 },
141 {
142 pattern: regexp.MustCompile("(.*?)/objects/info/http-alternates$"),
143 method: http.MethodGet,
144 handler: getTextFile,
145 },
146 {
147 pattern: regexp.MustCompile("(.*?)/objects/info/packs$"),
148 method: http.MethodGet,
149 handler: getInfoPacks,
150 },
151 {
152 pattern: regexp.MustCompile("(.*?)/objects/info/[^/]*$"),
153 method: http.MethodGet,
154 handler: getTextFile,
155 },
156 {
157 pattern: regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$"),
158 method: http.MethodGet,
159 handler: getLooseObject,
160 },
161 {
162 pattern: regexp.MustCompile("(.*?)/objects/pack/pack-[0-9a-f]{40}\\.pack$"),
163 method: http.MethodGet,
164 handler: getPackFile,
165 },
166 {
167 pattern: regexp.MustCompile("(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$"),
168 method: http.MethodGet,
169 handler: getIdxFile,
170 },
171 } {
172 route.cfg = cfg
173 route.be = be
174 route.logger = logger
175 route.handler = withAccess(route.handler)
176 routes = append(routes, route)
177 }
178
179 return routes
180}
181
182// withAccess handles auth.
183func withAccess(fn http.HandlerFunc) http.HandlerFunc {
184 return func(w http.ResponseWriter, r *http.Request) {
185 ctx := r.Context()
186 be := backend.FromContext(ctx)
187 logger := log.FromContext(ctx)
188
189 if !be.AllowKeyless() {
190 renderForbidden(w)
191 return
192 }
193
194 repo := pat.Param(r, "repo")
195 service := git.Service(pat.Param(r, "service"))
196 access := be.AccessLevel(repo, "")
197
198 switch service {
199 case git.ReceivePackService:
200 if access < backend.ReadWriteAccess {
201 renderUnauthorized(w)
202 return
203 }
204
205 // Create the repo if it doesn't exist.
206 if _, err := be.Repository(repo); err != nil {
207 if _, err := be.CreateRepository(repo, backend.RepositoryOptions{}); err != nil {
208 logger.Error("failed to create repository", "repo", repo, "err", err)
209 renderInternalServerError(w)
210 return
211 }
212 }
213 default:
214 if access < backend.ReadOnlyAccess {
215 renderUnauthorized(w)
216 return
217 }
218 }
219
220 fn(w, r)
221 }
222}
223
224func serviceRpc(w http.ResponseWriter, r *http.Request) {
225 ctx := r.Context()
226 logger := log.FromContext(ctx)
227 service, dir, repo := git.Service(pat.Param(r, "service")), pat.Param(r, "dir"), pat.Param(r, "repo")
228
229 if !isSmart(r, service) {
230 renderForbidden(w)
231 return
232 }
233
234 if service == git.ReceivePackService {
235 gitHttpReceiveCounter.WithLabelValues(repo)
236 }
237
238 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
239 w.Header().Set("Connection", "Keep-Alive")
240 w.Header().Set("Transfer-Encoding", "chunked")
241 w.Header().Set("X-Content-Type-Options", "nosniff")
242 w.WriteHeader(http.StatusOK)
243
244 version := r.Header.Get("Git-Protocol")
245
246 cmd := git.ServiceCommand{
247 Stdin: r.Body,
248 Stdout: w,
249 Dir: dir,
250 Args: []string{"--stateless-rpc"},
251 }
252
253 if len(version) != 0 {
254 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version))
255 }
256
257 // Handle gzip encoding
258 cmd.StdinHandler = func(in io.Reader, stdin io.WriteCloser) (err error) {
259 // We know that `in` is an `io.ReadCloser` because it's `r.Body`.
260 reader := in.(io.ReadCloser)
261 defer reader.Close() // nolint: errcheck
262 switch r.Header.Get("Content-Encoding") {
263 case "gzip":
264 reader, err = gzip.NewReader(reader)
265 if err != nil {
266 return err
267 }
268 defer reader.Close() // nolint: errcheck
269 }
270
271 _, err = io.Copy(stdin, reader)
272 return err
273 }
274
275 // Handle buffered output
276 // Useful when using proxies
277 cmd.StdoutHandler = func(out io.Writer, stdout io.ReadCloser) error {
278 // We know that `out` is an `http.ResponseWriter`.
279 flusher, ok := out.(http.Flusher)
280 if !ok {
281 return fmt.Errorf("expected http.ResponseWriter to be an http.Flusher, got %T", out)
282 }
283
284 p := make([]byte, 1024)
285 for {
286 nRead, err := stdout.Read(p)
287 if err == io.EOF {
288 break
289 }
290 nWrite, err := out.Write(p[:nRead])
291 if err != nil {
292 return err
293 }
294 if nRead != nWrite {
295 return fmt.Errorf("failed to write data: %d read, %d written", nRead, nWrite)
296 }
297 flusher.Flush()
298 }
299
300 return nil
301 }
302
303 if err := service.Handler(ctx, cmd); err != nil {
304 logger.Errorf("error executing service: %s", err)
305 }
306}
307
308func getInfoRefs(w http.ResponseWriter, r *http.Request) {
309 ctx := r.Context()
310 logger := log.FromContext(ctx)
311 dir, repo, file := pat.Param(r, "dir"), pat.Param(r, "repo"), pat.Param(r, "file")
312 service := getServiceType(r)
313 version := r.Header.Get("Git-Protocol")
314
315 gitHttpUploadCounter.WithLabelValues(repo, file).Inc()
316
317 if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
318 // Smart HTTP
319 var refs bytes.Buffer
320 cmd := git.ServiceCommand{
321 Stdout: &refs,
322 Dir: dir,
323 Args: []string{"--stateless-rpc", "--advertise-refs"},
324 }
325
326 if len(version) != 0 {
327 cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version))
328 }
329
330 if err := service.Handler(ctx, cmd); err != nil {
331 logger.Errorf("error executing service: %s", err)
332 renderNotFound(w)
333 return
334 }
335
336 hdrNocache(w)
337 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
338 w.WriteHeader(http.StatusOK)
339 if len(version) == 0 {
340 git.WritePktline(w, "# service="+service.String())
341 }
342
343 w.Write(refs.Bytes()) // nolint: errcheck
344 } else {
345 // Dumb HTTP
346 updateServerInfo(ctx, dir) // nolint: errcheck
347 hdrNocache(w)
348 sendFile("text/plain; charset=utf-8", w, r)
349 }
350}
351
352func getInfoPacks(w http.ResponseWriter, r *http.Request) {
353 hdrCacheForever(w)
354 sendFile("text/plain; charset=utf-8", w, r)
355}
356
357func getLooseObject(w http.ResponseWriter, r *http.Request) {
358 hdrCacheForever(w)
359 sendFile("application/x-git-loose-object", w, r)
360}
361
362func getPackFile(w http.ResponseWriter, r *http.Request) {
363 hdrCacheForever(w)
364 sendFile("application/x-git-packed-objects", w, r)
365}
366
367func getIdxFile(w http.ResponseWriter, r *http.Request) {
368 hdrCacheForever(w)
369 sendFile("application/x-git-packed-objects-toc", w, r)
370}
371
372func getTextFile(w http.ResponseWriter, r *http.Request) {
373 hdrNocache(w)
374 sendFile("text/plain", w, r)
375}
376
377func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
378 dir, file := pat.Param(r, "dir"), pat.Param(r, "file")
379 reqFile := filepath.Join(dir, file)
380
381 f, err := os.Stat(reqFile)
382 if os.IsNotExist(err) {
383 renderNotFound(w)
384 return
385 }
386
387 w.Header().Set("Content-Type", contentType)
388 w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
389 w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
390 http.ServeFile(w, r, reqFile)
391}
392
393func getServiceType(r *http.Request) git.Service {
394 service := r.FormValue("service")
395 if !strings.HasPrefix(service, "git-") {
396 return ""
397 }
398
399 return git.Service(service)
400}
401
402func isSmart(r *http.Request, service git.Service) bool {
403 if r.Header.Get("Content-Type") == fmt.Sprintf("application/x-%s-request", service) {
404 return true
405 }
406 return false
407}
408
409func updateServerInfo(ctx context.Context, dir string) error {
410 return gitb.UpdateServerInfo(ctx, dir)
411}
412
413// HTTP error response handling functions
414
415func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
416 if r.Proto == "HTTP/1.1" {
417 w.WriteHeader(http.StatusMethodNotAllowed)
418 w.Write([]byte("Method Not Allowed")) // nolint: errcheck
419 } else {
420 w.WriteHeader(http.StatusBadRequest)
421 w.Write([]byte("Bad Request")) // nolint: errcheck
422 }
423}
424
425func renderNotFound(w http.ResponseWriter) {
426 w.WriteHeader(http.StatusNotFound)
427 w.Write([]byte("Not Found")) // nolint: errcheck
428}
429
430func renderUnauthorized(w http.ResponseWriter) {
431 w.WriteHeader(http.StatusUnauthorized)
432 w.Write([]byte("Unauthorized")) // nolint: errcheck
433}
434
435func renderForbidden(w http.ResponseWriter) {
436 w.WriteHeader(http.StatusForbidden)
437 w.Write([]byte("Forbidden")) // nolint: errcheck
438}
439
440func renderInternalServerError(w http.ResponseWriter) {
441 w.WriteHeader(http.StatusInternalServerError)
442 w.Write([]byte("Internal Server Error")) // nolint: errcheck
443}
444
445// Header writing functions
446
447func hdrNocache(w http.ResponseWriter) {
448 w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
449 w.Header().Set("Pragma", "no-cache")
450 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
451}
452
453func hdrCacheForever(w http.ResponseWriter) {
454 now := time.Now().Unix()
455 expires := now + 31536000
456 w.Header().Set("Date", fmt.Sprintf("%d", now))
457 w.Header().Set("Expires", fmt.Sprintf("%d", expires))
458 w.Header().Set("Cache-Control", "public, max-age=31536000")
459}