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}