This repository has no description
21

Configure Feed

Select the types of activity you want to include in your feed.

refactor: drop only json; add events endpoint

isabel (Jun 14, 2026, 11:04 AM +0100) 7e570e9a d65f8058

+563 -118
+1
cmd/snot/main.go
··· 4 4 "github.com/alecthomas/kong" 5 5 ) 6 6 7 + // Version is overridden at build time via -ldflags "-X main.Version=...". 7 8 var Version = "dev" 8 9 9 10 type CLI struct {
+16 -1
cmd/snot/serve.go
··· 20 20 "github.com/isabelroses/snot/internal/repodid" 21 21 "github.com/isabelroses/snot/internal/resolve" 22 22 "github.com/isabelroses/snot/internal/state" 23 + "github.com/isabelroses/snot/internal/webhook" 23 24 "github.com/isabelroses/snot/internal/xrpc" 24 25 ) 25 26 ··· 46 47 } 47 48 rkeys := db.Rkeys() 48 49 dids := db.RepoDids() 50 + 51 + eventStore, err := db.SQL() 52 + if err != nil { 53 + return fmt.Errorf("event store: %w", err) 54 + } 49 55 50 56 scheme := "https" 51 57 if cfg.Dev { ··· 87 93 88 94 n := notifier.New() 89 95 96 + wh := &webhook.Handler{ 97 + Cfg: cfg, 98 + Resolve: rs, 99 + Store: eventStore, 100 + Notifier: &n, 101 + Logger: logger.With("component", "webhook"), 102 + } 103 + 90 104 r := chi.NewRouter() 91 105 r.Use(middleware.RealIP) 92 106 r.Use(loggingMiddleware(logger)) ··· 96 110 fmt.Fprintf(w, "this is snot, a tangled knot backed by forgejo.\n") 97 111 }) 98 112 r.Mount("/xrpc", x.Router()) 99 - r.Get("/events", events.Handler(&n, logger.With("component", "events"))) 113 + r.Get("/events", events.Handler(eventStore, &n, logger.With("component", "events"))) 114 + r.Post("/hooks/forgejo", wh.ServeHTTP) 100 115 r.Mount("/", gs.Routes()) 101 116 102 117 logger.Info("starting snot", "addr", cfg.ListenAddr, "hostname", cfg.Hostname)
+1 -1
default.nix
··· 5 5 6 6 src = ./.; 7 7 8 - vendorHash = "sha256-DH7PTmkZHjl8VJ4l/2zGg5qYq3+LqI4SvrE4nbNU9yo="; 8 + vendorHash = "sha256-b/HJO+zvxFZHr+Z6TEw92VLjaTntaEVhlwFwSa0mLz4="; 9 9 10 10 subPackages = [ "cmd/snot" ]; 11 11
+18 -16
internal/config/config.go
··· 28 28 // ("did:plc:abc=isabel,did:plc:def=alice") overrides/extends it. 29 29 UserMap map[string]string 30 30 31 - DbDsn string 32 - RepoRoot string 33 - PushRemote string 34 - StateDir string 35 - PlcUrl string 36 - Dev bool 31 + DbDsn string 32 + RepoRoot string 33 + PushRemote string 34 + WebhookSecret string // SNOT_WEBHOOK_SECRET / webhook_secret; verifies Forgejo push webhooks 35 + StateDir string 36 + PlcUrl string 37 + Dev bool 37 38 } 38 39 39 40 // Load reads configuration from the TOML file at path (optional; skipped if ··· 81 82 82 83 dev, _ := strconv.ParseBool(k.String("dev")) 83 84 cfg := &Config{ 84 - Hostname: k.String("hostname"), 85 - ListenAddr: k.String("listen_addr"), 86 - OwnerDid: k.String("owner_did"), 87 - UserMap: users, 88 - DbDsn: k.String("db_dsn"), 89 - RepoRoot: k.String("repo_root"), 90 - PushRemote: k.String("push_remote"), 91 - StateDir: k.String("state_dir"), 92 - PlcUrl: k.String("plc_url"), 93 - Dev: dev, 85 + Hostname: k.String("hostname"), 86 + ListenAddr: k.String("listen_addr"), 87 + OwnerDid: k.String("owner_did"), 88 + UserMap: users, 89 + DbDsn: k.String("db_dsn"), 90 + RepoRoot: k.String("repo_root"), 91 + PushRemote: k.String("push_remote"), 92 + WebhookSecret: k.String("webhook_secret"), 93 + StateDir: k.String("state_dir"), 94 + PlcUrl: k.String("plc_url"), 95 + Dev: dev, 94 96 } 95 97 96 98 if err := cfg.validate(); err != nil {
+4
internal/config/config_test.go
··· 46 46 db_dsn = "postgres:///forgejo" 47 47 repo_root = "/repos" 48 48 dev = true 49 + webhook_secret = "hunter2" 49 50 50 51 [users] 51 52 "did:plc:abc123" = "isabel" ··· 67 68 } 68 69 if c.UserMap["did:plc:abc123"] != "isabel" || c.UserMap["did:plc:def456"] != "alice" { 69 70 t.Errorf("UserMap = %v", c.UserMap) 71 + } 72 + if c.WebhookSecret != "hunter2" { 73 + t.Errorf("WebhookSecret = %q", c.WebhookSecret) 70 74 } 71 75 } 72 76
+10 -7
internal/events/events.go
··· 1 - // Package events serves a valid-but-empty knot eventstream: the appview's 2 - // consumer connects and idles; no history is fabricated. 1 + // Package events serves snot's knot eventstream over /events, backed by the 2 + // snot.db events table. Push events are written elsewhere (internal/webhook) 3 + // via eventstream.Insert; this just streams them to consumers. 3 4 package events 4 5 5 6 import ( ··· 10 11 "tangled.org/core/notifier" 11 12 ) 12 13 13 - type emptyBackend struct{} 14 + type storeBackend struct { 15 + store eventstream.Store 16 + } 14 17 15 - func (emptyBackend) GetEvents(cursor int64, limit int) ([]eventstream.Event, error) { 16 - return nil, nil 18 + func (b storeBackend) GetEvents(cursor int64, limit int) ([]eventstream.Event, error) { 19 + return eventstream.List(b.store, cursor, limit) 17 20 } 18 21 19 - func Handler(n *notifier.Notifier, l *slog.Logger) http.HandlerFunc { 22 + func Handler(store eventstream.Store, n *notifier.Notifier, l *slog.Logger) http.HandlerFunc { 20 23 return func(w http.ResponseWriter, r *http.Request) { 21 24 _ = eventstream.Stream(w, r, eventstream.StreamConfig{ 22 - Backend: emptyBackend{}, 25 + Backend: storeBackend{store: store}, 23 26 Notifier: n, 24 27 Logger: l, 25 28 })
+33 -5
internal/events/events_test.go
··· 1 1 package events 2 2 3 3 import ( 4 + "encoding/json" 4 5 "log/slog" 5 6 "net/http/httptest" 6 7 "strings" 7 8 "testing" 9 + "time" 8 10 9 11 "github.com/gorilla/websocket" 12 + "tangled.org/core/eventstream" 10 13 "tangled.org/core/notifier" 14 + 15 + "github.com/isabelroses/snot/internal/state" 11 16 ) 12 17 13 - func TestWebsocketConnects(t *testing.T) { 18 + func TestEventsStreamDeliversInserted(t *testing.T) { 19 + db, err := state.Open(t.TempDir()) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + store, err := db.SQL() 24 + if err != nil { 25 + t.Fatal(err) 26 + } 14 27 n := notifier.New() 15 - srv := httptest.NewServer(Handler(&n, slog.Default())) 28 + 29 + srv := httptest.NewServer(Handler(store, &n, slog.Default())) 16 30 defer srv.Close() 17 31 18 32 url := "ws" + strings.TrimPrefix(srv.URL, "http") + "?cursor=0" 19 - conn, res, err := websocket.DefaultDialer.Dial(url, nil) 33 + conn, _, err := websocket.DefaultDialer.Dial(url, nil) 20 34 if err != nil { 21 - t.Fatalf("dial: %v (res=%v)", err, res) 35 + t.Fatalf("dial: %v", err) 36 + } 37 + defer conn.Close() 38 + 39 + ev := eventstream.Event{Rkey: "rk-1", Nsid: "sh.tangled.git.refUpdate", EventJson: json.RawMessage(`{"a":1}`)} 40 + if err := eventstream.Insert(store, ev, &n); err != nil { 41 + t.Fatal(err) 42 + } 43 + 44 + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) 45 + _, msg, err := conn.ReadMessage() 46 + if err != nil { 47 + t.Fatalf("read: %v", err) 48 + } 49 + if !strings.Contains(string(msg), "sh.tangled.git.refUpdate") { 50 + t.Errorf("unexpected message: %s", msg) 22 51 } 23 - conn.Close() 24 52 }
+44
internal/state/events_test.go
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + 7 + "tangled.org/core/eventstream" 8 + "tangled.org/core/notifier" 9 + ) 10 + 11 + func TestEventsTableRoundTrip(t *testing.T) { 12 + db, err := Open(t.TempDir()) 13 + if err != nil { 14 + t.Fatal(err) 15 + } 16 + store, err := db.SQL() 17 + if err != nil { 18 + t.Fatal(err) 19 + } 20 + 21 + n := notifier.New() 22 + ev := eventstream.Event{ 23 + Rkey: "rk-1", 24 + Nsid: "sh.tangled.git.refUpdate", 25 + EventJson: json.RawMessage(`{"hello":"world"}`), 26 + } 27 + if err := eventstream.Insert(store, ev, &n); err != nil { 28 + t.Fatalf("insert: %v", err) 29 + } 30 + 31 + got, err := eventstream.List(store, 0, 10) 32 + if err != nil { 33 + t.Fatalf("list: %v", err) 34 + } 35 + if len(got) != 1 || got[0].Rkey != "rk-1" || got[0].Nsid != "sh.tangled.git.refUpdate" { 36 + t.Fatalf("got %+v", got) 37 + } 38 + if string(got[0].EventJson) != `{"hello":"world"}` { 39 + t.Errorf("event json = %s", got[0].EventJson) 40 + } 41 + if more, _ := eventstream.List(store, got[0].Created, 10); len(more) != 0 { 42 + t.Errorf("expected no rows past cursor, got %d", len(more)) 43 + } 44 + }
+19 -88
internal/state/state.go
··· 4 4 package state 5 5 6 6 import ( 7 - "encoding/json" 8 - "errors" 9 - "fmt" 7 + "database/sql" 10 8 "os" 11 9 "path/filepath" 12 10 ··· 33 31 34 32 func (repoDidRow) TableName() string { return "repo_dids" } 35 33 34 + type eventRow struct { 35 + Rkey string `gorm:"column:rkey;primaryKey"` 36 + Nsid string `gorm:"column:nsid;primaryKey"` 37 + Event []byte `gorm:"column:event"` 38 + Created int64 `gorm:"column:created;index"` 39 + } 40 + 41 + func (eventRow) TableName() string { return "events" } 42 + 36 43 // DB owns snot's persistent SQLite store and hands out typed accessors. 37 44 type DB struct { 38 45 gorm *gorm.DB 39 46 } 40 47 41 - // Open opens (creating if absent) snot.db inside dir, runs migrations, and 42 - // imports any legacy JSON state left by older versions. 48 + // Open opens (creating if absent) snot.db inside dir and runs migrations. 43 49 func Open(dir string) (*DB, error) { 44 50 if err := os.MkdirAll(dir, 0o700); err != nil { 45 51 return nil, err ··· 55 61 if err := gdb.Exec("PRAGMA busy_timeout = 5000").Error; err != nil { 56 62 return nil, err 57 63 } 58 - if err := gdb.AutoMigrate(&rkeyRow{}, &repoDidRow{}); err != nil { 64 + if err := gdb.AutoMigrate(&rkeyRow{}, &repoDidRow{}, &eventRow{}); err != nil { 59 65 return nil, err 60 66 } 61 67 // the db holds PLC rotation keys; keep it owner-only. 62 68 _ = os.Chmod(path, 0o600) 63 69 64 - db := &DB{gorm: gdb} 65 - if err := db.importLegacyJSON(dir); err != nil { 66 - return nil, fmt.Errorf("importing legacy json state: %w", err) 67 - } 68 - return db, nil 70 + return &DB{gorm: gdb}, nil 71 + } 72 + 73 + // SQL returns the underlying *sql.DB, which satisfies tangled's 74 + // eventstream.Store (Exec/Query). The events table holds the knot eventstream. 75 + func (db *DB) SQL() (*sql.DB, error) { 76 + return db.gorm.DB() 69 77 } 70 78 71 79 // Rkeys returns the rkey↔repo accessor. ··· 73 81 74 82 // RepoDids returns the repo-DID accessor. 75 83 func (db *DB) RepoDids() *RepoDids { return &RepoDids{db: db.gorm} } 76 - 77 - // importLegacyJSON one-time-imports rkeys.json / repodids.json written by 78 - // pre-SQLite versions. It only imports into an empty table, then renames the 79 - // file to *.migrated so it is kept as a backup and never re-imported. 80 - func (db *DB) importLegacyJSON(dir string) error { 81 - if err := db.importRkeysJSON(filepath.Join(dir, "rkeys.json")); err != nil { 82 - return err 83 - } 84 - return db.importRepoDidsJSON(filepath.Join(dir, "repodids.json")) 85 - } 86 - 87 - func (db *DB) importRkeysJSON(path string) error { 88 - b, ok, err := readLegacy(path) 89 - if err != nil || !ok { 90 - return err 91 - } 92 - var count int64 93 - if err := db.gorm.Model(&rkeyRow{}).Count(&count).Error; err != nil { 94 - return err 95 - } 96 - if count > 0 { 97 - return nil 98 - } 99 - var m map[string]string 100 - if err := json.Unmarshal(b, &m); err != nil { 101 - return err 102 - } 103 - rows := make([]rkeyRow, 0, len(m)) 104 - for rkey, repo := range m { 105 - rows = append(rows, rkeyRow{Rkey: rkey, RepoName: repo}) 106 - } 107 - if len(rows) > 0 { 108 - if err := db.gorm.Create(&rows).Error; err != nil { 109 - return err 110 - } 111 - } 112 - return os.Rename(path, path+".migrated") 113 - } 114 - 115 - func (db *DB) importRepoDidsJSON(path string) error { 116 - b, ok, err := readLegacy(path) 117 - if err != nil || !ok { 118 - return err 119 - } 120 - var count int64 121 - if err := db.gorm.Model(&repoDidRow{}).Count(&count).Error; err != nil { 122 - return err 123 - } 124 - if count > 0 { 125 - return nil 126 - } 127 - var m map[string]RepoDidInfo 128 - if err := json.Unmarshal(b, &m); err != nil { 129 - return err 130 - } 131 - rows := make([]repoDidRow, 0, len(m)) 132 - for did, info := range m { 133 - rows = append(rows, repoDidRow{Did: did, User: info.User, Repo: info.Repo, Key: info.Key}) 134 - } 135 - if len(rows) > 0 { 136 - if err := db.gorm.Create(&rows).Error; err != nil { 137 - return err 138 - } 139 - } 140 - return os.Rename(path, path+".migrated") 141 - } 142 - 143 - func readLegacy(path string) ([]byte, bool, error) { 144 - b, err := os.ReadFile(path) 145 - if errors.Is(err, os.ErrNotExist) { 146 - return nil, false, nil 147 - } 148 - if err != nil { 149 - return nil, false, err 150 - } 151 - return b, true, nil 152 - }
+66
internal/webhook/verify.go
··· 1 + // Package webhook receives Forgejo push webhooks and emits 2 + // sh.tangled.git.refUpdate events into snot's knot eventstream. 3 + package webhook 4 + 5 + import ( 6 + "crypto/hmac" 7 + "crypto/sha256" 8 + "encoding/hex" 9 + "encoding/json" 10 + ) 11 + 12 + // verifySignature checks a Forgejo X-Gitea-Signature (hex HMAC-SHA256 of the 13 + // raw body keyed by the shared secret) in constant time. 14 + func verifySignature(secret string, body []byte, sigHex string) bool { 15 + mac := hmac.New(sha256.New, []byte(secret)) 16 + mac.Write(body) 17 + expected := mac.Sum(nil) 18 + got, err := hex.DecodeString(sigHex) 19 + if err != nil { 20 + return false 21 + } 22 + return hmac.Equal(expected, got) 23 + } 24 + 25 + type pushPayload struct { 26 + Ref string `json:"ref"` 27 + Before string `json:"before"` 28 + After string `json:"after"` 29 + Repository struct { 30 + Name string `json:"name"` 31 + Owner struct { 32 + Username string `json:"username"` 33 + Login string `json:"login"` 34 + } `json:"owner"` 35 + } `json:"repository"` 36 + Pusher struct { 37 + Username string `json:"username"` 38 + Login string `json:"login"` 39 + } `json:"pusher"` 40 + } 41 + 42 + func parsePush(body []byte) (*pushPayload, error) { 43 + var p pushPayload 44 + if err := json.Unmarshal(body, &p); err != nil { 45 + return nil, err 46 + } 47 + return &p, nil 48 + } 49 + 50 + // owner/name/pusher prefer Forgejo's `username`, falling back to the 51 + // deprecated `login` alias. 52 + func (p *pushPayload) owner() string { 53 + if p.Repository.Owner.Username != "" { 54 + return p.Repository.Owner.Username 55 + } 56 + return p.Repository.Owner.Login 57 + } 58 + 59 + func (p *pushPayload) name() string { return p.Repository.Name } 60 + 61 + func (p *pushPayload) pusher() string { 62 + if p.Pusher.Username != "" { 63 + return p.Pusher.Username 64 + } 65 + return p.Pusher.Login 66 + }
+52
internal/webhook/verify_test.go
··· 1 + package webhook 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "testing" 8 + ) 9 + 10 + func sign(secret string, body []byte) string { 11 + mac := hmac.New(sha256.New, []byte(secret)) 12 + mac.Write(body) 13 + return hex.EncodeToString(mac.Sum(nil)) 14 + } 15 + 16 + func TestVerifySignature(t *testing.T) { 17 + body := []byte(`{"ref":"refs/heads/main"}`) 18 + good := sign("s3cr3t", body) 19 + 20 + if !verifySignature("s3cr3t", body, good) { 21 + t.Error("valid signature rejected") 22 + } 23 + if verifySignature("s3cr3t", body, sign("wrong", body)) { 24 + t.Error("signature from wrong secret accepted") 25 + } 26 + if verifySignature("s3cr3t", body, "not-hex") { 27 + t.Error("non-hex signature accepted") 28 + } 29 + if verifySignature("s3cr3t", body, "") { 30 + t.Error("empty signature accepted") 31 + } 32 + } 33 + 34 + func TestParsePush(t *testing.T) { 35 + body := []byte(`{ 36 + "ref": "refs/heads/main", 37 + "before": "1111111111111111111111111111111111111111", 38 + "after": "2222222222222222222222222222222222222222", 39 + "repository": {"name": "Demo", "owner": {"username": "Isabel"}}, 40 + "pusher": {"username": "Isabel"} 41 + }`) 42 + p, err := parsePush(body) 43 + if err != nil { 44 + t.Fatal(err) 45 + } 46 + if p.Ref != "refs/heads/main" || p.After != "2222222222222222222222222222222222222222" { 47 + t.Errorf("ref/after = %q %q", p.Ref, p.After) 48 + } 49 + if p.owner() != "Isabel" || p.name() != "Demo" || p.pusher() != "Isabel" { 50 + t.Errorf("owner/name/pusher = %q %q %q", p.owner(), p.name(), p.pusher()) 51 + } 52 + }
+142
internal/webhook/webhook.go
··· 1 + package webhook 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "strings" 10 + 11 + "github.com/go-git/go-git/v5/plumbing" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/eventstream" 14 + tgit "tangled.org/core/knotserver/git" 15 + "tangled.org/core/notifier" 16 + "tangled.org/core/tid" 17 + 18 + "github.com/isabelroses/snot/internal/config" 19 + "github.com/isabelroses/snot/internal/resolve" 20 + ) 21 + 22 + type Handler struct { 23 + Cfg *config.Config 24 + Resolve *resolve.Resolver 25 + Store eventstream.Store 26 + Notifier *notifier.Notifier 27 + Logger *slog.Logger 28 + } 29 + 30 + func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 31 + body, err := io.ReadAll(io.LimitReader(r.Body, 5<<20)) 32 + if err != nil { 33 + http.Error(w, "read error", http.StatusBadRequest) 34 + return 35 + } 36 + 37 + if h.Cfg.WebhookSecret != "" { 38 + if !verifySignature(h.Cfg.WebhookSecret, body, r.Header.Get("X-Gitea-Signature")) { 39 + h.Logger.Warn("webhook: bad signature") 40 + http.Error(w, "bad signature", http.StatusUnauthorized) 41 + return 42 + } 43 + } else { 44 + h.Logger.Warn("webhook: SNOT_WEBHOOK_SECRET unset; accepting unsigned webhook") 45 + } 46 + 47 + push, err := parsePush(body) 48 + if err != nil { 49 + http.Error(w, "bad payload", http.StatusBadRequest) 50 + return 51 + } 52 + if push.Ref == "" { 53 + // not a push event (e.g. ping); ack and ignore. 54 + w.WriteHeader(http.StatusOK) 55 + return 56 + } 57 + 58 + owner := strings.ToLower(push.owner()) 59 + name := strings.ToLower(push.name()) 60 + 61 + // Resolve the repo's did:plc; skip cleanly if the owner is unmapped or the 62 + // repo hasn't been published to tangled. 63 + ownerDid, ok := h.Resolve.DidFor(owner) 64 + if !ok { 65 + h.Logger.Info("webhook: skip, owner not mapped", "owner", owner) 66 + w.WriteHeader(http.StatusOK) 67 + return 68 + } 69 + repoDid, ok := h.Resolve.Dids.ByRepo(owner, name) 70 + if !ok { 71 + h.Logger.Info("webhook: skip, repo not published", "owner", owner, "name", name) 72 + w.WriteHeader(http.StatusOK) 73 + return 74 + } 75 + 76 + committerDid := ownerDid 77 + if did, ok := h.Resolve.DidFor(strings.ToLower(push.pusher())); ok { 78 + committerDid = did 79 + } 80 + 81 + refUpdate := tangled.GitRefUpdate{ 82 + Repo: repoDid, 83 + OwnerDid: &ownerDid, 84 + CommitterDid: committerDid, 85 + OldSha: push.Before, 86 + NewSha: push.After, 87 + Ref: push.Ref, 88 + } 89 + 90 + if meta, err := h.computeMeta(r.Context(), ownerDid, name, push); err != nil { 91 + h.Logger.Warn("webhook: meta computation failed, emitting without meta", "repo", repoDid, "err", err) 92 + } else if meta != nil { 93 + refUpdate.Meta = meta 94 + } 95 + 96 + eventJson, err := json.Marshal(refUpdate) 97 + if err != nil { 98 + http.Error(w, "encode error", http.StatusInternalServerError) 99 + return 100 + } 101 + ev := eventstream.Event{ 102 + Rkey: tid.TID(), 103 + Nsid: tangled.GitRefUpdateNSID, 104 + EventJson: eventJson, 105 + } 106 + if err := eventstream.Insert(h.Store, ev, h.Notifier); err != nil { 107 + h.Logger.Error("webhook: insert event", "err", err) 108 + http.Error(w, "store error", http.StatusInternalServerError) 109 + return 110 + } 111 + 112 + h.Logger.Info("webhook: emitted refUpdate", "repo", repoDid, "ref", push.Ref, "new", push.After) 113 + w.WriteHeader(http.StatusOK) 114 + } 115 + 116 + // computeMeta builds commit-count + language metadata from the bare repo, 117 + // mirroring tangled's knotserver. Returns nil (no error) for branch/tag 118 + // deletes (zero NewSha), matching upstream which only computes meta then. 119 + func (h *Handler) computeMeta(ctx context.Context, ownerDid, name string, push *pushPayload) (*tangled.GitRefUpdate_Meta, error) { 120 + newHash := plumbing.NewHash(push.After) 121 + if newHash.IsZero() { 122 + return nil, nil 123 + } 124 + _, repoPath, err := h.Resolve.Repo(ctx, ownerDid, name) 125 + if err != nil { 126 + return nil, err 127 + } 128 + gr, err := tgit.Open(repoPath, push.Ref) 129 + if err != nil { 130 + return nil, err 131 + } 132 + meta, err := gr.RefUpdateMeta(tgit.PostReceiveLine{ 133 + OldSha: plumbing.NewHash(push.Before), 134 + NewSha: newHash, 135 + Ref: push.Ref, 136 + }) 137 + if err != nil { 138 + return nil, err 139 + } 140 + rec := meta.AsRecord() 141 + return &rec, nil 142 + }
+157
internal/webhook/webhook_test.go
··· 1 + package webhook 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http/httptest" 8 + "os/exec" 9 + "path/filepath" 10 + "strings" 11 + "testing" 12 + 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/eventstream" 15 + "tangled.org/core/notifier" 16 + 17 + "github.com/isabelroses/snot/internal/config" 18 + "github.com/isabelroses/snot/internal/forgejo" 19 + "github.com/isabelroses/snot/internal/resolve" 20 + "github.com/isabelroses/snot/internal/state" 21 + "github.com/isabelroses/snot/internal/testutil" 22 + ) 23 + 24 + func headSHA(t *testing.T, bareDir string) string { 25 + t.Helper() 26 + out, err := exec.Command("git", "-C", bareDir, "rev-parse", "HEAD").Output() 27 + if err != nil { 28 + t.Fatalf("rev-parse: %v", err) 29 + } 30 + return strings.TrimSpace(string(out)) 31 + } 32 + 33 + func newHandler(t *testing.T) (*Handler, eventstream.Store, string) { 34 + t.Helper() 35 + root := testutil.FixtureRepo(t, "isabel", "demo") 36 + 37 + db, err := state.Open(t.TempDir()) 38 + if err != nil { 39 + t.Fatal(err) 40 + } 41 + store, err := db.SQL() 42 + if err != nil { 43 + t.Fatal(err) 44 + } 45 + if err := db.RepoDids().Put("did:plc:repo1", state.RepoDidInfo{User: "isabel", Repo: "demo", Key: []byte("k")}); err != nil { 46 + t.Fatal(err) 47 + } 48 + 49 + cfg := &config.Config{ 50 + Hostname: "knot.example.com", 51 + OwnerDid: "did:plc:knotadmin", 52 + UserMap: map[string]string{"did:plc:owner1": "isabel"}, 53 + RepoRoot: root, 54 + WebhookSecret: "s3cr3t", 55 + } 56 + stubStore := &testutil.StubStore{Repos: map[string]*forgejo.Repo{ 57 + "isabel/demo": testutil.StubRepo(1, "isabel", "demo"), 58 + }} 59 + rs := &resolve.Resolver{Cfg: cfg, Store: stubStore, Rkeys: db.Rkeys(), Dids: db.RepoDids()} 60 + 61 + n := notifier.New() 62 + h := &Handler{Cfg: cfg, Resolve: rs, Store: store, Notifier: &n, Logger: slog.Default()} 63 + return h, store, filepath.Join(root, "isabel", "demo.git") 64 + } 65 + 66 + func TestWebhookEmitsRefUpdate(t *testing.T) { 67 + h, store, bareDir := newHandler(t) 68 + head := headSHA(t, bareDir) 69 + 70 + body, _ := json.Marshal(map[string]any{ 71 + "ref": "refs/heads/main", 72 + "before": "0000000000000000000000000000000000000000", 73 + "after": head, 74 + "repository": map[string]any{ 75 + "name": "demo", 76 + "owner": map[string]any{"username": "isabel"}, 77 + }, 78 + "pusher": map[string]any{"username": "isabel"}, 79 + }) 80 + 81 + req := httptest.NewRequest("POST", "/hooks/forgejo", bytes.NewReader(body)) 82 + req.Header.Set("X-Gitea-Signature", sign("s3cr3t", body)) 83 + rec := httptest.NewRecorder() 84 + h.ServeHTTP(rec, req) 85 + 86 + if rec.Code != 200 { 87 + t.Fatalf("code = %d, body=%s", rec.Code, rec.Body) 88 + } 89 + 90 + events, err := eventstream.List(store, 0, 10) 91 + if err != nil { 92 + t.Fatal(err) 93 + } 94 + if len(events) != 1 { 95 + t.Fatalf("want 1 event, got %d", len(events)) 96 + } 97 + if events[0].Nsid != tangled.GitRefUpdateNSID { 98 + t.Errorf("nsid = %s", events[0].Nsid) 99 + } 100 + var ru tangled.GitRefUpdate 101 + if err := json.Unmarshal(events[0].EventJson, &ru); err != nil { 102 + t.Fatal(err) 103 + } 104 + if ru.Repo != "did:plc:repo1" || ru.Ref != "refs/heads/main" || ru.NewSha != head { 105 + t.Errorf("ru = %+v", ru) 106 + } 107 + if ru.CommitterDid != "did:plc:owner1" { 108 + t.Errorf("committer = %q, want mapped owner did", ru.CommitterDid) 109 + } 110 + if ru.OwnerDid == nil || *ru.OwnerDid != "did:plc:owner1" { 111 + t.Errorf("ownerDid = %v", ru.OwnerDid) 112 + } 113 + if ru.Meta == nil || !ru.Meta.IsDefaultRef { 114 + t.Errorf("meta missing or not default ref: %+v", ru.Meta) 115 + } 116 + } 117 + 118 + func TestWebhookRejectsBadSignature(t *testing.T) { 119 + h, store, _ := newHandler(t) 120 + body := []byte(`{"ref":"refs/heads/main","repository":{"name":"demo","owner":{"username":"isabel"}}}`) 121 + req := httptest.NewRequest("POST", "/hooks/forgejo", bytes.NewReader(body)) 122 + req.Header.Set("X-Gitea-Signature", "deadbeef") 123 + rec := httptest.NewRecorder() 124 + h.ServeHTTP(rec, req) 125 + 126 + if rec.Code != 401 { 127 + t.Fatalf("code = %d", rec.Code) 128 + } 129 + if events, _ := eventstream.List(store, 0, 10); len(events) != 0 { 130 + t.Errorf("no event should be emitted on bad signature, got %d", len(events)) 131 + } 132 + } 133 + 134 + func TestWebhookSkipsUnpublishedRepo(t *testing.T) { 135 + h, store, _ := newHandler(t) 136 + body, _ := json.Marshal(map[string]any{ 137 + "ref": "refs/heads/main", 138 + "before": "0000000000000000000000000000000000000000", 139 + "after": "2222222222222222222222222222222222222222", 140 + "repository": map[string]any{ 141 + "name": "other", 142 + "owner": map[string]any{"username": "isabel"}, 143 + }, 144 + "pusher": map[string]any{"username": "isabel"}, 145 + }) 146 + req := httptest.NewRequest("POST", "/hooks/forgejo", bytes.NewReader(body)) 147 + req.Header.Set("X-Gitea-Signature", sign("s3cr3t", body)) 148 + rec := httptest.NewRecorder() 149 + h.ServeHTTP(rec, req) 150 + 151 + if rec.Code != 200 { 152 + t.Fatalf("code = %d (should ack-and-skip)", rec.Code) 153 + } 154 + if events, _ := eventstream.List(store, 0, 10); len(events) != 0 { 155 + t.Errorf("unpublished repo should emit nothing, got %d", len(events)) 156 + } 157 + }