this repo has no description

initial commit

hailey.at 254aa4c5

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