@@ -9,10 +9,12 @@ import ( |
| 9 | 9 | "time" |
| 10 | 10 | |
| 11 | 11 | "github.com/go-chi/chi/v5" |
| 12 | + "github.com/jackc/pgx/v5" |
| 12 | 13 | |
| 13 | 14 | "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 14 | 15 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 15 | 16 | "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 17 | + "github.com/tenseleyFlow/shithub/internal/repos/sigverify" |
| 16 | 18 | "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage" |
| 17 | 19 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 18 | 20 | ) |
@@ -41,11 +43,49 @@ type commitAuthor struct { |
| 41 | 43 | } |
| 42 | 44 | |
| 43 | 45 | type commitListItem struct { |
| 44 | | - SHA string `json:"sha"` |
| 45 | | - ShortSHA string `json:"short_sha"` |
| 46 | | - Subject string `json:"subject"` |
| 47 | | - Author commitAuthor `json:"author"` |
| 48 | | - Body string `json:"body,omitempty"` |
| 46 | + SHA string `json:"sha"` |
| 47 | + ShortSHA string `json:"short_sha"` |
| 48 | + Subject string `json:"subject"` |
| 49 | + Author commitAuthor `json:"author"` |
| 50 | + Body string `json:"body,omitempty"` |
| 51 | + Verification verificationResponse `json:"verification"` |
| 52 | +} |
| 53 | + |
| 54 | +// verificationResponse mirrors gh's documented commit verification |
| 55 | +// object. Empty/never-verified commits emit the same shape gh uses |
| 56 | +// for unsigned commits: |
| 57 | +// |
| 58 | +// {verified: false, reason: "unsigned", signature: null, payload: null, verified_at: null} |
| 59 | +// |
| 60 | +// `payload` is the bytes the signature was computed over (commit |
| 61 | +// object body minus the gpgsig header). Surfaced as a string in gh's |
| 62 | +// JSON; we follow suit. nil bytes → JSON null via the pointer trick. |
| 63 | +type verificationResponse struct { |
| 64 | + Verified bool `json:"verified"` |
| 65 | + Reason string `json:"reason"` |
| 66 | + Signature *string `json:"signature"` |
| 67 | + Payload *string `json:"payload"` |
| 68 | + VerifiedAt *string `json:"verified_at"` |
| 69 | +} |
| 70 | + |
| 71 | +func presentVerification(v sigverify.View) verificationResponse { |
| 72 | + resp := verificationResponse{ |
| 73 | + Verified: v.Verified, |
| 74 | + Reason: string(v.Reason), |
| 75 | + } |
| 76 | + if v.Signature != "" { |
| 77 | + s := v.Signature |
| 78 | + resp.Signature = &s |
| 79 | + } |
| 80 | + if len(v.Payload) > 0 { |
| 81 | + s := string(v.Payload) |
| 82 | + resp.Payload = &s |
| 83 | + } |
| 84 | + if v.VerifiedAt != nil { |
| 85 | + s := v.VerifiedAt.UTC().Format(time.RFC3339) |
| 86 | + resp.VerifiedAt = &s |
| 87 | + } |
| 88 | + return resp |
| 49 | 89 | } |
| 50 | 90 | |
| 51 | 91 | type commitFile struct { |
@@ -72,7 +112,7 @@ type commitStats struct { |
| 72 | 112 | Total int `json:"total"` |
| 73 | 113 | } |
| 74 | 114 | |
| 75 | | -func presentCommit(c git.Commit) commitListItem { |
| 115 | +func presentCommit(c git.Commit, v sigverify.View) commitListItem { |
| 76 | 116 | return commitListItem{ |
| 77 | 117 | SHA: c.OID, |
| 78 | 118 | ShortSHA: c.ShortOID, |
@@ -83,6 +123,7 @@ func presentCommit(c git.Commit) commitListItem { |
| 83 | 123 | Email: c.AuthorEmail, |
| 84 | 124 | Date: c.AuthorWhen.UTC().Format(time.RFC3339), |
| 85 | 125 | }, |
| 126 | + Verification: presentVerification(v), |
| 86 | 127 | } |
| 87 | 128 | } |
| 88 | 129 | |
@@ -134,9 +175,23 @@ func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) { |
| 134 | 175 | writeJSON(w, http.StatusOK, []commitListItem{}) |
| 135 | 176 | return |
| 136 | 177 | } |
| 178 | + |
| 179 | + // Batch-load verification cache rows for the page's OIDs. Failure |
| 180 | + // here is non-fatal — we fall back to UnsignedView per row so the |
| 181 | + // payload still renders, just without verification metadata. |
| 182 | + oids := make([]string, len(commits)) |
| 183 | + for i, c := range commits { |
| 184 | + oids[i] = c.OID |
| 185 | + } |
| 186 | + verifications, err := sigverify.LoadViewsForOIDs(r.Context(), h.d.Pool, repo.ID, oids) |
| 187 | + if err != nil { |
| 188 | + h.d.Logger.WarnContext(r.Context(), "api: load verifications", "error", err, "repo_id", repo.ID) |
| 189 | + verifications = map[string]sigverify.View{} |
| 190 | + } |
| 191 | + |
| 137 | 192 | out := make([]commitListItem, 0, len(commits)) |
| 138 | 193 | for _, c := range commits { |
| 139 | | - out = append(out, presentCommit(c)) |
| 194 | + out = append(out, presentCommit(c, sigverify.LookupView(verifications, c.OID))) |
| 140 | 195 | } |
| 141 | 196 | // We don't have a cheap O(1) total count from `git log` without a |
| 142 | 197 | // second walk, so emit `next`/`prev` only when a follow-on page |
@@ -174,8 +229,14 @@ func (h *Handlers) commitGet(w http.ResponseWriter, r *http.Request) { |
| 174 | 229 | writeAPIError(w, http.StatusInternalServerError, "lookup failed") |
| 175 | 230 | return |
| 176 | 231 | } |
| 232 | + view, vErr := sigverify.LoadView(r.Context(), h.d.Pool, repo.ID, cd.OID) |
| 233 | + if vErr != nil && !errors.Is(vErr, pgx.ErrNoRows) { |
| 234 | + h.d.Logger.WarnContext(r.Context(), "api: load verification", "error", vErr, "repo_id", repo.ID, "oid", cd.OID) |
| 235 | + view = sigverify.UnsignedView() |
| 236 | + } |
| 237 | + |
| 177 | 238 | out := commitDetail{ |
| 178 | | - commitListItem: presentCommit(cd.Commit), |
| 239 | + commitListItem: presentCommit(cd.Commit, view), |
| 179 | 240 | Committer: commitAuthor{ |
| 180 | 241 | Name: cd.CommitterName, |
| 181 | 242 | Email: cd.CommitterEmail, |