+42
Makefile
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}