This repository has no description
21

Configure Feed

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

feat: store via db + knofig

isabel (Jun 13, 2026, 12:59 PM +0100) e05293df d3f15e0f

+438 -144
+3 -6
cmd/snot/serve.go
··· 40 40 } 41 41 defer store.Close() 42 42 43 - rkeys, err := state.LoadRkeyMap(cfg.StateDir) 43 + db, err := state.Open(cfg.StateDir) 44 44 if err != nil { 45 45 return fmt.Errorf("state: %w", err) 46 46 } 47 - 48 - dids, err := state.LoadRepoDids(cfg.StateDir) 49 - if err != nil { 50 - return fmt.Errorf("state (repodids): %w", err) 51 - } 47 + rkeys := db.Rkeys() 48 + dids := db.RepoDids() 52 49 53 50 scheme := "https" 54 51 if cfg.Dev {
+1 -1
default.nix
··· 5 5 6 6 src = ./.; 7 7 8 - vendorHash = "sha256-mCjuJWTTQ6pRBO2hdYhDVxPLJ2s7BI25XbwAn3V2Klo="; 8 + vendorHash = "sha256-uoHU3nySdQ/h1M9MWyrW/9h963byGV5AYvzW2xPkXyE="; 9 9 10 10 subPackages = [ "cmd/snot" ]; 11 11
+18 -1
go.mod
··· 7 7 github.com/bluekeyes/go-gitdiff v0.8.1 8 8 github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab 9 9 github.com/cyphar/filepath-securejoin v0.6.1 10 + github.com/glebarez/sqlite v1.11.0 10 11 github.com/go-chi/chi/v5 v5.2.0 11 12 github.com/go-git/go-git/v5 v5.14.0 12 13 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 13 14 github.com/jackc/pgx/v5 v5.10.0 14 - github.com/sethvargo/go-envconfig v1.3.0 15 + github.com/knadh/koanf/providers/confmap v1.0.0 16 + github.com/knadh/koanf/providers/env/v2 v2.0.0 17 + github.com/knadh/koanf/v2 v2.3.5 18 + gorm.io/gorm v1.31.1 15 19 tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 16 20 ) 17 21 ··· 56 60 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 57 61 github.com/emirpasic/gods v1.18.1 // indirect 58 62 github.com/felixge/httpsnoop v1.0.4 // indirect 63 + github.com/glebarez/go-sqlite v1.21.2 // indirect 59 64 github.com/go-enry/go-enry/v2 v2.9.6 // indirect 60 65 github.com/go-enry/go-oniguruma v1.2.1 // indirect 61 66 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect ··· 65 70 github.com/go-logr/logr v1.4.3 // indirect 66 71 github.com/go-logr/stdr v1.2.2 // indirect 67 72 github.com/go-redis/cache/v9 v9.0.0 // indirect 73 + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 68 74 github.com/gogo/protobuf v1.3.2 // indirect 69 75 github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 70 76 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect ··· 95 101 github.com/jackc/pgpassfile v1.0.0 // indirect 96 102 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 97 103 github.com/jackc/puddle/v2 v2.2.2 // indirect 104 + github.com/jinzhu/inflection v1.0.0 // indirect 105 + github.com/jinzhu/now v1.1.5 // indirect 98 106 github.com/kevinburke/ssh_config v1.2.0 // indirect 99 107 github.com/klauspost/compress v1.18.0 // indirect 100 108 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 109 + github.com/knadh/koanf/maps v0.1.2 // indirect 101 110 github.com/landlock-lsm/go-landlock v0.8.1 // indirect 102 111 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 103 112 github.com/mattn/go-isatty v0.0.20 // indirect ··· 105 114 github.com/mattn/go-sqlite3 v1.14.34 // indirect 106 115 github.com/microcosm-cc/bluemonday v1.0.27 // indirect 107 116 github.com/minio/sha256-simd v1.0.1 // indirect 117 + github.com/mitchellh/copystructure v1.2.0 // indirect 108 118 github.com/mitchellh/mapstructure v1.5.0 // indirect 119 + github.com/mitchellh/reflectwalk v1.0.2 // indirect 109 120 github.com/mr-tron/base58 v1.2.0 // indirect 110 121 github.com/muesli/termenv v0.16.0 // indirect 111 122 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 125 136 github.com/prometheus/common v0.67.5 // indirect 126 137 github.com/prometheus/procfs v0.19.2 // indirect 127 138 github.com/redis/go-redis/v9 v9.7.3 // indirect 139 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 128 140 github.com/rivo/uniseg v0.4.7 // indirect 129 141 github.com/ryanuber/go-glob v1.0.0 // indirect 130 142 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 143 + github.com/sethvargo/go-envconfig v1.3.0 // indirect 131 144 github.com/spaolacci/murmur3 v1.1.0 // indirect 132 145 github.com/stretchr/testify v1.11.1 // indirect 133 146 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect ··· 164 177 gopkg.in/yaml.v3 v3.0.1 // indirect 165 178 kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect 166 179 lukechampine.com/blake3 v1.4.1 // indirect 180 + modernc.org/libc v1.22.5 // indirect 181 + modernc.org/mathutil v1.5.0 // indirect 182 + modernc.org/memory v1.5.0 // indirect 183 + modernc.org/sqlite v1.23.1 // indirect 167 184 )
+37
go.sum
··· 102 102 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 103 103 github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 104 104 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 105 + github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 106 + github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 107 + github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= 108 + github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 105 109 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 106 110 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 107 111 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 135 139 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 136 140 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 137 141 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 142 + github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 143 + github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 138 144 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 139 145 github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 140 146 github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= ··· 166 172 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 167 173 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 168 174 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 175 + github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= 176 + github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= 169 177 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 170 178 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 171 179 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= ··· 240 248 github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= 241 249 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 242 250 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 251 + github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 252 + github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 253 + github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 254 + github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 243 255 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 244 256 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 245 257 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 251 263 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 252 264 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 253 265 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 266 + github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= 267 + github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 268 + github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE= 269 + github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= 270 + github.com/knadh/koanf/providers/env/v2 v2.0.0 h1:Ad5H3eun722u+FvchiIcEIJZsZ2M6oxCkgZfWN5B5KY= 271 + github.com/knadh/koanf/providers/env/v2 v2.0.0/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= 272 + github.com/knadh/koanf/v2 v2.3.5 h1:2dXJUYaKGm4SGYeoAtBviq9+02JZo/pxQ2ssOd60rJg= 273 + github.com/knadh/koanf/v2 v2.3.5/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= 254 274 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 255 275 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 256 276 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 275 295 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 276 296 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 277 297 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 298 + github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 299 + github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 278 300 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 279 301 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 302 + github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 303 + github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 280 304 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 281 305 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 282 306 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= ··· 350 374 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 351 375 github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 352 376 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 377 + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 378 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 379 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 353 380 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 354 381 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 355 382 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 601 628 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 602 629 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 603 630 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 631 + gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= 632 + gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 604 633 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 605 634 kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= 606 635 kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= 607 636 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 608 637 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 638 + modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= 639 + modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= 640 + modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= 641 + modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 642 + modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= 643 + modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 644 + modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= 645 + modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= 609 646 tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 h1:adp/YJS7cq6QI3xc3Ya/NB3KwWFFw0c/CxZvIDCTOvU= 610 647 tangled.org/core v0.0.0-20260612103734-ff6d47cfb983/go.mod h1:DzlYk2UwbYk1I2CIn0x2wQAedlJLdatg3YKZpKHNWjU= 611 648 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
+99 -15
internal/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 6 + "strconv" 7 + "strings" 5 8 6 - "github.com/sethvargo/go-envconfig" 9 + "github.com/knadh/koanf/providers/confmap" 10 + "github.com/knadh/koanf/providers/env/v2" 11 + "github.com/knadh/koanf/v2" 7 12 ) 8 13 9 14 type Config struct { 10 - Hostname string `env:"SNOT_HOSTNAME, required"` 11 - ListenAddr string `env:"SNOT_LISTEN_ADDR, default=0.0.0.0:5555"` 15 + Hostname string 16 + ListenAddr string 12 17 13 18 // OwnerDid identifies the knot's administrator alone (sh.tangled.owner, 14 19 // knot registration). Repo ownership is governed by UserMap. 15 - OwnerDid string `env:"SNOT_OWNER_DID, required"` 20 + OwnerDid string 16 21 17 22 // UserMap maps repo-owner DIDs to the Forgejo users whose repos they 18 - // expose, e.g. SNOT_USER_MAP="did:plc:abc=isabel,did:plc:def=alice". 19 - UserMap map[string]string `env:"SNOT_USER_MAP, required, separator=="` 23 + // expose, parsed from SNOT_USER_MAP="did:plc:abc=isabel,did:plc:def=alice". 24 + UserMap map[string]string 20 25 21 - DbDsn string `env:"SNOT_DB_DSN, required"` 22 - RepoRoot string `env:"SNOT_REPO_ROOT, required"` 23 - PushRemote string `env:"SNOT_PUSH_REMOTE"` 24 - StateDir string `env:"SNOT_STATE_DIR, default=/var/lib/snot"` 25 - PlcUrl string `env:"SNOT_PLC_URL, default=https://plc.directory"` 26 - Dev bool `env:"SNOT_DEV, default=false"` 26 + DbDsn string 27 + RepoRoot string 28 + PushRemote string 29 + StateDir string 30 + PlcUrl string 31 + Dev bool 27 32 } 28 33 34 + // Load reads configuration from SNOT_* environment variables. 29 35 func Load(ctx context.Context) (*Config, error) { 30 - var c Config 31 - if err := envconfig.Process(ctx, &c); err != nil { 36 + return loadWith(nil) 37 + } 38 + 39 + // loadWith builds the config from the given environ source (nil = os.Environ), 40 + // applying defaults first and SNOT_* env vars on top. 41 + func loadWith(environ func() []string) (*Config, error) { 42 + k := koanf.New(".") 43 + 44 + _ = k.Load(confmap.Provider(map[string]any{ 45 + "listen_addr": "0.0.0.0:5555", 46 + "state_dir": "/var/lib/snot", 47 + "plc_url": "https://plc.directory", 48 + "dev": "false", 49 + }, "."), nil) 50 + 51 + err := k.Load(env.Provider(".", env.Opt{ 52 + Prefix: "SNOT_", 53 + EnvironFunc: environ, 54 + TransformFunc: func(key, val string) (string, any) { 55 + return strings.ToLower(strings.TrimPrefix(key, "SNOT_")), val 56 + }, 57 + }), nil) 58 + if err != nil { 59 + return nil, fmt.Errorf("loading env: %w", err) 60 + } 61 + 62 + dev, _ := strconv.ParseBool(k.String("dev")) 63 + cfg := &Config{ 64 + Hostname: k.String("hostname"), 65 + ListenAddr: k.String("listen_addr"), 66 + OwnerDid: k.String("owner_did"), 67 + UserMap: parseUserMap(k.String("user_map")), 68 + DbDsn: k.String("db_dsn"), 69 + RepoRoot: k.String("repo_root"), 70 + PushRemote: k.String("push_remote"), 71 + StateDir: k.String("state_dir"), 72 + PlcUrl: k.String("plc_url"), 73 + Dev: dev, 74 + } 75 + 76 + if err := cfg.validate(); err != nil { 32 77 return nil, err 33 78 } 34 - return &c, nil 79 + return cfg, nil 80 + } 81 + 82 + func parseUserMap(s string) map[string]string { 83 + m := map[string]string{} 84 + for _, pair := range strings.Split(s, ",") { 85 + pair = strings.TrimSpace(pair) 86 + if pair == "" { 87 + continue 88 + } 89 + did, user, ok := strings.Cut(pair, "=") 90 + if !ok { 91 + continue 92 + } 93 + m[strings.TrimSpace(did)] = strings.TrimSpace(user) 94 + } 95 + return m 96 + } 97 + 98 + func (c *Config) validate() error { 99 + var missing []string 100 + if c.Hostname == "" { 101 + missing = append(missing, "SNOT_HOSTNAME") 102 + } 103 + if c.OwnerDid == "" { 104 + missing = append(missing, "SNOT_OWNER_DID") 105 + } 106 + if len(c.UserMap) == 0 { 107 + missing = append(missing, "SNOT_USER_MAP") 108 + } 109 + if c.DbDsn == "" { 110 + missing = append(missing, "SNOT_DB_DSN") 111 + } 112 + if c.RepoRoot == "" { 113 + missing = append(missing, "SNOT_REPO_ROOT") 114 + } 115 + if len(missing) > 0 { 116 + return fmt.Errorf("missing required config: %s", strings.Join(missing, ", ")) 117 + } 118 + return nil 35 119 }
+26 -9
internal/config/config_test.go
··· 1 1 package config 2 2 3 3 import ( 4 - "context" 5 4 "testing" 6 - 7 - "github.com/sethvargo/go-envconfig" 8 5 ) 9 6 10 7 func load(t *testing.T, env map[string]string) (*Config, error) { 11 8 t.Helper() 12 - var c Config 13 - err := envconfig.ProcessWith(context.Background(), &envconfig.Config{ 14 - Target: &c, 15 - Lookuper: envconfig.MapLookuper(env), 16 - }) 17 - return &c, err 9 + var environ []string 10 + for k, v := range env { 11 + environ = append(environ, k+"="+v) 12 + } 13 + return loadWith(func() []string { return environ }) 18 14 } 19 15 20 16 func TestLoadDefaults(t *testing.T) { ··· 39 35 } 40 36 if c.UserMap["did:plc:abc123"] != "isabel" || c.UserMap["did:plc:def456"] != "alice" { 41 37 t.Errorf("UserMap = %v", c.UserMap) 38 + } 39 + } 40 + 41 + func TestLoadOverrides(t *testing.T) { 42 + c, err := load(t, map[string]string{ 43 + "SNOT_HOSTNAME": "knot.example.com", 44 + "SNOT_OWNER_DID": "did:plc:abc123", 45 + "SNOT_USER_MAP": "did:plc:abc123=isabel", 46 + "SNOT_DB_DSN": "postgres:///forgejo", 47 + "SNOT_REPO_ROOT": "/repos", 48 + "SNOT_LISTEN_ADDR": "127.0.0.1:9000", 49 + "SNOT_DEV": "true", 50 + }) 51 + if err != nil { 52 + t.Fatal(err) 53 + } 54 + if c.ListenAddr != "127.0.0.1:9000" { 55 + t.Errorf("ListenAddr = %q", c.ListenAddr) 56 + } 57 + if !c.Dev { 58 + t.Error("Dev should be true") 42 59 } 43 60 } 44 61
+54
internal/state/migrate_test.go
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + ) 9 + 10 + func TestImportsLegacyJSON(t *testing.T) { 11 + dir := t.TempDir() 12 + 13 + rkeys := map[string]string{"3l5tid": "demo"} 14 + rb, _ := json.Marshal(rkeys) 15 + if err := os.WriteFile(filepath.Join(dir, "rkeys.json"), rb, 0o600); err != nil { 16 + t.Fatal(err) 17 + } 18 + 19 + dids := map[string]RepoDidInfo{ 20 + "did:plc:repo1": {User: "isabel", Repo: "demo", Key: []byte("plckey")}, 21 + } 22 + db, _ := json.Marshal(dids) 23 + if err := os.WriteFile(filepath.Join(dir, "repodids.json"), db, 0o600); err != nil { 24 + t.Fatal(err) 25 + } 26 + 27 + d, err := Open(dir) 28 + if err != nil { 29 + t.Fatal(err) 30 + } 31 + 32 + if got, ok := d.Rkeys().RepoByRkey("3l5tid"); !ok || got != "demo" { 33 + t.Errorf("rkey not imported: %q %v", got, ok) 34 + } 35 + info, ok := d.RepoDids().Get("did:plc:repo1") 36 + if !ok || info.Repo != "demo" || string(info.Key) != "plckey" { 37 + t.Errorf("repodid not imported: %+v %v", info, ok) 38 + } 39 + 40 + // Originals renamed to *.migrated (kept as backup, not re-imported). 41 + for _, name := range []string{"rkeys.json", "repodids.json"} { 42 + if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { 43 + t.Errorf("%s should have been renamed away", name) 44 + } 45 + if _, err := os.Stat(filepath.Join(dir, name+".migrated")); err != nil { 46 + t.Errorf("%s.migrated backup missing: %v", name, err) 47 + } 48 + } 49 + 50 + // Reopen: must not error or duplicate (table already populated, files gone). 51 + if _, err := Open(dir); err != nil { 52 + t.Fatalf("reopen after migration: %v", err) 53 + } 54 + }
+21 -53
internal/state/repodids.go
··· 1 1 package state 2 2 3 - import ( 4 - "encoding/json" 5 - "os" 6 - "path/filepath" 7 - "strings" 8 - "sync" 9 - ) 10 - 11 - const repodidFile = "repodids.json" 3 + import "gorm.io/gorm" 12 4 13 5 // RepoDidInfo records a minted repo DID: which repo it names and the PLC 14 6 // rotation/signing key that controls it. Losing the key means the DID's 15 - // document can never be updated, so the state dir must be backed up. 7 + // document can never be updated, so the state DB must be backed up. 16 8 type RepoDidInfo struct { 17 9 User string `json:"user"` // forgejo owner (lower) 18 10 Repo string `json:"repo"` // repo name (lower) 19 11 Key []byte `json:"key"` // raw private key bytes 20 12 } 21 13 14 + // RepoDids stores the per-repo did:plc identities the shim has minted. 22 15 type RepoDids struct { 23 - mu sync.Mutex 24 - path string 25 - m map[string]RepoDidInfo // keyed by DID 16 + db *gorm.DB 26 17 } 27 18 28 - func LoadRepoDids(stateDir string) (*RepoDids, error) { 29 - if err := os.MkdirAll(stateDir, 0o700); err != nil { 30 - return nil, err 31 - } 32 - r := &RepoDids{ 33 - path: filepath.Join(stateDir, repodidFile), 34 - m: make(map[string]RepoDidInfo), 35 - } 36 - b, err := os.ReadFile(r.path) 37 - if os.IsNotExist(err) { 38 - return r, nil 39 - } 19 + // LoadRepoDids opens the state DB in dir and returns its repo-DID accessor. 20 + func LoadRepoDids(dir string) (*RepoDids, error) { 21 + db, err := Open(dir) 40 22 if err != nil { 41 23 return nil, err 42 24 } 43 - if err := json.Unmarshal(b, &r.m); err != nil { 44 - return nil, err 45 - } 46 - return r, nil 25 + return db.RepoDids(), nil 47 26 } 48 27 28 + // Put records (or overwrites) the info for a repo DID. 49 29 func (r *RepoDids) Put(did string, info RepoDidInfo) error { 50 - r.mu.Lock() 51 - defer r.mu.Unlock() 52 - r.m[did] = info 53 - 54 - b, err := json.MarshalIndent(r.m, "", " ") 55 - if err != nil { 56 - return err 57 - } 58 - tmp := r.path + ".tmp" 59 - if err := os.WriteFile(tmp, b, 0o600); err != nil { 60 - return err 61 - } 62 - return os.Rename(tmp, r.path) 30 + return r.db.Save(&repoDidRow{Did: did, User: info.User, Repo: info.Repo, Key: info.Key}).Error 63 31 } 64 32 33 + // Get returns the info recorded for a repo DID. 65 34 func (r *RepoDids) Get(did string) (RepoDidInfo, bool) { 66 - r.mu.Lock() 67 - defer r.mu.Unlock() 68 - info, ok := r.m[did] 69 - return info, ok 35 + var row repoDidRow 36 + if err := r.db.First(&row, "did = ?", did).Error; err != nil { 37 + return RepoDidInfo{}, false 38 + } 39 + return RepoDidInfo{User: row.User, Repo: row.Repo, Key: row.Key}, true 70 40 } 71 41 72 42 // ByRepo returns the DID for the given (user, repo) pair (case-insensitive). 73 43 func (r *RepoDids) ByRepo(user, repo string) (string, bool) { 74 - r.mu.Lock() 75 - defer r.mu.Unlock() 76 - for did, info := range r.m { 77 - if strings.EqualFold(info.User, user) && strings.EqualFold(info.Repo, repo) { 78 - return did, true 79 - } 44 + var row repoDidRow 45 + err := r.db.First(&row, "user = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE", user, repo).Error 46 + if err != nil { 47 + return "", false 80 48 } 81 - return "", false 49 + return row.Did, true 82 50 }
+4 -4
internal/state/repodids_test.go
··· 19 19 t.Fatal(err) 20 20 } 21 21 22 - // Reload from disk. 22 + // Reopen against the same db file. 23 23 r2, err := LoadRepoDids(dir) 24 24 if err != nil { 25 25 t.Fatal(err) ··· 57 57 } 58 58 } 59 59 60 - func TestRepoDidsFilePerms(t *testing.T) { 60 + func TestDBFilePerms(t *testing.T) { 61 61 dir := t.TempDir() 62 62 r, err := LoadRepoDids(dir) 63 63 if err != nil { ··· 67 67 t.Fatal(err) 68 68 } 69 69 70 - info, err := os.Stat(filepath.Join(dir, repodidFile)) 70 + info, err := os.Stat(filepath.Join(dir, dbFile)) 71 71 if err != nil { 72 - t.Fatalf("repodids.json not written: %v", err) 72 + t.Fatalf("%s not written: %v", dbFile, err) 73 73 } 74 74 if info.Mode().Perm() != 0o600 { 75 75 t.Errorf("file mode = %v, want 0600", info.Mode().Perm())
+20 -53
internal/state/rkeys.go
··· 1 - // Package state persists the small amount of mutable state the shim owns: 2 - // the mapping from sh.tangled.repo record rkeys to Forgejo repo names. 3 1 package state 4 2 5 - import ( 6 - "encoding/json" 7 - "os" 8 - "path/filepath" 9 - "sync" 10 - ) 3 + import "gorm.io/gorm" 11 4 12 - const rkeyFile = "rkeys.json" 13 - 5 + // RkeyMap maps sh.tangled.repo record rkeys to Forgejo repo names. 14 6 type RkeyMap struct { 15 - mu sync.Mutex 16 - path string 17 - m map[string]string // rkey -> repo lower_name 7 + db *gorm.DB 18 8 } 19 9 20 - func LoadRkeyMap(stateDir string) (*RkeyMap, error) { 21 - if err := os.MkdirAll(stateDir, 0o700); err != nil { 22 - return nil, err 23 - } 24 - r := &RkeyMap{ 25 - path: filepath.Join(stateDir, rkeyFile), 26 - m: make(map[string]string), 27 - } 28 - b, err := os.ReadFile(r.path) 29 - if os.IsNotExist(err) { 30 - return r, nil 31 - } 10 + // LoadRkeyMap opens the state DB in dir and returns its rkey accessor. 11 + func LoadRkeyMap(dir string) (*RkeyMap, error) { 12 + db, err := Open(dir) 32 13 if err != nil { 33 14 return nil, err 34 15 } 35 - if err := json.Unmarshal(b, &r.m); err != nil { 36 - return nil, err 37 - } 38 - return r, nil 16 + return db.Rkeys(), nil 39 17 } 40 18 19 + // Put records (or overwrites) the repo name for an rkey. 41 20 func (r *RkeyMap) Put(rkey, repoName string) error { 42 - r.mu.Lock() 43 - defer r.mu.Unlock() 44 - r.m[rkey] = repoName 45 - 46 - b, err := json.MarshalIndent(r.m, "", " ") 47 - if err != nil { 48 - return err 49 - } 50 - tmp := r.path + ".tmp" 51 - if err := os.WriteFile(tmp, b, 0o600); err != nil { 52 - return err 53 - } 54 - return os.Rename(tmp, r.path) 21 + return r.db.Save(&rkeyRow{Rkey: rkey, RepoName: repoName}).Error 55 22 } 56 23 24 + // RepoByRkey returns the repo name recorded for an rkey. 57 25 func (r *RkeyMap) RepoByRkey(rkey string) (string, bool) { 58 - r.mu.Lock() 59 - defer r.mu.Unlock() 60 - repo, ok := r.m[rkey] 61 - return repo, ok 26 + var row rkeyRow 27 + if err := r.db.First(&row, "rkey = ?", rkey).Error; err != nil { 28 + return "", false 29 + } 30 + return row.RepoName, true 62 31 } 63 32 33 + // RkeyByRepo returns an rkey recorded for a repo name. 64 34 func (r *RkeyMap) RkeyByRepo(repoName string) (string, bool) { 65 - r.mu.Lock() 66 - defer r.mu.Unlock() 67 - for rkey, repo := range r.m { 68 - if repo == repoName { 69 - return rkey, true 70 - } 35 + var row rkeyRow 36 + if err := r.db.First(&row, "repo_name = ?", repoName).Error; err != nil { 37 + return "", false 71 38 } 72 - return "", false 39 + return row.Rkey, true 73 40 }
+3 -2
internal/state/rkeys_test.go
··· 17 17 t.Fatal(err) 18 18 } 19 19 20 + // Reopen against the same db file. 20 21 m2, err := LoadRkeyMap(dir) 21 22 if err != nil { 22 23 t.Fatal(err) ··· 31 32 t.Error("expected miss") 32 33 } 33 34 34 - if _, err := os.Stat(filepath.Join(dir, "rkeys.json")); err != nil { 35 - t.Fatalf("rkeys.json not written: %v", err) 35 + if _, err := os.Stat(filepath.Join(dir, dbFile)); err != nil { 36 + t.Fatalf("%s not written: %v", dbFile, err) 36 37 } 37 38 } 38 39
+152
internal/state/state.go
··· 1 + // Package state persists the small amount of mutable state the shim owns in a 2 + // SQLite database (via GORM): the mapping from sh.tangled.repo record rkeys to 3 + // Forgejo repo names, and the per-repo did:plc identities it has minted. 4 + package state 5 + 6 + import ( 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "os" 11 + "path/filepath" 12 + 13 + "github.com/glebarez/sqlite" 14 + "gorm.io/gorm" 15 + "gorm.io/gorm/logger" 16 + ) 17 + 18 + const dbFile = "snot.db" 19 + 20 + type rkeyRow struct { 21 + Rkey string `gorm:"primaryKey"` 22 + RepoName string `gorm:"index"` 23 + } 24 + 25 + func (rkeyRow) TableName() string { return "rkeys" } 26 + 27 + type repoDidRow struct { 28 + Did string `gorm:"primaryKey"` 29 + User string `gorm:"index:idx_repodid_user_repo"` 30 + Repo string `gorm:"index:idx_repodid_user_repo"` 31 + Key []byte 32 + } 33 + 34 + func (repoDidRow) TableName() string { return "repo_dids" } 35 + 36 + // DB owns snot's persistent SQLite store and hands out typed accessors. 37 + type DB struct { 38 + gorm *gorm.DB 39 + } 40 + 41 + // Open opens (creating if absent) snot.db inside dir, runs migrations, and 42 + // imports any legacy JSON state left by older versions. 43 + func Open(dir string) (*DB, error) { 44 + if err := os.MkdirAll(dir, 0o700); err != nil { 45 + return nil, err 46 + } 47 + path := filepath.Join(dir, dbFile) 48 + 49 + gdb, err := gorm.Open(sqlite.Open(path), &gorm.Config{ 50 + Logger: logger.Default.LogMode(logger.Silent), 51 + }) 52 + if err != nil { 53 + return nil, err 54 + } 55 + if err := gdb.Exec("PRAGMA busy_timeout = 5000").Error; err != nil { 56 + return nil, err 57 + } 58 + if err := gdb.AutoMigrate(&rkeyRow{}, &repoDidRow{}); err != nil { 59 + return nil, err 60 + } 61 + // the db holds PLC rotation keys; keep it owner-only. 62 + _ = os.Chmod(path, 0o600) 63 + 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 69 + } 70 + 71 + // Rkeys returns the rkey↔repo accessor. 72 + func (db *DB) Rkeys() *RkeyMap { return &RkeyMap{db: db.gorm} } 73 + 74 + // RepoDids returns the repo-DID accessor. 75 + 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 + }