package httpd import ( "context" "crypto/sha256" "embed" "encoding/hex" "errors" "io/fs" "log/slog" "net/http" "sort" "strings" "sync" "time" "github.com/barerepo/server/internal/artifact" "github.com/barerepo/server/internal/config" "github.com/barerepo/server/internal/gitx" "github.com/barerepo/server/internal/proposal" "github.com/barerepo/server/internal/repo" "github.com/barerepo/server/internal/repocfg" "github.com/barerepo/server/internal/store" "github.com/barerepo/server/internal/token" "github.com/barerepo/server/internal/transport" ) //go:embed static var static embed.FS type Server struct { Cfg config.Config DB *store.DB Transport *transport.Server Log *slog.Logger // signups counts per address for chapter 27, and losing it on restart is not worth a table. signupsMu sync.Mutex signups map[string][]time.Time // comments counts per account per thread, the other half of chapter 27, on the same terms. commentsMu sync.Mutex comments map[string][]time.Time } // assetTag is a hash of everything under static, so a changed stylesheet gets a url nobody has cached. var assetTag = hashAssets() // hashAssets reads the embedded files once, in name order, because a map's order is not one. func hashAssets() string { names, err := fs.Glob(static, "static/*") if err != nil { return Version } sort.Strings(names) sum := sha256.New() for _, name := range names { body, err := static.ReadFile(name) if err != nil { continue } sum.Write([]byte(name)) sum.Write(body) } return hex.EncodeToString(sum.Sum(nil))[:12] } // keepable lets a browser keep the stylesheet, because a page that refetches it flashes unstyled. func keepable(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("v") == assetTag { // The url carries the hash of the body, so what is under it can never change. Chapter 25. w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { // An unversioned url is somebody's bookmark, and a minute is enough to stop a flash. w.Header().Set("Cache-Control", "public, max-age=60") } next.ServeHTTP(w, r) }) } // Handler is the whole http surface. Routes are in appendix C. func (s *Server) Handler() http.Handler { // The ring is built once here, so a restart still knows whose keys signed what. s.rebuildKeyring(context.Background()) mux := http.NewServeMux() sub, err := fs.Sub(static, "static") if err != nil { panic(err) } mux.Handle("GET /static/", webPolicy(keepable(http.StripPrefix("/static/", http.FileServerFS(sub))))) // Matched by shape, not prefix, because the same prefix carries every web route too. mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if route, ok := parseGitPath(r.URL.Path); ok { switch { case r.Method == http.MethodGet && route.Advertise, r.Method == http.MethodPost && route.Service != "": s.serveGit(w, r, route) return } http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } webPolicy(http.HandlerFunc(s.serveWeb)).ServeHTTP(w, r) }) return baseHeaders(mux) } // baseHeaders go on everything, including the git routes. func baseHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") next.ServeHTTP(w, r) }) } // webPolicy wraps the handlers, not the path, because a repository named `git-anything` is legal. func webPolicy(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 'self' is for the two shortcuts only, and inline, eval and other hosts stay blocked. w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'self'; script-src 'self'; font-src 'self'; "+ "form-action 'self'; frame-ancestors 'none'; base-uri 'none'") next.ServeHTTP(w, r) }) } // authenticate reads a token from the basic password field, no credential is anonymous, and the scope rides along because chapter 15's job token names one repository and no more. func (s *Server) authenticate(ctx context.Context, r *http.Request) (account, scope string, err error) { _, pass, ok := r.BasicAuth() if !ok || pass == "" { return "", "", nil } t, err := s.DB.AccountForToken(ctx, token.Git, pass) if err != nil { return "", "", errors.New("that token is not valid") } return t.Account, t.Scope, nil } // askForCredentials is the one 401, and a private repository gets it, because existence leaks. func (s *Server) askForCredentials(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="barerepo"`) http.Error(w, "a token goes in the password field. the username is ignored.", http.StatusUnauthorized) } // movedPath rewrites the path for a moved repository, keeping whatever git appended. func (s *Server) movedPath(r *http.Request, to transport.Redirect) string { parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 3) rest := "" if len(parts) == 3 { rest = "/" + parts[2] } u := "/" + to.Owner + "/" + to.Name + rest if r.URL.RawQuery != "" { u += "?" + r.URL.RawQuery } return u } func (s *Server) oops(w http.ResponseWriter, r *http.Request, err error) { s.log(r, err) http.Error(w, "something went wrong on the server", http.StatusInternalServerError) } func (s *Server) log(r *http.Request, err error) { if s.Log == nil { return } s.Log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err) } // TrashWindow is chapter 44.4's 30 days, and the config page states the number. const TrashWindow = 30 * 24 * time.Hour // Sweep discards what has expired, and a failure is logged for the next pass to retry. func (s *Server) Sweep(ctx context.Context) { if err := s.DB.SweepExpired(ctx); err != nil { s.Log.Error("sweep: expired tokens", "err", err) } if err := s.DB.DropOldEvents(ctx); err != nil { s.Log.Error("sweep: old events", "err", err) } if n, err := s.DB.RequeueLostJobs(ctx, 30*time.Minute); err != nil { s.Log.Error("sweep: lost jobs", "err", err) } else if n > 0 { s.Log.Info("requeued jobs whose runner stopped", "count", n) } if n := gitx.CloseIdleReaders(gitx.IdleLife); n > 0 { s.Log.Info("closed idle object readers", "count", n) } if n, err := repo.EmptyTrash(s.Cfg.Paths.Repos, TrashWindow, time.Now()); err != nil { s.Log.Error("sweep: trash", "err", err) } else if n > 0 { s.Log.Info("erased trash past its window", "count", n) } s.sweepRateBuckets() if n, err := artifact.SweepParts(s.Cfg.Paths.Artifacts, artifact.PartWindow, time.Now()); err != nil { s.Log.Error("sweep: unfinished uploads", "err", err) } else if n > 0 { s.Log.Info("removed uploads no process finished", "count", n) } s.expireProposals(ctx) } // sweepRateBuckets drops the counters nobody is spending, since a key per thread is a key forever. func (s *Server) sweepRateBuckets() { cutoff := time.Now().Add(-time.Hour) prune := func(mu *sync.Mutex, buckets map[string][]time.Time) { mu.Lock() defer mu.Unlock() for key, at := range buckets { live := at[:0] for _, t := range at { if t.After(cutoff) { live = append(live, t) } } // An hour with nothing in it is the same as never having been counted. if len(live) == 0 { delete(buckets, key) continue } buckets[key] = live } } prune(&s.signupsMu, s.signups) prune(&s.commentsMu, s.comments) } // expireProposals drops the refs that pin commits nobody has touched, keeping every thread. 26. func (s *Server) expireProposals(ctx context.Context) { err := repo.Walk(s.Cfg.Paths.Repos, func(owner, name, dir string) error { // The fallback carries the window, so a file that will not parse does not also stop the sweep it configures. Chapter 14. cfg, _ := repocfg.Load(ctx, dir) window := time.Duration(cfg.Proposals.ExpireDays) * 24 * time.Hour gone, err := proposal.Expire(ctx, dir, window, time.Now()) if err != nil { s.Log.Error("sweep: proposals", "repo", owner+"/"+name, "err", err) return nil } if len(gone) > 0 { s.Log.Info("expired proposal refs, threads kept", "repo", owner+"/"+name, "proposals", gone) } // An expired proposal keeps no revisions either, since its ref went for the same reason. deleted := len(gone) > 0 expired := map[int]bool{} for _, n := range gone { expired[n] = true } for _, n := range proposal.Numbers(dir) { keep := proposal.RevisionsKept if expired[n] { keep = 0 } dropped, err := proposal.PruneRevisions(ctx, dir, n, keep) if err != nil { s.Log.Error("sweep: revisions", "repo", owner+"/"+name, "err", err) continue } if len(dropped) > 0 { s.Log.Info("pruned retained revisions", "repo", owner+"/"+name, "proposal", n, "revisions", dropped) deleted = true } } // Chapter 26: repack after bulk ref deletion, or the packs retain what was just deleted. if err := repo.Collect(ctx, dir, deleted); err != nil { s.Log.Error("sweep: gc", "repo", owner+"/"+name, "err", err) } return nil }) if err != nil { s.Log.Error("sweep: proposals", "err", err) } } // SweepEvery runs Sweep until the context ends. func (s *Server) SweepEvery(ctx context.Context, every time.Duration) { t := time.NewTicker(every) defer t.Stop() s.Sweep(ctx) for { select { case <-ctx.Done(): return case <-t.C: s.Sweep(ctx) } } }