package main import ( "bufio" "errors" "fmt" "html/template" "net/http" "os" "path" "path/filepath" "strings" ) func installHook(root, dir string) { binary, err := os.Executable() if err != nil { return } hooks := filepath.Join(dir, "hooks") os.MkdirAll(hooks, 0o755) script := fmt.Sprintf("#!/bin/sh\nd=$(cd \"$(dirname \"$0\")/..\" && pwd)\nexec %s -root %s hook pre-receive \"$(basename \"$(dirname \"$d\")\")|$(basename \"$d\")\"\n", binary, root) os.WriteFile(filepath.Join(hooks, "pre-receive"), []byte(script), 0o755) } func (s *Server) installHooks() { owners, err := os.ReadDir(filepath.Join(s.root, "repos")) if err != nil { return } for _, o := range owners { repos, _ := os.ReadDir(filepath.Join(s.root, "repos", o.Name())) for _, r := range repos { if strings.HasSuffix(r.Name(), ".git") { installHook(s.root, filepath.Join(s.root, "repos", o.Name(), r.Name())) } } } } func hook(root, kind, spec string) int { if kind != "pre-receive" { return 0 } owner, repoDir, ok := strings.Cut(spec, "|") if !ok { return 0 } name := strings.TrimSuffix(repoDir, ".git") store, err := openStore(filepath.Join(root, "gitpocket.db"), true) if err != nil { fmt.Fprintln(os.Stderr, "gitpocket: database unavailable, push refused") return 1 } repo, err := store.RepoByPath(owner, name) if err != nil { fmt.Fprintln(os.Stderr, "gitpocket: unknown repository, push refused") return 1 } for _, kv := range os.Environ() { if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") { passEnv = append(passEnv, kv) } } g := Git{Dir: filepath.Join(root, "repos", owner, repoDir)} zero := strings.Repeat("0", 40) scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { f := strings.Fields(scanner.Text()) if len(f) != 3 || !strings.HasPrefix(f[2], "refs/heads/") { continue } branch := strings.TrimPrefix(f[2], "refs/heads/") prot, ok := store.Protection(repo.ID, branch) if !ok { continue } if f[1] == zero { fmt.Fprintf(os.Stderr, "gitpocket: %s is protected and cannot be deleted\n", branch) return 1 } if prot.RequirePR && f[0] != zero { fmt.Fprintf(os.Stderr, "gitpocket: %s only accepts changes through pull requests\n", branch) return 1 } if f[0] != zero { if _, err := g.run(nil, "merge-base", "--is-ancestor", f[0], f[1]); err != nil { fmt.Fprintf(os.Stderr, "gitpocket: %s is protected, force pushes are refused\n", branch) return 1 } } } return 0 } func (g Git) Commit1(branch, parent, filePath string, content []byte, remove bool, message, name, email string) (string, error) { index, err := os.CreateTemp("", "gitpocket-index") if err != nil { return "", err } index.Close() defer os.Remove(index.Name()) env := []string{"GIT_INDEX_FILE=" + index.Name()} if _, err := g.runEnv(env, nil, "read-tree", parent); err != nil { return "", err } if remove { if _, err := g.runEnv(env, []byte("0 "+strings.Repeat("0", 40)+"\t"+filePath+"\n"), "update-index", "--index-info"); err != nil { return "", err } } else { blob, err := g.run(content, "hash-object", "-w", "--stdin") if err != nil { return "", err } if _, err := g.runEnv(env, nil, "update-index", "--add", "--cacheinfo", "100644,"+strings.TrimSpace(string(blob))+","+filePath); err != nil { return "", err } } tree, err := g.runEnv(env, nil, "write-tree") if err != nil { return "", err } commit, err := g.runEnv(ident(name, email, timeNow()), []byte(message), "commit-tree", strings.TrimSpace(string(tree)), "-p", parent) if err != nil { return "", err } hash := strings.TrimSpace(string(commit)) if _, err := g.run(nil, "update-ref", "refs/heads/"+branch, hash, parent); err != nil { return "", errors.New("the branch moved while you were editing, reload and try again") } return hash, nil } func (s *Server) editAllowed(w http.ResponseWriter, r *http.Request, repo Repo, branch string) (repoPage, Git, bool) { rp, g, err := s.repoPage(r, repo, branch) if err != nil { s.fail(w, r, 404, "No such branch") return rp, g, false } if rp.User == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return rp, g, false } if !rp.Owner { s.fail(w, r, 403, "You cannot write to this repository") return rp, g, false } if repo.Archived { s.fail(w, r, 403, "This repository is archived") return rp, g, false } if _, err := g.Resolve("refs/heads/" + branch); err != nil || !validBranch(branch) { s.fail(w, r, 404, "Edits need a branch, not a tag or commit") return rp, g, false } if prot, ok := s.store.Protection(repo.ID, branch); ok && prot.RequirePR { s.fail(w, r, 403, branch+" only accepts changes through pull requests") return rp, g, false } rp.Tab = "tree" return rp, g, true } func (s *Server) edit(w http.ResponseWriter, r *http.Request, repo Repo, spec string, create bool) { g := Git{Dir: s.repoDir(repo)} branch, filePath, ok := s.splitRef(g, spec) if !ok { s.fail(w, r, 404, "No such branch") return } filePath, ok = cleanPath(filePath) if !ok { s.fail(w, r, 404, "No such path") return } rp, g, ok := s.editAllowed(w, r, repo, branch) if !ok { return } data := map[string]any{"Page": rp, "Path": filePath, "Create": create, "Dir": filePath} if r.Method == "GET" { if !create { if g.Kind(rp.Commit, filePath) != "blob" { s.fail(w, r, 404, "No such file") return } content, err := g.Blob(rp.Commit, filePath) if err != nil || isBinary(content) || len(content) > maxRender { s.fail(w, r, 400, "Only text files under 4 MiB can be edited here") return } data["Content"] = string(content) data["Message"] = "Update " + path.Base(filePath) } else { data["Message"] = "Create " } s.render(w, r, "edit", page{Title: "Edit " + filePath, Data: data}) return } target := filePath if create { name, ok := cleanPath(strings.TrimSpace(r.FormValue("name"))) if !ok || name == "" { s.fail(w, r, 400, "Give the new file a name") return } target = path.Join(filePath, name) if g.Kind(rp.Commit, target) != "" { s.fail(w, r, 400, target+" already exists") return } } content := strings.ReplaceAll(r.FormValue("content"), "\r\n", "\n") message := strings.TrimSpace(r.FormValue("message")) if message == "" { message = "Update " + path.Base(target) } hash, err := g.Commit1(branch, rp.Commit, target, []byte(content), false, message, rp.User.Name, rp.User.Name+"@"+s.host) if err != nil { data["Content"], data["Message"], data["Name"] = r.FormValue("content"), message, r.FormValue("name") w.WriteHeader(400) s.render(w, r, "edit", page{Title: "Edit " + filePath, Error: err.Error(), Data: data}) return } _ = hash http.Redirect(w, r, "/"+repo.Path()+"/tree/"+branch+"/"+target, http.StatusSeeOther) } func (s *Server) removeFile(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { g := Git{Dir: s.repoDir(repo)} branch, filePath, ok := s.splitRef(g, spec) if !ok { s.fail(w, r, 404, "No such branch") return } filePath, ok = cleanPath(filePath) if !ok || filePath == "" { s.fail(w, r, 404, "No such path") return } rp, g, ok := s.editAllowed(w, r, repo, branch) if !ok { return } if g.Kind(rp.Commit, filePath) != "blob" { s.fail(w, r, 404, "No such file") return } if _, err := g.Commit1(branch, rp.Commit, filePath, nil, true, "Delete "+path.Base(filePath), rp.User.Name, rp.User.Name+"@"+s.host); err != nil { s.fail(w, r, 400, template.HTMLEscapeString(err.Error())) return } dir := path.Dir(filePath) if dir == "." { dir = "" } http.Redirect(w, r, "/"+repo.Path()+"/tree/"+branch+"/"+dir, http.StatusSeeOther) }