package main import ( "bytes" "crypto/sha256" "embed" "encoding/hex" "errors" "fmt" "html" "html/template" "io" "io/fs" "log" "math/rand/v2" "mime" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "slices" "strconv" "strings" "sync" "time" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" mathjax "github.com/litao91/goldmark-mathjax" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/extension" gmhtml "github.com/yuin/goldmark/renderer/html" ) //go:embed templates/*.html var templateFS embed.FS //go:embed static var staticFS embed.FS type Server struct { mailer *Mailer version string limiter *limiter requests *buckets heavy *buckets store *Store root string host string name string sshUser string pages map[string]*template.Template brand map[string][]byte markdown goldmark.Markdown policy *bluemonday.Policy } type page struct { Version string Fluid bool Title string User *User Host string Name string SSHUser string Error string Math bool Data any Registration string Admin bool Logo bool Icon bool } func newServer(store *Store, root, host, name string, sshUser string) *Server { s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20)} funcs := template.FuncMap{ "short": func(h string) string { if len(h) > 10 { return h[:10] } return h }, "ago": ago, "size": humanSize, "date": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04") }, "lines": func(b []byte) []string { s := strings.TrimSuffix(string(b), "\n") return strings.Split(s, "\n") }, "inc": func(i int) int { return i + 1 }, "perm": perm, "signer": s.signer, "lang": func(r Repo) language { if l := s.repoLanguages(r); len(l) > 0 { return l[0] } return language{} }, "bare": func(u string) string { return strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://"), "/") }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "person": func(email string) string { if u, err := s.store.UserByEmail(email); err == nil { return u.Name } return "" }, "dict": func(kv ...any) map[string]any { m := map[string]any{} for i := 0; i+1 < len(kv); i += 2 { if k, ok := kv[i].(string); ok { m[k] = kv[i+1] } } return m }, "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") }, } names, _ := fs.Glob(templateFS, "templates/*.html") for _, name := range names { base := filepath.Base(name) if base == "layout.html" { continue } if base == "meta.html" { continue } s.pages[strings.TrimSuffix(base, ".html")] = template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", "templates/meta.html", name)) } s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, mathjax.MathJax, highlighting.NewHighlighting(highlighting.WithGuessLanguage(true), highlighting.WithFormatOptions(chromahtml.WithClasses(true)))), goldmark.WithRendererOptions(gmhtml.WithUnsafe())) s.policy = bluemonday.UGCPolicy() s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code") s.policy.AllowElements("video", "source") s.policy.AllowAttrs("src", "type").OnElements("video", "source") s.policy.AllowAttrs("controls", "muted", "loop", "playsinline", "poster", "width", "height").OnElements("video") s.writeSigners() s.writeKeyring() s.installHooks() sum := sha256.New() fs.WalkDir(staticFS, "static", func(path string, d fs.DirEntry, err error) error { if err == nil && !d.IsDir() { b, _ := staticFS.ReadFile(path) sum.Write(b) } return nil }) sum.Write(highlightCSS()) s.brand = map[string][]byte{} for _, f := range []string{"logo.png", "icon.png"} { if b, err := os.ReadFile(filepath.Join(root, f)); err == nil { s.brand[f] = b sum.Write(b) } } s.version = hex.EncodeToString(sum.Sum(nil))[:12] return s } func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p page) { p.Host = s.host p.Name = s.name p.SSHUser = s.sshUser p.Version = s.version p.Logo = s.brand["logo.png"] != nil p.Icon = s.brand["icon.png"] != nil p.Registration = s.store.Registration() if d, ok := p.Data.(map[string]any); ok && d["File"] == true { p.Fluid = true } if p.User == nil { p.User = s.current(r) } if p.User != nil { p.Admin = s.store.IsAdmin(p.User.ID) } t, ok := s.pages[name] if !ok { http.Error(w, "missing template "+name, 500) return } var buf bytes.Buffer if err := t.Execute(&buf, p); err != nil { log.Printf("render %s: %v", name, err) http.Error(w, "template error", 500) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") buf.WriteTo(w) } func (s *Server) fail(w http.ResponseWriter, r *http.Request, code int, msg string) { w.WriteHeader(code) s.render(w, r, "error", page{Title: msg, Error: msg}) } func (s *Server) current(r *http.Request) *User { c, err := r.Cookie("session") if err != nil { return nil } u, err := s.store.SessionUser(c.Value) if err != nil { return nil } return &u } func (s *Server) handler() http.Handler { mux := http.NewServeMux() static, _ := fs.Sub(staticFS, "static") files := http.StripPrefix("/static/", http.FileServerFS(static)) mux.HandleFunc("GET /static/", func(w http.ResponseWriter, r *http.Request) { s.staticCache(w, r) files.ServeHTTP(w, r) }) css := highlightCSS() mux.HandleFunc("GET /static/highlight.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css; charset=utf-8") s.staticCache(w, r) w.Write(css) }) for name, b := range s.brand { mux.HandleFunc("GET /static/site/"+name, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/png") s.staticCache(w, r) w.Write(b) }) } mux.HandleFunc("GET /{$}", s.index) mux.HandleFunc("GET /projects", s.projects) mux.HandleFunc("GET /search", s.find) mux.HandleFunc("GET /login", s.loginForm) mux.HandleFunc("POST /login", s.login) mux.HandleFunc("POST /logout", s.logout) mux.HandleFunc("GET /login/totp", s.totpForm) mux.HandleFunc("POST /login/totp", s.totpLogin) mux.HandleFunc("GET /reset", s.resetForm) mux.HandleFunc("POST /reset", s.resetRequest) mux.HandleFunc("GET /reset/{token}", s.resetTokenForm) mux.HandleFunc("POST /reset/{token}", s.resetPassword) mux.HandleFunc("GET /verify/{token}", s.verifyEmail) mux.HandleFunc("POST /settings/email", s.setEmail) mux.HandleFunc("POST /settings/notify", s.setNotify) mux.HandleFunc("POST /settings/totp", s.setupTOTP) mux.HandleFunc("POST /settings/totp/enable", s.enableTOTP) mux.HandleFunc("POST /settings/totp/disable", s.disableTOTP) mux.HandleFunc("GET /register", s.registerForm) mux.HandleFunc("POST /register", s.register) mux.HandleFunc("GET /create", s.createForm) mux.HandleFunc("POST /create", s.create) mux.HandleFunc("GET /settings", s.settings) mux.HandleFunc("GET /settings/security", s.security) mux.HandleFunc("GET /settings/keys", s.keysPage) mux.HandleFunc("GET /settings/tokens", s.tokensPage) mux.HandleFunc("POST /settings/sessions", s.endSessions) mux.HandleFunc("POST /settings/keys", s.addKey) mux.HandleFunc("POST /settings/keys/{id}/delete", s.deleteKey) mux.HandleFunc("POST /settings/pgp", s.addPGP) mux.HandleFunc("POST /settings/pgp/{id}/delete", s.deletePGP) mux.HandleFunc("POST /settings/tokens", s.addToken) mux.HandleFunc("POST /settings/tokens/{id}/delete", s.deleteToken) mux.HandleFunc("POST /settings/password", s.password) mux.HandleFunc("POST /settings/profile", s.profile) mux.HandleFunc("POST /settings/avatar", s.avatar) mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar) mux.HandleFunc("/admin", s.admin) mux.HandleFunc("/orgs/new", s.newOrg) mux.HandleFunc("/", s.route) cop := http.NewCrossOriginProtection() return secure(s.throttle(cop.Handler(mux))) } func (s *Server) index(w http.ResponseWriter, r *http.Request) { user := s.current(r) recent, err := s.store.PublicRepos("", "") if err != nil { s.fail(w, r, 500, err.Error()) return } recent = s.byUpdate(recent) featured, _ := s.store.FeaturedRepos() if len(featured) == 0 { featured = slices.Clone(recent[:min(len(recent), 6)]) } var repos, shared []Repo if user != nil { repos, _ = s.store.ReposByOwner(user.ID, true) shared, _ = s.store.SharedRepos(user.ID) repos, shared = s.byUpdate(repos), s.byUpdate(shared) recent = slices.DeleteFunc(recent, func(r Repo) bool { return r.Owner.ID == user.ID }) } s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Shared": shared, "Recent": recent[:min(len(recent), 10)], "Featured": featured}}) } func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) { s.render(w, r, "login", page{Title: "Log in"}) } func (s *Server) login(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) name := strings.TrimSpace(r.FormValue("name")) if s.limiter.blocked(ip) || s.limiter.blocked("~"+name) { w.WriteHeader(429) s.render(w, r, "login", page{Title: "Log in", Error: "Too many attempts, try again in a few minutes"}) return } u, ok := s.store.CheckPassword(name, r.FormValue("password")) if !ok { s.limiter.fail(ip) s.limiter.fail("~" + name) w.WriteHeader(401) s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"}) return } if s.store.Account(u.ID).TOTP != "" { token, _ := s.store.CreateMailToken(u.ID, "totp", "") http.SetCookie(w, &http.Cookie{Name: "pending", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600}) http.Redirect(w, r, "/login/totp", http.StatusSeeOther) return } s.startSession(w, u) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) startSession(w http.ResponseWriter, u User) { id, err := s.store.CreateSession(u.ID) if err != nil { return } http.SetCookie(w, &http.Cookie{Name: "session", Value: id, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 90 * 86400}) } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("session"); err == nil { s.store.DeleteSession(c.Value) } http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1}) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) registerForm(w http.ResponseWriter, r *http.Request) { s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Mode": s.store.Registration(), "Invite": r.URL.Query().Get("invite"), "Mail": s.mailer != nil}}) } func (s *Server) register(w http.ResponseWriter, r *http.Request) { invite := strings.TrimSpace(r.FormValue("invite")) retry := func(code int, msg string) { w.WriteHeader(code) s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Mode": s.store.Registration(), "Invite": invite, "Mail": s.mailer != nil, "Email": r.FormValue("email"), "Name": r.FormValue("name")}}) } mode := s.store.Registration() if mode == "closed" { retry(403, "Registration is closed") return } ip := clientIP(r) if s.limiter.blocked(ip) || s.limiter.blocked("register:"+ip) { retry(429, "Too many attempts, try again in a few minutes") return } name := strings.TrimSpace(r.FormValue("name")) if err := checkCredentials(name, r.FormValue("password")); err != nil { retry(400, err.Error()) return } email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) if !validEmail(email) { retry(400, "A valid email address is needed to register") return } if mode == "invite" && !s.store.UseInvite(invite) { s.limiter.fail(ip) retry(400, "That invite is invalid or already used") return } u, err := s.store.CreateUser(name, r.FormValue("password")) if err != nil { retry(400, err.Error()) return } s.limiter.fail("register:" + ip) s.startSession(w, u) if email != "" && s.store.SetEmail(u.ID, email) == nil && s.mailer != nil { go func() { mailSlots <- struct{}{} defer func() { <-mailSlots }() if err := s.sendVerification(u, email); err != nil { log.Printf("verification mail for ~%s: %v", u.Name, err) } }() } http.Redirect(w, r, "/settings", http.StatusSeeOther) } func (s *Server) require(w http.ResponseWriter, r *http.Request) *User { u := s.current(r) if u == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) } return u } func (s *Server) createForm(w http.ResponseWriter, r *http.Request) { if u := s.require(w, r); u != nil { s.render(w, r, "create", page{Title: "New repository", User: u, Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Owner": r.URL.Query().Get("owner"), "Suggest": suggestName()}}) } } func (s *Server) create(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } owner := *u if o := r.FormValue("owner"); o != "" && o != u.Name { org, err := s.store.UserByName(o) if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) { s.fail(w, r, 403, "You are not a member of that organisation") return } owner = org } name := strings.TrimSpace(r.FormValue("name")) repo, err := s.store.CreateRepo(owner, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")) if err != nil { w.WriteHeader(400) s.render(w, r, "create", page{Title: "New repository", User: u, Error: err.Error(), Data: map[string]any{"Orgs": s.store.Orgs(u.ID), "Suggest": suggestName(), "Owner": owner.Name, "Name": name, "Description": r.FormValue("description"), "Tags": r.FormValue("tags")}}) return } dir := s.repoDir(repo) if err := os.MkdirAll(filepath.Dir(dir), 0o755); err == nil { err = initRepo(dir) } if err != nil { s.store.DeleteRepo(repo.ID) s.fail(w, r, 500, err.Error()) return } installHook(s.root, dir) http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) } func (s *Server) signer(c Commit) string { switch c.Signature { case "G", "U": default: return "" } if c.Key != "" { if u, ok := s.store.UserByFingerprint(c.Key); ok { return u.Name } } if c.PGP != "" { if u, ok := s.store.UserByPGP(c.PGP); ok { return u.Name } } return "" } func (s *Server) writeKeyring() { home := filepath.Join(s.root, "gnupg") if err := os.MkdirAll(home, 0o700); err != nil { return } keys, err := s.store.AllPGP() if err != nil { return } os.Remove(filepath.Join(home, "pubring.kbx")) os.Remove(filepath.Join(home, "pubring.kbx~")) for _, k := range keys { cmd := exec.Command("gpg", "--batch", "--quiet", "--import") cmd.Env = []string{"GNUPGHOME=" + home, "PATH=" + os.Getenv("PATH")} cmd.Stdin = strings.NewReader(k) cmd.Run() } exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run() gnupgHome = home } func pgpFingerprint(armored string) (string, error) { cmd := exec.Command("gpg", "--batch", "--with-colons", "--import-options", "show-only", "--import") dir, err := os.MkdirTemp("", "gitpocket-gpg") if err != nil { return "", err } defer os.RemoveAll(dir) cmd.Env = []string{"GNUPGHOME=" + dir, "PATH=" + os.Getenv("PATH")} cmd.Stdin = strings.NewReader(armored) out, err := cmd.Output() if err != nil { return "", errors.New("That is not a valid armored PGP public key") } for _, line := range strings.Split(string(out), "\n") { if strings.HasPrefix(line, "fpr:") { f := strings.Split(line, ":") if len(f) > 9 && f[9] != "" { return strings.ToUpper(f[9]), nil } } } return "", errors.New("That is not a valid armored PGP public key") } func (s *Server) writeSigners() { keys, err := s.store.AllKeys() if err != nil { return } var b strings.Builder for _, k := range keys { b.WriteString("* namespaces=\"git\" " + k + "\n") } path := filepath.Join(s.root, "signers") if err := os.WriteFile(path+".tmp", []byte(b.String()), 0o644); err == nil { if os.Rename(path+".tmp", path) == nil { signersFile = path } } } func visibility(v string) int { switch v { case "private": return Private case "unlisted": return Unlisted } return Public } func (s *Server) projects(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("search")) if tag := strings.TrimSpace(r.URL.Query().Get("tag")); tag != "" { q = strings.TrimSpace(q + " tag:" + tag) } http.Redirect(w, r, "/search?q="+url.QueryEscape(q), http.StatusMovedPermanently) } func (s *Server) byUpdate(repos []Repo) []Repo { for i := range repos { repos[i].Updated = repoUpdated(s.repoDir(repos[i])) } slices.SortStableFunc(repos, func(a, b Repo) int { return b.Updated.Compare(a.Updated) }) return repos } func repoUpdated(dir string) time.Time { var latest time.Time consider := func(p string) { if info, err := os.Stat(p); err == nil && info.ModTime().After(latest) { latest = info.ModTime() } } consider(filepath.Join(dir, "packed-refs")) for _, sub := range []string{"heads", "tags"} { entries, _ := os.ReadDir(filepath.Join(dir, "refs", sub)) for _, e := range entries { consider(filepath.Join(dir, "refs", sub, e.Name())) } } if latest.IsZero() { consider(dir) } return latest } func (s *Server) repoDir(repo Repo) string { return filepath.Join(s.root, "repos", repo.Owner.Name, repo.Name+".git") } func (s *Server) settings(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } used := usage(filepath.Join(s.root, "repos", u.Name)) s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent")}}) } func (s *Server) security(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } a := s.store.Account(u.ID) s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security", "Account": a, "Mail": s.mailer != nil}}) } func (s *Server) keysPage(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } keys, _ := s.store.Keys(u.ID) pgp, _ := s.store.PGPKeys(u.ID) s.render(w, r, "keys", page{Title: "Keys", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "keys", "Keys": keys, "PGP": pgp}}) } func (s *Server) addPGP(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } armored := strings.TrimSpace(r.FormValue("key")) if len(armored) > 64<<10 { http.Redirect(w, r, "/settings/keys?error=Key+too+large", http.StatusSeeOther) return } fp, err := pgpFingerprint(armored) if err == nil { err = s.store.AddPGP(u.ID, fp, armored) } if err != nil { http.Redirect(w, r, "/settings/keys?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } s.writeKeyring() http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) } func (s *Server) deletePGP(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) s.store.DeletePGP(u.ID, id) s.writeKeyring() http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) } func (s *Server) tokensPage(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } tokens, _ := s.store.Tokens(u.ID) s.render(w, r, "tokens", page{Title: "Tokens", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "tokens", "Tokens": tokens, "NewToken": r.URL.Query().Get("token")}}) } func (s *Server) endSessions(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } s.store.DeleteSessions(u.ID) http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1}) http.Redirect(w, r, "/login", http.StatusSeeOther) } func (s *Server) subject(w http.ResponseWriter, r *http.Request) (*User, string) { u := s.require(w, r) if u == nil { return nil, "" } name := r.FormValue("org") if name == "" { return u, "/settings" } org, err := s.store.UserByName(name) if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) { s.fail(w, r, 403, "You are not a member of that organisation") return nil, "" } return &org, "/~" + org.Name + "/settings" } func (s *Server) profile(w http.ResponseWriter, r *http.Request) { u, back := s.subject(w, r) if u == nil { return } field := func(name string, max int) string { v := strings.TrimSpace(r.FormValue(name)) if len(v) > max { v = v[:max] } return v } p := Profile{Display: field("display", 100), Bio: field("bio", 4000), Location: field("location", 100), Workplace: field("workplace", 100), Website: field("website", 200)} if err := s.store.UpdateProfile(u.ID, p); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther) } func (s *Server) avatarPath(id int64) string { return filepath.Join(s.root, "avatars", strconv.FormatInt(id, 10)) } func (s *Server) avatar(w http.ResponseWriter, r *http.Request) { u, back := s.subject(w, r) if u == nil { return } r.Body = http.MaxBytesReader(w, r.Body, 16<<20) f, _, err := r.FormFile("avatar") if err != nil { http.Redirect(w, r, back+"?error=Choose+an+image+under+16+MiB", http.StatusSeeOther) return } defer f.Close() data, err := io.ReadAll(f) if err != nil { http.Redirect(w, r, back+"?error=Choose+an+image+under+16+MiB", http.StatusSeeOther) return } x, _ := strconv.Atoi(r.FormValue("x")) y, _ := strconv.Atoi(r.FormValue("y")) size, _ := strconv.Atoi(r.FormValue("size")) out, err := cropAvatar(data, x, y, size) if err == nil { if err = os.MkdirAll(filepath.Join(s.root, "avatars"), 0o755); err == nil { err = os.WriteFile(s.avatarPath(u.ID), out, 0o644) } } if err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } s.store.SetAvatar(u.ID, time.Now().Unix()) http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther) } func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) { u, back := s.subject(w, r) if u == nil { return } os.Remove(s.avatarPath(u.ID)) s.store.SetAvatar(u.ID, 0) http.Redirect(w, r, back, http.StatusSeeOther) } func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User) { data, err := os.ReadFile(s.avatarPath(owner.ID)) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(data) } func (s *Server) addKey(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } if err := s.store.AddKey(u.ID, strings.TrimSpace(r.FormValue("title")), r.FormValue("key")); err != nil { http.Redirect(w, r, "/settings/keys?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } s.writeSigners() http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) } func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) s.store.DeleteKey(u.ID, id) s.writeSigners() http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) } func (s *Server) addToken(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } token, err := s.store.CreateToken(u.ID, strings.TrimSpace(r.FormValue("title"))) if err != nil { http.Redirect(w, r, "/settings/tokens?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/settings/tokens?token="+token, http.StatusSeeOther) } func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) s.store.DeleteToken(u.ID, id) http.Redirect(w, r, "/settings/tokens", http.StatusSeeOther) } func (s *Server) password(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } if _, ok := s.store.CheckPassword(u.Name, r.FormValue("current")); !ok { s.limiter.fail(clientIP(r)) http.Redirect(w, r, "/settings/security?error=Current+password+is+wrong", http.StatusSeeOther) return } if err := s.store.SetPassword(u.ID, r.FormValue("password")); err != nil { http.Redirect(w, r, "/settings/security?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/settings/security", http.StatusSeeOther) } func (s *Server) route(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/~") { s.fail(w, r, 404, "Page not found") return } parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/~"), "/", 3) if len(parts) == 1 && (strings.HasSuffix(parts[0], ".keys") || strings.HasSuffix(parts[0], ".pgp")) { s.publicKeys(w, r, parts[0]) return } owner, err := s.store.UserByName(parts[0]) if err != nil { s.fail(w, r, 404, "No such user") return } user := s.current(r) if len(parts) == 1 || (len(parts) == 2 && parts[1] == "") { org := s.store.IsOrg(owner.ID) member := user != nil && (user.ID == owner.ID || s.store.IsMember(owner.ID, user.ID)) repos, err := s.store.ReposByOwner(owner.ID, member) if err != nil { s.fail(w, r, 500, err.Error()) return } repos = s.byUpdate(repos) profile := s.store.Profile(owner.ID) var bio template.HTML if profile.Bio != "" { bio = s.renderMarkdown([]byte(profile.Bio)) } year, _ := strconv.Atoi(r.URL.Query().Get("year")) if year < 1970 || year > time.Now().Year() { year = 0 } data := map[string]any{"Owner": owner, "Repos": pinnedRepos(repos, s.store.Pins(owner.ID)), "Total": len(repos), "Profile": profile, "Bio": bio, "Count": s.store.StarredCount(owner.ID), "Activity": s.activity(owner, year), "Member": member} if org { var people []userHit for _, m := range s.store.Members(owner.ID) { people = append(people, userHit{User: m, Profile: s.store.Profile(m.ID)}) } data["Members"] = people s.render(w, r, "org", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: data}) return } var orgs []userHit for _, o := range s.store.Orgs(owner.ID) { orgs = append(orgs, userHit{User: o, Profile: s.store.Profile(o.ID)}) } data["Orgs"] = orgs intro := s.profileReadme(owner) data["Intro"] = intro if name := strings.ToLower(strings.TrimLeft(strings.TrimSpace(profile.Workplace), "@~")); validName(name) && name != owner.Name { if place, err := s.store.UserByName(name); err == nil { data["Workplace"] = place.Name } } s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio) || hasMath(intro.HTML), Data: data}) return } name := parts[1] if name == "avatar" && len(parts) == 2 { s.serveAvatar(w, r, owner) return } if name == "feed" && len(parts) == 2 && r.Method == "GET" { s.userFeed(w, r, owner) return } if (name == "settings" || name == "members") && len(parts) == 2 && s.store.IsOrg(owner.ID) { s.orgSettings(w, r, owner, name) return } if name == "repos" && len(parts) == 2 && r.Method == "GET" { s.repos(w, r, owner, user) return } if name == "stars" && len(parts) == 2 && r.Method == "GET" { starred, _ := s.store.StarredRepos(owner.ID) s.render(w, r, "stars", page{Title: "~" + owner.Name + " stars", User: user, Data: map[string]any{"Owner": owner, "Starred": starred, "Count": len(starred)}}) return } rest := "" if len(parts) == 3 { rest = parts[2] } if strings.HasSuffix(name, ".git") || isSmart(r, rest) { s.smart(w, r, owner, strings.TrimSuffix(name, ".git"), rest) return } repo, err := s.store.RepoByPath(owner.Name, name) if err != nil { if !s.redirectMoved(w, r, owner.Name, name, user, http.StatusMovedPermanently) { s.fail(w, r, 404, "No such repository") } return } if !s.store.CanRead(repo, user) { s.fail(w, r, 404, "No such repository") return } switch { case rest == "" && r.Method == "GET": s.summary(w, r, repo) case rest == "settings" && r.Method == "GET": s.repoSettings(w, r, repo) case rest == "settings" && r.Method == "POST": s.updateRepo(w, r, repo) case rest == "delete" && r.Method == "POST": s.deleteRepo(w, r, repo) case rest == "collaborators" && r.Method == "POST": s.collaborators(w, r, repo) case rest == "labels" && r.Method == "POST": s.manageLabels(w, r, repo) case rest == "protection" && r.Method == "POST": s.manageProtection(w, r, repo) case strings.HasPrefix(rest, "edit/"): s.edit(w, r, repo, strings.TrimPrefix(rest, "edit/"), false) case strings.HasPrefix(rest, "new/"): s.edit(w, r, repo, strings.TrimPrefix(rest, "new/"), true) case strings.HasPrefix(rest, "remove/") && r.Method == "POST": s.removeFile(w, r, repo, strings.TrimPrefix(rest, "remove/")) case rest == "rename" && r.Method == "POST": s.renameRepo(w, r, repo) case rest == "transfer" && r.Method == "POST": s.transferRepo(w, r, repo) case rest == "archive" && r.Method == "POST": s.archiveRepo(w, r, repo) case rest == "star" && r.Method == "POST": s.star(w, r, repo) case strings.HasPrefix(rest, "blame/") && r.Method == "GET": s.blame(w, r, repo, strings.TrimPrefix(rest, "blame/")) case strings.HasPrefix(rest, "compare") && r.Method == "GET": s.compare(w, r, repo, strings.TrimPrefix(strings.TrimPrefix(rest, "compare"), "/")) case strings.HasPrefix(rest, "archive/") && r.Method == "GET": s.download(w, r, repo, strings.TrimPrefix(rest, "archive/")) case rest == "feed" && r.Method == "GET": s.feed(w, r, repo) case rest == "issues/feed" && r.Method == "GET": s.issueFeed(w, r, repo) case rest == "pulls/feed" && r.Method == "GET": s.pullFeed(w, r, repo) case rest == "issues" && r.Method == "GET": s.issues(w, r, repo) case rest == "issues/new" && r.Method == "GET": s.newIssue(w, r, repo) case rest == "issues/new" && r.Method == "POST": s.createIssue(w, r, repo) case strings.HasPrefix(rest, "issues/"): s.issue(w, r, repo, strings.TrimPrefix(rest, "issues/")) case rest == "refs" && r.Method == "GET": s.refs(w, r, repo) case strings.HasPrefix(rest, "tree/") && r.Method == "GET": s.tree(w, r, repo, strings.TrimPrefix(rest, "tree/")) case strings.HasPrefix(rest, "blob/") && r.Method == "GET": s.tree(w, r, repo, strings.TrimPrefix(rest, "blob/")) case strings.HasPrefix(rest, "raw/") && r.Method == "GET": s.raw(w, r, repo, strings.TrimPrefix(rest, "raw/")) case strings.HasPrefix(rest, "log/") && r.Method == "GET": s.log(w, r, repo, strings.TrimPrefix(rest, "log/")) case rest == "log" && r.Method == "GET": s.log(w, r, repo, "") case strings.HasPrefix(rest, "commit/") && r.Method == "GET": s.commit(w, r, repo, strings.TrimPrefix(rest, "commit/")) case rest == "search" && r.Method == "GET": s.search(w, r, repo) case rest == "pulls" && r.Method == "GET": s.pulls(w, r, repo) case rest == "pulls/new" && r.Method == "GET": s.newPull(w, r, repo) case rest == "pulls/new" && r.Method == "POST": s.createPull(w, r, repo) case strings.HasPrefix(rest, "pulls/"): s.pull(w, r, repo, strings.TrimPrefix(rest, "pulls/")) case rest == "fork" && r.Method == "POST": s.fork(w, r, repo) default: s.fail(w, r, 404, "Page not found") } } func (s *Server) publicKeys(w http.ResponseWriter, r *http.Request, spec string) { name, kind, _ := strings.Cut(spec, ".") u, err := s.store.UserByName(name) if err != nil { http.NotFound(w, r) return } ssh, pgp := s.store.PublicKeys(u.ID) w.Header().Set("Content-Type", "text/plain; charset=utf-8") list := ssh if kind == "pgp" { list = pgp } for _, k := range list { fmt.Fprintln(w, k) } } type repoPage struct { Repo Repo Ref string Commit string Owner bool Empty bool Tab string Open int OpenIssues int Parent *Repo User *User IsOwner bool Stars int Starred bool } func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) { g := Git{Dir: s.repoDir(repo)} user := s.current(r) rp := repoPage{Repo: repo, Owner: s.store.CanWrite(repo, user), IsOwner: s.store.Owns(repo, user), Open: s.store.OpenPullCount(repo.ID), OpenIssues: s.store.OpenIssueCount(repo.ID), User: user, Stars: repo.Stars} if user != nil { rp.Starred = s.store.Starred(user.ID, repo.ID) } if repo.Parent != 0 { if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private { rp.Parent = &parent } } head := g.Head() if ref == "" { ref = head } commit, known := s.refTable(g).byName[ref] var err error if !known { commit, err = g.Resolve(ref) } if err != nil { if g.Empty() { rp.Empty = true rp.Ref = head return rp, g, nil } return rp, g, err } rp.Ref = ref rp.Commit = commit return rp, g, nil } func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) { rp, g, err := s.repoPage(r, repo, "") if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "summary" data := map[string]any{"Page": rp} data["Pushed"] = s.recentPushes(repo, g, rp) if !rp.Empty { sum := s.summaryData(repo, g, rp) data["Commits"], data["Branches"], data["Tags"] = sum.commits, sum.branches, sum.tags data["Languages"] = s.repoLanguages(repo) if sum.readmeName != "" { data["ReadmeName"] = sum.readmeName if sum.readme != "" { data["Readme"] = sum.readme } else { data["ReadmeText"] = sum.readmeText } } } s.render(w, r, "repo", page{Title: repo.Path(), Math: hasMath(data["Readme"]), Data: data}) } var displayMath = regexp.MustCompile(`(?m)^( {0,3})\$\$([^$\n]+)\$\$[ \t]*$`) const maxRender = 4 << 20 const maxHighlight = 512 << 10 func (s *Server) renderMarkdown(src []byte) template.HTML { return s.renderMarkdownAt(src, "", "") } var relLink = regexp.MustCompile(`(src|href)="([^"]+)"`) func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template.HTML { src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$")) var buf bytes.Buffer if err := s.markdown.Convert(src, &buf); err != nil { return template.HTML("
" + template.HTMLEscapeString(string(src)) + "
") } out := s.policy.SanitizeBytes(buf.Bytes()) if rawBase != "" { out = relLink.ReplaceAllFunc(out, func(m []byte) []byte { parts := relLink.FindSubmatch(m) attr, target := string(parts[1]), string(parts[2]) if strings.Contains(target, "://") || strings.HasPrefix(target, "/") || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") { return m } target = strings.TrimPrefix(target, "./") base := treeBase if attr == "src" { base = rawBase } return []byte(attr + `="` + html.EscapeString(escapePath(base)) + target + `"`) }) } return template.HTML(out) } func hasMath(h any) bool { t, ok := h.(template.HTML) return ok && strings.Contains(string(t), `class="math `) } func (s *Server) splitRef(g Git, spec string) (string, string, bool) { spec = strings.Trim(spec, "/") if spec == "" { return "", "", false } parts := strings.Split(spec, "/") known := s.refTable(g).byName for i := len(parts); i >= 1; i-- { if _, ok := known[strings.Join(parts[:i], "/")]; ok { return strings.Join(parts[:i], "/"), strings.Join(parts[i:], "/"), true } } if _, err := g.Resolve(parts[0]); err == nil { return parts[0], strings.Join(parts[1:], "/"), true } for i := len(parts); i >= 2; i-- { ref := strings.Join(parts[:i], "/") if _, err := g.Resolve(ref); err == nil { return ref, strings.Join(parts[i:], "/"), true } } return "", "", false } func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := s.splitRef(g, spec) if !ok { s.fail(w, r, 404, "No such ref") return } filePath, ok = cleanPath(filePath) if !ok { s.fail(w, r, 404, "No such path") return } rp, g, err := s.repoPage(r, repo, ref) if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "tree" crumbs := strings.Split(filePath, "/") if filePath == "" { crumbs = nil } data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs, "Branches": s.refTable(g).branches()} meta := treeInfo(g, rp.Commit, filePath) if meta.last != nil { data["LastCommit"] = *meta.last } if meta.entry != nil { data["Entry"] = meta.entry } switch meta.kind { case "tree", "commit": if meta.entries == nil { s.fail(w, r, 404, "No such path") return } data["Entries"] = meta.entries data["Last"] = meta.lastByName s.render(w, r, "tree", page{Title: repo.Path() + "/" + filePath, Data: data}) case "blob": size := g.Size(rp.Commit, filePath) data["Size"] = size var content []byte if size <= maxRender { if content, err = g.Blob(rp.Commit, filePath); err != nil { s.fail(w, r, 404, "No such path") return } } data["File"] = true if size > maxRender { data["Large"] = true } else if isBinary(content) { data["Binary"] = true } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" { dir := "" if i := strings.LastIndex(filePath, "/"); i >= 0 { dir = filePath[:i+1] } data["Markdown"] = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/"+dir, "/"+repo.Path()+"/tree/"+rp.Ref+"/"+dir) } else if h, ok := highlightLimited(filePath, content); ok { data["Highlighted"] = h } else { data["Content"] = content } s.render(w, r, "blob", page{Title: repo.Path() + "/" + filePath, Math: hasMath(data["Markdown"]), Data: data}) default: s.fail(w, r, 404, "No such path") } } const maxMedia = 64 << 20 var mediaTypes = map[string]string{ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".flac": "audio/flac", } func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { g := Git{Dir: s.repoDir(repo)} ref, filePath, ok := s.splitRef(g, spec) if !ok { s.fail(w, r, 404, "No such ref") return } filePath, ok = cleanPath(filePath) if !ok || filePath == "" { s.fail(w, r, 404, "No such path") return } commit, err := g.Resolve(ref) if err != nil { s.fail(w, r, 404, "No such ref") return } if g.Kind(commit, filePath) != "blob" { s.fail(w, r, 404, "No such path") return } w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(filePath)})) size := g.Size(commit, filePath) if kind, ok := mediaTypes[strings.ToLower(filepath.Ext(filePath))]; ok && size <= maxMedia { data, err := g.Blob(commit, filePath) if err != nil { s.fail(w, r, 500, err.Error()) return } w.Header().Set("Content-Type", kind) w.Header().Set("Cache-Control", cacheScope(repo)+", max-age=300") w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox") http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) g.Stream(w, commit, filePath) } func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref string) { ref = strings.Trim(ref, "/") rp, g, err := s.repoPage(r, repo, ref) if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "log" filePath, ok := cleanPath(r.URL.Query().Get("path")) if !ok { s.fail(w, r, 404, "No such path") return } query, mode := r.URL.Query().Get("q"), r.URL.Query().Get("mode") var pickaxe []string if query != "" { if mode == "regex" { pickaxe = []string{"-G" + query} } else { mode = "text" pickaxe = []string{"-S" + query} } } data := map[string]any{"Page": rp, "Path": filePath, "Query": query, "Mode": mode} if !rp.Empty { start := rp.Commit if from := r.URL.Query().Get("from"); from != "" { if start, err = g.Resolve(from); err != nil { s.fail(w, r, 404, "No such commit") return } } const per = 50 commits, err := g.Log(start, per+1, filePath, pickaxe...) if err != nil && query != "" { data["Error"] = "That search could not run, check the pattern" } else if err != nil { s.fail(w, r, 500, err.Error()) return } if len(commits) > per { data["Next"] = commits[per].Hash commits = commits[:per] } data["Commits"] = commits } s.render(w, r, "log", page{Title: repo.Path() + " log", Data: data}) } func (s *Server) commit(w http.ResponseWriter, r *http.Request, repo Repo, hash string) { rp, g, err := s.repoPage(r, repo, hash) if err != nil { s.fail(w, r, 404, "No such commit") return } rp.Tab = "log" c, err := g.Commit(rp.Commit) if err != nil { s.fail(w, r, 404, "No such commit") return } files, err := g.Diff(rp.Commit) if err != nil { s.fail(w, r, 500, err.Error()) return } added, removed := 0, 0 for _, f := range files { added += f.Added removed += f.Removed } s.render(w, r, "commit", page{Title: c.Subject, Data: map[string]any{"Page": rp, "Commit": c, "Files": files, "Added": added, "Removed": removed}}) } type matchGroup struct { Path string Matches []Match } func (s *Server) search(w http.ResponseWriter, r *http.Request, repo Repo) { ref := strings.TrimSpace(r.URL.Query().Get("ref")) rp, g, err := s.repoPage(r, repo, ref) if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "tree" query := strings.TrimSpace(r.URL.Query().Get("q")) data := map[string]any{"Page": rp, "Query": query} if query != "" && !rp.Empty { if len(query) > 200 { query = query[:200] } matches, truncated, err := g.Grep(rp.Commit, query, 500) if err != nil { s.fail(w, r, 500, err.Error()) return } var groups []matchGroup for _, m := range matches { if len(groups) == 0 || groups[len(groups)-1].Path != m.Path { groups = append(groups, matchGroup{Path: m.Path}) } groups[len(groups)-1].Matches = append(groups[len(groups)-1].Matches, m) } data["Groups"] = groups data["Count"] = len(matches) data["Truncated"] = truncated } s.render(w, r, "search", page{Title: repo.Path() + " search", Data: data}) } func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) { rp, g, err := s.repoPage(r, repo, "") if err != nil { s.fail(w, r, 404, "No such ref") return } rp.Tab = "refs" var branches, tags []Ref for _, ref := range s.refTable(g).list { if ref.Tag { tags = append(tags, ref) } else { branches = append(branches, ref) } } s.render(w, r, "refs", page{Title: repo.Path() + " refs", Data: map[string]any{"Page": rp, "Branches": branches, "Tags": tags}}) } func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } rp.Tab = "settings" collabs, _ := s.store.Collaborators(repo.ID) labels, _ := s.store.Labels(repo.ID) s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs, "Labels": labels, "Protections": s.store.Protections(repo.ID), "Pinned": s.store.Pins(repo.Owner.ID)[repo.ID] > 0}}) } func (s *Server) collaborators(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } back := "/" + repo.Path() + "/settings" if id, err := strconv.ParseInt(r.FormValue("remove"), 10, 64); err == nil { s.store.RemoveCollaborator(repo.ID, id) } else if name := strings.TrimSpace(strings.TrimPrefix(r.FormValue("name"), "~")); name != "" { if name == repo.Owner.Name { http.Redirect(w, r, back+"?error=That+is+you", http.StatusSeeOther) return } if err := s.store.AddCollaborator(repo.ID, name); err != nil { http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) return } } http.Redirect(w, r, back, http.StatusSeeOther) } func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")) if pin := r.FormValue("pinned") != ""; err == nil && pin != (s.store.Pins(repo.Owner.ID)[repo.ID] > 0) { err = s.store.Pin(repo.Owner.ID, repo.ID, pin) } if err != nil { rp.Tab = "settings" w.WriteHeader(400) s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: err.Error(), Data: map[string]any{"Page": rp}}) return } http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) } func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.IsOwner { s.fail(w, r, 403, "Not your repository") return } if r.FormValue("confirm") != repo.Name { http.Redirect(w, r, "/"+repo.Path()+"/settings", http.StatusSeeOther) return } if err := s.store.DeleteRepo(repo.ID); err != nil { s.fail(w, r, 500, err.Error()) return } os.RemoveAll(s.repoDir(repo)) http.Redirect(w, r, "/~"+repo.Owner.Name, http.StatusSeeOther) } func ago(t time.Time) string { d := time.Since(t) switch { case d < time.Minute: return "just now" case d < 2*time.Minute: return "a minute ago" case d < time.Hour: return fmt.Sprintf("%d minutes ago", int(d.Minutes())) case d < 2*time.Hour: return "an hour ago" case d < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(d.Hours())) case d < 48*time.Hour: return "yesterday" case d < 30*24*time.Hour: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) case d < 365*24*time.Hour: return fmt.Sprintf("%d months ago", int(d.Hours()/24/30)) default: return fmt.Sprintf("%d years ago", int(d.Hours()/24/365)) } } func perm(mode string) string { bits := func(o string) string { n, _ := strconv.ParseInt(o, 8, 16) out := []byte("---------") for i := 0; i < 9; i++ { if n&(1<<(8-i)) != 0 { out[i] = "rwx"[i%3] } } return string(out) } switch { case strings.HasPrefix(mode, "040"): return "drwxr-xr-x" case strings.HasPrefix(mode, "120"): return "lrwxrwxrwx" case strings.HasPrefix(mode, "160"): return "m---------" case len(mode) >= 3: return "-" + bits(mode[len(mode)-3:]) } return mode } func humanSize(n int64) string { switch { case n < 1024: return fmt.Sprintf("%d bytes", n) case n < 1<<20: return fmt.Sprintf("%.1f KiB", float64(n)/1024) default: return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) } } func (s *Server) orgSettings(w http.ResponseWriter, r *http.Request, org User, action string) { u := s.require(w, r) if u == nil { return } if !s.store.IsMember(org.ID, u.ID) { s.fail(w, r, 403, "You are not a member of this organisation") return } back := "/~" + org.Name + "/settings" if r.Method == "POST" && action == "members" { name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~") target, err := s.store.UserByName(name) switch { case err != nil || s.store.IsOrg(target.ID): back += "?error=No+such+user" case r.FormValue("remove") != "": if len(s.store.Members(org.ID)) == 1 { back += "?error=An+organisation+needs+at+least+one+member" } else { s.store.SetMember(org.ID, target.ID, false) } default: s.store.SetMember(org.ID, target.ID, true) } http.Redirect(w, r, back, http.StatusSeeOther) return } if r.Method != "GET" || action != "settings" { http.NotFound(w, r) return } var people []userHit for _, m := range s.store.Members(org.ID) { people = append(people, userHit{User: m, Profile: s.store.Profile(m.ID)}) } s.render(w, r, "orgsettings", page{Title: "~" + org.Name + " settings", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Org": org, "Profile": s.store.Profile(org.ID), "Members": people}}) } func (s *Server) redirectMoved(w http.ResponseWriter, r *http.Request, owner, name string, user *User, code int) bool { moved, ok := s.store.Redirect(owner, strings.TrimSuffix(name, ".git")) if !ok || !s.store.CanRead(moved, user) { return false } _, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/~"+owner+"/"), "/") target := "/" + moved.Path() if strings.HasSuffix(name, ".git") { target += ".git" } if rest != "" || strings.HasSuffix(r.URL.Path, "/") { target += "/" + rest } if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, code) return true } const contentPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; media-src 'self' https:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" func secure(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", contentPolicy) h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "same-origin") h.Set("Cross-Origin-Opener-Policy", "same-origin") next.ServeHTTP(w, r) }) } func escapePath(p string) string { parts := strings.Split(p, "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") } func cacheScope(repo Repo) string { if repo.Private { return "private" } return "public" } func pinnedRepos(repos []Repo, pins map[int64]int) []Repo { var out []Repo for _, r := range repos { if pins[r.ID] > 0 { out = append(out, r) } } slices.SortFunc(out, func(a, b Repo) int { return pins[a.ID] - pins[b.ID] }) return out } func (s *Server) repos(w http.ResponseWriter, r *http.Request, owner User, user *User) { member := user != nil && (user.ID == owner.ID || s.store.IsMember(owner.ID, user.ID)) repos, err := s.store.ReposByOwner(owner.ID, member) if err != nil { s.fail(w, r, 500, err.Error()) return } data := map[string]any{"Owner": owner, "Repos": s.byUpdate(repos), "Member": member, "Count": s.store.StarredCount(owner.ID), "Org": s.store.IsOrg(owner.ID)} s.render(w, r, "repos", page{Title: "~" + owner.Name + " repos", User: user, Data: data}) } type intro struct { Path, Name, Ref string HTML template.HTML } type introEntry struct { stamp time.Time intro intro } var introCache = struct { sync.Mutex m map[int64]introEntry }{m: map[int64]introEntry{}} func (s *Server) profileReadme(owner User) intro { repo, err := s.store.RepoByPath(owner.Name, owner.Name) if err != nil || repo.Private || repo.Unlisted { return intro{} } g := Git{Dir: s.repoDir(repo)} stamp := repoUpdated(g.Dir) introCache.Lock() e, ok := introCache.m[repo.ID] introCache.Unlock() if ok && e.stamp.Equal(stamp) { return e.intro } var in intro ref := g.Head() if commit, err := g.Resolve(ref); err == nil { if name, content := g.Readme(commit); strings.HasSuffix(strings.ToLower(name), ".md") && len(content) <= 256<<10 { in = intro{Path: repo.Path(), Name: name, Ref: ref, HTML: s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+ref+"/", "/"+repo.Path()+"/tree/"+ref+"/")} } } introCache.Lock() introCache.m[repo.ID] = introEntry{stamp, in} introCache.Unlock() return in } var nameWords = []string{"heron", "lantern", "harbour", "kestrel", "thicket", "anvil", "orchard", "beacon", "lattice", "meadow", "quarry", "sparrow", "tundra", "compass", "furnace", "willow", "ledger", "marrow", "pebble", "spindle", "cinder", "bramble", "atlas", "ferry", "loom", "estuary", "magpie", "bellows", "cairn", "fathom", "gantry", "hearth", "juniper", "keel", "lichen", "mortar", "nettle", "osprey", "plinth", "quill", "rookery", "sextant", "tinder", "umber", "vellum", "wicket", "yarrow", "zephyr", "sorrel", "flint"} func suggestName() string { return nameWords[rand.IntN(len(nameWords))] } func (s *Server) staticCache(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("v") == s.version { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=3600") } } type summaryEntry struct { stamp time.Time ref string commit string commits []Commit branches []Ref tags []Ref readmeName string readme template.HTML readmeText string } var summaries = struct { sync.Mutex m map[int64]summaryEntry }{m: map[int64]summaryEntry{}} func (s *Server) summaryData(repo Repo, g Git, rp repoPage) summaryEntry { stamp := repoUpdated(g.Dir) summaries.Lock() e, ok := summaries.m[repo.ID] summaries.Unlock() if ok && e.stamp.Equal(stamp) && e.ref == rp.Ref && e.commit == rp.Commit { return e } e = summaryEntry{stamp: stamp, ref: rp.Ref, commit: rp.Commit} e.commits, _ = g.Log(rp.Commit, 4, "") for _, r := range s.refTable(g).list { if r.Tag { e.tags = append(e.tags, r) } else { e.branches = append(e.branches, r) } } name, content := g.Readme(rp.Commit) e.readmeName = name if strings.HasSuffix(strings.ToLower(name), ".md") { e.readme = s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+rp.Ref+"/", "/"+repo.Path()+"/tree/"+rp.Ref+"/") } else if name != "" { e.readmeText = string(content) } summaries.Lock() if len(summaries.m) > 1024 { clear(summaries.m) } summaries.m[repo.ID] = e summaries.Unlock() return e } type refSet struct { stamp time.Time list []Ref byName map[string]string } var refCache = struct { sync.Mutex m map[string]refSet }{m: map[string]refSet{}} func (s *Server) refTable(g Git) refSet { stamp := repoUpdated(g.Dir) refCache.Lock() rs, ok := refCache.m[g.Dir] refCache.Unlock() if ok && rs.stamp.Equal(stamp) { return rs } list, err := g.Refs("refs/heads", "refs/tags") rs = refSet{stamp: stamp, list: list, byName: map[string]string{}} for _, r := range list { if _, dup := rs.byName[r.Name]; !dup || !r.Tag { rs.byName[r.Name] = r.Hash } } if err == nil { refCache.Lock() if len(refCache.m) > 1024 { clear(refCache.m) } refCache.m[g.Dir] = rs refCache.Unlock() } return rs } func (rs refSet) branches() []Ref { var out []Ref for _, r := range rs.list { if !r.Tag { out = append(out, r) } } return out } type pushedBranch struct { Ref Ahead int } func (s *Server) recentPushes(repo Repo, g Git, rp repoPage) []pushedBranch { if !rp.Owner || rp.Empty { return nil } head := g.Head() var out []pushedBranch for _, b := range s.refTable(g).branches() { if len(out) == 3 || time.Since(b.Time) > 72*time.Hour { break } if b.Name == head || s.store.HasOpenPull(repo.ID, b.Name) { continue } n, err := g.run(nil, "rev-list", "--count", "--end-of-options", "refs/heads/"+head+"..refs/heads/"+b.Name) if ahead, _ := strconv.Atoi(strings.TrimSpace(string(n))); err == nil && ahead > 0 { out = append(out, pushedBranch{b, ahead}) } } return out } type treeMeta struct { kind string last *Commit entry *Entry entries []Entry lastByName map[string]Commit } var treeCache = struct { sync.Mutex m map[string]treeMeta }{m: map[string]treeMeta{}} func treeInfo(g Git, commit, filePath string) treeMeta { key := commit + "\x00" + filePath treeCache.Lock() m, ok := treeCache.m[key] treeCache.Unlock() if ok { return m } if last, _ := g.Log(commit, 1, filePath); len(last) > 0 { m.last = &last[0] } m.entry = g.Entry(commit, filePath) m.kind = g.Kind(commit, filePath) if m.kind == "tree" || m.kind == "commit" { if entries, err := g.Tree(commit, filePath); err == nil { m.entries = entries if m.entries == nil { m.entries = []Entry{} } names := make([]string, len(entries)) for i, e := range entries { names[i] = e.Name } m.lastByName = g.LastCommits(commit, filePath, names) } } if m.kind == "" || ((m.kind == "tree" || m.kind == "commit") && m.entries == nil) { return m } treeCache.Lock() if len(treeCache.m) > 2048 { clear(treeCache.m) } treeCache.m[key] = m treeCache.Unlock() return m }