this repo has no description

initial commit

hailey.at 254aa4c5

+2
.gitignore
··· 1 + .env 2 + peruse-bin
+42
Makefile
··· 1 + SHELL = /bin/bash 2 + .SHELLFLAGS = -o pipefail -c 3 + GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null) 4 + GIT_COMMIT := $(shell git rev-parse --short=9 HEAD) 5 + VERSION := $(if $(GIT_TAG),$(GIT_TAG),dev-$(GIT_COMMIT)) 6 + 7 + .PHONY: help 8 + help: ## Print info about all commands 9 + @echo "Commands:" 10 + @echo 11 + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[01;32m%-20s\033[0m %s\n", $$1, $$2}' 12 + 13 + .PHONY: build 14 + build: ## Build all executables 15 + go build -ldflags "-X main.Version=$(VERSION)" -o peruse-bin ./cmd/peruse 16 + 17 + .PHONY: run 18 + run: 19 + go build -ldflags "-X main.Version=dev-local" -o photocopy ./cmd/photocopy && ./photocopy run 20 + 21 + .PHONY: all 22 + all: build 23 + 24 + .PHONY: test 25 + test: ## Run tests 26 + go clean -testcache && go test -v ./... 27 + 28 + .PHONY: lint 29 + lint: ## Verify code style and run static checks 30 + go vet ./... 31 + test -z $(gofmt -l ./...) 32 + 33 + .PHONY: fmt 34 + fmt: ## Run syntax re-formatting (modify in place) 35 + go fmt ./... 36 + 37 + .PHONY: check 38 + check: ## Compile everything, checking syntax (does not output binaries) 39 + go build ./... 40 + 41 + .env: 42 + if [ ! -f ".env" ]; then cp example.dev.env .env; fi
+124
cmd/peruse/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + "os/signal" 8 + "syscall" 9 + 10 + "github.com/haileyok/peruse/peruse" 11 + "github.com/urfave/cli/v2" 12 + 13 + "net/http" 14 + _ "net/http/pprof" 15 + ) 16 + 17 + func main() { 18 + app := cli.App{ 19 + Name: "peruse", 20 + Flags: []cli.Flag{ 21 + &cli.StringFlag{ 22 + Name: "http-addr", 23 + EnvVars: []string{"PERUSE_HTTP_ADDR"}, 24 + }, 25 + &cli.StringFlag{ 26 + Name: "clickhouse-addr", 27 + EnvVars: []string{"PERUSE_CLICKHOUSE_ADDR"}, 28 + Required: true, 29 + }, 30 + &cli.StringFlag{ 31 + Name: "clickhouse-database", 32 + EnvVars: []string{"PERUSE_CLICKHOUSE_DATABASE"}, 33 + Required: true, 34 + }, 35 + &cli.StringFlag{ 36 + Name: "clickhouse-user", 37 + EnvVars: []string{"PERUSE_CLICKHOUSE_USER"}, 38 + Required: true, 39 + }, 40 + &cli.StringFlag{ 41 + Name: "clickhouse-pass", 42 + EnvVars: []string{"PERUSE_CLICKHOUSE_PASS"}, 43 + Required: true, 44 + }, 45 + &cli.StringFlag{ 46 + Name: "pprof-addr", 47 + EnvVars: []string{"PERUSE_PPROF_ADDR"}, 48 + Value: ":10390", 49 + }, 50 + &cli.StringFlag{ 51 + Name: "feed-owner-did", 52 + EnvVars: []string{"PERUSE_FEED_OWNER_DID"}, 53 + Required: true, 54 + }, 55 + &cli.StringFlag{ 56 + Name: "service-did", 57 + EnvVars: []string{"PERUSE_SERVICE_DID"}, 58 + Required: true, 59 + }, 60 + &cli.StringFlag{ 61 + Name: "service-endpoint", 62 + EnvVars: []string{"PERSUSE_SERVICE_ENDPOINT"}, 63 + Required: true, 64 + }, 65 + &cli.StringFlag{ 66 + Name: "chrono-feed-rkey", 67 + EnvVars: []string{"PERUSE_CHRONO_FEED_RKEY"}, 68 + Required: true, 69 + }, 70 + }, 71 + Action: run, 72 + } 73 + 74 + app.Run(os.Args) 75 + } 76 + 77 + var run = func(cmd *cli.Context) error { 78 + ctx := cmd.Context 79 + ctx, cancel := context.WithCancel(ctx) 80 + defer cancel() 81 + 82 + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 83 + Level: slog.LevelDebug, 84 + })) 85 + 86 + server, err := peruse.NewServer(peruse.ServerArgs{ 87 + HttpAddr: cmd.String("http-addr"), 88 + ClickhouseAddr: cmd.String("clickhouse-addr"), 89 + ClickhouseDatabase: cmd.String("clickhouse-database"), 90 + ClickhouseUser: cmd.String("clickhouse-user"), 91 + ClickhousePass: cmd.String("clickhouse-pass"), 92 + Logger: logger, 93 + FeedOwnerDid: cmd.String("feed-owner-did"), 94 + ServiceDid: cmd.String("service-did"), 95 + ServiceEndpoint: cmd.String("service-endpoint"), 96 + ChronoFeedRkey: cmd.String("chrono-feed-rkey"), 97 + }) 98 + if err != nil { 99 + logger.Error("error creating server", "error", err) 100 + return err 101 + } 102 + 103 + go func() { 104 + exitSigs := make(chan os.Signal, 1) 105 + signal.Notify(exitSigs, syscall.SIGINT, syscall.SIGTERM) 106 + 107 + sig := <-exitSigs 108 + 109 + logger.Info("received os exit signal", "signal", sig) 110 + cancel() 111 + }() 112 + 113 + go func() { 114 + if err := http.ListenAndServe(cmd.String("pprof-addr"), nil); err != nil { 115 + logger.Error("error starting pprof", "error", err) 116 + } 117 + }() 118 + 119 + if err := server.Run(ctx); err != nil { 120 + logger.Error("error running server", "error", err) 121 + } 122 + 123 + return nil 124 + }
+54
go.mod
··· 1 + module github.com/haileyok/peruse 2 + 3 + go 1.24.4 4 + 5 + require ( 6 + github.com/ClickHouse/clickhouse-go/v2 v2.37.2 7 + github.com/bluesky-social/indigo v0.0.0-20250626183556-5641d3c27325 8 + github.com/golang-jwt/jwt/v5 v5.2.2 9 + github.com/haileyok/photocopy v0.0.0-20250630043251-10829c777ef4 10 + github.com/hashicorp/golang-lru/v2 v2.0.7 11 + github.com/labstack/echo/v4 v4.13.4 12 + github.com/urfave/cli/v2 v2.27.7 13 + golang.org/x/time v0.11.0 14 + ) 15 + 16 + require ( 17 + github.com/ClickHouse/ch-go v0.66.1 // indirect 18 + github.com/andybalholm/brotli v1.1.1 // indirect 19 + github.com/beorn7/perks v1.0.1 // indirect 20 + github.com/carlmjohnson/versioninfo v0.22.5 // indirect 21 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 23 + github.com/go-faster/city v1.0.1 // indirect 24 + github.com/go-faster/errors v0.7.1 // indirect 25 + github.com/google/uuid v1.6.0 // indirect 26 + github.com/klauspost/compress v1.18.0 // indirect 27 + github.com/labstack/gommon v0.4.2 // indirect 28 + github.com/mattn/go-colorable v0.1.14 // indirect 29 + github.com/mattn/go-isatty v0.0.20 // indirect 30 + github.com/mr-tron/base58 v1.2.0 // indirect 31 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 32 + github.com/paulmach/orb v0.11.1 // indirect 33 + github.com/pierrec/lz4/v4 v4.1.22 // indirect 34 + github.com/prometheus/client_golang v1.22.0 // indirect 35 + github.com/prometheus/client_model v0.6.1 // indirect 36 + github.com/prometheus/common v0.62.0 // indirect 37 + github.com/prometheus/procfs v0.15.1 // indirect 38 + github.com/russross/blackfriday/v2 v2.1.0 // indirect 39 + github.com/segmentio/asm v1.2.0 // indirect 40 + github.com/shopspring/decimal v1.4.0 // indirect 41 + github.com/valyala/bytebufferpool v1.0.0 // indirect 42 + github.com/valyala/fasttemplate v1.2.2 // indirect 43 + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 44 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 45 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 46 + go.opentelemetry.io/otel v1.36.0 // indirect 47 + go.opentelemetry.io/otel/trace v1.36.0 // indirect 48 + golang.org/x/crypto v0.39.0 // indirect 49 + golang.org/x/net v0.41.0 // indirect 50 + golang.org/x/sys v0.33.0 // indirect 51 + golang.org/x/text v0.26.0 // indirect 52 + google.golang.org/protobuf v1.36.6 // indirect 53 + gopkg.in/yaml.v3 v3.0.1 // indirect 54 + )
+173
go.sum
··· 1 + github.com/ClickHouse/ch-go v0.66.1 h1:LQHFslfVYZsISOY0dnOYOXGkOUvpv376CCm8g7W74A4= 2 + github.com/ClickHouse/ch-go v0.66.1/go.mod h1:NEYcg3aOFv2EmTJfo4m2WF7sHB/YFbLUuIWv9iq76xY= 3 + github.com/ClickHouse/clickhouse-go/v2 v2.37.2 h1:wRLNKoynvHQEN4znnVHNLaYnrqVc9sGJmGYg+GGCfto= 4 + github.com/ClickHouse/clickhouse-go/v2 v2.37.2/go.mod h1:pH2zrBGp5Y438DMwAxXMm1neSXPPjSI7tD4MURVULw8= 5 + github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 6 + github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 7 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 8 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 9 + github.com/bluesky-social/indigo v0.0.0-20250626183556-5641d3c27325 h1:Bftt2EcoLZK2Z2m12Ih5QqbReX8j29hbf4zJU/FKzaY= 10 + github.com/bluesky-social/indigo v0.0.0-20250626183556-5641d3c27325/go.mod h1:8FlFpF5cIq3DQG0kEHqyTkPV/5MDQoaWLcVwza5ZPJU= 11 + github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 12 + github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 13 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 14 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 + github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 16 + github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 17 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 + github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= 21 + github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= 22 + github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= 23 + github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= 24 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 25 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 26 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 27 + github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 28 + github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 29 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 30 + github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 31 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 32 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 33 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 34 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 35 + github.com/haileyok/photocopy v0.0.0-20250630043251-10829c777ef4 h1:4SrZuwjrzC3PR3ayzPrv4K4m7fa8SGygre3qrx0wQe0= 36 + github.com/haileyok/photocopy v0.0.0-20250630043251-10829c777ef4/go.mod h1:U4EKU/HqQiO/dPQuOkjSu18Z9ch4F4rNIeANPp44P1s= 37 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 38 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 39 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 40 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 41 + github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 42 + github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 43 + github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 44 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 45 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 46 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 47 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 48 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 49 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 50 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 51 + github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= 52 + github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 53 + github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 54 + github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 55 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 56 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 57 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 58 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 59 + github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= 60 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 61 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 62 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 63 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 64 + github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= 65 + github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= 66 + github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= 67 + github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= 68 + github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 69 + github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 70 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 71 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 72 + github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 73 + github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 74 + github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 75 + github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 76 + github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 77 + github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 78 + github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 79 + github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 80 + github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 81 + github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 82 + github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 83 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 84 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 85 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 86 + github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 87 + github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 88 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 89 + github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 90 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 91 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 92 + github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 93 + github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 94 + github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 95 + github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 96 + github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 97 + github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 98 + github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 99 + github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 100 + github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= 101 + github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= 102 + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 103 + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 104 + github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 105 + github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 106 + github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= 107 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 108 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 109 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 110 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 111 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 112 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 113 + go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= 114 + go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 115 + go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 116 + go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 117 + go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 118 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 119 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 120 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 121 + golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 122 + golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 123 + golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 124 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 125 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 126 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 127 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 128 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 129 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 130 + golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 131 + golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 132 + golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 133 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 134 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 135 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 136 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 137 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 138 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 139 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 140 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 141 + golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 142 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 143 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 144 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 145 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 146 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 147 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 148 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 149 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 150 + golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 151 + golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 152 + golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 153 + golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 154 + golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 155 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 156 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 157 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 158 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 159 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 160 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 161 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 162 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 163 + google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 164 + google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 165 + google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 166 + google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 167 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 168 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 169 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 170 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 171 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 172 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 173 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+31
internal/helpers/helpers.go
··· 1 + package helpers 2 + 3 + import "github.com/labstack/echo/v4" 4 + 5 + func InputError(e echo.Context, error, msg string) error { 6 + if error == "" { 7 + return e.NoContent(400) 8 + } 9 + 10 + resp := map[string]string{} 11 + resp["error"] = error 12 + if msg != "" { 13 + resp["message"] = msg 14 + } 15 + 16 + return e.JSON(400, resp) 17 + } 18 + 19 + func ServerError(e echo.Context, error, msg string) error { 20 + if error == "" { 21 + return e.NoContent(500) 22 + } 23 + 24 + resp := map[string]string{} 25 + resp["error"] = error 26 + if msg != "" { 27 + resp["message"] = msg 28 + } 29 + 30 + return e.JSON(500, resp) 31 + }
+156
peruse/auth.go
··· 1 + package peruse 2 + 3 + import ( 4 + "context" 5 + "crypto" 6 + "errors" 7 + "fmt" 8 + "time" 9 + 10 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/golang-jwt/jwt/v5" 13 + ) 14 + 15 + func (s *Server) getKeyForDid(ctx context.Context, did syntax.DID) (crypto.PublicKey, error) { 16 + ident, err := s.directory.LookupDID(ctx, did) 17 + if err != nil { 18 + return nil, err 19 + } 20 + 21 + return ident.PublicKey() 22 + } 23 + 24 + func (s *Server) fetchKeyFunc(ctx context.Context) func(tok *jwt.Token) (any, error) { 25 + return func(tok *jwt.Token) (any, error) { 26 + issuer, ok := tok.Claims.(jwt.MapClaims)["iss"].(string) 27 + if !ok { 28 + return nil, fmt.Errorf("missing 'iss' field from auth header JWT") 29 + } 30 + did, err := syntax.ParseDID(issuer) 31 + if err != nil { 32 + return nil, fmt.Errorf("invalid DID in 'iss' field from auth header JWT") 33 + } 34 + 35 + val, ok := s.keyCache.Get(did.String()) 36 + if ok { 37 + return val, nil 38 + } 39 + 40 + k, err := s.getKeyForDid(ctx, did) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to look up public key for DID (%q): %w", did, err) 43 + } 44 + s.keyCache.Add(did.String(), k) 45 + return k, nil 46 + } 47 + } 48 + 49 + func (s *Server) checkJwt(ctx context.Context, tok string) (string, error) { 50 + ctx, cancel := context.WithTimeout(ctx, time.Second*5) 51 + defer cancel() 52 + 53 + return s.checkJwtConfig(ctx, tok) 54 + } 55 + 56 + func (s *Server) checkJwtConfig(ctx context.Context, tok string, config ...jwt.ParserOption) (string, error) { 57 + validMethods := []string{SigningMethodES256K.Alg(), SigningMethodES256.Alg()} 58 + config = append(config, jwt.WithValidMethods(validMethods)) 59 + p := jwt.NewParser(config...) 60 + t, err := p.Parse(tok, s.fetchKeyFunc(ctx)) 61 + if err != nil { 62 + return "", fmt.Errorf("failed to parse auth header jwt: %w", err) 63 + } 64 + 65 + clms, ok := t.Claims.(jwt.MapClaims) 66 + if !ok { 67 + return "", fmt.Errorf("invalid token claims") 68 + } 69 + 70 + did, ok := clms["iss"].(string) 71 + if !ok { 72 + return "", fmt.Errorf("no issuer present in returned claims") 73 + } 74 + 75 + return did, nil 76 + } 77 + 78 + // copied from Jaz's https://github.com/ericvolp12/jwt-go-secp256k1 79 + 80 + var ( 81 + SigningMethodES256K *SigningMethodAtproto 82 + SigningMethodES256 *SigningMethodAtproto 83 + ) 84 + 85 + // implementation of jwt.SigningMethod. 86 + type SigningMethodAtproto struct { 87 + alg string 88 + hash crypto.Hash 89 + toOutSig toOutSig 90 + sigLen int 91 + } 92 + 93 + type toOutSig func(sig []byte) []byte 94 + 95 + func init() { 96 + SigningMethodES256K = &SigningMethodAtproto{ 97 + alg: "ES256K", 98 + hash: crypto.SHA256, 99 + toOutSig: toES256K, 100 + sigLen: 64, 101 + } 102 + jwt.RegisterSigningMethod(SigningMethodES256K.Alg(), func() jwt.SigningMethod { 103 + return SigningMethodES256K 104 + }) 105 + SigningMethodES256 = &SigningMethodAtproto{ 106 + alg: "ES256", 107 + hash: crypto.SHA256, 108 + toOutSig: toES256, 109 + sigLen: 64, 110 + } 111 + jwt.RegisterSigningMethod(SigningMethodES256.Alg(), func() jwt.SigningMethod { 112 + return SigningMethodES256 113 + }) 114 + } 115 + 116 + // Errors returned on different problems. 117 + var ( 118 + ErrWrongKeyFormat = errors.New("wrong key type") 119 + ErrBadSignature = errors.New("bad signature") 120 + ErrVerification = errors.New("signature verification failed") 121 + ErrFailedSigning = errors.New("failed generating signature") 122 + ErrHashUnavailable = errors.New("hasher unavailable") 123 + ) 124 + 125 + func (sm *SigningMethodAtproto) Verify(signingString string, sig []byte, key interface{}) error { 126 + pub, ok := key.(atcrypto.PublicKey) 127 + if !ok { 128 + return ErrWrongKeyFormat 129 + } 130 + 131 + if !sm.hash.Available() { 132 + return ErrHashUnavailable 133 + } 134 + 135 + if len(sig) != sm.sigLen { 136 + return ErrBadSignature 137 + } 138 + 139 + return pub.HashAndVerifyLenient([]byte(signingString), sig) 140 + } 141 + 142 + func (sm *SigningMethodAtproto) Sign(signingString string, key interface{}) ([]byte, error) { 143 + return nil, ErrFailedSigning 144 + } 145 + 146 + func (sm *SigningMethodAtproto) Alg() string { 147 + return sm.alg 148 + } 149 + 150 + func toES256K(sig []byte) []byte { 151 + return sig[:64] 152 + } 153 + 154 + func toES256(sig []byte) []byte { 155 + return sig[:64] 156 + }
+254
peruse/get_close_by.go
··· 1 + package peruse 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + type CloseBy struct { 9 + Did string `ch:"did"` 10 + TheirLikes int `ch:"their_likes"` 11 + MyLikes int `ch:"my_likes"` 12 + TheirReplies int `ch:"their_replies"` 13 + MyReplies int `ch:"my_replies"` 14 + FriendConnectionScore int `ch:"friend_connection_score"` 15 + ClosenessScore int `ch:"closeness_score"` 16 + InteractionType int `ch:"interaction_type"` 17 + } 18 + 19 + func (u *User) getCloseBy(ctx context.Context, s *Server) ([]CloseBy, error) { 20 + // TODO: this "if you have more than 10" feels a little bit too low? 21 + if !time.Now().After(u.closeByExpiresAt) && len(u.following) > 10 { 22 + return u.closeBy, nil 23 + } 24 + 25 + var closeBy []CloseBy 26 + if err := s.conn.Select(ctx, &closeBy, getCloseByQuery, u.did); err != nil { 27 + return nil, err 28 + } 29 + return closeBy, nil 30 + } 31 + 32 + var getCloseByQuery = ` 33 + WITH 34 + ? AS my_did 35 + 36 + SELECT 37 + all_dids.did AS did, 38 + coalesce(likes.their_likes, 0) AS their_likes, 39 + coalesce(likes.my_likes, 0) AS my_likes, 40 + coalesce(replies.their_replies, 0) AS their_replies, 41 + coalesce(replies.my_replies, 0) AS my_replies, 42 + coalesce(friends.friend_connection_score, 0) AS friend_connection_score, 43 + 44 + (coalesce(likes.their_likes, 0) + coalesce(likes.my_likes, 0)) * 1.0 + 45 + (coalesce(replies.their_replies, 0) + coalesce(replies.my_replies, 0)) * 2.0 + 46 + coalesce(friends.friend_connection_score, 0) AS closeness_score, 47 + 48 + multiIf( 49 + coalesce(likes.their_likes, 0) > 0 AND coalesce(likes.my_likes, 0) > 0, 'mutual_likes', 50 + coalesce(replies.their_replies, 0) > 0 AND coalesce(replies.my_replies, 0) > 0, 'mutual_replies', 51 + coalesce(likes.my_likes, 0) > 0 OR coalesce(replies.my_replies, 0) > 0, 'one_way_from_me', 52 + coalesce(likes.their_likes, 0) > 0 OR coalesce(replies.their_replies, 0) > 0, 'one_way_to_me', 53 + coalesce(friends.friend_connection_score, 0) > 0, 'friend_of_friends', 54 + 'unknown' 55 + ) AS interaction_type 56 + 57 + FROM ( 58 + SELECT did FROM ( 59 + SELECT subject_did AS did FROM default.interaction WHERE did = my_did AND kind = 'like' 60 + UNION DISTINCT 61 + SELECT did FROM default.interaction WHERE subject_did = my_did AND kind = 'like' 62 + UNION DISTINCT 63 + SELECT parent_did AS did FROM default.post WHERE did = my_did AND parent_did IS NOT NULL 64 + UNION DISTINCT 65 + SELECT did FROM default.post WHERE parent_did = my_did 66 + UNION DISTINCT 67 + SELECT i.subject_did AS did 68 + FROM default.interaction i 69 + WHERE i.kind = 'like' 70 + AND i.subject_did != my_did 71 + AND i.did IN ( 72 + SELECT did FROM ( 73 + SELECT 74 + coalesce(top_l.did, top_r.did) AS did, 75 + (coalesce(top_l.their_likes, 0) + coalesce(top_l.my_likes, 0)) * 1.0 + 76 + (coalesce(top_r.their_replies, 0) + coalesce(top_r.my_replies, 0)) * 2.0 AS friend_score 77 + FROM ( 78 + SELECT 79 + lm.them AS did, 80 + lm.their_likes, 81 + il.my_likes 82 + FROM ( 83 + SELECT did AS them, count(*) AS their_likes 84 + FROM default.interaction 85 + WHERE subject_did = my_did AND kind = 'like' 86 + GROUP BY did 87 + ) AS lm 88 + INNER JOIN ( 89 + SELECT subject_did AS them, count(*) as my_likes 90 + FROM default.interaction 91 + WHERE did = my_did AND kind = 'like' 92 + GROUP BY subject_did 93 + ) AS il ON lm.them = il.them 94 + ) AS top_l 95 + FULL OUTER JOIN ( 96 + SELECT 97 + replies_to_you.them AS did, 98 + replies_to_you.their_replies, 99 + replies_to_them.my_replies 100 + FROM ( 101 + SELECT did AS them, count(*) AS their_replies 102 + FROM default.post 103 + WHERE parent_did = my_did 104 + GROUP BY did 105 + ) AS replies_to_you 106 + INNER JOIN ( 107 + SELECT parent_did AS them, count(*) AS my_replies 108 + FROM default.post 109 + WHERE did = my_did 110 + GROUP BY parent_did 111 + ) AS replies_to_them ON replies_to_you.them = replies_to_them.them 112 + ) AS top_r ON top_l.did = top_r.did 113 + ORDER BY friend_score DESC 114 + LIMIT 50 115 + ) 116 + ) 117 + AND i.subject_did NOT IN ( 118 + SELECT subject_did FROM default.interaction WHERE did = my_did 119 + UNION DISTINCT 120 + SELECT did FROM default.interaction WHERE subject_did = my_did 121 + UNION DISTINCT 122 + SELECT parent_did FROM default.post WHERE did = my_did AND parent_did IS NOT NULL 123 + UNION DISTINCT 124 + SELECT did FROM default.post WHERE parent_did = my_did 125 + ) 126 + GROUP BY i.subject_did 127 + HAVING count(*) >= 3 128 + ) 129 + ) AS all_dids 130 + 131 + LEFT JOIN ( 132 + SELECT 133 + did, 134 + sum(their_likes) as their_likes, 135 + sum(my_likes) as my_likes 136 + FROM ( 137 + SELECT 138 + subject_did AS did, 139 + 0 as their_likes, 140 + count(*) as my_likes 141 + FROM default.interaction 142 + WHERE did = my_did AND kind = 'like' 143 + GROUP BY subject_did 144 + 145 + UNION ALL 146 + 147 + SELECT 148 + did, 149 + count(*) AS their_likes, 150 + 0 as my_likes 151 + FROM default.interaction 152 + WHERE subject_did = my_did AND kind = 'like' 153 + GROUP BY did 154 + ) 155 + GROUP BY did 156 + ) AS likes ON all_dids.did = likes.did 157 + 158 + LEFT JOIN ( 159 + SELECT 160 + did, 161 + sum(their_replies) as their_replies, 162 + sum(my_replies) as my_replies 163 + FROM ( 164 + SELECT 165 + parent_did AS did, 166 + 0 as their_replies, 167 + count(*) AS my_replies 168 + FROM default.post 169 + WHERE did = my_did AND parent_did IS NOT NULL 170 + GROUP BY parent_did 171 + 172 + UNION ALL 173 + 174 + SELECT 175 + did, 176 + count(*) AS their_replies, 177 + 0 as my_replies 178 + FROM default.post 179 + WHERE parent_did = my_did 180 + GROUP BY did 181 + ) 182 + GROUP BY did 183 + ) AS replies ON all_dids.did = replies.did 184 + 185 + LEFT JOIN ( 186 + SELECT 187 + i.subject_did AS did, 188 + count(*) * 0.3 AS friend_connection_score 189 + FROM default.interaction i 190 + WHERE i.kind = 'like' 191 + AND i.subject_did != my_did 192 + AND i.did IN ( 193 + SELECT did FROM ( 194 + SELECT 195 + coalesce(top_l.did, top_r.did) AS did, 196 + (coalesce(top_l.their_likes, 0) + coalesce(top_l.my_likes, 0)) * 1.0 + 197 + (coalesce(top_r.their_replies, 0) + coalesce(top_r.my_replies, 0)) * 2.0 AS friend_score 198 + FROM ( 199 + SELECT 200 + lm.them AS did, 201 + lm.their_likes, 202 + il.my_likes 203 + FROM ( 204 + SELECT did AS them, count(*) AS their_likes 205 + FROM default.interaction 206 + WHERE subject_did = my_did AND kind = 'like' 207 + GROUP BY did 208 + ) AS lm 209 + INNER JOIN ( 210 + SELECT subject_did AS them, count(*) as my_likes 211 + FROM default.interaction 212 + WHERE did = my_did AND kind = 'like' 213 + GROUP BY subject_did 214 + ) AS il ON lm.them = il.them 215 + ) AS top_l 216 + FULL OUTER JOIN ( 217 + SELECT 218 + replies_to_you.them AS did, 219 + replies_to_you.their_replies, 220 + replies_to_them.my_replies 221 + FROM ( 222 + SELECT did AS them, count(*) AS their_replies 223 + FROM default.post 224 + WHERE parent_did = my_did 225 + GROUP BY did 226 + ) AS replies_to_you 227 + INNER JOIN ( 228 + SELECT parent_did AS them, count(*) AS my_replies 229 + FROM default.post 230 + WHERE did = my_did 231 + GROUP BY parent_did 232 + ) AS replies_to_them ON replies_to_you.them = replies_to_them.them 233 + ) AS top_r ON top_l.did = top_r.did 234 + ORDER BY friend_score DESC 235 + LIMIT 50 236 + ) 237 + ) 238 + AND i.subject_did NOT IN ( 239 + SELECT subject_did FROM default.interaction WHERE did = my_did 240 + UNION DISTINCT 241 + SELECT did FROM default.interaction WHERE subject_did = my_did 242 + UNION DISTINCT 243 + SELECT parent_did FROM default.post WHERE did = my_did AND parent_did IS NOT NULL 244 + UNION DISTINCT 245 + SELECT did FROM default.post WHERE parent_did = my_did 246 + ) 247 + GROUP BY i.subject_did 248 + HAVING count(*) >= 3 249 + ) AS friends ON all_dids.did = friends.did 250 + 251 + WHERE all_dids.did IS NOT NULL 252 + ORDER BY closeness_score DESC 253 + LIMIT 200 254 + `
+60
peruse/handle_chrono_feed.go
··· 1 + package peruse 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/haileyok/peruse/internal/helpers" 7 + "github.com/haileyok/photocopy/models" 8 + "github.com/labstack/echo/v4" 9 + ) 10 + 11 + func (s *Server) handleChronoFeed(e echo.Context, req FeedSkeletonRequest) error { 12 + ctx := e.Request().Context() 13 + u := e.Get("user").(*User) 14 + 15 + closeBy, err := u.getCloseBy(ctx, s) 16 + if err != nil { 17 + s.logger.Error("error getting close by for user", "user", u.did, "error", err) 18 + return helpers.ServerError(e, "FeedError", "") 19 + } 20 + 21 + cbdids := []string{} 22 + for _, cb := range closeBy { 23 + cbdids = append(cbdids, cb.Did) 24 + } 25 + cbdids = cbdids[1:] // remove self 26 + 27 + if req.Cursor == "" { 28 + req.Cursor = "9999999999999" // hack for simplicity... 29 + } 30 + 31 + var posts []models.Post 32 + if err := s.conn.Select(ctx, &posts, fmt.Sprintf(` 33 + SELECT uri 34 + FROM default.post 35 + WHERE did IN (?) 36 + AND rkey < 37 + ORDER BY created_at DESC 38 + LIMIT 50 39 + `), cbdids, req.Cursor); err != nil { 40 + s.logger.Error("error getting close by chrono posts", "error", err) 41 + return helpers.ServerError(e, "FeedError", "") 42 + } 43 + 44 + if len(posts) == 0 { 45 + return helpers.ServerError(e, "FeedError", "Not enough posts") 46 + } 47 + 48 + var fpis []FeedPostItem 49 + 50 + for _, p := range posts { 51 + fpis = append(fpis, FeedPostItem{ 52 + Post: p.Uri, 53 + }) 54 + } 55 + 56 + return e.JSON(200, FeedSkeletonResponse{ 57 + Cursor: &posts[len(posts)-1].Rkey, 58 + Feed: fpis, 59 + }) 60 + }
+25
peruse/handle_describe_feed_generator.go
··· 1 + package peruse 2 + 3 + import ( 4 + "github.com/labstack/echo/v4" 5 + ) 6 + 7 + type describeFeedGeneratorResponse struct { 8 + Did string `json:"did"` 9 + Feeds []string `json:"feeds"` 10 + } 11 + 12 + func makeFeedUri(accountDid, rkey string) string { 13 + return "at://" + accountDid + "/app.bsky.feed.generator/" + rkey 14 + } 15 + 16 + func (s *Server) handleDescribeFeedGenerator(e echo.Context) error { 17 + feedUris := []string{ 18 + makeFeedUri(s.args.FeedOwnerDid, s.args.ChronoFeedRkey), 19 + } 20 + 21 + return e.JSON(200, &describeFeedGeneratorResponse{ 22 + Did: s.args.ServiceDid, 23 + Feeds: feedUris, 24 + }) 25 + }
+42
peruse/handle_feed_skeleton.go
··· 1 + package peruse 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + "github.com/haileyok/peruse/internal/helpers" 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + type FeedSkeletonRequest struct { 10 + Feed string `query:"feed"` 11 + Cursor string `query:"cursor"` 12 + } 13 + 14 + type FeedSkeletonResponse struct { 15 + Cursor *string `json:"cursor,omitempty"` 16 + Feed []FeedPostItem `json:"feed"` 17 + } 18 + 19 + type FeedPostItem struct { 20 + Post string `json:"post"` 21 + Reason *string `json:"reason,omitempty"` 22 + } 23 + 24 + func (s *Server) handleFeedSkeleton(e echo.Context) error { 25 + var req FeedSkeletonRequest 26 + if err := e.Bind(&req); err != nil { 27 + s.logger.Error("unable to bind feed skeleton request", "error", err) 28 + return helpers.ServerError(e, "", "") 29 + } 30 + 31 + aturi, err := syntax.ParseATURI(req.Feed) 32 + if err != nil { 33 + return helpers.InputError(e, "InvalidFeed", "") 34 + } 35 + 36 + switch aturi.RecordKey().String() { 37 + case s.args.ChronoFeedRkey: 38 + return s.handleChronoFeed(e, req) 39 + default: 40 + return helpers.InputError(e, "FeedNotFound", "") 41 + } 42 + }
+29
peruse/handle_well_known.go
··· 1 + package peruse 2 + 3 + import "github.com/labstack/echo/v4" 4 + 5 + type wellKnownResponse struct { 6 + Context []string `json:"@context"` 7 + Id string `json:"id"` 8 + Service []wellKnownService 9 + } 10 + 11 + type wellKnownService struct { 12 + Id string `json:"id"` 13 + Type string `json:"type"` 14 + ServiceEndpoint string `json:"serviceEndpoint"` 15 + } 16 + 17 + func (s *Server) handleWellKnown(e echo.Context) error { 18 + return e.JSON(200, wellKnownResponse{ 19 + Context: []string{"https://www.w3.org/ns/did/v1"}, 20 + Id: s.args.ServiceDid, 21 + Service: []wellKnownService{ 22 + { 23 + Id: "#bsky_fg", 24 + Type: "BskyFeedGenerator", 25 + ServiceEndpoint: s.args.ServiceEndpoint, 26 + }, 27 + }, 28 + }) 29 + }
+143
peruse/peruse.go
··· 1 + package peruse 2 + 3 + import ( 4 + "context" 5 + "crypto" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "strings" 10 + "time" 11 + 12 + "github.com/ClickHouse/clickhouse-go/v2" 13 + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" 14 + "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/haileyok/peruse/internal/helpers" 16 + lru "github.com/hashicorp/golang-lru/v2" 17 + "github.com/labstack/echo/v4" 18 + "golang.org/x/time/rate" 19 + ) 20 + 21 + type Server struct { 22 + httpd *http.Server 23 + echo *echo.Echo 24 + conn driver.Conn 25 + logger *slog.Logger 26 + args *ServerArgs 27 + keyCache *lru.Cache[string, crypto.PublicKey] 28 + directory identity.Directory 29 + userManager *UserManager 30 + } 31 + 32 + type ServerArgs struct { 33 + Logger *slog.Logger 34 + HttpAddr string 35 + ClickhouseAddr string 36 + ClickhouseDatabase string 37 + ClickhouseUser string 38 + ClickhousePass string 39 + FeedOwnerDid string 40 + ServiceDid string 41 + ServiceEndpoint string 42 + ChronoFeedRkey string 43 + } 44 + 45 + func NewServer(args ServerArgs) (*Server, error) { 46 + if args.Logger == nil { 47 + args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 48 + Level: slog.LevelInfo, 49 + })) 50 + } 51 + 52 + e := echo.New() 53 + 54 + httpd := &http.Server{ 55 + Addr: args.HttpAddr, 56 + Handler: e, 57 + } 58 + 59 + conn, err := clickhouse.Open(&clickhouse.Options{ 60 + Addr: []string{args.ClickhouseAddr}, 61 + Auth: clickhouse.Auth{ 62 + Database: args.ClickhouseDatabase, 63 + Username: args.ClickhouseUser, 64 + Password: args.ClickhousePass, 65 + }, 66 + }) 67 + if err != nil { 68 + return nil, err 69 + } 70 + 71 + kc, _ := lru.New[string, crypto.PublicKey](100_000) 72 + 73 + baseDir := identity.BaseDirectory{ 74 + PLCURL: "https://plc.directory", 75 + HTTPClient: http.Client{ 76 + Timeout: time.Second * 5, 77 + }, 78 + PLCLimiter: rate.NewLimiter(rate.Limit(10), 1), // TODO: what is this rate limit anyway? 79 + TryAuthoritativeDNS: false, 80 + SkipDNSDomainSuffixes: []string{".bsky.social", ".staging.bsky.dev"}, 81 + } 82 + 83 + dir := identity.NewCacheDirectory(&baseDir, 100_000, time.Hour*48, time.Minute*15, time.Minute*15) 84 + 85 + return &Server{ 86 + echo: e, 87 + httpd: httpd, 88 + conn: conn, 89 + args: &args, 90 + logger: args.Logger, 91 + keyCache: kc, 92 + directory: &dir, 93 + userManager: NewUserManager(), 94 + }, nil 95 + } 96 + 97 + func (s *Server) Run(ctx context.Context) error { 98 + ctx, cancel := context.WithCancel(ctx) 99 + defer cancel() 100 + 101 + s.addRoutes() 102 + 103 + go func() { 104 + if err := s.httpd.ListenAndServe(); err != nil { 105 + s.logger.Error("error starting http server", "error", err) 106 + } 107 + }() 108 + 109 + <-ctx.Done() 110 + 111 + s.logger.Info("shutting down server...") 112 + 113 + s.conn.Close() 114 + 115 + return nil 116 + } 117 + 118 + func (s *Server) addRoutes() { 119 + s.echo.GET("/xrpc/app.bsky.feed.getFeedSkeleton", s.handleFeedSkeleton, s.handleAuthMiddleware) 120 + s.echo.GET("/xrpc/app.bsky.feed.describeFeedGenerator", s.handleDescribeFeedGenerator) 121 + s.echo.GET("/.well-known/did.json", s.handleWellKnown) 122 + } 123 + 124 + func (s *Server) handleAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 125 + return func(e echo.Context) error { 126 + auth := e.Request().Header.Get("authorization") 127 + pts := strings.Split(auth, " ") 128 + if auth == "" || len(pts) != 2 || pts[0] != "Bearer" { 129 + return helpers.InputError(e, "AuthRequired", "") 130 + } 131 + 132 + did, err := s.checkJwt(e.Request().Context(), pts[1]) 133 + if err != nil { 134 + return helpers.InputError(e, "AuthRequired", err.Error()) 135 + } 136 + 137 + u := s.userManager.getUser(did) 138 + 139 + e.Set("user", u) 140 + 141 + return next(e) 142 + } 143 + }
+63
peruse/user.go
··· 1 + package peruse 2 + 3 + import ( 4 + "sync" 5 + "time" 6 + 7 + lru "github.com/hashicorp/golang-lru/v2" 8 + ) 9 + 10 + type UserManager struct { 11 + mu sync.RWMutex 12 + users *lru.Cache[string, *User] 13 + } 14 + 15 + func NewUserManager() *UserManager { 16 + uc, _ := lru.New[string, *User](20_000) 17 + return &UserManager{ 18 + users: uc, 19 + } 20 + } 21 + 22 + func (um *UserManager) getUser(did string) *User { 23 + um.mu.RLock() 24 + u, ok := um.users.Get(did) 25 + um.mu.RUnlock() 26 + if ok { 27 + return u 28 + } 29 + 30 + um.mu.Lock() 31 + defer um.mu.Unlock() 32 + 33 + if u, ok := um.users.Get(did); ok { 34 + return u 35 + } 36 + 37 + u = NewUser(did) 38 + um.users.Add(did, u) 39 + 40 + return u 41 + } 42 + 43 + type User struct { 44 + mu sync.Mutex 45 + 46 + did string 47 + 48 + following []string 49 + followingExpiresAt time.Time 50 + 51 + closeBy []CloseBy 52 + closeByExpiresAt time.Time 53 + } 54 + 55 + func NewUser(did string) *User { 56 + return &User{ 57 + did: did, 58 + } 59 + } 60 + 61 + func (u *User) getFollowing() []string { 62 + return nil 63 + }