package main import ( "bytes" "embed" "fmt" "html/template" "io" "io/fs" "log" "mime" "net/http" "os" "path/filepath" "regexp" "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 { limiter *limiter store *Store root string host string open bool pages map[string]*template.Template markdown goldmark.Markdown policy *bluemonday.Policy } type page struct { Title string User *User Host string Error string Math bool Data any } func newServer(store *Store, root, host string, open bool) *Server { s := &Server{store: store, root: root, host: host, open: open, 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, "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") return s } func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p page) { p.Host = s.host 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 /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/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 []Repo if user != nil { repos, _ = s.store.ReposByOwner(user.ID, true) } users, public := s.store.Stats() s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "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")), r.FormValue("private") == "on") 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) 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 } 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)}}) } 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) s.render(w, r, "keys", page{Title: "Keys", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "keys", "Keys": keys}}) } 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 } 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) 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) 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 repo.Private && (user == nil || user.ID != owner.ID) { 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 == "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/")) default: s.fail(w, r, 404, "Page not found") } } type repoPage struct { Repo Repo Ref string Commit string Owner bool Empty bool Tab string } 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: user != nil && user.ID == repo.Owner.ID} 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 = 1 << 20 const maxRaw = 64 << 20 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 } } 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 := highlight(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.Size(commit, filePath) > maxRaw { s.fail(w, r, 413, "File too large to serve") return } content, err := g.Blob(commit, filePath) if err != nil { 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-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(filePath)})) w.Write(content) } 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}}) } 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.Owner { s.fail(w, r, 403, "Not your repository") return } rp.Tab = "settings" s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Data: map[string]any{"Page": rp}}) } func (s *Server) updateRepo(w http.ResponseWriter, r *http.Request, repo Repo) { rp, _, _ := s.repoPage(r, repo, "") if !rp.Owner { s.fail(w, r, 403, "Not your repository") return } if err := s.store.UpdateRepo(repo.ID, strings.TrimSpace(r.FormValue("description")), r.FormValue("private") == "on"); err != nil { s.fail(w, r, 500, err.Error()) 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.Owner { 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 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 B", n) case n < 1<<20: return fmt.Sprintf("%.1f KiB", float64(n)/1024) default: return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) } }