├── Makefile
├── .vscode
└── settings.json
├── medium
├── medium.go
├── email_test.go
└── email.go
├── storage
├── storage.go
├── memory.go
└── gcp.go
├── Dockerfile
├── .envrc.example
├── .gitignore
├── auth
├── jwt_test.go
├── password.go
├── password_test.go
└── jwt.go
├── main.go
├── db
└── mongo.go
├── README.md
├── .github
└── workflows
│ └── workflow.yml
├── highlight
├── highlight.go
├── store.go
├── handler_test.go
└── handler.go
├── config
└── config.go
├── static
└── unsubscribe.html
├── daily.tmpl
├── template
└── daily.tmpl
├── book
└── store.go
├── utils
└── utils.go
├── api.go
├── user
├── store.go
└── handler.go
├── go.mod
├── types
└── types.go
└── go.sum
/Makefile:
--------------------------------------------------------------------------------
1 | run: build
2 | @./bin/notebase
3 |
4 | build:
5 | @go build -o bin/notebase
6 |
7 | test:
8 | @go test -v ./... -count=1
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "GCPID",
4 | "notebase",
5 | "sendgrid",
6 | "sikozonpc",
7 | "Subrouter",
8 | "tmpl",
9 | "unmarshalling"
10 | ]
11 | }
--------------------------------------------------------------------------------
/medium/medium.go:
--------------------------------------------------------------------------------
1 | package medium
2 |
3 | import types "github.com/sikozonpc/notebase/types"
4 |
5 | type Medium interface {
6 | SendInsights(u *types.User, insights []*types.DailyInsight, authToken string) error
7 | }
8 |
--------------------------------------------------------------------------------
/storage/storage.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | // Storage is an interface for interacting with the File System
4 | // or a cloud storage service like GCP
5 | type Storage interface {
6 | Read(filename string) (string, error)
7 | }
8 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # The build stage
2 | FROM golang:1.21 as builder
3 | WORKDIR /app
4 | COPY . .
5 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o api *.go
6 |
7 | # The run stage
8 | FROM scratch
9 | WORKDIR /app
10 | COPY --from=builder /app/api .
11 | EXPOSE 8080
12 | CMD ["./api"]
--------------------------------------------------------------------------------
/.envrc.example:
--------------------------------------------------------------------------------
1 | export ENV="development"
2 |
3 | export PORT="3000"
4 |
5 | export JWT_SECRET="mysuperbigsecret"
6 | export API_KEY="myapikey"
7 |
8 | export MONGODB_URI="mongodb://localhost:27017/boilerplate"
9 |
10 | export SENDGRID_API_KEY=""
11 | export SENDGRID_FROM_EMAIL=""
12 |
13 | export PUBLIC_URL="http://localhost:3000"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, build with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | bin
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
17 | # env
18 | .env
19 | .envrc
--------------------------------------------------------------------------------
/auth/jwt_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestCreateJWT(t *testing.T) {
10 | secret := []byte("secret")
11 | userID := "123"
12 |
13 | token, err := CreateJWT(secret, userID)
14 | if err != nil {
15 | t.Errorf("error creating JWT: %v", err)
16 | }
17 |
18 | assert.NotEmpty(t, token)
19 | }
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/sikozonpc/notebase/config"
8 | "github.com/sikozonpc/notebase/db"
9 | )
10 |
11 | func main() {
12 | mongoClient, err := db.ConnectToMongo(config.Envs.MongoURI)
13 | if err != nil {
14 | log.Fatal(err)
15 | }
16 |
17 | server := NewAPIServer(fmt.Sprintf(":%s", config.Envs.Port), mongoClient)
18 | if err := server.Run(); err != nil {
19 | log.Fatal(err)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/auth/password.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "golang.org/x/crypto/bcrypt"
5 | )
6 |
7 | func HashPassword(password string) (string, error) {
8 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
9 | if err != nil {
10 | return "", err
11 | }
12 |
13 | return string(hash), nil
14 | }
15 |
16 | func ComparePasswords(hashed string, plain []byte) bool {
17 | err := bcrypt.CompareHashAndPassword([]byte(hashed), plain)
18 | return err == nil
19 | }
20 |
--------------------------------------------------------------------------------
/db/mongo.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "go.mongodb.org/mongo-driver/mongo"
8 | "go.mongodb.org/mongo-driver/mongo/options"
9 | "go.mongodb.org/mongo-driver/mongo/readpref"
10 | )
11 |
12 | func ConnectToMongo(uri string) (*mongo.Client, error) {
13 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
14 | defer cancel()
15 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
16 | if err != nil {
17 | return nil, err
18 | }
19 |
20 | err = client.Ping(ctx, readpref.Primary())
21 | return client, err
22 | }
--------------------------------------------------------------------------------
/auth/password_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestHashPassword(t *testing.T) {
10 | hash, err := HashPassword("password")
11 | if err != nil {
12 | t.Errorf("error hashing password: %v", err)
13 | }
14 |
15 | assert.NotEqual(t, hash, "password")
16 | assert.NotEmpty(t, hash)
17 | }
18 |
19 | func TestComparePasswords(t *testing.T) {
20 | hash, err := HashPassword("password")
21 | if err != nil {
22 | t.Errorf("error hashing password: %v", err)
23 | }
24 |
25 | assert.True(t, ComparePasswords(hash, []byte("password")))
26 | assert.False(t, ComparePasswords(hash, []byte("notpassword")))
27 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Notebase
2 |
3 | (WIP) A centralized highlights and notes ecosystem that supports multiple sources (Kindle, Notion, etc.)
4 |
5 | ## Features
6 |
7 | - Automatically import from Kindle and other sources.
8 | - Notes are automatically categorized and indexed.
9 | - Daily review of notes by email or REST.
10 | - Suggested content based on your notes.
11 | - Advanced search and filtering.
12 | - Export to Markdown, HTML, etc.
13 |
14 | ## Installation
15 |
16 | Make sure to have GO 1.12+ installed. And then run:
17 | ```bash
18 | make run
19 | ```
20 |
21 | The project requires environment variables to be set. You can find the list of required variables in the `.envrc.example` file.
22 |
--------------------------------------------------------------------------------
/.github/workflows/workflow.yml:
--------------------------------------------------------------------------------
1 | name: Audit
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 |
11 | audit:
12 | runs-on: ubuntu-20.04
13 | steps:
14 | - uses: actions/checkout@v2
15 |
16 | - name: Set up Go
17 | uses: actions/setup-go@v2
18 | with:
19 | go-version: 1.21
20 |
21 | - name: Verify dependencies
22 | run: go mod verify
23 |
24 | - name: Build
25 | run: go build -v ./...
26 |
27 | - name: Run go vet
28 | run: go vet ./...
29 |
30 | - name: Install staticcheck
31 | run: go install honnef.co/go/tools/cmd/staticcheck@latest
32 |
33 | - name: Run staticcheck
34 | run: staticcheck ./...
35 |
36 | - name: Run tests
37 | run: go test -race ./...
--------------------------------------------------------------------------------
/highlight/highlight.go:
--------------------------------------------------------------------------------
1 | package highlight
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | "mime/multipart"
7 |
8 | t "github.com/sikozonpc/notebase/types"
9 | )
10 |
11 | func parseKindleExtractFromString(file string) (*t.RawExtractBook, error) {
12 | raw := new(t.RawExtractBook)
13 | f := []byte(file)
14 |
15 | err := json.Unmarshal(f, raw)
16 | if err != nil {
17 | log.Println("error unmarshalling file: ", err)
18 | return nil, err
19 | }
20 |
21 | return raw, nil
22 | }
23 |
24 | func parseKindleExtractFromFile(file multipart.File) (*t.RawExtractBook, error) {
25 | decoder := json.NewDecoder(file)
26 |
27 | raw := new(t.RawExtractBook)
28 | if err := decoder.Decode(raw); err != nil {
29 | log.Println("error decoding file: ", err)
30 | return nil, err
31 | }
32 |
33 | return raw, nil
34 | }
35 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 |
6 | t "github.com/sikozonpc/notebase/types"
7 | )
8 |
9 | var Envs = initConfig()
10 |
11 | func initConfig() t.Config {
12 | return t.Config{
13 | Env: getEnv("ENV", "development"),
14 | Port: getEnv("PORT", "8080"),
15 | MongoURI: getEnv("MONGODB_URI", "mongodb://localhost:27017"),
16 | PublicURL: getEnv("PUBLIC_URL", "http://localhost:3000"),
17 | JWTSecret: getEnv("JWT_SECRET", "JWT secret is required"),
18 | SendGridAPIKey: getEnv("SENDGRID_API_KEY", "SendGrid API KEY is required"),
19 | SendGridFromEmail: getEnv("SENDGRID_FROM_EMAIL", "SendGrid From email is required"),
20 | APIKey: getEnv("API_KEY", "API Key is required"),
21 | }
22 | }
23 |
24 | func getEnv(key, fallback string) string {
25 | if value, ok := os.LookupEnv(key); ok {
26 | return value
27 | }
28 |
29 | return fallback
30 | }
31 |
--------------------------------------------------------------------------------
/static/unsubscribe.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Unsubscribe
6 |
7 |
8 |
9 | Are you sure you want to unsubscribe?
10 |
15 | Unsubscribe
16 |
17 |
18 |
39 |
--------------------------------------------------------------------------------
/daily.tmpl:
--------------------------------------------------------------------------------
1 |
2 |
3 | Insights
4 |
5 |
6 | Hey {{ .User.FirstName }}, here are your daily daily insight(s)
7 |
8 |
9 | {{ range .Insights }}
10 | -
11 |
12 |
15 | "{{ .Text }}"
16 |
17 | {{ .Note }}
18 |
19 |
20 |
- {{ .BookTitle }} - {{ .BookAuthors }}
21 |
22 |
23 | {{ end }}
24 |
25 |
26 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/template/daily.tmpl:
--------------------------------------------------------------------------------
1 |
2 |
3 | Insights
4 |
5 |
6 | Hey {{ .User.FirstName }}, here are your daily daily insight(s)
7 |
8 |
9 | {{ range .Insights }}
10 | -
11 |
12 |
15 | "{{ .Text }}"
16 |
17 | {{ .Note }}
18 |
19 |
20 |
- {{ .BookTitle }} - {{ .BookAuthors }}
21 |
22 |
23 | {{ end }}
24 |
25 |
26 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/book/store.go:
--------------------------------------------------------------------------------
1 | package book
2 |
3 | import (
4 | "context"
5 |
6 | t "github.com/sikozonpc/notebase/types"
7 | "go.mongodb.org/mongo-driver/bson"
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | "go.mongodb.org/mongo-driver/mongo"
10 | )
11 |
12 | const (
13 | DbName = "notebase"
14 | CollName = "books"
15 | )
16 |
17 | type Store struct {
18 | db *mongo.Client
19 | }
20 |
21 | func NewStore(db *mongo.Client) *Store {
22 | return &Store{db: db}
23 | }
24 |
25 | func (s *Store) GetByISBN(ctx context.Context, isbn string) (*t.Book, error) {
26 | col := s.db.Database(DbName).Collection(CollName)
27 |
28 | oID, _ := primitive.ObjectIDFromHex(isbn)
29 |
30 | var b t.Book
31 | err := col.FindOne(ctx, bson.M{
32 | "isbn": oID,
33 | }).Decode(&b)
34 |
35 | return &b, err
36 | }
37 |
38 | func (s *Store) Create(ctx context.Context, b *t.CreateBookRequest) (primitive.ObjectID, error) {
39 | col := s.db.Database(DbName).Collection(CollName)
40 |
41 | newBook, err := col.InsertOne(ctx, b)
42 |
43 | id := newBook.InsertedID.(primitive.ObjectID)
44 | return id, err
45 | }
46 |
--------------------------------------------------------------------------------
/storage/memory.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | type MemoryStorage struct{}
4 |
5 | func NewMemoryStorage() *MemoryStorage {
6 | return &MemoryStorage{}
7 | }
8 |
9 | func (m *MemoryStorage) Read(filename string) (string, error) {
10 | return fileContent, nil
11 | }
12 |
13 | var fileContent = `
14 | {
15 | "asin": "SOMERANDOMASIN",
16 | "title": "Some random book on kindle",
17 | "authors": "Some random author",
18 | "highlights": [
19 | {
20 | "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,",
21 | "isNoteOnly": false,
22 | "location": {
23 | "url": "kindle://book?action=open&asin=SOMERANDOMASIN&location=307",
24 | "value": 307
25 | },
26 | "note": "This is a note"
27 | },
28 |
29 | {
30 | "text": "consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,",
31 | "isNoteOnly": false,
32 | "location": {
33 | "url": "kindle://book?action=open&asin=SOMERANDOMASIN&location=742",
34 | "value": 742
35 | },
36 | "note": null
37 | }
38 | ]
39 | }
40 | `
41 |
--------------------------------------------------------------------------------
/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "net/http"
8 |
9 | "github.com/gorilla/mux"
10 | t "github.com/sikozonpc/notebase/types"
11 | )
12 |
13 | func WriteJSON(w http.ResponseWriter, status int, v any) error {
14 | w.Header().Add("Content-Type", "application/json")
15 | w.WriteHeader(status)
16 | return json.NewEncoder(w).Encode(v)
17 | }
18 |
19 | // Wraps a handler function by handling any errors that occur
20 | func MakeHTTPHandler(fn t.EndpointHandler) http.HandlerFunc {
21 | return func(w http.ResponseWriter, r *http.Request) {
22 | if err := fn(w, r); err != nil {
23 | WriteJSON(w, http.StatusInternalServerError, t.APIError{
24 | Error: err.Error(),
25 | })
26 | }
27 | }
28 | }
29 |
30 | func GetStringParamFromRequest(r *http.Request, param string) (string, error) {
31 | vars := mux.Vars(r)
32 | str, ok := vars[param]
33 | if !ok {
34 | log.Printf("no param: %v in request", param)
35 | return "", fmt.Errorf("no param: %v in request", param)
36 | }
37 |
38 | return str, nil
39 | }
40 |
41 | func GetTokenFromRequest(r *http.Request) string {
42 | tokenAuth := r.Header.Get("Authorization")
43 | tokenQuery := r.URL.Query().Get("token")
44 |
45 | if tokenAuth != "" {
46 | return tokenAuth
47 | }
48 |
49 | if tokenQuery != "" {
50 | return tokenQuery
51 | }
52 |
53 | return ""
54 | }
--------------------------------------------------------------------------------
/medium/email_test.go:
--------------------------------------------------------------------------------
1 | package medium
2 |
3 | import (
4 | "bytes"
5 | "testing"
6 |
7 | "github.com/sikozonpc/notebase/types"
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | )
10 |
11 | func TestBuildInsightsMailTemplate(t *testing.T) {
12 | {
13 |
14 | t.Run("BuildInsightsMailTemplate should return a list with insights", func(t *testing.T) {
15 | insights := []*types.DailyInsight{
16 | {
17 | Text: "This is an insight",
18 | Note: "This is a note",
19 | BookAuthors: "John Doe",
20 | BookTitle: "Gopher",
21 | },
22 | }
23 |
24 | u := &types.User{
25 | FirstName: "Test",
26 | LastName: "Test",
27 | Email: "gopher@gopher.xyz",
28 | ID: primitive.NewObjectID(),
29 | IsActive: true,
30 | }
31 |
32 | html := BuildInsightsMailTemplate("../template", u, insights, "some-random-token")
33 |
34 | if html == "" {
35 | t.Errorf("BuildInsightsMailTemplate() = %v; want %v", html, "html")
36 | }
37 |
38 | if !bytes.Contains([]byte(html), []byte("This is an insight")) {
39 | t.Errorf("BuildInsightsMailTemplate() = %v; want %v", html, "html")
40 | }
41 |
42 | if !bytes.Contains([]byte(html), []byte("This is a note")) {
43 | t.Errorf("BuildInsightsMailTemplate() = %v; want %v", html, "html")
44 | }
45 |
46 | if !bytes.Contains([]byte(html), []byte("John Doe")) {
47 | t.Errorf("BuildInsightsMailTemplate() = %v; want %v", html, "html")
48 | }
49 |
50 | if !bytes.Contains([]byte(html), []byte("Gopher")) {
51 | t.Errorf("BuildInsightsMailTemplate() = %v; want %v", html, "html")
52 | }
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/storage/gcp.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "io"
8 | "log"
9 |
10 | "cloud.google.com/go/storage"
11 | "github.com/sikozonpc/notebase/config"
12 | )
13 |
14 | type GCPStorage struct {
15 | client *storage.Client
16 |
17 | w io.Writer
18 | ctx context.Context
19 | // failed indicates that one or more of the steps failed.
20 | failed bool
21 | }
22 |
23 | func NewGCPStorage(ctx context.Context) (*GCPStorage, error) {
24 | client, err := storage.NewClient(ctx)
25 | if err != nil {
26 | log.Println(ctx, "failed to create client: %v", err)
27 | return nil, err
28 | }
29 | defer client.Close()
30 |
31 | buf := &bytes.Buffer{}
32 | config := &GCPStorage{
33 | w: buf,
34 | ctx: ctx,
35 | client: client,
36 | }
37 |
38 | return config, nil
39 | }
40 |
41 | func (d *GCPStorage) errorf(format string, args ...interface{}) {
42 | d.failed = true
43 | fmt.Fprintln(d.w, fmt.Sprintf(format, args...))
44 | // cloud logging: log.Println(d.ctx, format, args...)
45 | }
46 |
47 | func (s *GCPStorage) Read(filename string) (string, error) {
48 | bucketName := config.Envs.GCPBooksBucketName
49 | bucket := s.client.Bucket(bucketName)
50 |
51 | rc, err := bucket.Object(filename).NewReader(s.ctx)
52 | if err != nil {
53 | s.errorf("unable to open file from bucket %q, file %q: %v", bucketName, filename, err)
54 | return "", err
55 | }
56 | defer rc.Close()
57 |
58 | slurp, err := io.ReadAll(rc)
59 | if err != nil {
60 | s.errorf("unable to read data from bucket %q, file %q: %v", bucketName, filename, err)
61 | return "", err
62 | }
63 |
64 | fmt.Fprintf(s.w, "%s\n", bytes.SplitN(slurp, []byte("\n"), 2)[0])
65 |
66 | if len(slurp) > 1024 {
67 | fmt.Fprintf(s.w, "...%s\n", slurp[len(slurp)-1024:])
68 | } else {
69 | fmt.Fprintf(s.w, "%s\n", slurp)
70 | }
71 |
72 | return string(slurp), nil
73 | }
74 |
--------------------------------------------------------------------------------
/api.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log"
6 | "net/http"
7 | "os"
8 | "reflect"
9 |
10 | "github.com/gorilla/mux"
11 | "github.com/sikozonpc/notebase/book"
12 | "github.com/sikozonpc/notebase/config"
13 | "github.com/sikozonpc/notebase/highlight"
14 | "github.com/sikozonpc/notebase/medium"
15 | "github.com/sikozonpc/notebase/storage"
16 | "github.com/sikozonpc/notebase/user"
17 | "go.mongodb.org/mongo-driver/mongo"
18 | )
19 |
20 | type APIServer struct {
21 | addr string
22 | db *mongo.Client
23 | }
24 |
25 | func NewAPIServer(addr string, db *mongo.Client) *APIServer {
26 | return &APIServer{
27 | addr: addr,
28 | db: db,
29 | }
30 | }
31 |
32 | func (s *APIServer) Run() error {
33 | router := mux.NewRouter()
34 | subrouter := router.PathPrefix("/api/v1").Subrouter()
35 |
36 | ctx := context.Background()
37 |
38 | gcpStorage, err := storage.NewGCPStorage(ctx)
39 | if err != nil {
40 | log.Fatal(err)
41 | }
42 |
43 | mailer := medium.NewMailer(config.Envs.SendGridAPIKey, config.Envs.SendGridFromEmail)
44 |
45 | bookStore := book.NewStore(s.db)
46 |
47 | userStore := user.NewStore(s.db)
48 | userHandler := user.NewHandler(userStore)
49 | userHandler.RegisterRoutes(subrouter)
50 |
51 | highlightStore := highlight.NewStore(s.db)
52 | highlightHandler := highlight.NewHandler(highlightStore, userStore, gcpStorage, bookStore, mailer)
53 | highlightHandler.RegisterRoutes(subrouter)
54 |
55 | // Serve static files
56 | router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static")))
57 |
58 | log.Println("Listening on", s.addr)
59 | log.Println("Process PID", os.Getpid())
60 |
61 | env := config.Envs.Env
62 | if env == "development" {
63 | v := reflect.ValueOf(config.Envs)
64 |
65 | for i := 0; i < v.NumField(); i++ {
66 | log.Println(v.Type().Field(i).Name, "=", v.Field(i).Interface())
67 | }
68 | }
69 |
70 | return http.ListenAndServe(s.addr, router)
71 | }
72 |
--------------------------------------------------------------------------------
/medium/email.go:
--------------------------------------------------------------------------------
1 | package medium
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "log"
7 | "path/filepath"
8 | "text/template"
9 |
10 | "github.com/sendgrid/sendgrid-go"
11 | "github.com/sendgrid/sendgrid-go/helpers/mail"
12 | "github.com/sikozonpc/notebase/config"
13 | t "github.com/sikozonpc/notebase/types"
14 | )
15 |
16 | var FromName = "Notebase"
17 |
18 | type Mailer struct {
19 | FromEmail string
20 | Client *sendgrid.Client
21 | }
22 |
23 | func NewMailer(apiKey, fromEmail string) *Mailer {
24 | client := sendgrid.NewSendClient(apiKey)
25 |
26 | return &Mailer{
27 | FromEmail: fromEmail,
28 | Client: client,
29 | }
30 | }
31 |
32 | func (m *Mailer) SendInsights(u *t.User, insights []*t.DailyInsight, authToken string) error {
33 | from := mail.NewEmail(FromName, m.FromEmail)
34 | subject := "Daily Insight(s)"
35 | userName := fmt.Sprintf("%v %v", u.FirstName, u.LastName)
36 |
37 | if u.Email == "" {
38 | return fmt.Errorf("user has no email")
39 | }
40 |
41 | to := mail.NewEmail(userName, u.Email)
42 |
43 | htmlContent := BuildInsightsMailTemplate("template", u, insights, authToken)
44 |
45 | message := mail.NewSingleEmail(from, subject, to, "", htmlContent)
46 | response, err := m.Client.Send(message)
47 | if err != nil {
48 | log.Println(err)
49 | }
50 |
51 | log.Printf("Email sent to %v with status code %v", u.Email, response.StatusCode)
52 |
53 | return nil
54 | }
55 |
56 | func BuildInsightsMailTemplate(templateDir string, u *t.User, insights []*t.DailyInsight, authToken string) string {
57 | filename := filepath.Join(templateDir, "daily.tmpl")
58 | tmpl, err := template.ParseFiles(filename)
59 | if err != nil {
60 | panic(err)
61 | }
62 |
63 | payload := struct {
64 | UnsubscribeURL string
65 | User *t.User
66 | Insights []*t.DailyInsight
67 | }{
68 | UnsubscribeURL: fmt.Sprintf("%s/unsubscribe.html?token=%s", config.Envs.PublicURL, authToken),
69 | User: u,
70 | Insights: insights,
71 | }
72 |
73 | var out bytes.Buffer
74 | err = tmpl.Execute(&out, payload)
75 | if err != nil {
76 | panic(err)
77 | }
78 |
79 | return out.String()
80 | }
81 |
--------------------------------------------------------------------------------
/user/store.go:
--------------------------------------------------------------------------------
1 | package user
2 |
3 | import (
4 | "context"
5 |
6 | t "github.com/sikozonpc/notebase/types"
7 | "go.mongodb.org/mongo-driver/bson"
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | "go.mongodb.org/mongo-driver/mongo"
10 | )
11 |
12 | const (
13 | DbName = "notebase"
14 | CollName = "users"
15 | )
16 |
17 | type Store struct {
18 | db *mongo.Client
19 | }
20 |
21 | func NewStore(db *mongo.Client) *Store {
22 | return &Store{db: db}
23 | }
24 |
25 | func (s *Store) Create(ctx context.Context, b t.RegisterRequest) (primitive.ObjectID, error) {
26 | col := s.db.Database(DbName).Collection(CollName)
27 |
28 | newUser, err := col.InsertOne(ctx, b)
29 |
30 | id := newUser.InsertedID.(primitive.ObjectID)
31 | return id, err
32 | }
33 |
34 | func (s *Store) GetUserByEmail(ctx context.Context, email string) (*t.User, error) {
35 | col := s.db.Database(DbName).Collection(CollName)
36 |
37 | var u t.User
38 | err := col.FindOne(ctx, bson.M{
39 | "email": email,
40 | }).Decode(&u)
41 |
42 | return &u, err
43 | }
44 |
45 | func (s *Store) GetUserByID(ctx context.Context, id string) (*t.User, error) {
46 | col := s.db.Database(DbName).Collection(CollName)
47 |
48 | oID, _ := primitive.ObjectIDFromHex(id)
49 |
50 | var u t.User
51 | err := col.FindOne(ctx, bson.M{
52 | "_id": oID,
53 | }).Decode(&u)
54 |
55 | return &u, err
56 | }
57 |
58 | func (s *Store) GetUsers(ctx context.Context) ([]*t.User, error) {
59 | col := s.db.Database(DbName).Collection(CollName)
60 |
61 | cursor, err := col.Find(ctx, bson.M{})
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | users := make([]*t.User, 0)
67 | for cursor.Next(ctx) {
68 | var u t.User
69 | if err := cursor.Decode(&u); err != nil {
70 | return nil, err
71 | }
72 |
73 | users = append(users, &u)
74 | }
75 |
76 | return users, nil
77 | }
78 |
79 | func (s *Store) UpdateUser(ctx context.Context, u t.User) error {
80 | col := s.db.Database(DbName).Collection(CollName)
81 |
82 | _, err := col.UpdateOne(ctx, bson.M{
83 | "_id": u.ID,
84 | }, bson.M{
85 | "$set": bson.M{
86 | "firstName": u.FirstName,
87 | "lastName": u.LastName,
88 | "email": u.Email,
89 | "password": u.Password,
90 | "isActive": u.IsActive,
91 | },
92 | })
93 |
94 | return err
95 | }
96 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/sikozonpc/notebase
2 |
3 | go 1.21.5
4 |
5 | require github.com/gorilla/mux v1.8.1
6 |
7 | require (
8 | cloud.google.com/go v0.110.8 // indirect
9 | cloud.google.com/go/compute v1.23.1 // indirect
10 | cloud.google.com/go/compute/metadata v0.2.3 // indirect
11 | cloud.google.com/go/iam v1.1.3 // indirect
12 | github.com/davecgh/go-spew v1.1.1 // indirect
13 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
14 | github.com/golang/protobuf v1.5.3 // indirect
15 | github.com/golang/snappy v0.0.4 // indirect
16 | github.com/google/s2a-go v0.1.7 // indirect
17 | github.com/google/uuid v1.4.0 // indirect
18 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
19 | github.com/googleapis/gax-go/v2 v2.12.0 // indirect
20 | github.com/klauspost/compress v1.13.6 // indirect
21 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
22 | github.com/pmezard/go-difflib v1.0.0 // indirect
23 | github.com/sendgrid/rest v2.6.9+incompatible // indirect
24 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect
25 | github.com/xdg-go/scram v1.1.2 // indirect
26 | github.com/xdg-go/stringprep v1.0.4 // indirect
27 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
28 | go.opencensus.io v0.24.0 // indirect
29 | golang.org/x/net v0.17.0 // indirect
30 | golang.org/x/oauth2 v0.13.0 // indirect
31 | golang.org/x/sync v0.5.0 // indirect
32 | golang.org/x/sys v0.15.0 // indirect
33 | golang.org/x/text v0.14.0 // indirect
34 | golang.org/x/time v0.3.0 // indirect
35 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
36 | google.golang.org/api v0.150.0 // indirect
37 | google.golang.org/appengine v1.6.7 // indirect
38 | google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect
39 | google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect
40 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect
41 | google.golang.org/grpc v1.59.0 // indirect
42 | google.golang.org/protobuf v1.31.0 // indirect
43 | gopkg.in/yaml.v3 v3.0.1 // indirect
44 | )
45 |
46 | require (
47 | cloud.google.com/go/storage v1.36.0
48 | github.com/golang-jwt/jwt/v5 v5.2.0
49 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible
50 | github.com/stretchr/testify v1.8.4
51 | go.mongodb.org/mongo-driver v1.15.0
52 | golang.org/x/crypto v0.17.0
53 | )
54 |
--------------------------------------------------------------------------------
/highlight/store.go:
--------------------------------------------------------------------------------
1 | package highlight
2 |
3 | import (
4 | "context"
5 |
6 | t "github.com/sikozonpc/notebase/types"
7 | "go.mongodb.org/mongo-driver/bson"
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | "go.mongodb.org/mongo-driver/mongo"
10 | )
11 |
12 | const (
13 | DbName = "notebase"
14 | CollName = "highlights"
15 | )
16 |
17 | type Store struct {
18 | db *mongo.Client
19 | }
20 |
21 | func NewStore(db *mongo.Client) *Store {
22 | return &Store{db: db}
23 | }
24 |
25 | func (s *Store) GetUserHighlights(ctx context.Context, userID primitive.ObjectID) ([]*t.Highlight, error) {
26 | col := s.db.Database(DbName).Collection(CollName)
27 |
28 | cursor, err := col.Find(ctx, bson.M{
29 | "userId": userID,
30 | })
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | var highlights []*t.Highlight
36 | if err = cursor.All(ctx, &highlights); err != nil {
37 | return nil, err
38 | }
39 |
40 | return highlights, nil
41 | }
42 |
43 | func (s *Store) CreateHighlight(ctx context.Context, h *t.CreateHighlightRequest) (primitive.ObjectID, error) {
44 | col := s.db.Database(DbName).Collection(CollName)
45 |
46 | newHighlight, err := col.InsertOne(ctx, h)
47 | if err != nil {
48 | return primitive.NilObjectID, err
49 | }
50 |
51 | id := newHighlight.InsertedID.(primitive.ObjectID)
52 | return id, nil
53 | }
54 |
55 | func (s *Store) GetHighlightByID(ctx context.Context, id primitive.ObjectID, userID primitive.ObjectID) (*t.Highlight, error) {
56 | col := s.db.Database(DbName).Collection(CollName)
57 |
58 | var h t.Highlight
59 | err := col.FindOne(ctx, bson.M{
60 | "_id": id,
61 | "userId": userID,
62 | }).Decode(&h)
63 |
64 | if err != nil {
65 | return nil, err
66 | }
67 |
68 | return &h, nil
69 | }
70 |
71 | func (s *Store) DeleteHighlight(ctx context.Context, id primitive.ObjectID) error {
72 | col := s.db.Database(DbName).Collection(CollName)
73 |
74 | _, err := col.DeleteOne(ctx, bson.M{
75 | "_id": id,
76 | })
77 | if err != nil {
78 | return err
79 | }
80 |
81 | return nil
82 | }
83 |
84 | func (s *Store) GetRandomHighlights(ctx context.Context, userID primitive.ObjectID, limit int) ([]*t.Highlight, error) {
85 | col := s.db.Database(DbName).Collection(CollName)
86 |
87 | cursor, err := col.Aggregate(ctx, mongo.Pipeline{
88 | bson.D{
89 | {Key: `$match`, Value: bson.M{
90 | "userId": userID,
91 | }},
92 | },
93 | bson.D{
94 | {Key: "$sample", Value: bson.M{
95 | "size": limit,
96 | }},
97 | },
98 | })
99 | if err != nil {
100 | return nil, err
101 | }
102 |
103 | var highlights []*t.Highlight
104 | if err = cursor.All(ctx, &highlights); err != nil {
105 | return nil, err
106 | }
107 |
108 | return highlights, nil
109 | }
110 |
--------------------------------------------------------------------------------
/auth/jwt.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | "os"
9 | "time"
10 |
11 | "github.com/golang-jwt/jwt/v5"
12 | "github.com/sikozonpc/notebase/config"
13 | t "github.com/sikozonpc/notebase/types"
14 | u "github.com/sikozonpc/notebase/utils"
15 | )
16 |
17 | func permissionDenied(w http.ResponseWriter) {
18 | u.WriteJSON(w, http.StatusUnauthorized, t.APIError{
19 | Error: fmt.Errorf("permission denied").Error(),
20 | })
21 | }
22 |
23 | func GetUserFromToken(t string) (string, error) {
24 | token, err := validateJWT(t)
25 | if err != nil {
26 | return "", err
27 | }
28 |
29 | claims := token.Claims.(jwt.MapClaims)
30 | claimsUserID := claims["userID"].(string)
31 |
32 | return claimsUserID, nil
33 | }
34 |
35 | func WithAPIKey(handlerFunc http.HandlerFunc) http.HandlerFunc {
36 | apiKey := config.Envs.APIKey
37 |
38 | return func(w http.ResponseWriter, r *http.Request) {
39 | apiKeyFromRequest := r.Header.Get("X-API-KEY")
40 |
41 | if apiKeyFromRequest != apiKey {
42 | log.Println("invalid api key")
43 | permissionDenied(w)
44 | return
45 | }
46 |
47 | handlerFunc(w, r)
48 | }
49 | }
50 |
51 | func WithJWTAuth(handlerFunc http.HandlerFunc, store t.UserStore) http.HandlerFunc {
52 | return func(w http.ResponseWriter, r *http.Request) {
53 | tokenString := u.GetTokenFromRequest(r)
54 |
55 | token, err := validateJWT(tokenString)
56 | if err != nil {
57 | log.Printf("failed to validate token: %v", err)
58 | permissionDenied(w)
59 | return
60 | }
61 |
62 | if !token.Valid {
63 | log.Println("invalid token")
64 | permissionDenied(w)
65 | return
66 | }
67 |
68 | claims := token.Claims.(jwt.MapClaims)
69 | claimsUserID := claims["userID"].(string)
70 |
71 | _, err = store.GetUserByID(context.Background(), claimsUserID)
72 | if err != nil {
73 | log.Printf("failed to get user by id: %v", err)
74 | permissionDenied(w)
75 | return
76 | }
77 |
78 | // Call the function if the token is valid
79 | handlerFunc(w, r)
80 | }
81 | }
82 |
83 | func CreateJWT(secret []byte, userID string) (string, error) {
84 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
85 | "userID": userID,
86 | "expiresAt": time.Now().Add(time.Hour * 24 * 120).Unix(),
87 | })
88 |
89 | tokenString, err := token.SignedString(secret)
90 | if err != nil {
91 | return "", err
92 | }
93 |
94 | return tokenString, err
95 | }
96 |
97 | func validateJWT(tokenString string) (*jwt.Token, error) {
98 | secret := os.Getenv("JWT_SECRET")
99 |
100 | return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
101 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
102 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
103 | }
104 |
105 | return []byte(secret), nil
106 | })
107 | }
108 |
--------------------------------------------------------------------------------
/user/handler.go:
--------------------------------------------------------------------------------
1 | package user
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 |
8 | "github.com/gorilla/mux"
9 | "github.com/sikozonpc/notebase/auth"
10 | "github.com/sikozonpc/notebase/config"
11 | t "github.com/sikozonpc/notebase/types"
12 | u "github.com/sikozonpc/notebase/utils"
13 | )
14 |
15 | type Handler struct {
16 | store t.UserStore
17 | }
18 |
19 | func NewHandler(store t.UserStore) *Handler {
20 | return &Handler{store: store}
21 | }
22 |
23 | func (h *Handler) RegisterRoutes(router *mux.Router) {
24 | router.HandleFunc(
25 | "/users/{userID}",
26 | u.MakeHTTPHandler(h.handleGetUser),
27 | ).Methods("GET")
28 |
29 | router.HandleFunc(
30 | "/login",
31 | u.MakeHTTPHandler(h.handleLogin),
32 | ).Methods("POST")
33 | router.HandleFunc(
34 | "/register",
35 | u.MakeHTTPHandler(h.handleRegister),
36 | ).Methods("POST")
37 | }
38 |
39 | func (h *Handler) handleGetUser(w http.ResponseWriter, r *http.Request) error {
40 | if r.Method != http.MethodGet {
41 | return fmt.Errorf("method %s not allowed", r.Method)
42 | }
43 |
44 | userID, err := u.GetStringParamFromRequest(r, "userID")
45 | if err != nil {
46 | return err
47 | }
48 |
49 | user, err := h.store.GetUserByID(r.Context(), string(userID))
50 | if err != nil {
51 | return err
52 | }
53 |
54 | return u.WriteJSON(w, http.StatusOK, user)
55 | }
56 |
57 | func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) error {
58 | if r.Method != http.MethodPost {
59 | return fmt.Errorf("method %s not allowed", r.Method)
60 | }
61 |
62 | payload := new(LoginRequest)
63 | if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
64 | return err
65 | }
66 |
67 | user, err := h.store.GetUserByEmail(r.Context(), payload.Email)
68 | if err != nil {
69 | return err
70 | }
71 |
72 | if !auth.ComparePasswords(user.Password, []byte(payload.Password)) {
73 | return fmt.Errorf("invalid password or user does not exist")
74 | }
75 |
76 | token, err := createAndSetAuthCookie(user.ID.Hex(), w)
77 | if err != nil {
78 | return err
79 | }
80 |
81 | return u.WriteJSON(w, http.StatusOK, token)
82 | }
83 |
84 | func (h *Handler) handleRegister(w http.ResponseWriter, r *http.Request) error {
85 | if r.Method != http.MethodPost {
86 | return fmt.Errorf("method %s not allowed", r.Method)
87 | }
88 |
89 | payload := new(t.RegisterRequest)
90 | if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
91 | return err
92 | }
93 |
94 | hashedPassword, err := auth.HashPassword(payload.Password)
95 | if err != nil {
96 | return err
97 | }
98 |
99 | payload.Password = string(hashedPassword)
100 |
101 | id, err := h.store.Create(r.Context(), *payload)
102 | if err != nil {
103 | return err
104 | }
105 |
106 | token, err := createAndSetAuthCookie(id.Hex(), w)
107 | if err != nil {
108 | return err
109 | }
110 |
111 | return u.WriteJSON(w, http.StatusOK, token)
112 | }
113 | func createAndSetAuthCookie(userID string, w http.ResponseWriter) (string, error) {
114 | secret := []byte(config.Envs.JWTSecret)
115 | token, err := auth.CreateJWT(secret, userID)
116 | if err != nil {
117 | return "", err
118 | }
119 |
120 | http.SetCookie(w, &http.Cookie{
121 | Name: "Authorization",
122 | Value: token,
123 | })
124 |
125 | return token, nil
126 | }
127 |
128 | type LoginRequest struct {
129 | Email string `json:"email"`
130 | Password string `json:"password"`
131 | }
132 |
--------------------------------------------------------------------------------
/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "time"
7 |
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | )
10 |
11 | type EndpointHandler func(w http.ResponseWriter, r *http.Request) error
12 |
13 | type Config struct {
14 | Env string
15 | Port string
16 | MongoURI string
17 | JWTSecret string // Used for signing JWT tokens
18 | GCPID string // Google Cloud Project ID
19 | GCPBooksBucketName string // Google CLoud Storage Bucket Name from where upload books are parsed
20 | SendGridAPIKey string
21 | SendGridFromEmail string
22 | PublicURL string // Used for generating links in emails
23 | APIKey string // Used for authentication with external clients like GCP pub/sub
24 | }
25 |
26 | type APIError struct {
27 | Error string `json:"error"`
28 | }
29 |
30 | type Highlight struct {
31 | ID primitive.ObjectID `json:"id" bson:"_id"`
32 | Text string `json:"text" bson:"text"`
33 | Location string `json:"location" bson:"location"`
34 | Note string `json:"note" bson:"note"`
35 | UserID primitive.ObjectID `json:"userId" bson:"userId"`
36 | BookID string `json:"bookId" bson:"bookId"`
37 | CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
38 | UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"`
39 | }
40 |
41 | type User struct {
42 | ID primitive.ObjectID `json:"id" bson:"_id"`
43 | FirstName string `json:"firstName" bson:"firstName"`
44 | LastName string `json:"lastName" bson:"lastName"`
45 | Email string `json:"email" bson:"email"`
46 | Password string `json:"-" bson:"password"`
47 | IsActive bool `json:"isActive" bson:"isActive"`
48 | CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
49 | }
50 |
51 | type Book struct {
52 | ID primitive.ObjectID `json:"id" bson:"_id"`
53 | ISBN string `json:"isbn" bson:"isbn"`
54 | Title string `json:"title" bson:"title"`
55 | Authors string `json:"authors" bson:"authors"`
56 | CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
57 | }
58 |
59 | // This is the format of the file that is downloaded from web tool
60 | type RawExtractBook struct {
61 | ASIN string `json:"asin"`
62 | Title string `json:"title"`
63 | Authors string `json:"authors"`
64 | Highlights []RawExtractHighlight `json:"highlights"`
65 | }
66 |
67 | // This is the format of the file that is downloaded from web tool
68 | type RawExtractHighlight struct {
69 | Text string `json:"text"`
70 | Location struct {
71 | Value int `json:"value"`
72 | URL string `json:"url"`
73 | } `json:"location"`
74 | Note string `json:"note"`
75 | IsNoteOnly bool `json:"isNoteOnly"`
76 | }
77 |
78 | type UserStore interface {
79 | Create(context.Context, RegisterRequest) (primitive.ObjectID, error)
80 | GetUserByEmail(context.Context, string) (*User, error)
81 | GetUserByID(context.Context, string) (*User, error)
82 | GetUsers(context.Context) ([]*User, error)
83 | UpdateUser(context.Context, User) error
84 | }
85 |
86 | type HighlightStore interface {
87 | CreateHighlight(context.Context, *CreateHighlightRequest) (primitive.ObjectID, error)
88 | GetHighlightByID(context.Context, primitive.ObjectID, primitive.ObjectID) (*Highlight, error)
89 | GetUserHighlights(context.Context, primitive.ObjectID) ([]*Highlight, error)
90 | DeleteHighlight(context.Context, primitive.ObjectID) error
91 | GetRandomHighlights(context.Context, primitive.ObjectID, int) ([]*Highlight, error)
92 | }
93 |
94 | type BookStore interface {
95 | GetByISBN(context.Context, string) (*Book, error)
96 | Create(context.Context, *CreateBookRequest) (primitive.ObjectID, error)
97 | }
98 |
99 | type CreateBookRequest struct {
100 | ISBN string `json:"isbn" bson:"isbn"`
101 | Title string `json:"title" bson:"title"`
102 | Authors string `json:"authors" bson:"authors"`
103 | }
104 |
105 | type CreateHighlightRequest struct {
106 | Text string `json:"text" bson:"text"`
107 | Location string `json:"location" bson:"location"`
108 | Note string `json:"note" bson:"note"`
109 | UserID primitive.ObjectID `json:"userId" bson:"userId"`
110 | BookID string `json:"bookId" bson:"bookId"`
111 | }
112 |
113 | type DailyInsight struct {
114 | Text string
115 | Note string
116 | BookAuthors string
117 | BookTitle string
118 | }
119 |
120 | type RegisterRequest struct {
121 | FirstName string `json:"firstName" bson:"firstName" validate:"required"`
122 | LastName string `json:"lastName" bson:"lastName" validate:"required"`
123 | Email string `json:"email" bson:"email" validate:"required"`
124 | Password string `json:"password" bson:"password" validate:"required"`
125 | }
126 |
--------------------------------------------------------------------------------
/highlight/handler_test.go:
--------------------------------------------------------------------------------
1 | package highlight
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "net/http"
8 | "net/http/httptest"
9 | "testing"
10 |
11 | "github.com/gorilla/mux"
12 | "github.com/sikozonpc/notebase/storage"
13 | types "github.com/sikozonpc/notebase/types"
14 | u "github.com/sikozonpc/notebase/utils"
15 | "go.mongodb.org/mongo-driver/bson/primitive"
16 | )
17 |
18 | var fakeHighlight *types.Highlight
19 |
20 | func TestHandleUserHighlights(t *testing.T) {
21 | memStore := storage.NewMemoryStorage()
22 | bookStore := &mockBookStore{}
23 | mockMailer := &mockMailer{}
24 |
25 | store := &mockHighlightStore{}
26 | userStore := &mockUserStore{}
27 | handler := NewHandler(store, userStore, memStore, bookStore, mockMailer)
28 |
29 | t.Run("should handle get user highlights", func(t *testing.T) {
30 | req, err := http.NewRequest(http.MethodGet, "/user/1/highlight", nil)
31 | if err != nil {
32 | t.Fatal(err)
33 | }
34 |
35 | rr := httptest.NewRecorder()
36 | router := mux.NewRouter()
37 |
38 | router.HandleFunc("/user/{userID}/highlight", u.MakeHTTPHandler(handler.handleGetUserHighlights))
39 |
40 | router.ServeHTTP(rr, req)
41 |
42 | if rr.Code != http.StatusOK {
43 | t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code)
44 | }
45 | })
46 |
47 | t.Run("should fail to handle get highlight by ID if highlight does not exist", func(t *testing.T) {
48 | fakeHighlight = nil
49 |
50 | req, err := http.NewRequest(http.MethodGet, "/user/1/highlight/1", nil)
51 | if err != nil {
52 | t.Fatal(err)
53 | }
54 |
55 | rr := httptest.NewRecorder()
56 | router := mux.NewRouter()
57 |
58 | router.HandleFunc("/user/{userID}/highlight/{id}", u.MakeHTTPHandler(handler.handleGetHighlightByID)).Methods(http.MethodGet)
59 |
60 | router.ServeHTTP(rr, req)
61 |
62 | if rr.Code != http.StatusNotFound {
63 | t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code)
64 | }
65 | })
66 |
67 | t.Run("should handle get highlight by ID", func(t *testing.T) {
68 | fakeHighlight = &types.Highlight{
69 | ID: primitive.NewObjectID(),
70 | Text: "test",
71 | Location: "test",
72 | Note: "test",
73 | BookID: "B004XCFJ3E",
74 | UserID: primitive.NewObjectID(),
75 | }
76 |
77 | req, err := http.NewRequest(http.MethodGet, "/user/1/highlight/1", nil)
78 | if err != nil {
79 | t.Fatal(err)
80 | }
81 |
82 | rr := httptest.NewRecorder()
83 | router := mux.NewRouter()
84 |
85 | router.HandleFunc("/user/{userID}/highlight/{id}", u.MakeHTTPHandler(handler.handleGetHighlightByID)).Methods(http.MethodGet)
86 |
87 | router.ServeHTTP(rr, req)
88 |
89 | if rr.Code != http.StatusOK {
90 | t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code)
91 | }
92 | })
93 |
94 | t.Run("should handle create highlight", func(t *testing.T) {
95 | payload := CreateHighlightRequest{
96 | Text: "test",
97 | Location: "test",
98 | Note: "test",
99 | BookId: "B004XCFJ3E",
100 | }
101 |
102 | marshalled, err := json.Marshal(payload)
103 | if err != nil {
104 | t.Fatal(err)
105 | }
106 |
107 | req, err := http.NewRequest(http.MethodPost, "/user/1/highlight", bytes.NewBuffer(marshalled))
108 | if err != nil {
109 | t.Fatal(err)
110 | }
111 |
112 | rr := httptest.NewRecorder()
113 | router := mux.NewRouter()
114 |
115 | router.HandleFunc("/user/{userID}/highlight", u.MakeHTTPHandler(handler.handleCreateHighlight))
116 |
117 | router.ServeHTTP(rr, req)
118 |
119 | if rr.Code != http.StatusOK {
120 | t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code)
121 | }
122 |
123 | if rr.Body.String() == "" {
124 | t.Errorf("expected response body to be non-empty")
125 | }
126 |
127 | var response types.Highlight
128 | err = json.NewDecoder(rr.Body).Decode(&response)
129 | if err != nil {
130 | t.Fatal(err)
131 | }
132 |
133 | if response.Text != payload.Text {
134 | t.Errorf("expected text to be %s, got %s", payload.Text, response.Text)
135 | }
136 |
137 | if response.Note != payload.Note {
138 | t.Errorf("expected location to be %s, got %s", payload.Location, response.Location)
139 | }
140 | })
141 |
142 | t.Run("should handle delete highlight", func(t *testing.T) {
143 | req, err := http.NewRequest(http.MethodDelete, "/user/1/highlight/1", nil)
144 | if err != nil {
145 | t.Fatal(err)
146 | }
147 |
148 | rr := httptest.NewRecorder()
149 | router := mux.NewRouter()
150 |
151 | router.HandleFunc("/user/{userID}/highlight/{id}", u.MakeHTTPHandler(handler.handleDeleteHighlight)).Methods(http.MethodDelete)
152 |
153 | router.ServeHTTP(rr, req)
154 |
155 | if rr.Code != http.StatusOK {
156 | t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code)
157 | }
158 | })
159 |
160 | t.Run("should handle parse kindle extract", func(t *testing.T) {
161 | req, err := http.NewRequest(http.MethodPost, "/user/1/cloud/parse-kindle-extract/file.json", nil)
162 | if err != nil {
163 | t.Fatal(err)
164 | }
165 |
166 | rr := httptest.NewRecorder()
167 | router := mux.NewRouter()
168 |
169 | router.HandleFunc("/user/{userID}/cloud/parse-kindle-extract/{fileName}", u.MakeHTTPHandler(handler.handleCloudKindleParse))
170 |
171 | router.ServeHTTP(rr, req)
172 |
173 | if rr.Code != http.StatusOK {
174 | t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code)
175 | }
176 | })
177 | }
178 |
179 | type mockHighlightStore struct{}
180 |
181 | func (m *mockHighlightStore) CreateHighlight(context.Context, *types.CreateHighlightRequest) (primitive.ObjectID, error) {
182 | return primitive.NilObjectID, nil
183 | }
184 |
185 | func (m *mockHighlightStore) GetHighlightByID(context.Context, primitive.ObjectID, primitive.ObjectID) (*types.Highlight, error) {
186 | return fakeHighlight, nil
187 | }
188 |
189 | func (m *mockHighlightStore) GetUserHighlights(context.Context, primitive.ObjectID) ([]*types.Highlight, error) {
190 | return []*types.Highlight{}, nil
191 | }
192 |
193 | func (m *mockHighlightStore) DeleteHighlight(context.Context, primitive.ObjectID) error {
194 | return nil
195 | }
196 |
197 | func (m *mockHighlightStore) GetRandomHighlights(context.Context, primitive.ObjectID, int) ([]*types.Highlight, error) {
198 | return []*types.Highlight{}, nil
199 | }
200 |
201 | type mockUserStore struct{}
202 |
203 | func (m *mockUserStore) Create(context.Context, types.RegisterRequest) (primitive.ObjectID, error) {
204 | return primitive.NilObjectID, nil
205 | }
206 |
207 | func (m *mockUserStore) GetUserByID(context.Context, string) (*types.User, error) {
208 | return &types.User{}, nil
209 | }
210 |
211 | func (m *mockUserStore) GetUsers(context.Context) ([]*types.User, error) {
212 | return []*types.User{}, nil
213 | }
214 |
215 | func (m *mockUserStore) GetUserByEmail(context.Context, string) (*types.User, error) {
216 | return &types.User{}, nil
217 | }
218 |
219 | func (m *mockUserStore) UpdateUser(context.Context, types.User) error {
220 | return nil
221 | }
222 |
223 | type mockBookStore struct{}
224 |
225 | func (m *mockBookStore) GetByISBN(context.Context, string) (*types.Book, error) {
226 | return &types.Book{}, nil
227 | }
228 |
229 | func (m *mockBookStore) Create(context.Context, *types.CreateBookRequest) (primitive.ObjectID, error) {
230 | return primitive.NilObjectID, nil
231 | }
232 |
233 | type mockMailer struct{}
234 |
235 | func (m *mockMailer) SendMail(string, string, string) error {
236 | return nil
237 | }
238 |
239 | func (m *mockMailer) SendInsights(*types.User, []*types.DailyInsight, string) error {
240 | return nil
241 | }
242 |
--------------------------------------------------------------------------------
/highlight/handler.go:
--------------------------------------------------------------------------------
1 | package highlight
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "mime/multipart"
9 | "net/http"
10 |
11 | "github.com/gorilla/mux"
12 | "github.com/sikozonpc/notebase/auth"
13 | "github.com/sikozonpc/notebase/medium"
14 | "github.com/sikozonpc/notebase/storage"
15 | t "github.com/sikozonpc/notebase/types"
16 | u "github.com/sikozonpc/notebase/utils"
17 | "go.mongodb.org/mongo-driver/bson/primitive"
18 | )
19 |
20 | type Handler struct {
21 | store t.HighlightStore
22 | userStore t.UserStore
23 | storage storage.Storage
24 | bookStore t.BookStore
25 | mailer medium.Medium
26 | }
27 |
28 | func NewHandler(
29 | store t.HighlightStore,
30 | userStore t.UserStore,
31 | storage storage.Storage,
32 | bookStore t.BookStore,
33 | mailer medium.Medium,
34 | ) *Handler {
35 | return &Handler{
36 | store: store,
37 | userStore: userStore,
38 | storage: storage,
39 | bookStore: bookStore,
40 | mailer: mailer,
41 | }
42 | }
43 |
44 | func (h *Handler) RegisterRoutes(router *mux.Router) {
45 | router.HandleFunc(
46 | "/user/{userID}/highlight",
47 | auth.WithJWTAuth(u.MakeHTTPHandler(h.handleGetUserHighlights), h.userStore),
48 | ).Methods("GET")
49 |
50 | router.HandleFunc(
51 | "/user/{userID}/highlight",
52 | auth.WithJWTAuth(u.MakeHTTPHandler(h.handleCreateHighlight), h.userStore),
53 | ).Methods("POST")
54 |
55 | router.HandleFunc(
56 | "/user/{userID}/highlight/{id}",
57 | auth.WithJWTAuth(u.MakeHTTPHandler(h.handleGetHighlightByID), h.userStore),
58 | ).Methods("GET")
59 |
60 | router.HandleFunc(
61 | "/user/{userID}/highlight/{id}",
62 | auth.WithJWTAuth(u.MakeHTTPHandler(h.handleDeleteHighlight), h.userStore),
63 | ).Methods("DELETE")
64 |
65 | router.HandleFunc(
66 | "/user/{userID}/parse-kindle-extract",
67 | u.MakeHTTPHandler(h.handleParseKindleFile),
68 | ).
69 | Methods("POST")
70 |
71 | router.HandleFunc(
72 | "/cloud/parse-kindle-extract/{fileName}",
73 | auth.WithAPIKey(u.MakeHTTPHandler(h.handleCloudKindleParse)),
74 | ).
75 | Methods("POST")
76 |
77 | router.HandleFunc(
78 | "/cloud/daily-insights",
79 | auth.WithAPIKey(u.MakeHTTPHandler(h.handleSendDailyInsights)),
80 | ).
81 | Methods("GET")
82 |
83 | router.HandleFunc(
84 | "/unsubscribe",
85 | auth.WithJWTAuth(u.MakeHTTPHandler(h.handleUnsubscribe), h.userStore),
86 | ).
87 | Methods("GET")
88 | }
89 |
90 | func (s *Handler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) error {
91 | token := u.GetTokenFromRequest(r)
92 |
93 | userID, err := auth.GetUserFromToken(token)
94 | if err != nil {
95 | return err
96 | }
97 |
98 | user, err := s.userStore.GetUserByID(r.Context(), userID)
99 | if err != nil {
100 | return err
101 | }
102 |
103 | user.IsActive = false
104 | if err := s.userStore.UpdateUser(r.Context(), *user); err != nil {
105 | return err
106 | }
107 |
108 | log.Printf("User %s unsubscribed", user.Email)
109 |
110 | return u.WriteJSON(w, http.StatusOK, nil)
111 | }
112 |
113 | func (s *Handler) handleSendDailyInsights(w http.ResponseWriter, r *http.Request) error {
114 | authToken := u.GetTokenFromRequest(r)
115 |
116 | users, err := s.userStore.GetUsers(r.Context())
117 | if err != nil {
118 | return err
119 | }
120 |
121 | for _, u := range users {
122 | user, err := s.userStore.GetUserByID(r.Context(), u.ID.Hex())
123 | if err != nil {
124 | return fmt.Errorf("user with id %d not found", u.ID)
125 | }
126 |
127 | hs, err := s.store.GetRandomHighlights(r.Context(), u.ID, 3)
128 | if err != nil {
129 | return err
130 | }
131 |
132 | // Don't send daily insights if there are none
133 | if len(hs) == 0 {
134 | continue
135 | }
136 |
137 | insights, err := buildInsights(hs, s.bookStore)
138 | if err != nil {
139 | return err
140 | }
141 |
142 | if err = s.mailer.SendInsights(user, insights, authToken); err != nil {
143 | return err
144 | }
145 | }
146 |
147 | return u.WriteJSON(w, http.StatusOK, nil)
148 | }
149 |
150 | func (s *Handler) handleCloudKindleParse(w http.ResponseWriter, r *http.Request) error {
151 | userID, err := u.GetStringParamFromRequest(r, "userID")
152 | if err != nil {
153 | return err
154 | }
155 |
156 | filename, err := u.GetStringParamFromRequest(r, "fileName")
157 | if err != nil {
158 | return u.WriteJSON(w, http.StatusBadRequest, fmt.Errorf("filename is required"))
159 | }
160 |
161 | file, err := s.storage.Read(filename)
162 | if err != nil {
163 | return u.WriteJSON(w, http.StatusInternalServerError, err)
164 | }
165 |
166 | raw, err := parseKindleExtractFromString(file)
167 | if err != nil {
168 | return err
169 | }
170 |
171 | err = s.createDataFromRawBook(raw, userID)
172 | if err != nil {
173 | return err
174 | }
175 |
176 | return u.WriteJSON(w, http.StatusOK, raw)
177 | }
178 |
179 | func (s *Handler) handleParseKindleFile(w http.ResponseWriter, r *http.Request) error {
180 | userID, err := u.GetStringParamFromRequest(r, "userID")
181 | if err != nil {
182 | return err
183 | }
184 |
185 | file, _, err := r.FormFile("file")
186 | if err != nil {
187 | return u.WriteJSON(w, http.StatusBadRequest, err)
188 | }
189 | defer file.Close()
190 |
191 | raw, err := parseKindleExtractFromFile(file)
192 | if err != nil {
193 | return u.WriteJSON(w, http.StatusBadRequest, err)
194 | }
195 |
196 | err = s.createDataFromRawBook(raw, userID)
197 | if err != nil {
198 | return err
199 | }
200 |
201 | return u.WriteJSON(w, http.StatusNoContent, "")
202 | }
203 |
204 | func (s *Handler) handleGetUserHighlights(w http.ResponseWriter, r *http.Request) error {
205 | userID, err := u.GetStringParamFromRequest(r, "userID")
206 | if err != nil {
207 | return err
208 | }
209 |
210 | oID, _ := primitive.ObjectIDFromHex(string(userID))
211 |
212 | hs, err := s.store.GetUserHighlights(r.Context(), oID)
213 | if err != nil {
214 | return err
215 | }
216 |
217 | return u.WriteJSON(w, http.StatusOK, hs)
218 | }
219 |
220 | func (s *Handler) handleDeleteHighlight(w http.ResponseWriter, r *http.Request) error {
221 | id, err := u.GetStringParamFromRequest(r, "id")
222 | if err != nil {
223 | return err
224 | }
225 |
226 | oID, _ := primitive.ObjectIDFromHex(string(id))
227 | err = s.store.DeleteHighlight(r.Context(), oID)
228 | if err != nil {
229 | return err
230 | }
231 |
232 | return u.WriteJSON(w, http.StatusOK, nil)
233 | }
234 |
235 | func (s *Handler) handleGetHighlightByID(w http.ResponseWriter, r *http.Request) error {
236 | userID, err := u.GetStringParamFromRequest(r, "userID")
237 | if err != nil {
238 | return err
239 | }
240 | oUserID, _ := primitive.ObjectIDFromHex(string(userID))
241 |
242 | id, err := u.GetStringParamFromRequest(r, "id")
243 | if err != nil {
244 | return err
245 | }
246 | oID, _ := primitive.ObjectIDFromHex(string(id))
247 |
248 | h, err := s.store.GetHighlightByID(r.Context(), oID, oUserID)
249 | if err != nil {
250 | return err
251 | }
252 |
253 | if h == nil {
254 | return u.WriteJSON(w, http.StatusNotFound, t.APIError{Error: fmt.Errorf("highlight with id %v not found", id).Error()})
255 | }
256 |
257 | return u.WriteJSON(w, http.StatusOK, h)
258 |
259 | }
260 |
261 | func (s *Handler) handleCreateHighlight(w http.ResponseWriter, r *http.Request) error {
262 | payload := new(CreateHighlightRequest)
263 | if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
264 | return err
265 | }
266 |
267 | oID, _ := primitive.ObjectIDFromHex(string(payload.UserId))
268 |
269 | highlight := &t.CreateHighlightRequest{
270 | Text: payload.Text,
271 | Location: payload.Location,
272 | Note: payload.Note,
273 | UserID: oID,
274 | BookID: payload.BookId,
275 | }
276 |
277 | if _, err := s.store.CreateHighlight(r.Context(), highlight); err != nil {
278 | return err
279 | }
280 |
281 | return u.WriteJSON(w, http.StatusOK, highlight)
282 |
283 | }
284 |
285 | type CreateHighlightRequest struct {
286 | Text string `json:"text"`
287 | Location string `json:"location"`
288 | Note string `json:"note"`
289 | UserId string `json:"userId"`
290 | BookId string `json:"bookId"`
291 | }
292 |
293 | type ParseKindleFileRequest struct {
294 | File multipart.File `json:"file"`
295 | }
296 |
297 | func buildInsights(hs []*t.Highlight, bookStore t.BookStore) ([]*t.DailyInsight, error) {
298 | var insights []*t.DailyInsight
299 |
300 | for _, h := range hs {
301 | book, err := bookStore.GetByISBN(context.Background(), h.BookID)
302 | if err != nil {
303 | log.Println("Error getting book: ", err)
304 | return nil, err
305 | }
306 |
307 | insights = append(insights, &t.DailyInsight{
308 | Text: h.Text,
309 | Note: h.Note,
310 | BookAuthors: book.Authors,
311 | BookTitle: book.Title,
312 | })
313 | }
314 |
315 | return insights, nil
316 | }
317 |
318 | func (s *Handler) createDataFromRawBook(raw *t.RawExtractBook, userID string) error {
319 | // Create book
320 | _, err := s.bookStore.GetByISBN(context.Background(), raw.ASIN)
321 | if err != nil {
322 | s.bookStore.Create(context.Background(), &t.CreateBookRequest{
323 | ISBN: raw.ASIN,
324 | Title: raw.Title,
325 | Authors: raw.Authors,
326 | })
327 | }
328 |
329 | oID, _ := primitive.ObjectIDFromHex(string(userID))
330 |
331 | // Create highlights
332 | hs := make([]*t.CreateHighlightRequest, len(raw.Highlights))
333 | for i, h := range raw.Highlights {
334 | hs[i] = &t.CreateHighlightRequest{
335 | Text: h.Text,
336 | Location: h.Location.URL,
337 | Note: h.Note,
338 | UserID: oID,
339 | BookID: raw.ASIN,
340 | }
341 | }
342 |
343 | for _, h := range hs {
344 | _, err := s.store.CreateHighlight(context.Background(), h)
345 | if err != nil {
346 | return err
347 | }
348 | }
349 |
350 | return nil
351 | }
352 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 | cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME=
3 | cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk=
4 | cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0=
5 | cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78=
6 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
7 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
8 | cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc=
9 | cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE=
10 | cloud.google.com/go/storage v1.36.0 h1:P0mOkAcaJxhCTvAkMhxMfrTKiNcub4YmmPBtlhAyTr8=
11 | cloud.google.com/go/storage v1.36.0/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
12 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
13 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
14 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
15 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
20 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
21 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
22 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
23 | github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
24 | github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
25 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
26 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
27 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
28 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
29 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
30 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
31 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
32 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
33 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
34 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
35 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
36 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
37 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
38 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
39 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
40 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
41 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
42 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
43 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
44 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
45 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
46 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
47 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
48 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
49 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
50 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
51 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
52 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
53 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
54 | github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
55 | github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
56 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
57 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
58 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
59 | github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
60 | github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
61 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
62 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
63 | github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
64 | github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
65 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
66 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
67 | github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
68 | github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
69 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
70 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
71 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
72 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
73 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
74 | github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
75 | github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
76 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA=
77 | github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
78 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
79 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
80 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
81 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
82 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
83 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
84 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
85 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
86 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
87 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
88 | github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
89 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
90 | github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
91 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
92 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
93 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
94 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
95 | go.mongodb.org/mongo-driver v1.15.0 h1:rJCKC8eEliewXjZGf0ddURtl7tTVy1TK3bfl0gkUSLc=
96 | go.mongodb.org/mongo-driver v1.15.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
97 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
98 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
99 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
100 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
101 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
102 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
103 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
104 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
105 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
106 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
107 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
108 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
109 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
110 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
111 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
112 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
113 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
114 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
115 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
116 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
117 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
118 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
119 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
120 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
121 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
122 | golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
123 | golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
124 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
125 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
126 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
127 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
128 | golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
129 | golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
130 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
131 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
132 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
133 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
134 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
135 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
136 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
137 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
138 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
139 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
140 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
141 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
142 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
143 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
144 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
145 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
146 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
147 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
148 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
149 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
150 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
151 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
152 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
153 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
154 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
155 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
156 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
157 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
158 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
159 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
160 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
161 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
162 | google.golang.org/api v0.150.0 h1:Z9k22qD289SZ8gCJrk4DrWXkNjtfvKAUo/l1ma8eBYE=
163 | google.golang.org/api v0.150.0/go.mod h1:ccy+MJ6nrYFgE3WgRx/AMXOxOmU8Q4hSa+jjibzhxcg=
164 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
165 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
166 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
167 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
168 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
169 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
170 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
171 | google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA=
172 | google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI=
173 | google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k=
174 | google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870=
175 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik=
176 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE=
177 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
178 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
179 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
180 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
181 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
182 | google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
183 | google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
184 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
185 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
186 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
187 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
188 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
189 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
190 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
191 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
192 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
193 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
194 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
195 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
196 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
197 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
198 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
199 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
200 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
201 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
202 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
203 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
204 |
--------------------------------------------------------------------------------