├── 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 |
  1. 11 |
    12 | 15 | "{{ .Text }}" 16 | 17 | {{ .Note }} 18 | 19 | 20 |
    - {{ .BookTitle }} - {{ .BookAuthors }}
    21 |
    22 |
  2. 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 |
  1. 11 |
    12 | 15 | "{{ .Text }}" 16 | 17 | {{ .Note }} 18 | 19 | 20 |
    - {{ .BookTitle }} - {{ .BookAuthors }}
    21 |
    22 |
  2. 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 | --------------------------------------------------------------------------------