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