package main import ( "bytes" "crypto/sha256" "embed" "encoding/hex" "errors" "fmt" "html/template" "io" "io/fs" "log" "mime" "net/http" "os" "os/exec" "path/filepath" "regexp" "slices" "strconv" "strings" "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" ) //go:embed templates/*.html var templateFS embed.FS //go:embed static var staticFS embed.FS type Server struct { version string limiter *limiter store *Store root string host string sshUser string open bool pages map[string]*template.Template markdown goldmark.Markdown policy *bluemonday.Policy } type page struct { Version string Fluid bool Title string User *User Host string SSHUser string Error string Math bool Data any } func newServer(store *Store, root, host string, open bool, sshUser string) *Server { s := &Server{store: store, root: root, host: host, open: open, sshUser: sshUser, pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)} 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, "mark": mark, "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") }, "string": func(b []byte) string { return string(b) }, } names, _ := fs.Glob(templateFS, "templates/*.html") for _, name := range names { base := filepath.Base(name) if base == "layout.html" { continue } s.pages[strings.TrimSuffix(base, ".html")] = template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", name)) } s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, mathjax.MathJax, highlighting.NewHighlighting(highlighting.WithFormatOptions(chromahtml.WithClasses(true))))) s.policy = bluemonday.UGCPolicy() s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code") s.writeSigners() s.writeKeyring() sum := sha256.New() css, _ := staticFS.ReadFile("static/style.css") sum.Write(css) sum.Write(highlightCSS()) 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.SSHUser = s.sshUser p.Version = s.version if d, ok := p.Data.(map[string]any); ok && d["File"] == true { p.Fluid = true } if p.User == nil { p.User = s.current(r) } 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") mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static))) 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") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(css) }) mux.HandleFunc("GET /{$}", s.index) mux.HandleFunc("GET /projects", s.projects) mux.HandleFunc("GET /login", s.loginForm) mux.HandleFunc("POST /login", s.login) mux.HandleFunc("POST /logout", s.logout) 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("/", s.route) cop := http.NewCrossOriginProtection() return cop.Handler(mux) } func (s *Server) index(w http.ResponseWriter, r *http.Request) { user := s.current(r) recent, err := s.store.RecentRepos(20) if err != nil { s.fail(w, r, 500, err.Error()) return } var repos, shared []Repo if user != nil { repos, _ = s.store.ReposByOwner(user.ID, true) shared, _ = s.store.SharedRepos(user.ID) } users, public := s.store.Stats() s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Shared": shared, "Recent": recent, "Users": users, "Public": public}}) } 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) if s.limiter.blocked(ip) { 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(strings.TrimSpace(r.FormValue("name")), r.FormValue("password")) if !ok { s.limiter.fail(ip) w.WriteHeader(401) s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"}) 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{"Open": s.open, "Invite": r.URL.Query().Get("invite")}}) } 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{"Open": s.open, "Invite": invite}}) } ip := clientIP(r) if s.limiter.blocked(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 } if !s.open && !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.startSession(w, u) 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}) } } func (s *Server) create(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } name := strings.TrimSpace(r.FormValue("name")) repo, err := s.store.CreateRepo(*u, 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()}) 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 } 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("", "basin-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) { query := strings.TrimSpace(r.URL.Query().Get("search")) tag := strings.TrimSpace(r.URL.Query().Get("tag")) sort := r.URL.Query().Get("sort") repos, err := s.store.PublicRepos(query, tag) if err != nil { s.fail(w, r, 500, err.Error()) return } for i := range repos { repos[i].Updated = repoUpdated(s.repoDir(repos[i])) } if sort != "created" { slices.SortStableFunc(repos, func(a, b Repo) int { return b.Updated.Compare(a.Updated) }) } users, public := s.store.Stats() s.render(w, r, "projects", page{Title: "Browse public projects", Data: map[string]any{"Repos": repos, "Query": query, "Tag": tag, "Sort": sort, "Users": users, "Public": public}}) } 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)}}) } func (s *Server) security(w http.ResponseWriter, r *http.Request) { u := s.require(w, r) if u == nil { return } s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security"}}) } 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) profile(w http.ResponseWriter, r *http.Request) { u := s.require(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, "/settings?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 := s.require(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, "/settings?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, "/settings?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, "/settings?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 := s.require(w, r) if u == nil { return } os.Remove(s.avatarPath(u.ID)) s.store.SetAvatar(u.ID, 0) http.Redirect(w, r, "/settings", 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] == "") { repos, err := s.store.ReposByOwner(owner.ID, user != nil && user.ID == owner.ID) if err != nil { s.fail(w, r, 500, err.Error()) return } profile := s.store.Profile(owner.ID) var bio template.HTML if profile.Bio != "" { bio = s.renderMarkdown([]byte(profile.Bio)) } s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio}}) return } name := parts[1] if name == "avatar" && len(parts) == 2 { s.serveAvatar(w, r, owner) 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 { 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 == "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 Parent *Repo User *User IsOwner 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: user != nil && user.ID == repo.Owner.ID, Open: s.store.OpenPullCount(repo.ID), User: user} if repo.Parent != 0 { if parent, err := s.store.RepoByID(repo.Parent); err == nil && !parent.Private { rp.Parent = &parent } } if g.Empty() { rp.Empty = true rp.Ref = g.Head() return rp, g, nil } if ref == "" { ref = g.Head() } commit, err := g.Resolve(ref) if err != 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} if !rp.Empty { commits, _ := g.Log(rp.Commit, 3, "") data["Commits"] = commits branches, _ := g.Refs("refs/heads") tags, _ := g.Refs("refs/tags") data["Branches"] = branches data["Tags"] = tags name, content := g.Readme(rp.Commit) if name != "" { data["ReadmeName"] = name if strings.HasSuffix(strings.ToLower(name), ".md") { data["Readme"] = s.renderMarkdown(content) } else { data["ReadmeText"] = string(content) } } } 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 { 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)) + "
") } return template.HTML(s.policy.SanitizeBytes(buf.Bytes())) } 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, "/") for i := len(parts); i >= 1; 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} if last, _ := g.Log(rp.Commit, 1, filePath); len(last) > 0 { data["LastCommit"] = last[0] } if entry := g.Entry(rp.Commit, filePath); entry != nil { data["Entry"] = entry } switch g.Kind(rp.Commit, filePath) { case "tree", "commit": entries, err := g.Tree(rp.Commit, filePath) if err != nil { s.fail(w, r, 404, "No such path") return } data["Entries"] = entries names := make([]string, len(entries)) for i, e := range entries { names[i] = e.Name } data["Last"] = g.LastCommits(rp.Commit, filePath, names) 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") == "" { data["Markdown"] = s.renderMarkdown(content) } 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") } } 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("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Length", strconv.FormatInt(g.Size(commit, filePath), 10)) w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(filePath)})) 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 } data := map[string]any{"Page": rp, "Path": filePath} 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) 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" branches, _ := g.Refs("refs/heads") tags, _ := g.Refs("refs/tags") 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) s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Error: r.URL.Query().Get("error"), Data: map[string]any{"Page": rp, "Collaborators": collabs}}) } 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 } if err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags")); 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 < 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 mark(text, query string) template.HTML { if query == "" { return template.HTML(template.HTMLEscapeString(text)) } var b strings.Builder lower := strings.ToLower(text) q := strings.ToLower(query) for { i := strings.Index(lower, q) if i < 0 { b.WriteString(template.HTMLEscapeString(text)) break } b.WriteString(template.HTMLEscapeString(text[:i])) b.WriteString("") b.WriteString(template.HTMLEscapeString(text[i : i+len(q)])) b.WriteString("") text = text[i+len(q):] lower = lower[i+len(q):] } return template.HTML(b.String()) } 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)) } }