package control import ( "encoding/json" "errors" "fmt" "io" "strconv" "strings" "time" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/sig" "gitbay.org/gitbay/internal/store" ) func init() { register(Command{Path: []string{"pgp", "add"}, NeedsRecentSignIn: true, Summary: "register an OpenPGP public key (armored)", Usage: "pgp add < key.asc", Examples: []string{"pgp add < key.asc"}, ReadsStdin: true, Run: runPGPAdd}) register(Command{Path: []string{"pgp", "list"}, Summary: "list registered OpenPGP keys", Usage: "pgp list", Examples: []string{"pgp list"}, ReadOnly: true, Run: runPGPList}) register(Command{Path: []string{"pgp", "remove"}, Summary: "remove an OpenPGP key by fingerprint", Usage: "pgp remove ", Examples: []string{"pgp remove ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234"}, Run: runPGPRemove}) register(Command{Path: []string{"repo", "commit"}, Summary: "show one commit with its patch", Usage: "repo commit ", Examples: []string{"repo commit krz/gitbay a1b2c3d"}, ReadOnly: true, Run: runRepoCommit}) register(Command{Path: []string{"repo", "log"}, Summary: "commit log with signature states", Usage: "repo log [--ref ] [--limit n] [--path ]", Flags: []Flag{ {"--ref", "", "branch, tag or commit to start from", "the default branch"}, {"--limit", "n", "rows to show", "30"}, {"--path", "", "only commits touching this path", ""}, }, Examples: []string{"repo log krz/gitbay --limit 10"}, ReadOnly: true, Run: runRepoLog}) } func runPGPAdd(c *Ctx, args []string) int { if len(args) != 0 { return c.usage() } raw, err := io.ReadAll(io.LimitReader(c.Stdin, 1<<20)) if err != nil { return c.fail(protocol.ExitFailure, "reading key: %v", err) } meta, err := sig.ParsePGPKey(raw) if err != nil { return c.failInput(err) } uids, _ := json.Marshal(meta.Emails) if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil { if errors.Is(err, store.ErrDuplicateKey) { return c.failErr(err) } return c.fail(protocol.ExitFailure, "adding key: %v", err) } type out struct { Fingerprint string `json:"fingerprint"` Emails []string `json:"emails"` } d := out{meta.Fingerprint, meta.Emails} return c.emit(d, func(w io.Writer) { fmt.Fprintf(w, "added %s (%v)\n", d.Fingerprint, d.Emails) }) } func runPGPList(c *Ctx, args []string) int { keys, err := c.Store.ListPGPKeys(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type out struct { Fingerprint string `json:"fingerprint"` Emails string `json:"emails"` ExpiresAt *time.Time `json:"expires_at,omitempty"` RevokedAt *time.Time `json:"revoked_at,omitempty"` } var ds []out for _, k := range keys { ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt}) } return c.emitView(ds, func(w io.Writer) { tb := c.table(w, "FINGERPRINT", "EMAILS") for _, d := range ds { tb.row(cRef(d.Fingerprint), cText(d.Emails)) } tb.flush() }, func() screen { rows := make([]row, len(ds)) for i, d := range ds { emails := d.Emails var uids []string if json.Unmarshal([]byte(d.Emails), &uids) == nil { emails = strings.Join(uids, ", ") } lead, note := cGlyph(""), "" switch { case d.RevokedAt != nil: lead, note = cGlyph("failed"), "revoked" case d.ExpiresAt != nil && !d.ExpiresAt.After(time.Now()): lead, note = cGlyph("failed"), "expired" case d.ExpiresAt != nil: note = "expires " + d.ExpiresAt.Format("2006-01-02") } rows[i] = rowOf(cFlexRef(d.Fingerprint), lead, cText(emails), cMeta(note)) } return listScreen("OpenPGP keys", rows, action{"Keys", []string{"pgp", "remove", ""}}, ) }) } func runPGPRemove(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0]) } return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) { fmt.Fprintf(w, "removed %s\n", args[0]) }) } // sigParse is a package-local alias so callers avoid importing sig directly. func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) } // VerifyCommitCached verifies one commit with the epoch cache. Shared with // the web UI. func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) { epoch, err := st.KeyEpoch() if err != nil { return sig.Result{}, err } if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil { return sig.Result{}, err } else if ok { return res, nil } res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed) if err != nil { return sig.Result{}, err } if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil { return sig.Result{}, err } return res, nil } func runRepoLog(c *Ctx, args []string) int { f, perr := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log [--ref ] [--limit n] [--path ]"}) if perr != nil { return c.fail(protocol.ExitUsage, "%v", perr) } limit, path, filePath, ref := 30, f.pos(0), f.Value("--path"), f.Value("--ref") if f.Has("--limit") { n, err := strconv.Atoi(f.Value("--limit")) if err != nil || n < 1 || n > 1000 { return c.fail(protocol.ExitUsage, "--limit must be 1..1000") } limit = n } if path == "" { return c.usage() } repo, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if ref == "" { ref = repo.DefaultBranch } if _, err := gitutil.ResolveRef(dir, ref); err != nil { return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) } var shas []string var err error if filePath != "" { shas, err = gitutil.RevListPath(dir, ref, filePath, limit) } else { shas, err = gitutil.RevList(dir, ref, limit) } if err != nil { return c.fail(protocol.ExitFailure, "reading log: %v", err) } type sigOut struct { State string `json:"state"` Signer string `json:"signer,omitempty"` Fingerprint string `json:"key_fingerprint,omitempty"` } type out struct { SHA string `json:"sha"` Subject string `json:"subject"` AuthorName string `json:"author_name"` AuthorEmail string `json:"author_email"` CommitterEmail string `json:"committer_email,omitempty"` // only when it differs Date string `json:"date"` Signature sigOut `json:"signature"` } var ds []out for _, sha := range shas { raw, err := gitutil.ReadCommit(dir, sha) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } parsed, err := sig.ParseCommit(raw) if err != nil { return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err) } res, err := VerifyCommitCached(c.Store, repo, parsed, sha) if err != nil { return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err) } d := out{ SHA: sha, Subject: parsed.Subject, AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail, Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint}, } if parsed.CommitterEmail != parsed.AuthorEmail { d.CommitterEmail = parsed.CommitterEmail } if res.SignerUserID != 0 { if u, err := c.Store.UserByID(res.SignerUserID); err == nil { d.Signature.Signer = u.Username } } ds = append(ds, d) } return c.emitView(ds, func(w io.Writer) { tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR") for _, d := range ds { tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject), cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail))) } tb.flush() }, func() screen { title := "Commits on " + ref if filePath != "" { title += " touching " + filePath } rows := make([]row, len(ds)) for i, d := range ds { rows[i] = rowOf(cRef(fmt.Sprintf("%.10s", d.SHA)), cGlyph(d.Signature.State), cFlex(d.Subject), cMeta(d.AuthorName, relAge(d.Date, termNow()))) } s := listScreen(title, rows) if len(ds) > 0 { s.actions = append(s.actions, action{"Read", []string{"repo", "commit", repo.Path(), ds[0].SHA[:min(12, len(ds[0].SHA))]}}) } s.actions = append(s.actions, action{"Read", []string{"repo", "tree", repo.Path(), "--ref", ref}}) return s }) } // runRepoCommit shows one commit: its metadata, signature verdict, check // statuses, and its patch. The web's commit page read these straight from // git, which is why no other surface could open a commit. func runRepoCommit(c *Ctx, args []string) int { if len(args) != 2 { return c.usage() } repo, code := resolveRepo(c, args[0], policy.CanRead) if code >= 0 { return code } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) full, err := gitutil.ResolveRef(dir, args[1]) if err != nil { return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path()) } raw, err := gitutil.ReadCommit(dir, full) if err != nil { return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path()) } parsed, err := sig.ParseCommit(raw) if err != nil { return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err) } res, err := VerifyCommitCached(c.Store, repo, parsed, full) if err != nil { return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err) } patch, truncated, err := gitutil.ShowPatch(dir, full, 4<<20) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if truncated { fmt.Fprintln(c.Stderr, "patch truncated at 4 MiB; clone the repository for the rest") } statuses, err := c.Store.ListCommitStatuses(repo.ID, full) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // The message body is everything after the subject line. message := "" if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 { message = string(parsed.Payload)[i+2:] } type checkOut struct { Context string `json:"context"` State string `json:"state"` URL string `json:"url,omitempty"` } type sigOut struct { State string `json:"state"` Signer string `json:"signer,omitempty"` Fingerprint string `json:"key_fingerprint,omitempty"` } type out struct { Path string `json:"path"` SHA string `json:"sha"` Subject string `json:"subject"` Message string `json:"message,omitempty"` AuthorName string `json:"author_name"` AuthorEmail string `json:"author_email"` CommitterEmail string `json:"committer_email,omitempty"` Date string `json:"date"` Signature sigOut `json:"signature"` Checks []checkOut `json:"checks,omitempty"` // Diff is the unified patch, parsed by the client the same way // mr diff is. Diff string `json:"diff"` } d := out{ Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message, AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail, Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint}, Diff: patch, } if parsed.CommitterEmail != parsed.AuthorEmail { d.CommitterEmail = parsed.CommitterEmail } if res.SignerUserID != 0 { if u, err := c.Store.UserByID(res.SignerUserID); err == nil { d.Signature.Signer = u.Username } } for _, st := range statuses { d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL}) } // Message carries the subject paragraph too; the views print it once. body := "" if _, rest, ok := strings.Cut(d.Message, "\n\n"); ok { body = strings.TrimRight(rest, "\n") } return c.emit(d, func(w io.Writer) { if c.Term.Cols == 0 { fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n", d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject) if body != "" { fmt.Fprintf(w, "\n%s\n", body) } fmt.Fprintf(w, "\n%s", d.Diff) return } short, url := d.SHA[:min(10, len(d.SHA))], c.siteURL(repo.OwnerName, repo.Name, "commit", d.SHA[:min(12, len(d.SHA))]) s := screen{body: body, format: "text", fields: []field{ {"Commit", []cell{cLink(short, url), cText(d.Subject)}}, {"Author", []cell{cText(fmt.Sprintf("%s <%s>", d.AuthorName, d.AuthorEmail)), cAge(d.Date)}}, }} if d.CommitterEmail != "" && d.CommitterEmail != d.AuthorEmail { s.fields = append(s.fields, field{"Committer", []cell{cText(d.CommitterEmail)}}) } s.fields = append(s.fields, field{"Signed", []cell{cGlyph(d.Signature.State), cState(d.Signature.State), cMeta(d.Signature.Signer, d.Signature.Fingerprint)}}) if !c.Term.Links { s.fields = append(s.fields, field{"URL", []cell{cText(url)}}) } checks := section{title: "Checks", n: len(d.Checks)} for _, ch := range d.Checks { checks.rows = append(checks.rows, rowOf(cGlyph(ch.State), cFlex(ch.Context))) } s.sections = []section{checks} s.actions = []action{ {"Read", []string{"repo", "log", repo.Path(), "--ref", short}}, {"Read", []string{"repo", "tree", repo.Path(), "--ref", short}}, } c.render(w, s) fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff)) }) }