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