package forge import ( "bufio" "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" ) const ( maxPkt = 65520 maxCommands = 64 maxOptions = 64 maxTopic = 100 ) var zeroOID = strings.Repeat("0", 40) func hooksDir(root string) string { return filepath.Join(root, "hooks") } func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } func hookScript(root, host, binary, kind string) string { return fmt.Sprintf("#!/bin/sh\nGITPOCKET_HOST=%s exec %s -root %s hook %s\n", shellQuote(host), shellQuote(binary), shellQuote(root), kind) } func writeHook(path, script string) error { if b, err := os.ReadFile(path); err == nil && string(b) == script { return os.Chmod(path, 0o755) } tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(script), 0o755); err != nil { return err } return os.Rename(tmp, path) } func installCentralHooks(root, host string) error { binary, err := os.Executable() if err != nil { return err } dir := hooksDir(root) if err := os.MkdirAll(dir, 0o755); err != nil { return err } for _, kind := range []string{"pre-receive", "proc-receive"} { if err := writeHook(filepath.Join(dir, kind), hookScript(root, host, binary, kind)); err != nil { return err } } return nil } func hooksReady(root string) bool { for _, kind := range []string{"pre-receive", "proc-receive"} { fi, err := os.Stat(filepath.Join(hooksDir(root), kind)) if err != nil || !fi.Mode().IsRegular() || fi.Mode()&0o111 == 0 { return false } } return true } func receiveArgs(root string) []string { return []string{"-c", "core.hooksPath=" + hooksDir(root), "-c", "receive.procReceiveRefs=refs/for", "-c", "receive.advertisePushOptions=true"} } func receiveEnv(repo Repo, pusher User) []string { return []string{"GITPOCKET_REPO=" + repo.Owner.Name + "/" + repo.Name, "GITPOCKET_PUSHER=" + strconv.FormatInt(pusher.ID, 10)} } type hookContext struct { store *Store repo Repo pusher User writer bool g Git } func loadHook(root string, readonly bool) (hookContext, error) { path := os.Getenv("GITPOCKET_REPO") id, err := strconv.ParseInt(os.Getenv("GITPOCKET_PUSHER"), 10, 64) owner, name, ok := strings.Cut(path, "/") if !ok || err != nil || id <= 0 { return hookContext{}, errors.New("push did not come through gitpocket") } store, err := openStore(filepath.Join(root, "gitpocket.db"), readonly) if err != nil { return hookContext{}, errors.New("database unavailable") } repo, err := store.RepoByPath(owner, name) if err != nil { return hookContext{}, errors.New("unknown repository") } pusher, err := store.userByID(id) if err != nil || store.IsBanned(id) { return hookContext{}, errors.New("unknown account") } if !store.CanRead(repo, &pusher) { return hookContext{}, errors.New("unknown repository") } return hookContext{store: store, repo: repo, pusher: pusher, writer: store.CanWrite(repo, &pusher), g: Git{Dir: filepath.Join(root, "repos", repo.Owner.Name, repo.Name+".git")}}, nil } func hook(root, kind string) int { switch kind { case "pre-receive": return preReceive(root, os.Stdin, os.Stderr) case "proc-receive": return procReceive(root, os.Stdin, os.Stdout, os.Stderr) } return 0 } func preReceive(root string, in io.Reader, errw io.Writer) int { refuse := func(format string, args ...any) int { fmt.Fprintf(errw, "gitpocket: "+format+"\n", args...) return 1 } hc, err := loadHook(root, true) if err != nil { return refuse("%v, push refused", err) } 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) } } mirror := hc.store.IsPullMirror(hc.repo.ID) if !hc.writer && (!hc.store.GuestPushBudget(hc.pusher.ID) || hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID) || hc.repo.Archived || mirror) { return refuse("you cannot open pull requests here right now") } scanner := bufio.NewScanner(in) for scanner.Scan() { f := strings.Fields(scanner.Text()) if len(f) != 3 { return refuse("malformed update") } ref := f[2] if strings.HasPrefix(ref, "refs/for/") { continue } if !hc.writer { return refuse("you can only open pull requests here, push to refs/for/") } if mirror { return refuse("%s is a pull mirror and read only", hc.repo.Path()) } if !strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/") { return refuse("only branches and tags can be pushed, not %s", ref) } branch, ok := strings.CutPrefix(ref, "refs/heads/") if !ok { continue } prot, ok := hc.store.Protection(hc.repo.ID, branch) if !ok { continue } if f[1] == zeroOID { return refuse("%s is protected and cannot be deleted", branch) } if prot.RequirePR && f[0] != zeroOID { return refuse("%s only accepts changes through pull requests", branch) } if f[0] != zeroOID { if _, err := hc.g.run(nil, "merge-base", "--is-ancestor", f[0], f[1]); err != nil { return refuse("%s is protected, force pushes are refused", branch) } } } return 0 } type pktReader struct{ r *bufio.Reader } func (p pktReader) read() (string, bool, error) { var head [4]byte if _, err := io.ReadFull(p.r, head[:]); err != nil { return "", false, err } n, err := strconv.ParseUint(string(head[:]), 16, 16) if err != nil { return "", false, errors.New("bad pkt-line length") } if n == 0 { return "", true, nil } if n < 4 || n > maxPkt { return "", false, errors.New("bad pkt-line length") } buf := make([]byte, n-4) if _, err := io.ReadFull(p.r, buf); err != nil { return "", false, err } return strings.TrimSuffix(string(buf), "\n"), false, nil } func (p pktReader) section(limit int) ([]string, error) { var out []string for { line, flush, err := p.read() if err != nil { return nil, err } if flush { return out, nil } if len(out) >= limit { return nil, errors.New("too many lines") } out = append(out, line) } } func writePkt(w io.Writer, line string) error { if line == "" { _, err := io.WriteString(w, "0000") return err } _, err := fmt.Fprintf(w, "%04x%s\n", len(line)+5, line) return err } type agitCommand struct{ old, new, ref string } type agitResult struct { ok bool reason string ref, oldOID string newOID string } func procReceive(root string, in io.Reader, out, errw io.Writer) int { pr := pktReader{bufio.NewReader(in)} hello, err := pr.section(4) if err != nil || len(hello) == 0 { return 1 } version, caps, _ := strings.Cut(hello[0], "\x00") if version != "version=1" { return 1 } options := false for _, c := range strings.Fields(caps) { options = options || c == "push-options" } reply := "version=1" if options { reply += "\x00push-options" } if writePkt(out, reply) != nil || writePkt(out, "") != nil { return 1 } lines, err := pr.section(maxCommands) if err != nil { return 1 } var cmds []agitCommand for _, line := range lines { f := strings.Fields(line) if len(f) != 3 { return 1 } cmds = append(cmds, agitCommand{f[0], f[1], f[2]}) } opts := map[string]string{} if options { raw, err := pr.section(maxOptions) if err != nil { return 1 } for _, o := range raw { k, v, _ := strings.Cut(o, "=") opts[k] = v } } hc, herr := loadHook(root, false) if herr == nil && !hc.writer { hc.store.GuestPushed(hc.pusher.ID) } for _, c := range cmds { var res agitResult if herr != nil { res = agitResult{reason: herr.Error()} } else { res = hc.agit(c, opts, errw) } res.ref = c.ref if !res.ok { writePkt(out, "ng "+c.ref+" "+sanitizeReason(res.reason)) continue } writePkt(out, "ok "+c.ref) writePkt(out, "option refname "+res.ref) writePkt(out, "option old-oid "+res.oldOID) writePkt(out, "option new-oid "+res.newOID) } writePkt(out, "") return 0 } func sanitizeReason(s string) string { s = strings.Map(func(r rune) rune { if r < 0x20 || r == 0x7f { return ' ' } return r }, s) if len(s) > 200 { s = s[:200] } return s } func validOID(s string) bool { if len(s) != 40 && len(s) != 64 { return false } for _, c := range s { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true } func (hc hookContext) agitTarget(rest, option string) (target, topic string, ok bool) { parts := strings.Split(rest, "/") for i := len(parts); i > 0; i-- { branch := strings.Join(parts[:i], "/") if !validBranch(branch) { continue } if _, err := hc.g.Resolve("refs/heads/" + branch); err != nil { continue } topic = strings.Join(parts[i:], "/") if topic == "" { topic = option } if topic == "" { topic = branch } return branch, topic, validBranch(topic) && len(topic) <= maxTopic } return "", "", false } func (hc hookContext) agit(c agitCommand, opts map[string]string, errw io.Writer) agitResult { fail := func(msg string) agitResult { return agitResult{reason: msg} } switch { case !validOID(c.new) || !validOID(c.old): return fail("malformed update") case c.new == zeroOID || strings.Trim(c.new, "0") == "": return fail("pull requests cannot be deleted by push") case hc.repo.Archived: return fail("repository is archived") case hc.store.IsPullMirror(hc.repo.ID): return fail("repository is a pull mirror") case hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID): return fail("you cannot open pull requests here") } rest, _ := strings.CutPrefix(c.ref, "refs/for/") target, topic, ok := hc.agitTarget(rest, strings.TrimSpace(opts["topic"])) if !ok { return fail("push to refs/for/[/]") } if t, err := hc.g.run(nil, "cat-file", "-t", c.new); err != nil || strings.TrimSpace(string(t)) != "commit" { return fail("not a commit") } if _, err := hc.g.run(nil, "merge-base", "--is-ancestor", c.new, "refs/heads/"+target); err == nil { return fail("no new commits compared to " + target) } if pull, found := hc.store.AgitPull(hc.repo, hc.pusher.ID, target, topic); found { old, _ := hc.g.Resolve(pull.Ref()) if old == c.new { return fail("pull request is already up to date") } if _, err := hc.g.run(nil, "update-ref", pull.Ref(), c.new, old); err != nil { return fail("pull request moved, push again") } hc.g.RecordRevision(pull) hc.store.TouchPull(pull.ID) hc.store.Notify(hc.store.Watchers(hc.repo, "pull", pull.ID, pull.Author.ID), hc.pusher, hc.repo, "pull", pull.Number, pull.Title, "updated") fmt.Fprintf(errw, "\nupdated pull request #%d:\n %s\n\n", pull.Number, pullURL(hc.repo, pull.Number)) return agitResult{ok: true, ref: pull.Ref(), oldOID: old, newOID: c.new} } if !hc.store.ThreadBudget(hc.pusher.ID) { return fail("too many new issues and pull requests, try again later") } title, body := strings.TrimSpace(opts["title"]), strings.TrimSpace(opts["description"]) if title == "" { msg, _ := hc.g.run(nil, "log", "-1", "--format=%B", c.new) subject, rest, _ := strings.Cut(strings.TrimSpace(string(msg)), "\n") title = strings.TrimSpace(subject) if body == "" { body = strings.TrimSpace(rest) } } if len(title) > 200 { title = title[:200] } if len(body) > 20000 { body = body[:20000] } pull, err := hc.store.CreatePull(hc.repo, hc.pusher, title, body, hc.repo, "", target) if err != nil { return fail(err.Error()) } hc.store.SetAgit(pull.ID, topic) pull.Agit = topic if _, err := hc.g.run(nil, "update-ref", pull.Ref(), c.new); err != nil { hc.store.DeletePull(pull.ID) return fail("could not record the pull request") } if opts["draft"] == "true" || opts["draft"] == "1" { hc.store.SetDraft(pull.ID, true) } hc.g.RecordRevision(pull) hc.store.Notify(hc.store.Watchers(hc.repo, "pull", pull.ID, pull.Author.ID), hc.pusher, hc.repo, "pull", pull.Number, pull.Title, "opened") fmt.Fprintf(errw, "\ncreated pull request #%d:\n %s\n\n", pull.Number, pullURL(hc.repo, pull.Number)) return agitResult{ok: true, ref: pull.Ref(), oldOID: zeroOID, newOID: c.new} } func pullURL(repo Repo, n int64) string { host := os.Getenv("GITPOCKET_HOST") if host == "" { return fmt.Sprintf("/%s/pulls/%d", repo.Path(), n) } return fmt.Sprintf("https://%s/%s/pulls/%d", host, repo.Path(), n) } func (s *Store) AgitPull(repo Repo, author int64, target, topic string) (Pull, bool) { var number int64 err := s.db.QueryRow(`select number from pulls where repo = ? and author = ? and agit = ? and target_branch = ? and state = 'open' order by number desc limit 1`, repo.ID, author, topic, target).Scan(&number) if err != nil { return Pull{}, false } p, err := s.Pull(repo, number) return p, err == nil } func (s *Store) SetAgit(id int64, topic string) { s.db.Exec(`update pulls set agit = ? where id = ?`, topic, id) } func (s *Store) DeletePull(id int64) { s.db.Exec(`delete from pulls where id = ?`, id) }