├── .air.conf ├── .circleci └── config.yml ├── .gcloudignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.yaml ├── architecture.jpg ├── bjson └── bjson.go ├── clients ├── db │ ├── client.go │ ├── middleware.go │ └── transaction.go ├── magic │ └── magic.go ├── mail │ └── mail.go ├── mongo │ └── mongo.go ├── notification │ └── notification.go ├── oauth │ └── oauth.go ├── opengraph │ └── opengraph.go ├── places │ └── places.go ├── pluck │ └── pluck.go ├── queue │ └── queue.go ├── search │ └── search.go ├── secrets │ └── secrets.go └── storage │ └── storage.go ├── cmd ├── migrate-first-messages │ └── main.go ├── migrate-message-timestamps-and-photos │ └── main.go ├── migrate-thread-previews │ └── main.go └── server │ └── main.go ├── cron.yaml ├── db ├── event.go ├── message.go ├── note.go ├── thread.go └── user.go ├── digest └── digest.go ├── docker-compose.yaml ├── errors └── errors.go ├── go.mod ├── go.sum ├── handler ├── contact │ └── contact.go ├── event │ └── event.go ├── handler.go ├── inbound │ └── inbound.go ├── middleware │ └── middleware.go ├── note │ └── note.go ├── task │ └── task.go ├── thread │ └── thread.go └── user │ └── user.go ├── index.yaml ├── integ ├── contact_test.go ├── event_test.go ├── inbound_test.go ├── main_test.go ├── note_test.go ├── task_test.go ├── thread_test.go └── user_test.go ├── log └── log.go ├── mail ├── content │ ├── merge-accounts.txt │ ├── password-reset.txt │ └── verify-email.txt └── mail.go ├── model ├── digest.go ├── event.go ├── merge.go ├── message.go ├── note.go ├── pagination.go ├── read.go ├── tag.go ├── thread.go └── user.go ├── random └── random.go ├── template ├── includes │ ├── base.html │ ├── button.html │ ├── message.html │ └── profile.html ├── layouts │ ├── admin.html │ ├── cancellation.html │ ├── digest.html │ ├── event.html │ └── thread.html ├── renderable.go └── template.go ├── testutil └── handler.go ├── valid └── valid.go └── welcome ├── content └── welcome.md └── welcome.go /.air.conf: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format 2 | 3 | # Working directory 4 | # . or absolute path, please note that the directories following must be under root. 5 | root = "." 6 | tmp_dir = "tmp" 7 | 8 | [build] 9 | # Just plain old shell command. You could use `make` as well. 10 | cmd = "go build -o ./tmp/main ./cmd/server/main.go" 11 | # Binary file yields from `cmd`. 12 | bin = "tmp/main" 13 | # Customize binary. 14 | full_bin = "./tmp/main" 15 | # Watch these filename extensions. 16 | include_ext = ["go", "tpl", "tmpl", "html"] 17 | # Ignore these filename extensions or directories. 18 | exclude_dir = ["tmp", "vendor", ".git"] 19 | # Watch these directories if you specified. 20 | include_dir = [] 21 | # Exclude files. 22 | exclude_file = [] 23 | # It's not necessary to trigger build each time file changes if it's too frequent. 24 | delay = 1000 # ms 25 | # Stop to run old binary when build errors occur. 26 | stop_on_error = true 27 | # This log file places in your tmp_dir. 28 | log = "air_errors.log" 29 | 30 | [log] 31 | # Show log time 32 | time = false 33 | 34 | [color] 35 | # Customize each part's color. If no color found, use the raw app log. 36 | main = "magenta" 37 | watcher = "cyan" 38 | build = "yellow" 39 | runner = "green" 40 | 41 | [misc] 42 | # Delete tmp directory on exit 43 | clean_on_exit = true 44 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test: 4 | docker: 5 | - image: hiconvo/docker:ci 6 | auth: 7 | username: aor215 8 | password: $DOCKERHUB_PASSWORD 9 | environment: 10 | DATASTORE_PROJECT_ID: local-convo-api 11 | DATASTORE_LISTEN_ADDRESS: localhost:8081 12 | DATASTORE_DATASET: local-convo-api 13 | DATASTORE_EMULATOR_HOST: localhost:8081 14 | DATASTORE_EMULATOR_HOST_PATH: localhost:8081/datastore 15 | DATASTORE_HOST: http://localhost:8081 16 | ELASTICSEARCH_HOST: localhost 17 | CGO_ENABLED: "0" 18 | - image: singularities/datastore-emulator 19 | auth: 20 | username: aor215 21 | password: $DOCKERHUB_PASSWORD 22 | command: --no-store-on-disk --consistency=1.0 23 | environment: 24 | DATASTORE_PROJECT_ID: local-convo-api 25 | DATASTORE_LISTEN_ADDRESS: localhost:8081 26 | - image: elasticsearch:7.1.1 27 | auth: 28 | username: aor215 29 | password: $DOCKERHUB_PASSWORD 30 | environment: 31 | cluster.name: docker-cluster 32 | bootstrap.memory_lock: "true" 33 | ES_JAVA_OPTS: "-Xms512m -Xmx512m" 34 | discovery.type: single-node 35 | 36 | steps: 37 | - checkout 38 | 39 | - restore_cache: 40 | keys: 41 | - pkg-cache-{{ checksum "go.mod" }} 42 | 43 | - run: 44 | name: run tests 45 | command: | 46 | go test -coverprofile=cover.out -coverpkg=./... ./... 47 | 48 | - run: 49 | name: upload coverage report 50 | command: | 51 | bash <(curl -s https://codecov.io/bash) 52 | 53 | - save_cache: 54 | key: pkg-cache-{{ checksum "go.mod" }} 55 | paths: 56 | - "/go/pkg" 57 | 58 | deploy: 59 | docker: 60 | - image: hiconvo/docker:ci 61 | auth: 62 | username: aor215 63 | password: $DOCKERHUB_PASSWORD 64 | 65 | steps: 66 | - run: 67 | name: authenticate gcloud sdk 68 | command: | 69 | echo $GCLOUD_SERVICE_KEY | gcloud auth activate-service-account --key-file=- 70 | gcloud --quiet config set project ${GOOGLE_PROJECT_ID} 71 | 72 | - checkout 73 | 74 | - deploy: 75 | command: gcloud beta -q app deploy app.yaml 76 | 77 | workflows: 78 | version: 2 79 | test_deploy: 80 | jobs: 81 | - test 82 | - deploy: 83 | requires: 84 | - test 85 | filters: 86 | branches: 87 | only: master 88 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Binaries for programs and plugins 17 | *.exe 18 | *.exe~ 19 | *.dll 20 | *.so 21 | *.dylib 22 | # Test binary, build with `go test -c` 23 | *.test 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | static 2 | .local-object-store 3 | .env 4 | coverage.txt 5 | tmp 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This is to be used in development only 2 | FROM golang:1.13.4 3 | 4 | RUN apt-get update 5 | RUN apt-get install imagemagick -y 6 | 7 | WORKDIR /var/www 8 | 9 | COPY . . 10 | 11 | RUN go get ./... 12 | 13 | RUN go get -u github.com/cosmtrek/air 14 | 15 | VOLUME ["/var/www"] 16 | 17 | CMD ["air"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Convo LLC. All rights reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Convo API 2 | 3 | [![CircleCI](https://img.shields.io/circleci/build/github/hiconvo/api?label=circleci)](https://circleci.com/gh/hiconvo/api) [![codecov](https://img.shields.io/codecov/c/gh/hiconvo/api)](https://codecov.io/gh/hiconvo/api) [![goreportcard](https://goreportcard.com/badge/github.com/hiconvo/api)](https://goreportcard.com/badge/github.com/hiconvo/api) 4 | 5 | The repo holds the source code for Convo's RESTful API. Learn more about Convo at [convo.events](https://convo.events). 6 | 7 | ## Development 8 | 9 | We use docker based development. In order to run the project locally, you need to create an `.env` file and place it at the root of the project. The `.env` file should contain a Google Maps API key, Sendgrid API key, and a Stream API key and secret. It should look something like this: 10 | 11 | ``` 12 | GOOGLE_MAPS_API_KEY= 13 | SENDGRID_API_KEY= 14 | STREAM_API_KEY= 15 | STREAM_API_SECRET= 16 | ``` 17 | 18 | If you don't include this file, the app will panic during startup. 19 | 20 | After your `.env` file is ready, all you need to do is run `docker-compose up`. The source code is shared between your machine and the docker container via a volume. The default command runs [`air`](https://github.com/cosmtrek/air), a file watcher that automatically compiles the code and restarts the server when the source changes. By default, the server listens on port `:8080`. 21 | 22 | ### Running Tests 23 | 24 | Run `docker ps` to get the ID of the container running the API. Then run 25 | 26 | ``` 27 | docker exec -it go test ./... 28 | ``` 29 | 30 | Be mindful that this command will *wipe everything from the database*. There is probably a better way of doing this, but I haven't taken the time to improve this yet. 31 | 32 | ## Maintenance Commands 33 | 34 | ``` 35 | # Update datastore indexes 36 | gcloud datastore indexes create index.yaml 37 | 38 | # Delete unused indexes 39 | gcloud datastore cleanup-indexes index.yaml 40 | 41 | # Update cron jobs 42 | gcloud app deploy cron.yaml 43 | ``` 44 | 45 | ## One-Off Commands 46 | 47 | ``` 48 | # Get credentials to connect to the production database. [DANGEROUS] 49 | gcloud auth application-default login 50 | 51 | # Run the command. Example: 52 | go run cmd/migrate-message-timestamps-and-photos/main.go --dry-run 53 | 54 | # Clean up. [ALWAYS REMEMBER] 55 | gcloud auth application-default revoke 56 | ``` 57 | 58 | ## Architecture 59 | 60 | ![Architecture](architecture.jpg) 61 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: go113 2 | 3 | main: ./cmd/server 4 | 5 | vpc_access_connector: 6 | name: "projects/convo-api/locations/us-central1/connectors/convo-internal" 7 | 8 | handlers: 9 | - url: .* 10 | script: auto 11 | secure: always 12 | 13 | instance_class: F2 14 | 15 | inbound_services: 16 | - warmup 17 | 18 | automatic_scaling: 19 | max_instances: 1 20 | min_instances: 0 21 | max_concurrent_requests: 80 22 | -------------------------------------------------------------------------------- /architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiconvo/api/c9848aa22cd7f268fabe07a6324ff93123f16fb7/architecture.jpg -------------------------------------------------------------------------------- /bjson/bjson.go: -------------------------------------------------------------------------------- 1 | // Package bjson is better json. It provides helpers for working with JSON in http handlers. 2 | package bjson 3 | 4 | import ( 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/hiconvo/api/errors" 9 | "github.com/hiconvo/api/log" 10 | ) 11 | 12 | // nolint 13 | var encodedErrResp []byte = json.RawMessage(`{"message":"There was an internal server error while processing the request"}`) 14 | 15 | // HandleError writes an appropriate error response to the given response 16 | // writer. If the given error implements ErrorReporter, then the values from 17 | // ErrorReport() and StatusCode() are written to the response, except in 18 | // the case of a 5XX error, where the error is logged and a default message is 19 | // written to the response. 20 | func HandleError(w http.ResponseWriter, e error) { 21 | if r, ok := e.(errors.ClientReporter); ok { 22 | code := r.StatusCode() 23 | if code >= http.StatusInternalServerError { 24 | handleInternalServerError(w, e) 25 | return 26 | } 27 | 28 | log.Printf("Client Error: %v", e) 29 | 30 | WriteJSON(w, r.ClientReport(), code) 31 | 32 | return 33 | } 34 | 35 | handleInternalServerError(w, e) 36 | } 37 | 38 | // ReadJSON unmarshals JSON from the incoming request to the given sturct pointer. 39 | func ReadJSON(dst interface{}, r *http.Request) error { 40 | decoder := json.NewDecoder(r.Body) 41 | if err := decoder.Decode(dst); err != nil { 42 | return errors.E(errors.Op("bjson.ReadJSON"), http.StatusBadRequest, err, 43 | map[string]string{"message": "Could not decode JSON"}) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // WriteJSON writes the given interface to the response. If the interface 50 | // cannot be marshaled, a 500 error is written instead. 51 | func WriteJSON(w http.ResponseWriter, payload interface{}, status int) { 52 | encoded, err := json.Marshal(payload) 53 | if err != nil { 54 | handleInternalServerError(w, errors.E(errors.Op("bjson.WriteJSON"), http.StatusInternalServerError, err)) 55 | } else { 56 | w.Header().Add("Content-Type", "application/json") 57 | w.WriteHeader(status) 58 | w.Write(encoded) 59 | } 60 | } 61 | 62 | // handleInternalServerError writes the given error to stderr and returns a 63 | // 500 response with a default message. 64 | func handleInternalServerError(w http.ResponseWriter, e error) { 65 | log.Alarm(e) 66 | w.Header().Add("Content-Type", "application/json") 67 | w.WriteHeader(http.StatusInternalServerError) 68 | w.Write(encodedErrResp) 69 | } 70 | -------------------------------------------------------------------------------- /clients/db/client.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "cloud.google.com/go/datastore" 8 | 9 | "github.com/hiconvo/api/log" 10 | ) 11 | 12 | type Client interface { 13 | Transacter 14 | Count(ctx context.Context, q *datastore.Query) (n int, err error) 15 | Delete(ctx context.Context, key *datastore.Key) error 16 | DeleteMulti(ctx context.Context, keys []*datastore.Key) (err error) 17 | Get(ctx context.Context, key *datastore.Key, dst interface{}) (err error) 18 | GetAll(ctx context.Context, q *datastore.Query, dst interface{}) (keys []*datastore.Key, err error) 19 | GetMulti(ctx context.Context, keys []*datastore.Key, dst interface{}) (err error) 20 | Put(ctx context.Context, key *datastore.Key, src interface{}) (*datastore.Key, error) 21 | PutWithTransaction(ctx context.Context, key *datastore.Key, src interface{}) (*datastore.PendingKey, error) 22 | PutMulti(ctx context.Context, keys []*datastore.Key, src interface{}) (ret []*datastore.Key, err error) 23 | PutMultiWithTransaction(ctx context.Context, keys []*datastore.Key, src interface{}) (ret []*datastore.PendingKey, err error) 24 | Run(ctx context.Context, q *datastore.Query) *datastore.Iterator 25 | NewTransaction(ctx context.Context) (Transaction, error) 26 | AllocateIDs(ctx context.Context, keys []*datastore.Key) ([]*datastore.Key, error) 27 | Close() error 28 | } 29 | 30 | type Transacter interface { 31 | RunInTransaction(ctx context.Context, f func(tx Transaction) error) (*datastore.Commit, error) 32 | } 33 | 34 | func NewClient(ctx context.Context, projectID string) Client { 35 | client, err := datastore.NewClient(ctx, projectID) 36 | if err != nil { 37 | panic(err) 38 | } 39 | 40 | return &clientImpl{client: client} 41 | } 42 | 43 | // clientImpl is a shim around datastore.Client that detects 44 | // if a transaction is available on the current context and uses it 45 | // if there is. Otherwise, it calls the corresponding 46 | // datastore.Client method. 47 | type clientImpl struct { 48 | client *datastore.Client 49 | } 50 | 51 | func (c *clientImpl) Close() error { 52 | log.Print("Closing DB client") 53 | return c.client.Close() 54 | } 55 | 56 | func (c *clientImpl) AllocateIDs(ctx context.Context, keys []*datastore.Key) ([]*datastore.Key, error) { 57 | return c.client.AllocateIDs(ctx, keys) 58 | } 59 | 60 | func (c *clientImpl) Count(ctx context.Context, q *datastore.Query) (int, error) { 61 | return c.client.Count(ctx, q) 62 | } 63 | 64 | func (c *clientImpl) Delete(ctx context.Context, key *datastore.Key) error { 65 | if tx, ok := TransactionFromContext(ctx); ok { 66 | err := tx.Delete(key) 67 | if err != nil { 68 | tx.Rollback() 69 | return err 70 | } 71 | 72 | return nil 73 | } 74 | 75 | return c.client.Delete(ctx, key) 76 | } 77 | 78 | func (c *clientImpl) DeleteMulti(ctx context.Context, keys []*datastore.Key) error { 79 | if tx, ok := TransactionFromContext(ctx); ok { 80 | err := tx.DeleteMulti(keys) 81 | if err != nil { 82 | tx.Rollback() 83 | return err 84 | } 85 | 86 | return nil 87 | } 88 | 89 | return c.client.DeleteMulti(ctx, keys) 90 | } 91 | 92 | func (c *clientImpl) Get(ctx context.Context, key *datastore.Key, dst interface{}) error { 93 | if tx, ok := TransactionFromContext(ctx); ok { 94 | err := tx.Get(key, dst) 95 | if err != nil { 96 | tx.Rollback() 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | 103 | return c.client.Get(ctx, key, dst) 104 | } 105 | 106 | func (c *clientImpl) GetAll(ctx context.Context, q *datastore.Query, dst interface{}) (keys []*datastore.Key, err error) { 107 | return c.client.GetAll(ctx, q, dst) 108 | } 109 | 110 | func (c *clientImpl) GetMulti(ctx context.Context, keys []*datastore.Key, dst interface{}) (err error) { 111 | if tx, ok := TransactionFromContext(ctx); ok { 112 | err := tx.GetMulti(keys, dst) 113 | if err != nil { 114 | tx.Rollback() 115 | return err 116 | } 117 | 118 | return nil 119 | } 120 | 121 | return c.client.GetMulti(ctx, keys, dst) 122 | } 123 | 124 | func (c *clientImpl) Put(ctx context.Context, key *datastore.Key, src interface{}) (*datastore.Key, error) { 125 | return c.client.Put(ctx, key, src) 126 | } 127 | 128 | func (c *clientImpl) PutWithTransaction(ctx context.Context, key *datastore.Key, src interface{}) (*datastore.PendingKey, error) { 129 | if tx, ok := TransactionFromContext(ctx); ok { 130 | pendingKey, err := tx.Put(key, src) 131 | if err != nil { 132 | tx.Rollback() 133 | return pendingKey, err 134 | } 135 | 136 | return pendingKey, nil 137 | } 138 | 139 | return &datastore.PendingKey{}, fmt.Errorf("clientImpl: No transaction in context") 140 | } 141 | 142 | func (c *clientImpl) PutMulti(ctx context.Context, keys []*datastore.Key, src interface{}) (ret []*datastore.Key, err error) { 143 | return c.client.PutMulti(ctx, keys, src) 144 | } 145 | 146 | func (c *clientImpl) PutMultiWithTransaction(ctx context.Context, keys []*datastore.Key, src interface{}) (ret []*datastore.PendingKey, err error) { 147 | if tx, ok := TransactionFromContext(ctx); ok { 148 | pendingKeys, err := tx.PutMulti(keys, src) 149 | if err != nil { 150 | tx.Rollback() 151 | return pendingKeys, err 152 | } 153 | 154 | return pendingKeys, nil 155 | } 156 | 157 | return []*datastore.PendingKey{}, fmt.Errorf("clientImpl: No transaction in context") 158 | } 159 | 160 | func (c *clientImpl) RunInTransaction(ctx context.Context, f func(tx Transaction) error) (*datastore.Commit, error) { 161 | return c.client.RunInTransaction(ctx, func(tx *datastore.Transaction) error { 162 | return f(NewTransaction(tx)) 163 | }) 164 | } 165 | 166 | func (c *clientImpl) Run(ctx context.Context, q *datastore.Query) *datastore.Iterator { 167 | return c.client.Run(ctx, q) 168 | } 169 | 170 | func (c *clientImpl) NewTransaction(ctx context.Context) (Transaction, error) { 171 | tx, err := c.client.NewTransaction(ctx) 172 | 173 | return &transactionImpl{transaction: tx, IsPending: true}, err 174 | } 175 | -------------------------------------------------------------------------------- /clients/db/middleware.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/hiconvo/api/bjson" 8 | "github.com/hiconvo/api/errors" 9 | ) 10 | 11 | type ctxKey int 12 | 13 | const txKey ctxKey = iota 14 | 15 | // TransactionFromContext extracts a transaction from the given 16 | // context is one is present. 17 | func TransactionFromContext(ctx context.Context) (Transaction, bool) { 18 | tx, ok := ctx.Value(txKey).(Transaction) 19 | return tx, ok 20 | } 21 | 22 | // AddTransactionToContext returns a new context with a transaction added. 23 | func AddTransactionToContext(ctx context.Context, c Client) (context.Context, Transaction, error) { 24 | tx, err := c.NewTransaction(ctx) 25 | if err != nil { 26 | return ctx, tx, errors.E(errors.Op("db.AddTransactionToContext"), err) 27 | } 28 | 29 | nctx := context.WithValue(ctx, txKey, tx) 30 | 31 | return nctx, tx, nil 32 | } 33 | 34 | // WithTransaction is middleware that adds a transaction to the request context. 35 | func WithTransaction(c Client) func(http.Handler) http.Handler { 36 | return func(next http.Handler) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | ctx, tx, err := AddTransactionToContext(r.Context(), c) 39 | if err != nil { 40 | bjson.HandleError(w, errors.E( 41 | errors.Op("db.WithTransaction"), 42 | errors.Str("could not initialize database transaction"), 43 | err)) 44 | return 45 | } 46 | 47 | next.ServeHTTP(w, r.WithContext(ctx)) 48 | 49 | if tx.Pending() { 50 | tx.Rollback() 51 | } 52 | 53 | return 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /clients/db/transaction.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "cloud.google.com/go/datastore" 4 | 5 | // Transaction is a wrapper around datastore.Transaction. It adds a Pending() method that 6 | // allows it to be detected whether a transaction has been completed so that they are not 7 | // accidentally left hanging. 8 | type Transaction interface { 9 | Commit() (c *datastore.Commit, err error) 10 | Delete(key *datastore.Key) error 11 | DeleteMulti(keys []*datastore.Key) (err error) 12 | Get(key *datastore.Key, dst interface{}) (err error) 13 | GetMulti(keys []*datastore.Key, dst interface{}) (err error) 14 | Mutate(muts ...*datastore.Mutation) ([]*datastore.PendingKey, error) 15 | Put(key *datastore.Key, src interface{}) (*datastore.PendingKey, error) 16 | PutMulti(keys []*datastore.Key, src interface{}) (ret []*datastore.PendingKey, err error) 17 | Rollback() (err error) 18 | Pending() bool 19 | } 20 | 21 | type transactionImpl struct { 22 | transaction *datastore.Transaction 23 | 24 | IsPending bool 25 | } 26 | 27 | func NewTransaction(tx *datastore.Transaction) Transaction { 28 | return &transactionImpl{transaction: tx, IsPending: true} 29 | } 30 | 31 | func (t *transactionImpl) Commit() (c *datastore.Commit, err error) { 32 | cm, err := t.transaction.Commit() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | t.IsPending = false 38 | 39 | return cm, err 40 | } 41 | 42 | func (t *transactionImpl) Delete(key *datastore.Key) error { 43 | return t.transaction.Delete(key) 44 | } 45 | 46 | func (t *transactionImpl) DeleteMulti(keys []*datastore.Key) error { 47 | return t.transaction.DeleteMulti(keys) 48 | } 49 | 50 | func (t *transactionImpl) Get(key *datastore.Key, dst interface{}) error { 51 | return t.transaction.Get(key, dst) 52 | } 53 | 54 | func (t *transactionImpl) GetMulti(keys []*datastore.Key, dst interface{}) error { 55 | return t.transaction.GetMulti(keys, dst) 56 | } 57 | 58 | func (t *transactionImpl) Mutate(muts ...*datastore.Mutation) ([]*datastore.PendingKey, error) { 59 | return t.transaction.Mutate(muts...) 60 | } 61 | 62 | func (t *transactionImpl) Put(key *datastore.Key, src interface{}) (*datastore.PendingKey, error) { 63 | return t.transaction.Put(key, src) 64 | } 65 | 66 | func (t *transactionImpl) PutMulti(keys []*datastore.Key, src interface{}) ([]*datastore.PendingKey, error) { 67 | return t.transaction.PutMulti(keys, src) 68 | } 69 | 70 | func (t *transactionImpl) Rollback() error { 71 | err := t.transaction.Rollback() 72 | if err != nil { 73 | return err 74 | } 75 | 76 | t.IsPending = false 77 | 78 | return nil 79 | } 80 | 81 | func (t *transactionImpl) Pending() bool { 82 | return t.IsPending 83 | } 84 | -------------------------------------------------------------------------------- /clients/magic/magic.go: -------------------------------------------------------------------------------- 1 | package magic 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "encoding/hex" 9 | "fmt" 10 | "net/http" 11 | "strconv" 12 | "time" 13 | 14 | "cloud.google.com/go/datastore" 15 | 16 | "github.com/hiconvo/api/errors" 17 | ) 18 | 19 | type Client interface { 20 | NewLink(k *datastore.Key, salt, action string) string 21 | Verify(kenc, b64ts, salt, sig string) error 22 | } 23 | 24 | type clientImpl struct { 25 | secret string 26 | } 27 | 28 | func NewClient(secret string) Client { 29 | return &clientImpl{secret} 30 | } 31 | 32 | func (c *clientImpl) NewLink(k *datastore.Key, salt, action string) string { 33 | // Get time and convert to epoc string 34 | ts := time.Now().Unix() 35 | sts := strconv.FormatInt(ts, 10) 36 | b64ts := base64.URLEncoding.EncodeToString([]byte(sts)) 37 | 38 | // Get url-safe key 39 | kenc := k.Encode() 40 | 41 | return fmt.Sprintf("https://app.convo.events/%s/%s/%s/%s", 42 | action, kenc, b64ts, c.getSignature(kenc, b64ts, salt)) 43 | } 44 | 45 | func (c *clientImpl) Verify(kenc, b64ts, salt, sig string) error { 46 | if sig == c.getSignature(kenc, b64ts, salt) { 47 | return nil 48 | } 49 | 50 | return errors.E(errors.Op("magic.Verify"), http.StatusUnauthorized, errors.Str("InvalidSignature")) 51 | } 52 | 53 | func (c *clientImpl) getSignature(uid, b64ts, salt string) string { 54 | h := hmac.New(sha256.New, []byte(c.secret)) 55 | 56 | if _, err := h.Write([]byte(uid + b64ts + salt)); err != nil { 57 | panic(errors.E(errors.Opf("getSignature(uid=%s, b64ts=%s, salt=%s)", uid, b64ts, salt), err)) 58 | } 59 | 60 | sha := hex.EncodeToString(h.Sum(nil)) 61 | 62 | return sha 63 | } 64 | 65 | func GetTimeFromB64(b64ts string) (time.Time, error) { 66 | op := errors.Op("magic.GetTimeFromB64") 67 | 68 | byteTime, err := base64.URLEncoding.DecodeString(b64ts) 69 | if err != nil { 70 | return time.Now(), errors.E(op, http.StatusBadRequest, err) 71 | } 72 | 73 | stringTime := bytes.NewBuffer(byteTime).String() 74 | 75 | intTime, err := strconv.Atoi(stringTime) 76 | if err != nil { 77 | return time.Now(), errors.E(op, http.StatusBadRequest, err) 78 | } 79 | 80 | timestamp := time.Unix(int64(intTime), 0) 81 | 82 | return timestamp, nil 83 | } 84 | 85 | func TooOld(b64ts string, days ...int) error { 86 | op := errors.Op("magic.TooOld") 87 | 88 | ts, err := GetTimeFromB64(b64ts) 89 | if err != nil { 90 | return errors.E(op, http.StatusUnauthorized, err) 91 | } 92 | 93 | expiration := 24 94 | if len(days) > 0 { 95 | expiration = days[0] * expiration 96 | } 97 | 98 | diff := time.Since(ts) 99 | if diff.Hours() > float64(expiration) { 100 | return errors.E(op, http.StatusUnauthorized, errors.Str("TooOld")) 101 | } 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /clients/mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/http" 6 | 7 | "github.com/sendgrid/sendgrid-go" 8 | smail "github.com/sendgrid/sendgrid-go/helpers/mail" 9 | 10 | "github.com/hiconvo/api/errors" 11 | "github.com/hiconvo/api/log" 12 | ) 13 | 14 | // EmailMessage is a sendable email message. All of its fields 15 | // are strings. No additional processing or rendering is done 16 | // in this package. 17 | type EmailMessage struct { 18 | FromName string 19 | FromEmail string 20 | ToName string 21 | ToEmail string 22 | Subject string 23 | HTMLContent string 24 | TextContent string 25 | ICSAttachment string 26 | } 27 | 28 | type Client interface { 29 | Send(e EmailMessage) error 30 | } 31 | 32 | type senderImpl struct { 33 | client *sendgrid.Client 34 | } 35 | 36 | func NewClient(apiKey string) Client { 37 | return &senderImpl{ 38 | client: sendgrid.NewSendClient(apiKey), 39 | } 40 | } 41 | 42 | // Send sends the given EmailMessage. 43 | func (s *senderImpl) Send(e EmailMessage) error { 44 | from := smail.NewEmail(e.FromName, e.FromEmail) 45 | to := smail.NewEmail(e.ToName, e.ToEmail) 46 | email := smail.NewSingleEmail( 47 | from, e.Subject, to, e.TextContent, e.HTMLContent, 48 | ) 49 | 50 | if e.ICSAttachment != "" { 51 | attachment := smail.NewAttachment() 52 | attachment.SetContent(base64.StdEncoding.EncodeToString([]byte(e.ICSAttachment))) 53 | attachment.SetType("text/calendar") 54 | attachment.SetFilename("event.ics") 55 | 56 | email.AddAttachment(attachment) 57 | } 58 | 59 | resp, err := s.client.Send(email) 60 | if err != nil { 61 | return errors.E(errors.Op("mail.Send"), err) 62 | } 63 | 64 | if resp.StatusCode != http.StatusAccepted { 65 | log.Print(resp.Body) 66 | return errors.E(errors.Op("mail.Send"), errors.Str("received non-200 status from SendGrid")) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | type loggerImpl struct{} 73 | 74 | func NewLogger() Client { 75 | log.Print("mail.NewLogger: USING MAIL LOGGER FOR LOCAL DEVELOPMENT") 76 | return &loggerImpl{} 77 | } 78 | 79 | func (l *loggerImpl) Send(e EmailMessage) error { 80 | log.Printf("mail.Send(from='%s', to='%s')", e.FromEmail, e.ToEmail) 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /clients/mongo/mongo.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.mongodb.org/mongo-driver/mongo" 8 | "go.mongodb.org/mongo-driver/mongo/options" 9 | ) 10 | 11 | func NewClient(ctx context.Context, uri string) (*mongo.Client, func()) { 12 | client, err := mongo.NewClient(options.Client().ApplyURI(fmt.Sprintf("mongodb://%s", uri))) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | if err := client.Connect(ctx); err != nil { 18 | panic(err) 19 | } 20 | 21 | closer := func() { 22 | if err = client.Disconnect(ctx); err != nil { 23 | panic(err) 24 | } 25 | } 26 | 27 | return client, closer 28 | } 29 | -------------------------------------------------------------------------------- /clients/notification/notification.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "cloud.google.com/go/datastore" 8 | "gopkg.in/GetStream/stream-go2.v1" 9 | 10 | "github.com/hiconvo/api/errors" 11 | ) 12 | 13 | type ( 14 | verb string 15 | target string 16 | ) 17 | 18 | const ( 19 | NewEvent verb = "NewEvent" 20 | UpdateEvent verb = "UpdateEvent" 21 | DeleteEvent verb = "DeleteEvent" 22 | AddRSVP verb = "AddRSVP" 23 | RemoveRSVP verb = "RemoveRSVP" 24 | 25 | NewMessage verb = "NewMessage" 26 | 27 | Thread target = "thread" 28 | Event target = "event" 29 | ) 30 | 31 | // A Notification contains the information needed to dispatch a notification. 32 | type Notification struct { 33 | UserKeys []*datastore.Key 34 | Actor string 35 | Verb verb 36 | Target target 37 | TargetID string 38 | TargetName string 39 | } 40 | 41 | type Client interface { 42 | Put(n *Notification) error 43 | GenerateToken(userID string) string 44 | } 45 | 46 | type clientImpl struct { 47 | client *stream.Client 48 | } 49 | 50 | func NewClient(apiKey, apiSecret, apiRegion string) Client { 51 | c, err := stream.NewClient( 52 | apiKey, 53 | apiSecret, 54 | stream.WithAPIRegion(apiRegion)) 55 | if err != nil { 56 | panic(errors.E(errors.Op("notification.NewClient"), err)) 57 | } 58 | 59 | return &clientImpl{ 60 | client: c, 61 | } 62 | } 63 | 64 | // put is something like an adapter for stream.io. It takes the incoming notification 65 | // and dispatches it in the appropriate way. 66 | func (c *clientImpl) put(n *Notification, userID string) error { 67 | feed := c.client.NotificationFeed("notification", userID) 68 | 69 | _, err := feed.AddActivity(stream.Activity{ 70 | Actor: n.Actor, 71 | Verb: string(n.Verb), 72 | Object: fmt.Sprintf("%s:%s", string(n.Target), n.TargetID), 73 | Target: string(n.Target), 74 | Extra: map[string]interface{}{ 75 | "targetName": n.TargetName, 76 | }, 77 | }) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // Put dispatches a notification. 86 | func (c *clientImpl) Put(n *Notification) error { 87 | for _, key := range n.UserKeys { 88 | if err := c.put(n, key.Encode()); err != nil { 89 | return err 90 | } 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // GenerateToken generates a token for use on the frontend to retrieve notifications. 97 | func (c *clientImpl) GenerateToken(userID string) string { 98 | feed := c.client.NotificationFeed("notification", userID) 99 | return feed.RealtimeToken(true) 100 | } 101 | 102 | // FilterKey is a convenience function that filters a specific key from a slice. 103 | func FilterKey(keys []*datastore.Key, toFilter *datastore.Key) []*datastore.Key { 104 | var filtered []*datastore.Key 105 | 106 | for i := range keys { 107 | if keys[i].Equal(toFilter) { 108 | continue 109 | } 110 | 111 | filtered = append(filtered, keys[i]) 112 | } 113 | 114 | return filtered 115 | } 116 | 117 | type logger struct{} 118 | 119 | func NewLogger() Client { 120 | log.Print("notification.NewLogger: USING NOTIFICATION LOGGER FOR LOCAL DEVELOPMENT") 121 | return &logger{} 122 | } 123 | 124 | func (l *logger) Put(n *Notification) error { 125 | log.Printf("notification.Put(Actor=%s, Verb=%s, Target=%s, TargetID=%s)", 126 | n.Actor, n.Verb, string(n.Target), n.TargetID) 127 | return nil 128 | } 129 | 130 | func (l *logger) GenerateToken(userID string) string { 131 | return "nullToken" 132 | } 133 | -------------------------------------------------------------------------------- /clients/oauth/oauth.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/hiconvo/api/errors" 10 | ) 11 | 12 | type UserPayload struct { 13 | Provider string `validate:"regexp=^(google|facebook)$"` 14 | Token string `validate:"nonzero"` 15 | } 16 | 17 | type ProviderPayload struct { 18 | ID string 19 | Email string 20 | FirstName string 21 | LastName string 22 | Provider string 23 | TempAvatar string 24 | } 25 | 26 | type Client interface { 27 | Verify(context.Context, UserPayload) (ProviderPayload, error) 28 | } 29 | 30 | type clientImpl struct { 31 | googleAud string 32 | } 33 | 34 | func NewClient(googleAud string) Client { 35 | return &clientImpl{googleAud} 36 | } 37 | 38 | func (c *clientImpl) Verify(ctx context.Context, payload UserPayload) (ProviderPayload, error) { 39 | if payload.Provider == "google" { 40 | return c.verifyGoogleToken(ctx, payload) 41 | } 42 | 43 | return c.verifyFacebookToken(ctx, payload) 44 | } 45 | 46 | func (c *clientImpl) verifyGoogleToken(ctx context.Context, payload UserPayload) (ProviderPayload, error) { 47 | var op errors.Op = "oauth.verifyGoogleToken" 48 | 49 | url := fmt.Sprintf("https://oauth2.googleapis.com/tokeninfo?id_token=%s", payload.Token) 50 | 51 | res, err := http.Get(url) 52 | if err != nil { 53 | return ProviderPayload{}, errors.E(op, err) 54 | } 55 | 56 | data := make(map[string]string) 57 | if err = json.NewDecoder(res.Body).Decode(&data); err != nil { 58 | return ProviderPayload{}, errors.E(op, err) 59 | } 60 | 61 | if data["aud"] != c.googleAud { 62 | return ProviderPayload{}, errors.E(op, http.StatusBadRequest, errors.Str("Aud did not match")) 63 | } 64 | 65 | return ProviderPayload{ 66 | ID: data["sub"], 67 | Provider: "google", 68 | Email: data["email"], 69 | FirstName: data["given_name"], 70 | LastName: data["family_name"], 71 | TempAvatar: data["picture"] + "?sz=256", 72 | }, nil 73 | } 74 | 75 | func (c *clientImpl) verifyFacebookToken(ctx context.Context, payload UserPayload) (ProviderPayload, error) { 76 | var op errors.Op = "oauth.verifyFacebookToken" 77 | 78 | url := fmt.Sprintf( 79 | "https://graph.facebook.com/me?fields=id,email,first_name,last_name&access_token=%s", 80 | payload.Token) 81 | 82 | res, err := http.Get(url) 83 | if err != nil { 84 | return ProviderPayload{}, errors.E(op, err) 85 | } 86 | 87 | data := make(map[string]interface{}) 88 | if err = json.NewDecoder(res.Body).Decode(&data); err != nil { 89 | return ProviderPayload{}, errors.E(op, err) 90 | } 91 | 92 | tempAvatarURI := fmt.Sprintf( 93 | "https://graph.facebook.com/%s/picture?type=large&width=256&height=256&access_token=%s", 94 | data["id"].(string), payload.Token) 95 | 96 | return ProviderPayload{ 97 | ID: data["id"].(string), 98 | Provider: "facebook", 99 | Email: data["email"].(string), 100 | FirstName: data["first_name"].(string), 101 | LastName: data["last_name"].(string), 102 | TempAvatar: tempAvatarURI, 103 | }, nil 104 | } 105 | -------------------------------------------------------------------------------- /clients/opengraph/opengraph.go: -------------------------------------------------------------------------------- 1 | package opengraph 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "html" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | "github.com/dyatlov/go-htmlinfo/htmlinfo" 14 | xurls "mvdan.cc/xurls/v2" 15 | 16 | "github.com/hiconvo/api/errors" 17 | "github.com/hiconvo/api/log" 18 | ) 19 | 20 | type LinkData struct { 21 | URL string `json:"url"` 22 | Image string `json:"image" datastore:",noindex"` 23 | Favicon string `json:"favicon" datastore:",noindex"` 24 | Title string `json:"title"` 25 | Site string `json:"site"` 26 | Description string `json:"description" datastore:",noindex"` 27 | Original string `json:"-"` 28 | } 29 | 30 | type Client interface { 31 | Extract(ctx context.Context, text string) *LinkData 32 | } 33 | 34 | func NewClient() Client { 35 | return &clientImpl{ 36 | httpClient: &http.Client{Timeout: time.Duration(5) * time.Second}, 37 | } 38 | } 39 | 40 | type clientImpl struct { 41 | httpClient *http.Client 42 | } 43 | 44 | func (c *clientImpl) Extract(ctx context.Context, text string) *LinkData { 45 | found := xurls.Strict().FindString(text) 46 | if found == "" { 47 | return nil 48 | } 49 | 50 | op := errors.Opf("opengraph.Extract(%s)", found) 51 | 52 | urlobj, err := url.ParseRequestURI(found) 53 | if err != nil { 54 | log.Print(errors.E(op, err)) 55 | 56 | return nil 57 | } 58 | 59 | urlobj.Host = strings.TrimPrefix(urlobj.Host, "m.") 60 | urlobj.Host = strings.TrimPrefix(urlobj.Host, "mobile.") 61 | cleanURL := urlobj.String() 62 | 63 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, cleanURL, nil) 64 | if err != nil { 65 | log.Print(errors.E(op, err)) 66 | 67 | return nil 68 | } 69 | 70 | req.Header.Set("User-Agent", "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)") 71 | req.Header.Set("Cache-Control", "no-cache") 72 | 73 | resp, err := c.httpClient.Do(req) 74 | if err != nil { 75 | log.Print(errors.E(op, err)) 76 | 77 | return nil 78 | } 79 | defer resp.Body.Close() 80 | 81 | info := htmlinfo.NewHTMLInfo() 82 | 83 | err = info.Parse(resp.Body, &cleanURL, nil) 84 | if err != nil { 85 | log.Print(errors.E(op, err)) 86 | 87 | return nil 88 | } 89 | 90 | oembed := info.GenerateOembedFor(cleanURL) 91 | ld := &LinkData{ 92 | Title: oembed.Title, 93 | URL: oembed.URL, 94 | Site: oembed.ProviderName, 95 | Description: info.OGInfo.Description, 96 | Favicon: info.FaviconURL, 97 | Image: oembed.ThumbnailURL, 98 | Original: found, 99 | } 100 | 101 | // YouTube and Twitter are not reliable. Sometimes they give us what we're 102 | // looking for and other times they give us nothing. In that case, we 103 | // fall back to their oembed APIs which don't provide much info but which 104 | // provide more than nothing. 105 | if ld.Image == "" { 106 | if hn := urlobj.Hostname(); hn == "youtu.be" || strings.HasSuffix(hn, "youtube.com") { 107 | return c.handleYouTube(ctx, cleanURL, found) 108 | } else if strings.HasSuffix(hn, "twitter.com") { 109 | return c.handleTwitter(ctx, urlobj.String(), found) 110 | } 111 | } 112 | 113 | return ld 114 | } 115 | 116 | func (c *clientImpl) handleYouTube(ctx context.Context, found, original string) *LinkData { 117 | purl := fmt.Sprintf("https://www.youtube.com/oembed?url=%s&maxwidth=560&maxheight=400&format=json", 118 | html.EscapeString(found)) 119 | op := errors.Opf("opengraph.handleYouTube(%s)", purl) 120 | 121 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, purl, nil) 122 | if err != nil { 123 | log.Print(errors.E(op, err)) 124 | 125 | return nil 126 | } 127 | 128 | resp, err := c.httpClient.Do(req) 129 | if err != nil { 130 | log.Print(errors.E(op, err)) 131 | 132 | return nil 133 | } 134 | defer resp.Body.Close() 135 | 136 | if resp.StatusCode != http.StatusOK { 137 | log.Print(errors.E(op, errors.Errorf("received status code %d", resp.StatusCode))) 138 | 139 | return nil 140 | } 141 | 142 | var msi map[string]interface{} 143 | 144 | err = json.NewDecoder(resp.Body).Decode(&msi) 145 | if err != nil { 146 | log.Print(errors.E(op, err)) 147 | 148 | return nil 149 | } 150 | 151 | ld := &LinkData{ 152 | Site: "YouTube", 153 | Favicon: "https://www.youtube.com/favicon.ico", 154 | URL: found, 155 | Original: original, 156 | Description: "", 157 | } 158 | 159 | title, ok := msi["title"].(string) 160 | if !ok { 161 | log.Print(errors.E(op, errors.Str("no title in response"))) 162 | 163 | return nil 164 | } 165 | 166 | ld.Title = title 167 | 168 | thumbnail, ok := msi["thumbnail_url"].(string) 169 | if !ok { 170 | log.Print(errors.E(op, errors.Str("no thumbnail in response"))) 171 | } else { 172 | ld.Image = thumbnail 173 | } 174 | 175 | return ld 176 | } 177 | 178 | func (c *clientImpl) handleTwitter(ctx context.Context, found, original string) *LinkData { 179 | purl := fmt.Sprintf("https://publish.twitter.com/oembed?url=%s&omit_script=true&format=json", 180 | html.EscapeString(found)) 181 | op := errors.Opf("opengraph.handleTwitter(%s)", purl) 182 | 183 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, purl, nil) 184 | if err != nil { 185 | log.Print(errors.E(op, err)) 186 | 187 | return nil 188 | } 189 | 190 | resp, err := c.httpClient.Do(req) 191 | if err != nil { 192 | log.Print(errors.E(op, err)) 193 | 194 | return nil 195 | } 196 | defer resp.Body.Close() 197 | 198 | if resp.StatusCode != http.StatusOK { 199 | log.Print(errors.E(op, err)) 200 | 201 | return nil 202 | } 203 | 204 | var msi map[string]interface{} 205 | 206 | err = json.NewDecoder(resp.Body).Decode(&msi) 207 | if err != nil { 208 | log.Print(errors.E(op, err)) 209 | 210 | return nil 211 | } 212 | 213 | html, ok := msi["html"].(string) 214 | if !ok { 215 | log.Print(errors.E(op, err)) 216 | 217 | return nil 218 | } 219 | 220 | info := htmlinfo.NewHTMLInfo() 221 | 222 | err = info.Parse(strings.NewReader(html), &found, nil) 223 | if err != nil { 224 | log.Print(errors.E(op, err)) 225 | 226 | return nil 227 | } 228 | 229 | oembed := info.GenerateOembedFor(found) 230 | 231 | return &LinkData{ 232 | Title: oembed.Title, 233 | URL: found, 234 | Site: "Twitter", 235 | Favicon: "https://www.twitter.com/favicon.ico", 236 | Description: oembed.Description, 237 | Image: oembed.ThumbnailURL, 238 | Original: found, 239 | } 240 | } 241 | 242 | func NewNullClient() Client { 243 | return &nullClient{} 244 | } 245 | 246 | type nullClient struct{} 247 | 248 | func (c *nullClient) Extract(ctx context.Context, text string) *LinkData { 249 | return nil 250 | } 251 | -------------------------------------------------------------------------------- /clients/places/places.go: -------------------------------------------------------------------------------- 1 | package places 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | 8 | "googlemaps.github.io/maps" 9 | 10 | "github.com/hiconvo/api/errors" 11 | "github.com/hiconvo/api/log" 12 | ) 13 | 14 | type Place struct { 15 | PlaceID string 16 | Address string 17 | Lat float64 18 | Lng float64 19 | UTCOffset int 20 | } 21 | 22 | type Client interface { 23 | Resolve(ctx context.Context, placeID string, userUTCOffset int) (Place, error) 24 | } 25 | 26 | type clientImpl struct { 27 | client *maps.Client 28 | fields []maps.PlaceDetailsFieldMask 29 | voiceCallLocations []Place 30 | } 31 | 32 | func NewClient(apiKey string) Client { 33 | c, err := maps.NewClient(maps.WithAPIKey(apiKey)) 34 | if err != nil { 35 | panic(errors.E(errors.Op("places.NewClient"), err)) 36 | } 37 | 38 | fieldName, err := maps.ParsePlaceDetailsFieldMask("name") 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | fieldPlaceID, err := maps.ParsePlaceDetailsFieldMask("place_id") 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | fieldFormattedAddress, err := maps.ParsePlaceDetailsFieldMask("formatted_address") 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | fieldGeometry, err := maps.ParsePlaceDetailsFieldMask("geometry") 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | fieldUTCOffset, err := maps.ParsePlaceDetailsFieldMask("utc_offset") 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | return &clientImpl{ 64 | client: c, 65 | fields: []maps.PlaceDetailsFieldMask{ 66 | fieldPlaceID, 67 | fieldName, 68 | fieldFormattedAddress, 69 | fieldGeometry, 70 | fieldUTCOffset, 71 | }, 72 | voiceCallLocations: []Place{ 73 | { 74 | PlaceID: "whatsapp", 75 | Address: "WhatsApp", 76 | }, 77 | { 78 | PlaceID: "facetime", 79 | Address: "FaceTime", 80 | }, 81 | { 82 | PlaceID: "googlehangouts", 83 | Address: "Google Hangouts", 84 | }, 85 | { 86 | PlaceID: "zoom", 87 | Address: "Zoom", 88 | }, 89 | { 90 | PlaceID: "skype", 91 | Address: "Skype", 92 | }, 93 | { 94 | PlaceID: "other", 95 | Address: "Other", 96 | }, 97 | }, 98 | } 99 | } 100 | 101 | func (c *clientImpl) Resolve(ctx context.Context, placeID string, userUTCOffset int) (Place, error) { 102 | for i := range c.voiceCallLocations { 103 | if c.voiceCallLocations[i].PlaceID == placeID { 104 | pl := c.voiceCallLocations[i] 105 | pl.UTCOffset = userUTCOffset 106 | return pl, nil 107 | } 108 | } 109 | 110 | result, err := c.client.PlaceDetails(ctx, &maps.PlaceDetailsRequest{ 111 | PlaceID: placeID, 112 | Fields: c.fields, 113 | }) 114 | if err != nil { 115 | return Place{}, errors.E(errors.Op("places.Resolve"), 116 | map[string]string{"placeId": "Could not resolve place"}, 117 | http.StatusBadRequest, 118 | err) 119 | } 120 | 121 | var address string 122 | if strings.HasPrefix(result.FormattedAddress, result.Name) { 123 | address = result.FormattedAddress 124 | } else { 125 | address = strings.Join([]string{result.Name, result.FormattedAddress}, ", ") 126 | } 127 | 128 | return Place{ 129 | PlaceID: result.PlaceID, 130 | Address: address, 131 | Lat: result.Geometry.Location.Lat, 132 | Lng: result.Geometry.Location.Lng, 133 | UTCOffset: *result.UTCOffset * 60, 134 | }, nil 135 | } 136 | 137 | type loggerImpl struct{} 138 | 139 | func NewLogger() Client { 140 | log.Print("places.NewLogger: USING PLACES LOGGER FOR LOCAL DEVELOPMENT") 141 | return &loggerImpl{} 142 | } 143 | 144 | func (l *loggerImpl) Resolve(ctx context.Context, placeID string, userUTCOffset int) (Place, error) { 145 | log.Printf("places.Resolve(placeID=%s)", placeID) 146 | 147 | return Place{ 148 | PlaceID: "0123456789", 149 | Address: "1 Infinite Loop", 150 | Lat: 0.0, 151 | Lng: 0.0, 152 | UTCOffset: 0, 153 | }, nil 154 | } 155 | -------------------------------------------------------------------------------- /clients/pluck/pluck.go: -------------------------------------------------------------------------------- 1 | package pluck 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/mail" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/jaytaylor/html2text" 13 | 14 | "github.com/hiconvo/api/errors" 15 | ) 16 | 17 | const sigStripURL = "https://us-central1-convo-api.cloudfunctions.net/sigstrip" 18 | 19 | type Client interface { 20 | AddressesFromEnvelope(payload string) (toAddress, fromAddress string, err error) 21 | MessageText(htmlBody, textBody, from, to string) (message string, err error) 22 | ThreadInt64IDFromAddress(to string) (int64, error) 23 | } 24 | 25 | type clientImpl struct { 26 | sigStripURL string 27 | } 28 | 29 | func NewClient() Client { 30 | return &clientImpl{sigStripURL} 31 | } 32 | 33 | func (c *clientImpl) AddressesFromEnvelope(payload string) (string, string, error) { 34 | var op errors.Op = "pluck.AddressFromEnvelope" 35 | 36 | // Get to and from from envelope. 37 | envelope := make(map[string]interface{}) 38 | 39 | err := json.Unmarshal([]byte(payload), &envelope) 40 | if err != nil { 41 | return "", "", errors.E(op, err) 42 | } 43 | 44 | // Annoying type assertions. 45 | itos, itosOK := envelope["to"].([]interface{}) 46 | if !itosOK { 47 | return "", "", errors.E(op, errors.Str("Invalid 'to' address type")) 48 | } 49 | 50 | // If the sender added another recipient, then tos has more than 51 | // one address. This is not currently supported. 52 | if len(itos) > 1 { 53 | return "", "", errors.E(op, errors.Str("Multiple recipients are not supported")) 54 | } 55 | 56 | to, toOK := itos[0].(string) 57 | if !toOK { 58 | return "", "", errors.E(op, errors.Str("Invalid 'to' address type")) 59 | } 60 | 61 | from, fromOK := envelope["from"].(string) 62 | if !fromOK { 63 | return "", "", errors.E(op, errors.Str("Invalid 'from' address type")) 64 | } 65 | 66 | // Use the mail library to get the addresses. 67 | toAddress, err := mail.ParseAddress(to) 68 | if err != nil { 69 | return "", "", errors.E(op, errors.Str("Invalid 'to' address")) 70 | } 71 | 72 | fromAddress, err := mail.ParseAddress(from) 73 | if err != nil { 74 | return "", "", errors.E(op, errors.Str("Invalid 'from' address")) 75 | } 76 | 77 | return toAddress.Address, fromAddress.Address, nil 78 | } 79 | 80 | func (c *clientImpl) ThreadInt64IDFromAddress(to string) (int64, error) { 81 | split := strings.Split(to, "@") 82 | toName := split[0] 83 | nameSplit := strings.Split(toName, "-") 84 | ID := nameSplit[len(nameSplit)-1] 85 | 86 | return strconv.ParseInt(ID, 10, 64) 87 | } 88 | 89 | func (c *clientImpl) MessageText(htmlBody, textBody, from, to string) (string, error) { 90 | // Prefer plainText if available. Otherwise extract text. 91 | var body string 92 | if len(textBody) > 0 { 93 | body = textBody 94 | } else { 95 | stripped, err := html2text.FromString(htmlBody, html2text.Options{}) 96 | if err != nil { 97 | return "", err 98 | } 99 | 100 | body = stripped 101 | } 102 | 103 | message, err := c.removeRepliesAndSignature(body, from) 104 | if err != nil { 105 | return "", errors.E(errors.Op("pluck.MessageText"), err) 106 | } 107 | 108 | cleanMessage := strings.TrimSpace(strings.TrimRight(message, "-–—−")) // hyphen, en-dash, em-dash, minus 109 | 110 | return cleanMessage, nil 111 | } 112 | 113 | func (c *clientImpl) removeRepliesAndSignature(text, sender string) (string, error) { 114 | var op errors.Op = "pluck.removeRepliesAndSignature" 115 | 116 | b, err := json.Marshal(map[string]string{ 117 | "body": text, 118 | "sender": sender, 119 | }) 120 | if err != nil { 121 | return "", errors.E(op, err) 122 | } 123 | 124 | rsp, err := http.Post(c.sigStripURL, "application/json", bytes.NewReader(b)) 125 | if err != nil { 126 | return "", errors.E(op, err) 127 | } 128 | defer rsp.Body.Close() 129 | 130 | if rsp.StatusCode >= http.StatusBadRequest { 131 | return "", errors.E(op, errors.Str("Sigstrip returned error")) 132 | } 133 | 134 | body, err := ioutil.ReadAll(rsp.Body) 135 | if err != nil { 136 | return "", errors.E(op, err) 137 | } 138 | 139 | result := make(map[string]string) 140 | 141 | err = json.Unmarshal(body, &result) 142 | if err != nil { 143 | return "", errors.E(op, err) 144 | } 145 | 146 | return result["text"], nil 147 | } 148 | 149 | type loggerImpl struct { 150 | clientImpl 151 | } 152 | 153 | func NewLogger() Client { 154 | return &loggerImpl{} 155 | } 156 | 157 | func (l *loggerImpl) MessageText(htmlBody, textBody, from, to string) (message string, err error) { 158 | return "Hello from logger", nil 159 | } 160 | -------------------------------------------------------------------------------- /clients/queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | cloudtasks "cloud.google.com/go/cloudtasks/apiv2" 10 | taskspb "google.golang.org/genproto/googleapis/cloud/tasks/v2" 11 | 12 | "github.com/hiconvo/api/errors" 13 | "github.com/hiconvo/api/log" 14 | ) 15 | 16 | type ( 17 | emailType string 18 | emailAction string 19 | ) 20 | 21 | const ( 22 | User emailType = "User" 23 | Event emailType = "Event" 24 | Thread emailType = "Thread" 25 | 26 | SendInvites emailAction = "SendInvites" 27 | SendUpdatedInvites emailAction = "SendUpdatedInvites" 28 | SendThread emailAction = "SendThread" 29 | SendWelcome emailAction = "SendWelcome" 30 | ) 31 | 32 | // EmailPayload is a representation of an async email task. 33 | type EmailPayload struct { 34 | // IDs is a slice of strings that are the result of calling *datastore.Key.Encode(). 35 | IDs []string `json:"ids"` 36 | Type emailType `json:"type"` 37 | Action emailAction `json:"action"` 38 | } 39 | 40 | type Client interface { 41 | PutEmail(ctx context.Context, payload EmailPayload) error 42 | } 43 | 44 | type clientImpl struct { 45 | client *cloudtasks.Client 46 | path string 47 | } 48 | 49 | func NewClient(ctx context.Context, projectID string) Client { 50 | if projectID == "local-convo-api" { 51 | return NewLogger() 52 | } 53 | 54 | tc, err := cloudtasks.NewClient(ctx) 55 | if err != nil { 56 | panic(errors.E(errors.Op("queue.NewClient"), err)) 57 | } 58 | 59 | return &clientImpl{ 60 | client: tc, 61 | path: fmt.Sprintf("projects/%s/locations/us-central1/queues/convo-emails", projectID), 62 | } 63 | } 64 | 65 | // PutEmail enqueues an email to be sent. 66 | func (c *clientImpl) PutEmail(ctx context.Context, payload EmailPayload) error { 67 | op := errors.Opf("queue.PutEmail(type=%s, action=%s)", payload.Type, payload.Action) 68 | 69 | switch payload.Type { 70 | case Thread: 71 | if payload.Action != SendThread { 72 | return errors.E(op, errors.Errorf("'%v' is not a valid action for emailType.Thread", payload.Action)) 73 | } 74 | case Event: 75 | if !(payload.Action == SendInvites || payload.Action == SendUpdatedInvites) { 76 | return errors.E(op, errors.Errorf("'%v' is not a valid action for emailType.Event", payload.Action)) 77 | } 78 | case User: 79 | if payload.Action != SendWelcome { 80 | return errors.E(op, errors.Errorf("'%v' is not a valid action for emailType.User", payload.Action)) 81 | } 82 | } 83 | 84 | jsonBytes, err := json.Marshal(payload) 85 | if err != nil { 86 | return errors.E(op, err) 87 | } 88 | 89 | req := &taskspb.CreateTaskRequest{ 90 | Parent: c.path, 91 | Task: &taskspb.Task{ 92 | // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#AppEngineHttpRequest 93 | MessageType: &taskspb.Task_AppEngineHttpRequest{ 94 | AppEngineHttpRequest: &taskspb.AppEngineHttpRequest{ 95 | HttpMethod: taskspb.HttpMethod_POST, 96 | RelativeUri: "/tasks/emails", 97 | Body: jsonBytes, 98 | }, 99 | }, 100 | }, 101 | } 102 | 103 | _, err = c.client.CreateTask(ctx, req) 104 | if err != nil { 105 | return errors.E(op, err) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | type loggerImpl struct{} 112 | 113 | func NewLogger() Client { 114 | log.Print("queue.NewLogger: USING QUEUE LOGGER FOR LOCAL DEVELOPMENT") 115 | return &loggerImpl{} 116 | } 117 | 118 | func (c *loggerImpl) PutEmail(ctx context.Context, payload EmailPayload) error { 119 | log.Printf("queue.PutEmail(IDs=[%s], Type=%s, Action=%s)", 120 | strings.Join(payload.IDs, ", "), payload.Type, payload.Action) 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /clients/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/olivere/elastic/v7" 8 | 9 | "github.com/hiconvo/api/errors" 10 | "github.com/hiconvo/api/log" 11 | ) 12 | 13 | type Client interface { 14 | Update() *elastic.UpdateService 15 | Delete() *elastic.DeleteService 16 | Search(indicies ...string) *elastic.SearchService 17 | } 18 | 19 | func NewClient(hostname string) Client { 20 | var ( 21 | op = errors.Opf("search.NewClient(hostname=%s)", hostname) 22 | client *elastic.Client 23 | err error 24 | ) 25 | 26 | const ( 27 | maxAttempts = 5 28 | timeout = 3 29 | ) 30 | 31 | for i := 1; i <= maxAttempts; i++ { 32 | client, err = elastic.NewClient( 33 | elastic.SetSniff(false), 34 | elastic.SetURL(fmt.Sprintf("http://%s:9200", hostname)), 35 | ) 36 | if err != nil { 37 | if i == maxAttempts { 38 | panic(errors.E(op, errors.Str("Failed to connect to elasticsearch"))) 39 | } 40 | 41 | log.Printf("%s: Failed to connect to elasticsearch; attempt %d/%d; will retry in %d seconds\n%s\n", 42 | string(op), i, maxAttempts, timeout, err) 43 | time.Sleep(timeout * time.Second) 44 | } else { 45 | log.Printf("%s: Connected to elasticsearch", string(op)) 46 | 47 | break 48 | } 49 | } 50 | 51 | return client 52 | } 53 | -------------------------------------------------------------------------------- /clients/secrets/secrets.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "cloud.google.com/go/datastore" 8 | 9 | "github.com/hiconvo/api/clients/db" 10 | "github.com/hiconvo/api/errors" 11 | "github.com/hiconvo/api/log" 12 | ) 13 | 14 | type Client interface { 15 | Get(id, fallback string) string 16 | } 17 | 18 | type clientImpl struct { 19 | secrets map[string]string 20 | } 21 | 22 | func NewClient(ctx context.Context, db db.Client) Client { 23 | var s []struct { 24 | Name string 25 | Value string 26 | } 27 | 28 | q := datastore.NewQuery("Secret") 29 | if _, err := db.GetAll(ctx, q, &s); err != nil { 30 | panic(errors.E(errors.Op("secrets.NewClient()"), err)) 31 | } 32 | 33 | secretMap := make(map[string]string, len(s)) 34 | for i := range s { 35 | secretMap[s[i].Name] = s[i].Value 36 | } 37 | 38 | return &clientImpl{ 39 | secrets: secretMap, 40 | } 41 | } 42 | 43 | func (c *clientImpl) Get(id, fallback string) string { 44 | s := c.secrets[id] 45 | if s == "" { 46 | s = os.Getenv(id) 47 | } 48 | 49 | if s == "" { 50 | log.Printf("secrets.Get(id=%s): using fallback", id) 51 | return fallback 52 | } 53 | 54 | return s 55 | } 56 | -------------------------------------------------------------------------------- /clients/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | 14 | uuid "github.com/gofrs/uuid" 15 | "gocloud.dev/blob" 16 | 17 | // This sets up the plumbing to use blob with the local file system in development mode. 18 | _ "gocloud.dev/blob/fileblob" 19 | // This sets up the plumbing to use blob with GCS in production. 20 | _ "gocloud.dev/blob/gcsblob" 21 | 22 | "github.com/hiconvo/api/errors" 23 | "github.com/hiconvo/api/log" 24 | ) 25 | 26 | const _nullKey string = "null-key" 27 | 28 | type Client struct { 29 | avatarBucketName string 30 | photoBucketName string 31 | } 32 | 33 | func NewClient(avatarBucketName, photoBucketName string) *Client { 34 | if avatarBucketName == "" || photoBucketName == "" { 35 | localBucketName := initLocalStorageDir() 36 | 37 | return &Client{ 38 | avatarBucketName: localBucketName, 39 | photoBucketName: localBucketName, 40 | } 41 | } 42 | 43 | return &Client{ 44 | avatarBucketName: avatarBucketName, 45 | photoBucketName: photoBucketName, 46 | } 47 | } 48 | 49 | func initLocalStorageDir() string { 50 | op := errors.Op("storage.initLocalStorageDir") 51 | 52 | localpath, err := filepath.Abs("./.local-object-store/") 53 | if err != nil { 54 | panic(errors.E(op, err)) 55 | } 56 | 57 | fp := fmt.Sprintf("file://%s", localpath) 58 | 59 | log.Printf("storage.initLocalStorageDir: Creating temporary bucket storage at %s", fp) 60 | 61 | if err := os.MkdirAll(localpath, 0777); err != nil { 62 | panic(errors.E(op, err)) 63 | } 64 | 65 | return fp 66 | } 67 | 68 | // GetAvatarURLFromKey returns the public URL of the given avatar key. 69 | func (c *Client) GetAvatarURLFromKey(key string) string { 70 | return getURLPrefix(c.avatarBucketName) + key 71 | } 72 | 73 | // GetPhotoURLFromKey returns the public URL of the given photo key. 74 | func (c *Client) GetPhotoURLFromKey(key string) string { 75 | if strings.HasPrefix(key, "https://") { 76 | return key 77 | } 78 | 79 | return getURLPrefix(c.photoBucketName) + key 80 | } 81 | 82 | // GetSignedPhotoURL returns a signed URL of the given photo key. 83 | func (c *Client) GetSignedPhotoURL(ctx context.Context, key string) (string, error) { 84 | b, err := blob.OpenBucket(ctx, c.photoBucketName) 85 | if err != nil { 86 | return "", errors.E(errors.Op("storage.GetSignedPhotoURL"), err) 87 | } 88 | defer b.Close() 89 | 90 | return b.SignedURL(ctx, key, &blob.SignedURLOptions{ 91 | Expiry: blob.DefaultSignedURLExpiry, 92 | }) 93 | } 94 | 95 | // GetKeyFromAvatarURL accepts a url and returns the last segment of the 96 | // URL, which corresponds with the key of the avatar image, used in cloud 97 | // storage. 98 | func (c *Client) GetKeyFromAvatarURL(url string) string { 99 | if url == "" { 100 | return _nullKey 101 | } 102 | 103 | ss := strings.Split(url, "/") 104 | 105 | return ss[len(ss)-1] 106 | } 107 | 108 | // GetKeyFromPhotoURL accepts a url and returns the last segment of the 109 | // URL, which corresponds with the key of the image, used in cloud 110 | // storage. 111 | func (c *Client) GetKeyFromPhotoURL(url string) string { 112 | if url == "" { 113 | return _nullKey 114 | } 115 | 116 | ss := strings.Split(url, "/") 117 | 118 | return ss[len(ss)-2] + "/" + ss[len(ss)-1] 119 | } 120 | 121 | // PutAvatarFromURL requests the image at the given URL, resizes it to 256x256 and 122 | // saves it to the avatar bucket. It returns the full avatar URL. 123 | func (c *Client) PutAvatarFromURL(ctx context.Context, uri string) (string, error) { 124 | op := errors.Op("storage.PutAvatarFromURL") 125 | 126 | res, err := http.Get(uri) 127 | if err != nil { 128 | return "", err 129 | } 130 | 131 | if res.StatusCode != http.StatusOK { 132 | return "", errors.E(op, errors.Str("Could not download avatar image")) 133 | } 134 | 135 | key := uuid.Must(uuid.NewV4()).String() + ".jpg" 136 | 137 | bucket, err := blob.OpenBucket(ctx, c.avatarBucketName) 138 | if err != nil { 139 | return "", errors.E(op, err) 140 | } 141 | defer bucket.Close() 142 | 143 | outputBlob, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{ 144 | CacheControl: "525600", 145 | }) 146 | if err != nil { 147 | return "", errors.E(op, err) 148 | } 149 | defer outputBlob.Close() 150 | 151 | var stderr bytes.Buffer 152 | 153 | cmd := exec.Command("convert", "-", "-adaptive-resize", "256x256", "jpeg:-") 154 | cmd.Stdin = res.Body 155 | cmd.Stdout = outputBlob 156 | cmd.Stderr = &stderr 157 | 158 | if err := cmd.Run(); err != nil { 159 | log.Print(stderr.String()) 160 | return "", errors.E(op, err) 161 | } 162 | 163 | return c.GetAvatarURLFromKey(key), nil 164 | } 165 | 166 | // PutAvatarFromBlob crops and resizes the given image blob, saves it, and 167 | // returns the full URL of the image. 168 | func (c *Client) PutAvatarFromBlob(ctx context.Context, dat string, size, x, y int, oldKey string) (string, error) { 169 | op := errors.Op("storage.PutAvatarFromBlob") 170 | 171 | bucket, err := blob.OpenBucket(ctx, c.avatarBucketName) 172 | if err != nil { 173 | return "", errors.E(op, err) 174 | } 175 | defer bucket.Close() 176 | 177 | key := uuid.Must(uuid.NewV4()).String() + ".jpg" 178 | 179 | outputBlob, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{ 180 | CacheControl: "525600", 181 | }) 182 | if err != nil { 183 | return "", errors.E(op, err) 184 | } 185 | defer outputBlob.Close() 186 | 187 | inputBlob := base64.NewDecoder(base64.StdEncoding, strings.NewReader(dat)) 188 | 189 | var stderr bytes.Buffer 190 | 191 | cropGeo := fmt.Sprintf("%vx%v+%v+%v", size, size, x, y) 192 | cmd := exec.Command("convert", "-", "-crop", cropGeo, "-adaptive-resize", "256x256", "jpeg:-") 193 | cmd.Stdin = inputBlob 194 | cmd.Stdout = outputBlob 195 | cmd.Stderr = &stderr 196 | 197 | if err := cmd.Run(); err != nil { 198 | log.Print(stderr.String()) 199 | return "", errors.E(op, err) 200 | } 201 | 202 | if oldKey != "" && oldKey != _nullKey { 203 | exists, err := bucket.Exists(ctx, oldKey) 204 | if err != nil { 205 | log.Alarm(errors.E(op, err)) 206 | } 207 | 208 | if exists { 209 | bucket.Delete(ctx, oldKey) 210 | } 211 | } 212 | 213 | return c.GetAvatarURLFromKey(key), nil 214 | } 215 | 216 | // PutPhotoFromBlob resizes the given image blob, saves it, and returns full url of the image. 217 | func (c *Client) PutPhotoFromBlob(ctx context.Context, parentID, dat string) (string, error) { 218 | op := errors.Op("storage.PutPhotoFromBlob") 219 | 220 | if parentID == "" { 221 | return "", errors.E(op, errors.Str("No parentID given")) 222 | } 223 | 224 | bucket, err := blob.OpenBucket(ctx, c.photoBucketName) 225 | if err != nil { 226 | return "", errors.E(op, err) 227 | } 228 | defer bucket.Close() 229 | 230 | key := parentID + "/" + uuid.Must(uuid.NewV4()).String() + ".jpg" 231 | 232 | outputBlob, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{ 233 | CacheControl: "525600", 234 | }) 235 | if err != nil { 236 | return "", errors.E(op, err) 237 | } 238 | defer outputBlob.Close() 239 | 240 | inputBlob := base64.NewDecoder(base64.StdEncoding, strings.NewReader(dat)) 241 | 242 | var stderr bytes.Buffer 243 | 244 | cmd := exec.Command("convert", "-", "-resize", "2048x2048>", "-quality", "70", "jpeg:-") 245 | cmd.Stdin = inputBlob 246 | cmd.Stdout = outputBlob 247 | cmd.Stderr = &stderr 248 | 249 | if err := cmd.Run(); err != nil { 250 | log.Print(stderr.String()) 251 | return "", errors.E(op, err) 252 | } 253 | 254 | return c.GetPhotoURLFromKey(key), nil 255 | } 256 | 257 | // DeletePhoto deletes the given photo from the photo bucket. 258 | // This does not work for avatars. 259 | func (c *Client) DeletePhoto(ctx context.Context, key string) error { 260 | op := errors.Op("storage.DeletePhoto") 261 | 262 | bucket, err := blob.OpenBucket(ctx, c.photoBucketName) 263 | if err != nil { 264 | return errors.E(op, err) 265 | } 266 | defer bucket.Close() 267 | 268 | if err := bucket.Delete(ctx, key); err != nil { 269 | return errors.E(op, err) 270 | } 271 | 272 | return nil 273 | } 274 | 275 | // getURLPrefix returns the public URL prefix of the given bucket. 276 | // For example, it will convert "gs://convo-avatars" to 277 | // "https://storage.googleapis.com/convo-avatarts/". 278 | func getURLPrefix(bucketName string) string { 279 | if bucketName[:8] == "file:///" { 280 | return bucketName + "/" 281 | } 282 | 283 | if bucketName[:5] == "gs://" { 284 | return fmt.Sprintf("https://storage.googleapis.com/%s/", bucketName[5:]) 285 | } 286 | 287 | panic(errors.E( 288 | errors.Op("storage.getURLPrefix"), 289 | errors.Errorf("'%v' is not a valid bucketName", bucketName))) 290 | } 291 | -------------------------------------------------------------------------------- /cmd/migrate-first-messages/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "time" 10 | 11 | "cloud.google.com/go/datastore" 12 | dbc "github.com/hiconvo/api/clients/db" 13 | "github.com/hiconvo/api/db" 14 | "github.com/hiconvo/api/errors" 15 | "github.com/hiconvo/api/model" 16 | "google.golang.org/api/iterator" 17 | ) 18 | 19 | const ( 20 | exitCodeOK = 0 21 | ) 22 | 23 | // This command deletes the first message M of every 24 | // thread T if M's body is identical to T's preview body. 25 | func main() { 26 | var ( 27 | isDryRun bool 28 | projectID string 29 | sleepTime int = 3 30 | ctx, cancel = context.WithCancel(context.Background()) 31 | signalChan = make(chan os.Signal, 1) 32 | ) 33 | 34 | flag.BoolVar(&isDryRun, "dry-run", false, "if passed, nothing is mutated.") 35 | flag.StringVar(&projectID, "project-id", "local-convo-api", "overrides the default project ID.") 36 | flag.Parse() 37 | 38 | log.Printf("About to migrate messages with db=%s, dry-run=%v", projectID, isDryRun) 39 | log.Printf("You have %d seconds to ctl+c if this is incorrect", sleepTime) 40 | time.Sleep(time.Duration(sleepTime) * time.Second) 41 | 42 | dbClient := dbc.NewClient(ctx, projectID) 43 | defer dbClient.Close() 44 | 45 | signal.Notify(signalChan, os.Interrupt) 46 | defer signal.Stop(signalChan) 47 | 48 | go func() { 49 | <-signalChan // first signal: clean up and exit gracefully 50 | log.Print("Ctl+C detected, cleaning up") 51 | cancel() 52 | dbClient.Close() // close the db conn when ctl+c 53 | os.Exit(exitCodeOK) 54 | }() 55 | 56 | if err := run(ctx, dbClient, isDryRun); err != nil { 57 | log.Panic(err) 58 | } 59 | } 60 | 61 | func run(ctx context.Context, dbClient dbc.Client, isDryRun bool) error { 62 | var ( 63 | op = errors.Op("run") 64 | count int = 0 65 | messageStore = &db.MessageStore{DB: dbClient} 66 | ) 67 | 68 | iter := dbClient.Run(ctx, datastore.NewQuery("Thread")) 69 | 70 | log.Print("Starting loop...") 71 | 72 | for { 73 | count++ 74 | 75 | thread := new(model.Thread) 76 | _, err := iter.Next(&thread) 77 | 78 | if errors.Is(err, iterator.Done) { 79 | log.Printf("Done") 80 | 81 | return nil 82 | } 83 | 84 | if err != nil { 85 | return errors.E(op, err) 86 | } 87 | 88 | log.Printf("Count=%d, ThreadID=%d, Subject=%s", count, thread.Key.ID, thread.Subject) 89 | 90 | messages, err := messageStore.GetMessagesByThread(ctx, thread, &model.Pagination{}) 91 | if err != nil { 92 | log.Panicf(err.Error()) 93 | } 94 | 95 | if len(messages) == 0 { 96 | log.Print("CleaningCrew-> no messages in thread") 97 | log.Print("CleaningCrew-> skipping") 98 | continue 99 | } 100 | 101 | firstMessage := messages[0] 102 | 103 | if thread.Body != "" && firstMessage.Body == thread.Body { 104 | log.Print("CleaningCrew-> message has same body as thread preview") 105 | 106 | if isDryRun { 107 | log.Print("CleaningCrew-> skipping since this is a dry run") 108 | } else if err := messageStore.Delete(ctx, firstMessage); err != nil { 109 | return errors.E(op, err) 110 | } 111 | 112 | log.Printf("CleaningCrew-> deleted message id=%s", firstMessage.ID) 113 | } else { 114 | log.Print("CleaningCrew-> message and thread preview are not identical") 115 | log.Print("CleaningCrew-> skipping") 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /cmd/migrate-message-timestamps-and-photos/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "time" 11 | 12 | "cloud.google.com/go/datastore" 13 | dbc "github.com/hiconvo/api/clients/db" 14 | "github.com/hiconvo/api/errors" 15 | "github.com/hiconvo/api/model" 16 | "google.golang.org/api/iterator" 17 | ) 18 | 19 | const ( 20 | exitCodeOK = 0 21 | ) 22 | 23 | // This command fixes photos stored in the old way on messages and 24 | // brings all fields up to date on outdated messages. 25 | func main() { 26 | var ( 27 | isDryRun bool 28 | projectID string 29 | sleepTime int = 3 30 | ctx, cancel = context.WithCancel(context.Background()) 31 | signalChan = make(chan os.Signal, 1) 32 | ) 33 | 34 | flag.BoolVar(&isDryRun, "dry-run", false, "if passed, nothing is mutated.") 35 | flag.StringVar(&projectID, "project-id", "local-convo-api", "overrides the default project ID.") 36 | flag.Parse() 37 | 38 | log.Printf("About to migrate messages with db=%s, dry-run=%v", projectID, isDryRun) 39 | log.Printf("You have %d seconds to ctl+c if this is incorrect", sleepTime) 40 | time.Sleep(time.Duration(sleepTime) * time.Second) 41 | 42 | dbClient := dbc.NewClient(ctx, projectID) 43 | defer dbClient.Close() 44 | 45 | signal.Notify(signalChan, os.Interrupt) 46 | defer signal.Stop(signalChan) 47 | 48 | go func() { 49 | <-signalChan // first signal: clean up and exit gracefully 50 | log.Print("Ctl+C detected, cleaning up") 51 | cancel() 52 | dbClient.Close() // close the db conn when ctl+c 53 | os.Exit(exitCodeOK) 54 | }() 55 | 56 | if err := run(ctx, dbClient, isDryRun); err != nil { 57 | log.Panic(err) 58 | } 59 | } 60 | 61 | func run(ctx context.Context, dbClient dbc.Client, isDryRun bool) error { 62 | var ( 63 | op = errors.Op("run") 64 | count int = 0 65 | flushSize int = 20 66 | queue []*model.Message 67 | urlPrefix string = "https://storage.googleapis.com/convo-photos/" 68 | ) 69 | 70 | flush := func() error { 71 | log.Printf("Flushing-> len(queue)=%d", len(queue)) 72 | 73 | keys := make([]*datastore.Key, len(queue)) 74 | for i := range queue { 75 | keys[i] = queue[i].Key 76 | } 77 | 78 | if !isDryRun { 79 | log.Printf("Flushing-> putting %d messages", len(keys)) 80 | 81 | _, err := dbClient.PutMulti(ctx, keys, queue) 82 | if err != nil { 83 | return errors.E(errors.Op("flush"), err) 84 | } 85 | } 86 | 87 | log.Print("Flushing-> done putting messages") 88 | 89 | queue = queue[:0] 90 | 91 | log.Printf("Flushing-> len(queue)=%d", len(queue)) 92 | 93 | return nil 94 | } 95 | 96 | iter := dbClient.Run(ctx, datastore.NewQuery("Message").Order("Timestamp")) 97 | 98 | log.Print("Starting loop...") 99 | 100 | for { 101 | count++ 102 | 103 | message := new(model.Message) 104 | _, err := iter.Next(message) 105 | 106 | if errors.Is(err, iterator.Done) { 107 | log.Print("Done") 108 | 109 | return flush() 110 | } 111 | 112 | if err != nil { 113 | return errors.E(op, err) 114 | } 115 | 116 | log.Printf("Count=%d, MessageID=%d", count, message.Key.ID) 117 | 118 | if len(message.PhotoKeys) > 0 { 119 | for i := range message.PhotoKeys { 120 | if !strings.HasPrefix(message.PhotoKeys[i], "https://") { 121 | newURL := urlPrefix + message.PhotoKeys[i] 122 | message.PhotoKeys[i] = newURL 123 | log.Printf("Updating photo URL: %s", newURL) 124 | } 125 | } 126 | } 127 | 128 | queue = append(queue, message) 129 | 130 | if len(queue) >= flushSize { 131 | if err := flush(); err != nil { 132 | return errors.E(op, err) 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /cmd/migrate-thread-previews/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "time" 11 | 12 | "cloud.google.com/go/datastore" 13 | dbc "github.com/hiconvo/api/clients/db" 14 | "github.com/hiconvo/api/errors" 15 | "github.com/hiconvo/api/model" 16 | "google.golang.org/api/iterator" 17 | ) 18 | 19 | const ( 20 | exitCodeOK = 0 21 | ) 22 | 23 | // This command fixes migrates thread previews and populates the UpdatedAt field 24 | // to support sorting by UpdatedAt. 25 | func main() { 26 | var ( 27 | isDryRun bool 28 | projectID string 29 | sleepTime int = 3 30 | ctx, cancel = context.WithCancel(context.Background()) 31 | signalChan = make(chan os.Signal, 1) 32 | ) 33 | 34 | flag.BoolVar(&isDryRun, "dry-run", false, "if passed, nothing is mutated.") 35 | flag.StringVar(&projectID, "project-id", "local-convo-api", "overrides the default project ID.") 36 | flag.Parse() 37 | 38 | log.Printf("About to migrate threads with db=%s, dry-run=%v", projectID, isDryRun) 39 | log.Printf("You have %d seconds to ctl+c if this is incorrect", sleepTime) 40 | time.Sleep(time.Duration(sleepTime) * time.Second) 41 | 42 | dbClient := dbc.NewClient(ctx, projectID) 43 | defer dbClient.Close() 44 | 45 | signal.Notify(signalChan, os.Interrupt) 46 | defer signal.Stop(signalChan) 47 | 48 | go func() { 49 | <-signalChan // first signal: clean up and exit gracefully 50 | log.Print("Ctl+C detected, cleaning up") 51 | cancel() 52 | dbClient.Close() // close the db conn when ctl+c 53 | os.Exit(exitCodeOK) 54 | }() 55 | 56 | if err := run(ctx, dbClient, isDryRun); err != nil { 57 | log.Panic(err) 58 | } 59 | } 60 | 61 | func run(ctx context.Context, dbClient dbc.Client, isDryRun bool) error { 62 | var ( 63 | op = errors.Op("run") 64 | count int = 0 65 | flushSize int = 100 66 | queue []*model.Thread 67 | urlPrefix string = "https://storage.googleapis.com/convo-photos/" 68 | // t0 is when first thread messages were removed. 69 | t0 = time.Date(2020, time.Month(11), 23, 0, 0, 0, 0, time.UTC) 70 | ) 71 | 72 | flush := func() error { 73 | log.Printf("Flushing-> len(queue)=%d", len(queue)) 74 | 75 | keys := make([]*datastore.Key, len(queue)) 76 | for i := range queue { 77 | keys[i] = queue[i].Key 78 | } 79 | 80 | if !isDryRun { 81 | log.Printf("Flushing-> putting %d threads", len(keys)) 82 | 83 | _, err := dbClient.PutMulti(ctx, keys, queue) 84 | if err != nil { 85 | return errors.E(errors.Op("flush"), err) 86 | } 87 | } 88 | 89 | log.Print("Flushing-> done putting threads") 90 | 91 | queue = queue[:0] 92 | 93 | log.Printf("Flushing-> len(queue)=%d", len(queue)) 94 | 95 | return nil 96 | } 97 | 98 | iter := dbClient.Run(ctx, datastore.NewQuery("Thread").Order("CreatedAt")) 99 | 100 | log.Print("Starting loop...") 101 | 102 | for { 103 | count++ 104 | 105 | thread := new(model.Thread) 106 | _, err := iter.Next(thread) 107 | 108 | if errors.Is(err, iterator.Done) { 109 | log.Print("Done") 110 | 111 | return flush() 112 | } 113 | 114 | if err != nil { 115 | return errors.E(op, err) 116 | } 117 | 118 | log.Printf("Count=%d, ThreadID=%d", count, thread.Key.ID) 119 | 120 | if len(thread.Photos) > 0 { 121 | for i := range thread.Photos { 122 | if !strings.HasPrefix(thread.Photos[i], "https://") { 123 | newURL := urlPrefix + thread.Photos[i] 124 | thread.Photos[i] = newURL 125 | log.Printf("Updating photo URL: %s", newURL) 126 | } 127 | } 128 | } 129 | 130 | if thread.UpdatedAt.IsZero() { 131 | thread.UpdatedAt = thread.CreatedAt 132 | } 133 | 134 | if thread.CreatedAt.Before(t0) && thread.ResponseCount > 0 { 135 | thread.ResponseCount-- 136 | log.Printf("Decrementing thread response count to %d", thread.ResponseCount) 137 | } 138 | 139 | queue = append(queue, thread) 140 | 141 | if len(queue) >= flushSize { 142 | if err := flush(); err != nil { 143 | return errors.E(op, err) 144 | } 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | 11 | "github.com/getsentry/raven-go" 12 | 13 | dbc "github.com/hiconvo/api/clients/db" 14 | "github.com/hiconvo/api/clients/magic" 15 | sender "github.com/hiconvo/api/clients/mail" 16 | "github.com/hiconvo/api/clients/notification" 17 | "github.com/hiconvo/api/clients/oauth" 18 | "github.com/hiconvo/api/clients/opengraph" 19 | "github.com/hiconvo/api/clients/places" 20 | "github.com/hiconvo/api/clients/queue" 21 | "github.com/hiconvo/api/clients/search" 22 | "github.com/hiconvo/api/clients/secrets" 23 | "github.com/hiconvo/api/clients/storage" 24 | "github.com/hiconvo/api/db" 25 | "github.com/hiconvo/api/errors" 26 | "github.com/hiconvo/api/handler" 27 | "github.com/hiconvo/api/log" 28 | "github.com/hiconvo/api/mail" 29 | "github.com/hiconvo/api/template" 30 | "github.com/hiconvo/api/welcome" 31 | ) 32 | 33 | func main() { 34 | ctx := context.Background() 35 | projectID := getenv("GOOGLE_CLOUD_PROJECT", "local-convo-api") 36 | 37 | dbClient := dbc.NewClient(ctx, projectID) 38 | defer dbClient.Close() 39 | 40 | sc := secrets.NewClient(ctx, dbClient) 41 | 42 | raven.SetDSN(sc.Get("SENTRY_DSN", "")) 43 | raven.SetRelease(getenv("GAE_VERSION", "dev")) 44 | 45 | var ( 46 | // clients 47 | notifClient = notification.NewClient(sc.Get("STREAM_API_KEY", "streamKey"), sc.Get("STREAM_API_SECRET", "streamSecret"), "us-east") 48 | mailClient = mail.New(sender.NewClient(sc.Get("SENDGRID_API_KEY", "")), template.NewClient()) 49 | searchClient = search.NewClient(sc.Get("ELASTICSEARCH_HOST", "elasticsearch")) 50 | storageClient = storage.NewClient(sc.Get("AVATAR_BUCKET_NAME", ""), sc.Get("PHOTO_BUCKET_NAME", "")) 51 | placesClient = places.NewClient(sc.Get("GOOGLE_MAPS_API_KEY", "")) 52 | magicClient = magic.NewClient(sc.Get("APP_SECRET", "")) 53 | queueClient = queue.NewClient(ctx, projectID) 54 | oauthClient = oauth.NewClient(sc.Get("GOOGLE_OAUTH_KEY", "")) 55 | ogClient = opengraph.NewClient() 56 | 57 | // stores 58 | userStore = &db.UserStore{DB: dbClient, Notif: notifClient, S: searchClient, Queue: queueClient} 59 | threadStore = &db.ThreadStore{DB: dbClient} 60 | eventStore = &db.EventStore{DB: dbClient} 61 | messageStore = &db.MessageStore{DB: dbClient} 62 | noteStore = &db.NoteStore{DB: dbClient, S: searchClient} 63 | 64 | // welcomer 65 | welcomer = welcome.New(ctx, userStore, sc.Get("SUPPORT_PASSWORD", "support")) 66 | ) 67 | 68 | h := handler.New(&handler.Config{ 69 | DB: dbClient, 70 | Transacter: dbClient, 71 | UserStore: userStore, 72 | ThreadStore: threadStore, 73 | EventStore: eventStore, 74 | MessageStore: messageStore, 75 | NoteStore: noteStore, 76 | Welcome: welcomer, 77 | TxnMiddleware: dbc.WithTransaction(dbClient), 78 | Mail: mailClient, 79 | Magic: magicClient, 80 | OAuth: oauthClient, 81 | OG: ogClient, 82 | Storage: storageClient, 83 | Notif: notifClient, 84 | Places: placesClient, 85 | Queue: queueClient, 86 | }) 87 | 88 | port := getenv("PORT", "8080") 89 | srv := http.Server{Handler: h, Addr: fmt.Sprintf(":%s", port)} 90 | 91 | idleConnsClosed := make(chan struct{}) 92 | 93 | go func() { 94 | signalChan := make(chan os.Signal, 1) 95 | 96 | signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) 97 | defer signal.Stop(signalChan) 98 | 99 | <-signalChan // first signal: clean up and exit gracefully 100 | log.Print("Signal detected, cleaning up") 101 | 102 | if err := srv.Shutdown(ctx); err != nil { 103 | // Error from closing listeners, or context timeout: 104 | log.Printf("HTTP server shutdown error: %v", err) 105 | } else { 106 | log.Print("HTTP server shutdown") 107 | } 108 | 109 | close(idleConnsClosed) 110 | }() 111 | 112 | log.Printf("Listening on port :%s", port) 113 | 114 | if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { 115 | log.Alarm(err) 116 | log.Panicf("ListenAndServe: %v", err) 117 | } 118 | 119 | <-idleConnsClosed 120 | } 121 | 122 | func getenv(name, fallback string) string { 123 | if val, ok := os.LookupEnv(name); ok { 124 | return val 125 | } 126 | 127 | return fallback 128 | } 129 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: "daily digest email" 3 | url: "/tasks/digest" 4 | schedule: every day 19:00 5 | 6 | - description: "daily cloud datastore whole export" 7 | url: /cloud-datastore-export?output_url_prefix=gs://convo-backups/whole- 8 | target: cloud-datastore-admin 9 | schedule: every day 10:00 10 | 11 | - description: "daily cloud datastore parts export" 12 | url: /cloud-datastore-export?output_url_prefix=gs://convo-backups/parts-&kind=User&kind=Event&kind=Thread&kind=Message 13 | target: cloud-datastore-admin 14 | schedule: every day 10:05 15 | 16 | - description: "daily bigquery import" 17 | url: /bigquery_import?input_url_prefix=gs://convo-backups&dataset_id=app&kind=User&kind=Event&kind=Thread&kind=Message 18 | target: cloud-datastore-admin 19 | schedule: every day 10:30 20 | -------------------------------------------------------------------------------- /db/event.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "cloud.google.com/go/datastore" 9 | 10 | "github.com/hiconvo/api/clients/db" 11 | "github.com/hiconvo/api/errors" 12 | "github.com/hiconvo/api/model" 13 | ) 14 | 15 | var _ model.EventStore = (*EventStore)(nil) 16 | 17 | type EventStore struct { 18 | DB db.Client 19 | } 20 | 21 | func (s *EventStore) GetEventByID(ctx context.Context, id string) (*model.Event, error) { 22 | var e model.Event 23 | 24 | key, err := datastore.DecodeKey(id) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return s.handleGetEvent(ctx, key, e) 30 | } 31 | 32 | func (s *EventStore) GetUnhydratedEventsByUser( 33 | ctx context.Context, 34 | u *model.User, 35 | p *model.Pagination, 36 | ) ([]*model.Event, error) { 37 | var events []*model.Event 38 | 39 | q := datastore.NewQuery("Event"). 40 | Filter("UserKeys =", u.Key). 41 | Order("-CreatedAt"). 42 | Offset(p.Offset()). 43 | Limit(p.Limit()) 44 | 45 | _, err := s.DB.GetAll(ctx, q, &events) 46 | if err != nil { 47 | return events, err 48 | } 49 | 50 | return events, nil 51 | } 52 | 53 | func (s *EventStore) GetEventsByUser( 54 | ctx context.Context, 55 | u *model.User, 56 | p *model.Pagination, 57 | ) ([]*model.Event, error) { 58 | // Get all of the events of which the user is a member 59 | events, err := s.GetUnhydratedEventsByUser(ctx, u, p) 60 | if err != nil { 61 | return events, err 62 | } 63 | 64 | // Now that we have the events, we need to get the users. We keep track of 65 | // where the users of one event start and another begin by incrementing 66 | // an index. 67 | var ( 68 | uKeys []*datastore.Key 69 | idxs []int 70 | ) 71 | 72 | for _, e := range events { 73 | uKeys = append(uKeys, e.UserKeys...) 74 | idxs = append(idxs, len(e.UserKeys)) 75 | } 76 | 77 | // We get all of the users in one go. 78 | userPtrs := make([]*model.User, len(uKeys)) 79 | if err := s.DB.GetMulti(ctx, uKeys, userPtrs); err != nil { 80 | return events, err 81 | } 82 | 83 | // We add the just retrieved user objects to their corresponding events by 84 | // iterating through all of the events and assigning their users according 85 | // to the index which we created above. 86 | // 87 | // We also create a new slice of pointers to events which we'll finally 88 | // return. 89 | start := 0 90 | eventPtrs := make([]*model.Event, len(events)) 91 | 92 | for i := range events { 93 | eventUsers := userPtrs[start : start+idxs[i]] 94 | 95 | var ( 96 | eventRSVPs = make([]*model.User, 0) 97 | eventHosts = make([]*model.User, 0) 98 | owner *model.User 99 | ) 100 | 101 | for j := range eventUsers { 102 | if events[i].HasRSVP(eventUsers[j]) { 103 | eventRSVPs = append(eventRSVPs, eventUsers[j]) 104 | } 105 | 106 | if events[i].OwnerIs(eventUsers[j]) { 107 | owner = eventUsers[j] 108 | } 109 | 110 | if events[i].HostIs(eventUsers[j]) { 111 | eventHosts = append(eventHosts, eventUsers[j]) 112 | } 113 | } 114 | 115 | events[i].Owner = model.MapUserToUserPartial(owner) 116 | events[i].UserPartials = model.MapUsersToUserPartials(eventUsers) 117 | events[i].Users = eventUsers 118 | events[i].RSVPs = model.MapUsersToUserPartials(eventRSVPs) 119 | events[i].HostPartials = model.MapUsersToUserPartials(eventHosts) 120 | events[i].UserReads = model.MapReadsToUserPartials(events[i], eventUsers) 121 | 122 | start += idxs[i] 123 | eventPtrs[i] = events[i] 124 | } 125 | 126 | return eventPtrs, nil 127 | } 128 | 129 | func (s *EventStore) Commit(ctx context.Context, e *model.Event) error { 130 | if e.CreatedAt.IsZero() { 131 | e.CreatedAt = time.Now() 132 | } 133 | 134 | e.UpdatedAt = time.Now() 135 | 136 | key, err := s.DB.Put(ctx, e.Key, e) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | e.ID = key.Encode() 142 | e.Key = key 143 | 144 | return nil 145 | } 146 | 147 | func (s *EventStore) CommitMulti(ctx context.Context, events []*model.Event) error { 148 | keys := make([]*datastore.Key, len(events)) 149 | for i := range events { 150 | keys[i] = events[i].Key 151 | } 152 | 153 | if _, err := s.DB.PutMulti(ctx, keys, events); err != nil { 154 | return errors.E(errors.Op("EventStore.CommitMulti"), err) 155 | } 156 | 157 | return nil 158 | } 159 | 160 | func (s *EventStore) CommitWithTransaction( 161 | tx db.Transaction, 162 | e *model.Event, 163 | ) (*datastore.PendingKey, error) { 164 | if e.CreatedAt.IsZero() { 165 | e.CreatedAt = time.Now() 166 | } 167 | 168 | e.UpdatedAt = time.Now() 169 | 170 | return tx.Put(e.Key, e) 171 | } 172 | 173 | func (s *EventStore) Delete(ctx context.Context, e *model.Event) error { 174 | if err := s.DB.Delete(ctx, e.Key); err != nil { 175 | return err 176 | } 177 | 178 | return nil 179 | } 180 | 181 | func (s *EventStore) handleGetEvent( 182 | ctx context.Context, 183 | key *datastore.Key, 184 | e model.Event, 185 | ) (*model.Event, error) { 186 | if err := s.DB.Get(ctx, key, &e); err != nil { 187 | if errors.Is(err, datastore.ErrNoSuchEntity) { 188 | return nil, errors.E(errors.Op("models.handleGetEvent"), http.StatusNotFound, err) 189 | } 190 | 191 | return nil, err 192 | } 193 | 194 | users := make([]model.User, len(e.UserKeys)) 195 | if err := s.DB.GetMulti(ctx, e.UserKeys, users); err != nil { 196 | return nil, err 197 | } 198 | 199 | var ( 200 | userPointers = make([]*model.User, len(users)) 201 | rsvpPointers = make([]*model.User, 0) 202 | hostPointers = make([]*model.User, 0) 203 | owner model.User 204 | ) 205 | 206 | for i := range users { 207 | userPointers[i] = &users[i] 208 | 209 | if e.OwnerKey.Equal(users[i].Key) { 210 | owner = users[i] 211 | } 212 | 213 | if e.HasRSVP(userPointers[i]) { 214 | rsvpPointers = append(rsvpPointers, userPointers[i]) 215 | } 216 | 217 | if e.HostIs(userPointers[i]) { 218 | hostPointers = append(hostPointers, userPointers[i]) 219 | } 220 | } 221 | 222 | e.UserPartials = model.MapUsersToUserPartials(userPointers) 223 | e.Users = userPointers 224 | e.Owner = model.MapUserToUserPartial(&owner) 225 | e.HostPartials = model.MapUsersToUserPartials(hostPointers) 226 | e.RSVPs = model.MapUsersToUserPartials(rsvpPointers) 227 | e.UserReads = model.MapReadsToUserPartials(&e, userPointers) 228 | 229 | return &e, nil 230 | } 231 | -------------------------------------------------------------------------------- /db/message.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "cloud.google.com/go/datastore" 8 | 9 | "github.com/hiconvo/api/clients/db" 10 | "github.com/hiconvo/api/errors" 11 | "github.com/hiconvo/api/model" 12 | ) 13 | 14 | type OrderBy string 15 | 16 | const ( 17 | CreatedAtNewestFirst OrderBy = "-CreatedAt" 18 | CreatedAtOldestFirst OrderBy = "CreatedAt" 19 | ) 20 | 21 | var _ model.MessageStore = (*MessageStore)(nil) 22 | 23 | type MessageStore struct { 24 | DB db.Client 25 | } 26 | 27 | func (s *MessageStore) GetMessageByID(ctx context.Context, id string) (*model.Message, error) { 28 | var ( 29 | op = errors.Opf("models.GetMessageByID(id=%q)", id) 30 | message = new(model.Message) 31 | ) 32 | 33 | key, err := datastore.DecodeKey(id) 34 | if err != nil { 35 | return message, errors.E(op, err) 36 | } 37 | 38 | err = s.DB.Get(ctx, key, message) 39 | if err != nil { 40 | if errors.Is(err, datastore.ErrNoSuchEntity) { 41 | return nil, errors.E(op, err, http.StatusNotFound) 42 | } 43 | 44 | return message, errors.E(op, err) 45 | } 46 | 47 | return message, nil 48 | } 49 | 50 | func (s *MessageStore) GetMessagesByKey( 51 | ctx context.Context, 52 | k *datastore.Key, 53 | p *model.Pagination, 54 | opts ...model.GetMessagesOption, 55 | ) ([]*model.Message, error) { 56 | op := errors.Opf("MessageStore.GetMessagesByKey(key=%d)", k.ID) 57 | messages := make([]*model.Message, 0) 58 | 59 | m := map[string]interface{}{} 60 | for _, opt := range opts { 61 | opt(m) 62 | } 63 | 64 | q := datastore.NewQuery("Message"). 65 | Filter("ParentKey =", k). 66 | Offset(p.Offset()). 67 | Limit(p.Limit()) 68 | 69 | if val, ok := m["order"]; ok { 70 | if orderBy, ok := val.(string); ok { 71 | q = q.Order(orderBy) 72 | } else { 73 | return nil, errors.E(op, http.StatusBadRequest) 74 | } 75 | } else { 76 | q = q.Order("CreatedAt") 77 | } 78 | 79 | if _, err := s.DB.GetAll(ctx, q, &messages); err != nil { 80 | return messages, errors.E(op, err) 81 | } 82 | 83 | userKeys := make([]*datastore.Key, len(messages)) 84 | for i := range messages { 85 | userKeys[i] = messages[i].UserKey 86 | } 87 | 88 | users := make([]*model.User, len(userKeys)) 89 | if err := s.DB.GetMulti(ctx, userKeys, users); err != nil { 90 | return messages, errors.E(op, err) 91 | } 92 | 93 | for i := range messages { 94 | messages[i].User = model.MapUserToUserPartial(users[i]) 95 | } 96 | 97 | return messages, nil 98 | } 99 | 100 | func (s *MessageStore) GetMessagesByThread( 101 | ctx context.Context, 102 | t *model.Thread, 103 | p *model.Pagination, 104 | ops ...model.GetMessagesOption, 105 | ) ([]*model.Message, error) { 106 | return s.GetMessagesByKey(ctx, t.Key, p) 107 | } 108 | 109 | func (s *MessageStore) GetMessagesByEvent( 110 | ctx context.Context, 111 | e *model.Event, 112 | p *model.Pagination, 113 | ops ...model.GetMessagesOption, 114 | ) ([]*model.Message, error) { 115 | return s.GetMessagesByKey(ctx, e.Key, p) 116 | } 117 | 118 | func (s *MessageStore) GetUnhydratedMessagesByUser( 119 | ctx context.Context, 120 | u *model.User, 121 | p *model.Pagination, 122 | ops ...model.GetMessagesOption, 123 | ) ([]*model.Message, error) { 124 | var messages []*model.Message 125 | 126 | q := datastore.NewQuery("Message").Filter("UserKey =", u.Key) 127 | if _, err := s.DB.GetAll(ctx, q, &messages); err != nil { 128 | return messages, err 129 | } 130 | 131 | return messages, nil 132 | } 133 | 134 | func (s *MessageStore) Commit(ctx context.Context, m *model.Message) error { 135 | key, err := s.DB.Put(ctx, m.Key, m) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | m.ID = key.Encode() 141 | m.Key = key 142 | 143 | return nil 144 | } 145 | 146 | func (s *MessageStore) CommitMulti(ctx context.Context, messages []*model.Message) error { 147 | keys := make([]*datastore.Key, len(messages)) 148 | for i := range messages { 149 | keys[i] = messages[i].Key 150 | } 151 | 152 | _, err := s.DB.PutMulti(ctx, keys, messages) 153 | if err != nil { 154 | return errors.E(errors.Op("MessageStore.CommitMulti"), err) 155 | } 156 | 157 | return nil 158 | } 159 | 160 | func (s *MessageStore) Delete(ctx context.Context, m *model.Message) error { 161 | if err := s.DB.Delete(ctx, m.Key); err != nil { 162 | return err 163 | } 164 | 165 | return nil 166 | } 167 | 168 | func MessagesOrderBy(by OrderBy) model.GetMessagesOption { 169 | return func(m map[string]interface{}) { 170 | m["order"] = string(by) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /db/note.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "cloud.google.com/go/datastore" 11 | "github.com/olivere/elastic/v7" 12 | 13 | "github.com/hiconvo/api/clients/db" 14 | "github.com/hiconvo/api/clients/search" 15 | "github.com/hiconvo/api/errors" 16 | "github.com/hiconvo/api/log" 17 | "github.com/hiconvo/api/model" 18 | ) 19 | 20 | var _ model.NoteStore = (*NoteStore)(nil) 21 | 22 | type NoteStore struct { 23 | DB db.Client 24 | S search.Client 25 | } 26 | 27 | func (s *NoteStore) GetNoteByID(ctx context.Context, id string) (*model.Note, error) { 28 | op := errors.Opf("NoteStore.GetNoteByID(id=%s)", id) 29 | note := new(model.Note) 30 | 31 | key, err := datastore.DecodeKey(id) 32 | if err != nil { 33 | return nil, errors.E(op, err) 34 | } 35 | 36 | err = s.DB.Get(ctx, key, note) 37 | if err != nil { 38 | if errors.Is(err, datastore.ErrNoSuchEntity) { 39 | return nil, errors.E(op, err, http.StatusNotFound) 40 | } 41 | 42 | return nil, errors.E(op, err) 43 | } 44 | 45 | return note, nil 46 | } 47 | 48 | func (s *NoteStore) GetNotesByUser( 49 | ctx context.Context, 50 | u *model.User, 51 | p *model.Pagination, 52 | opts ...model.GetNotesOption, 53 | ) ([]*model.Note, error) { 54 | op := errors.Opf("NoteStore.GetNotesByUser(u=%s)", u.Email) 55 | 56 | notes := make([]*model.Note, 0) 57 | 58 | q := datastore.NewQuery("Note"). 59 | Filter("OwnerKey =", u.Key). 60 | Order("-CreatedAt"). 61 | Offset(p.Offset()). 62 | Limit(p.Limit()) 63 | 64 | m := map[string]interface{}{} 65 | 66 | for _, f := range opts { 67 | f(m) 68 | } 69 | 70 | if _, ok := m["pins"]; ok { 71 | q = q.Filter("Pin =", true) 72 | } 73 | 74 | if val, ok := m["tags"]; ok { 75 | if tag, ok := val.(string); ok { 76 | q = q.Filter("Tags =", tag) 77 | } else { 78 | return nil, errors.E(op, http.StatusBadRequest) 79 | } 80 | } 81 | 82 | if val, ok := m["filter"]; ok { 83 | if val == "note" { 84 | q = q.Filter("Variant =", "note") 85 | } else if val == "link" { 86 | q = q.Filter("Variant =", "link") 87 | } 88 | } 89 | 90 | if val, ok := m["search"]; ok { 91 | if len(m) > 1 { 92 | return nil, errors.E(op, errors.Str("search used with other params"), 93 | map[string]string{"message": "search cannot be combined with other parameters"}, 94 | http.StatusBadRequest) 95 | } 96 | 97 | if query, ok := val.(string); ok { 98 | return s.handleSearch(ctx, u, query) 99 | } 100 | 101 | return nil, errors.E(op, http.StatusBadRequest) 102 | } 103 | 104 | _, err := s.DB.GetAll(ctx, q, ¬es) 105 | if err != nil { 106 | return notes, errors.E(op, err) 107 | } 108 | 109 | return notes, nil 110 | } 111 | 112 | func (s *NoteStore) Commit(ctx context.Context, n *model.Note) error { 113 | op := errors.Op("NoteStore.Commit") 114 | 115 | if n.CreatedAt.IsZero() { 116 | n.CreatedAt = time.Now() 117 | } 118 | 119 | n.UpdatedAt = time.Now() 120 | 121 | key, err := s.DB.Put(ctx, n.Key, n) 122 | if err != nil { 123 | return errors.E(op, err) 124 | } 125 | 126 | n.ID = key.Encode() 127 | n.Key = key 128 | 129 | s.updateSearchIndex(ctx, n) 130 | 131 | return nil 132 | } 133 | 134 | func (s *NoteStore) Delete(ctx context.Context, n *model.Note) error { 135 | s.deleteSearchIndex(ctx, n) 136 | 137 | if err := s.DB.Delete(ctx, n.Key); err != nil { 138 | return err 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (s *NoteStore) handleSearch(ctx context.Context, u *model.User, q string) ([]*model.Note, error) { 145 | skip := 0 146 | take := 30 147 | 148 | notes := make([]*model.Note, 0) 149 | 150 | esQuery := elastic.NewBoolQuery(). 151 | Must(elastic.NewMultiMatchQuery(q, "body", "name", "url")). 152 | Filter(elastic.NewTermQuery("userId.keyword", u.Key.Encode())) 153 | 154 | result, err := s.S.Search(). 155 | Index("notes"). 156 | Query(esQuery). 157 | From(skip).Size(take). 158 | Do(ctx) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | for _, hit := range result.Hits.Hits { 164 | note := new(model.Note) 165 | 166 | if err := json.Unmarshal(hit.Source, note); err != nil { 167 | return notes, err 168 | } 169 | 170 | notes = append(notes, note) 171 | } 172 | 173 | return notes, nil 174 | } 175 | 176 | func (s *NoteStore) updateSearchIndex(ctx context.Context, n *model.Note) { 177 | _, upsertErr := s.S.Update(). 178 | Index("notes"). 179 | Id(n.ID). 180 | DocAsUpsert(true). 181 | Doc(n). 182 | Do(ctx) 183 | if upsertErr != nil { 184 | log.Printf("Failed to index note in elasticsearch: %v", upsertErr) 185 | } 186 | } 187 | 188 | func (s *NoteStore) deleteSearchIndex(ctx context.Context, n *model.Note) { 189 | _, upsertErr := s.S.Delete(). 190 | Index("notes"). 191 | Id(n.ID). 192 | Do(ctx) 193 | if upsertErr != nil { 194 | log.Printf("Failed to delete note in elasticsearch: %v", upsertErr) 195 | } 196 | } 197 | 198 | func GetNotesFilter(val string) model.GetNotesOption { 199 | return func(m map[string]interface{}) { 200 | if len(val) > 0 { 201 | m["filter"] = strings.ToLower(val) 202 | } 203 | } 204 | } 205 | 206 | func GetNotesSearch(val string) model.GetNotesOption { 207 | return func(m map[string]interface{}) { 208 | if len(val) > 0 { 209 | m["search"] = strings.ToLower(val) 210 | } 211 | } 212 | } 213 | 214 | func GetNotesTags(val string) model.GetNotesOption { 215 | return func(m map[string]interface{}) { 216 | if len(val) > 0 { 217 | m["tags"] = strings.ToLower(val) 218 | } 219 | } 220 | } 221 | 222 | func GetNotesPins() model.GetNotesOption { 223 | return func(m map[string]interface{}) { 224 | m["pins"] = true 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /db/thread.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "cloud.google.com/go/datastore" 9 | 10 | "github.com/hiconvo/api/clients/db" 11 | "github.com/hiconvo/api/errors" 12 | "github.com/hiconvo/api/model" 13 | ) 14 | 15 | var _ model.ThreadStore = (*ThreadStore)(nil) 16 | 17 | type ThreadStore struct { 18 | DB db.Client 19 | } 20 | 21 | func (s *ThreadStore) GetThreadByID(ctx context.Context, id string) (*model.Thread, error) { 22 | var t = new(model.Thread) 23 | 24 | key, err := datastore.DecodeKey(id) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return s.handleGetThread(ctx, key, t) 30 | } 31 | 32 | func (s *ThreadStore) GetThreadByInt64ID(ctx context.Context, id int64) (*model.Thread, error) { 33 | var t = new(model.Thread) 34 | 35 | key := datastore.IDKey("Thread", id, nil) 36 | 37 | return s.handleGetThread(ctx, key, t) 38 | } 39 | 40 | func (s *ThreadStore) GetUnhydratedThreadsByUser( 41 | ctx context.Context, 42 | u *model.User, 43 | p *model.Pagination, 44 | ) ([]*model.Thread, error) { 45 | var threads []*model.Thread 46 | 47 | q := datastore.NewQuery("Thread"). 48 | Filter("UserKeys =", u.Key). 49 | Order("-UpdatedAt"). 50 | Offset(p.Offset()). 51 | Limit(p.Limit()) 52 | 53 | _, err := s.DB.GetAll(ctx, q, &threads) 54 | if err != nil { 55 | return threads, err 56 | } 57 | 58 | return threads, nil 59 | } 60 | 61 | func (s *ThreadStore) GetThreadsByUser(ctx context.Context, u *model.User, p *model.Pagination) ([]*model.Thread, error) { 62 | op := errors.Opf("ThreadStore.GetThreadsByUser(%q)", u.Email) 63 | // Get all of the threads of which the user is a member 64 | threads, err := s.GetUnhydratedThreadsByUser(ctx, u, p) 65 | if err != nil { 66 | return threads, errors.E(op, err) 67 | } 68 | 69 | // Now that we have the threads, we need to get the users. We keep track of 70 | // where the users of one thread start and another begin by incrementing 71 | // an index. 72 | var ( 73 | userKeys []*datastore.Key 74 | idxs []int 75 | ) 76 | for _, t := range threads { 77 | userKeys = append(userKeys, t.UserKeys...) 78 | idxs = append(idxs, len(t.UserKeys)) 79 | } 80 | 81 | // We get all of the users in one go. 82 | userPtrs := make([]*model.User, len(userKeys)) 83 | if err := s.DB.GetMulti(ctx, userKeys, userPtrs); err != nil { 84 | return threads, errors.E(op, err) 85 | } 86 | 87 | // We add the just retrieved user objects to their corresponding threads by 88 | // iterating through all of the threads and assigning their users according 89 | // to the index which we created above. 90 | // 91 | // We also create a new slice of pointers to threads which we'll finally 92 | // return. 93 | start := 0 94 | threadPtrs := make([]*model.Thread, len(threads)) 95 | for i := range threads { 96 | threadUsers := userPtrs[start : start+idxs[i]] 97 | 98 | var owner *model.User 99 | for j := range threadUsers { 100 | if threads[i].OwnerKey.Equal(threadUsers[j].Key) { 101 | owner = threadUsers[j] 102 | break 103 | } 104 | } 105 | 106 | threads[i].Users = threadUsers 107 | threads[i].Owner = model.MapUserToUserPartial(owner) 108 | threads[i].UserPartials = model.MapUsersToUserPartials(threadUsers) 109 | threads[i].UserReads = model.MapReadsToUserPartials(threads[i], threadUsers) 110 | 111 | start += idxs[i] 112 | threadPtrs[i] = threads[i] 113 | } 114 | 115 | return threadPtrs, nil 116 | } 117 | 118 | func (s *ThreadStore) Commit(ctx context.Context, t *model.Thread) error { 119 | if t.CreatedAt.IsZero() { 120 | t.CreatedAt = time.Now() 121 | } 122 | 123 | t.UpdatedAt = time.Now() 124 | 125 | key, err := s.DB.Put(ctx, t.Key, t) 126 | if err != nil { 127 | return errors.E(errors.Op("thread.Commit"), err) 128 | } 129 | 130 | t.ID = key.Encode() 131 | t.Key = key 132 | 133 | return nil 134 | } 135 | 136 | func (s *ThreadStore) CommitMulti(ctx context.Context, threads []*model.Thread) error { 137 | keys := make([]*datastore.Key, len(threads)) 138 | for i := range threads { 139 | keys[i] = threads[i].Key 140 | } 141 | 142 | if _, err := s.DB.PutMulti(ctx, keys, threads); err != nil { 143 | return errors.E(errors.Op("ThreadStore.CommitMulti"), err) 144 | } 145 | 146 | return nil 147 | } 148 | 149 | func (s *ThreadStore) CommitWithTransaction(tx db.Transaction, t *model.Thread) (*datastore.PendingKey, error) { 150 | if t.CreatedAt.IsZero() { 151 | t.CreatedAt = time.Now() 152 | } 153 | 154 | t.UpdatedAt = time.Now() 155 | 156 | return tx.Put(t.Key, t) 157 | } 158 | 159 | func (s *ThreadStore) Delete(ctx context.Context, t *model.Thread) error { 160 | if err := s.DB.Delete(ctx, t.Key); err != nil { 161 | return err 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func (s *ThreadStore) AllocateKey(ctx context.Context) (*datastore.Key, error) { 168 | keys, err := s.DB.AllocateIDs(ctx, []*datastore.Key{datastore.IncompleteKey("Thread", nil)}) 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | return keys[0], nil 174 | } 175 | 176 | func (s *ThreadStore) handleGetThread(ctx context.Context, key *datastore.Key, t *model.Thread) (*model.Thread, error) { 177 | if err := s.DB.Get(ctx, key, t); err != nil { 178 | if errors.Is(err, datastore.ErrNoSuchEntity) { 179 | return nil, errors.E(errors.Op("ThreadStore.handleGetThread"), err, http.StatusNotFound) 180 | } 181 | 182 | return t, err 183 | } 184 | 185 | users := make([]model.User, len(t.UserKeys)) 186 | if err := s.DB.GetMulti(ctx, t.UserKeys, users); err != nil { 187 | return t, err 188 | } 189 | 190 | var ( 191 | userPointers = make([]*model.User, len(users)) 192 | owner model.User 193 | ) 194 | 195 | for i := range users { 196 | userPointers[i] = &users[i] 197 | 198 | if t.OwnerKey.Equal(users[i].Key) { 199 | owner = users[i] 200 | } 201 | } 202 | 203 | t.Users = userPointers 204 | t.UserPartials = model.MapUsersToUserPartials(userPointers) 205 | t.UserReads = model.MapReadsToUserPartials(t, userPointers) 206 | t.Owner = model.MapUserToUserPartial(&owner) 207 | 208 | return t, nil 209 | } 210 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | api: 5 | build: . 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | - DATASTORE_PROJECT_ID=local-convo-api 10 | - DATASTORE_LISTEN_ADDRESS=datastore:8081 11 | - DATASTORE_DATASET=local-convo-api 12 | - DATASTORE_EMULATOR_HOST=datastore:8081 13 | - DATASTORE_EMULATOR_HOST_PATH=datastore:8081/datastore 14 | - DATASTORE_HOST=http://datastore:8081 15 | env_file: 16 | - ./.env 17 | volumes: 18 | - .:/var/www 19 | links: 20 | - datastore 21 | - elasticsearch 22 | datastore: 23 | image: singularities/datastore-emulator 24 | environment: 25 | - DATASTORE_PROJECT_ID=local-convo-api 26 | - DATASTORE_LISTEN_ADDRESS=0.0.0.0:8081 27 | command: --consistency=1.0 28 | ports: 29 | - "8081" 30 | elasticsearch: 31 | image: elasticsearch:7.1.1 32 | ports: 33 | - "9200" 34 | environment: 35 | - cluster.name=docker-cluster 36 | - bootstrap.memory_lock=true 37 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 38 | - discovery.type=single-node 39 | ulimits: 40 | memlock: 41 | soft: -1 42 | hard: -1 43 | -------------------------------------------------------------------------------- /errors/errors.go: -------------------------------------------------------------------------------- 1 | // Package errors defines error handling resources used in Convo's app. 2 | // It is based on patterns developed at Upspin: 3 | // https://commandcenter.blogspot.com/2017/12/error-handling-in-upspin.html 4 | package errors 5 | 6 | import ( 7 | native "errors" 8 | "fmt" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | // ClientReporter provides information about an error such that client and 14 | // server errors can be distinguished and handled appropriately. 15 | type ClientReporter interface { 16 | error 17 | ClientReport() map[string]string 18 | StatusCode() int 19 | } 20 | 21 | // Error is the type that implements the error interface. 22 | // It contains a number of fields, each of different type. 23 | // An Error value may leave some values unset. 24 | type Error struct { 25 | err error 26 | code int 27 | op Op 28 | messages map[string]string 29 | } 30 | 31 | // Op describes an operation, usually as the package and method, 32 | // such as "models.GetUser". 33 | type Op string 34 | 35 | // E creates a new Error instance. The extras arguments can be (1) an error, (2) 36 | // a message for the client as map[string]string, or (3) an HTTP status code. If 37 | // one of the extras is an error that implements ClientReporter, its messages, 38 | // if it has any, are merged into the new error's messages. 39 | func E(op Op, extras ...interface{}) error { 40 | e := &Error{ 41 | op: op, 42 | code: http.StatusInternalServerError, 43 | messages: map[string]string{}, 44 | } 45 | 46 | for _, ex := range extras { 47 | switch t := ex.(type) { 48 | case ClientReporter: 49 | // Merge client reports. If it is attempted to write the same key more 50 | // than once, the later write always wins. 51 | for k, v := range t.ClientReport() { 52 | if _, has := e.messages[k]; !has { 53 | e.messages[k] = v 54 | } 55 | } 56 | 57 | // If there is more than one error, which, as a best practice, there 58 | // shouldn't be, the last error wins. 59 | e.err = t 60 | 61 | // If the code has already been set to something other than the default 62 | // don't reset it; otherwise, inherit from t. 63 | if e.code == http.StatusInternalServerError { 64 | e.code = t.StatusCode() 65 | } 66 | case int: 67 | e.code = t 68 | case error: 69 | e.err = t 70 | case map[string]string: 71 | // New error messages win. 72 | for k, v := range t { 73 | e.messages[k] = v 74 | } 75 | } 76 | } 77 | 78 | return e 79 | } 80 | 81 | // Error returns a string with information about the error for debugging purposes. 82 | // This value should not be returned to the user. 83 | func (e *Error) Error() string { 84 | b := new(strings.Builder) 85 | b.WriteString(fmt.Sprintf("%s: ", string(e.op))) 86 | 87 | if e.err != nil { 88 | b.WriteString(e.err.Error()) 89 | } 90 | 91 | return b.String() 92 | } 93 | 94 | // ClientReport returns a map of strings suitable to be returned to the end user. 95 | func (e *Error) ClientReport() map[string]string { 96 | if len(e.messages) == 0 { 97 | switch e.code { 98 | case http.StatusBadRequest: 99 | return map[string]string{"message": "The request was invalid"} 100 | case http.StatusUnauthorized: 101 | return map[string]string{"message": "Unauthorized"} 102 | case http.StatusForbidden: 103 | return map[string]string{"message": "You do not have permission to perform this action"} 104 | case http.StatusNotFound: 105 | return map[string]string{"message": "The requested resource was not found"} 106 | case http.StatusUnsupportedMediaType: 107 | return map[string]string{"message": "Unsupported content-type"} 108 | default: 109 | return map[string]string{"message": "Something went wrong"} 110 | } 111 | } 112 | 113 | return e.messages 114 | } 115 | 116 | // StatusCode returns the HTTP status code for the error. 117 | func (e *Error) StatusCode() int { 118 | if e.code >= http.StatusBadRequest { 119 | return e.code 120 | } 121 | 122 | return http.StatusInternalServerError 123 | } 124 | 125 | // Errorf is the same things as fmt.Errorf. It is exported for convenience and so that 126 | // this package can handle all errors. 127 | func Errorf(format string, args ...interface{}) error { 128 | return fmt.Errorf(format, args...) 129 | } 130 | 131 | // Str returns an error from the given string. 132 | func Str(s string) error { 133 | return fmt.Errorf(s) 134 | } 135 | 136 | // Opf returns an Op from the given format string. 137 | func Opf(format string, args ...interface{}) Op { 138 | return Op(fmt.Sprintf(format, args...)) 139 | } 140 | 141 | func Is(err, target error) bool { 142 | return native.Is(err, target) 143 | } 144 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hiconvo/api 2 | 3 | go 1.13 4 | 5 | require ( 6 | cloud.google.com/go v0.52.0 7 | cloud.google.com/go/datastore v1.1.0 8 | github.com/GetStream/stream-go2 v3.2.1+incompatible // indirect 9 | github.com/PuerkitoBio/goquery v1.5.1 // indirect 10 | github.com/arran4/golang-ical v0.0.0-20200314043952-7d4940584ecd 11 | github.com/aymerick/douceur v0.2.0 12 | github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 // indirect 13 | github.com/corpix/uarand v0.1.1 // indirect 14 | github.com/dyatlov/go-htmlinfo v0.0.0-20180517114536-d9417c75de65 15 | github.com/dyatlov/go-oembed v0.0.0-20191103150536-a57c85b3b37c // indirect 16 | github.com/dyatlov/go-opengraph v0.0.0-20180429202543-816b6608b3c8 // indirect 17 | github.com/dyatlov/go-readability v0.0.0-20150926130635-e7b2080f87f8 // indirect 18 | github.com/fatih/structs v1.1.0 // indirect 19 | github.com/getsentry/raven-go v0.2.0 20 | github.com/gofrs/uuid v3.3.0+incompatible 21 | github.com/gorilla/css v1.0.0 // indirect 22 | github.com/gorilla/handlers v1.4.2 23 | github.com/gorilla/mux v1.7.4 24 | github.com/gosimple/slug v1.9.0 25 | github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428 26 | github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 27 | github.com/microcosm-cc/bluemonday v1.0.2 28 | github.com/mitchellh/mapstructure v1.3.0 // indirect 29 | github.com/olekukonko/tablewriter v0.0.4 // indirect 30 | github.com/olivere/elastic/v7 v7.0.15 31 | github.com/otiai10/opengraph v1.1.1 32 | github.com/russross/blackfriday/v2 v2.0.1 33 | github.com/sendgrid/rest v2.4.1+incompatible // indirect 34 | github.com/sendgrid/sendgrid-go v3.5.0+incompatible 35 | github.com/sergi/go-diff v1.1.0 // indirect 36 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 37 | github.com/steinfletcher/apitest v1.4.6 38 | github.com/steinfletcher/apitest-jsonpath v1.5.0 39 | github.com/stretchr/testify v1.5.1 40 | go.mongodb.org/mongo-driver v1.4.0 41 | gocloud.dev v0.19.0 42 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 43 | google.golang.org/api v0.17.0 44 | google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce 45 | googlemaps.github.io/maps v1.1.2 46 | gopkg.in/GetStream/stream-go2.v1 v1.14.0 47 | gopkg.in/LeisureLink/httpsig.v1 v1.2.0 // indirect 48 | gopkg.in/dgrijalva/jwt-go.v3 v3.2.0 // indirect 49 | gopkg.in/validator.v2 v2.0.0-20191107172027-c3144fdedc21 50 | mvdan.cc/xurls/v2 v2.2.0 51 | ) 52 | -------------------------------------------------------------------------------- /handler/contact/contact.go: -------------------------------------------------------------------------------- 1 | package contact 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | 8 | "github.com/hiconvo/api/bjson" 9 | "github.com/hiconvo/api/errors" 10 | "github.com/hiconvo/api/handler/middleware" 11 | "github.com/hiconvo/api/model" 12 | ) 13 | 14 | type Config struct { 15 | UserStore model.UserStore 16 | } 17 | 18 | func NewHandler(c *Config) *mux.Router { 19 | r := mux.NewRouter() 20 | 21 | r.Use(middleware.WithUser(c.UserStore)) 22 | r.HandleFunc("/contacts", c.GetContacts).Methods("GET") 23 | r.HandleFunc("/contacts/{userID}", c.AddContact).Methods("POST") 24 | r.HandleFunc("/contacts/{userID}", c.RemoveContact).Methods("DELETE") 25 | 26 | return r 27 | } 28 | 29 | func (c *Config) GetContacts(w http.ResponseWriter, r *http.Request) { 30 | ctx := r.Context() 31 | u := middleware.UserFromContext(ctx) 32 | 33 | contacts, err := c.UserStore.GetContactsByUser(ctx, u) 34 | if err != nil { 35 | bjson.HandleError(w, err) 36 | return 37 | } 38 | 39 | bjson.WriteJSON(w, 40 | map[string][]*model.UserPartial{"contacts": model.MapUsersToUserPartials(contacts)}, 41 | http.StatusOK) 42 | } 43 | 44 | func (c *Config) AddContact(w http.ResponseWriter, r *http.Request) { 45 | ctx := r.Context() 46 | u := middleware.UserFromContext(ctx) 47 | vars := mux.Vars(r) 48 | userID := vars["userID"] 49 | 50 | if !u.IsRegistered() { 51 | bjson.HandleError(w, errors.E( 52 | errors.Op("handlers.AddContact"), 53 | errors.Str("not verified"), 54 | map[string]string{"message": "You must register before you can add contacts"}, 55 | http.StatusBadRequest)) 56 | return 57 | } 58 | 59 | userToBeAdded, err := c.UserStore.GetUserByID(ctx, userID) 60 | if err != nil { 61 | bjson.HandleError(w, err) 62 | return 63 | } 64 | 65 | if err := u.AddContact(userToBeAdded); err != nil { 66 | bjson.HandleError(w, err) 67 | return 68 | } 69 | 70 | if err := c.UserStore.Commit(ctx, u); err != nil { 71 | bjson.HandleError(w, err) 72 | return 73 | } 74 | 75 | bjson.WriteJSON(w, model.MapUserToUserPartial(userToBeAdded), http.StatusCreated) 76 | } 77 | 78 | func (c *Config) RemoveContact(w http.ResponseWriter, r *http.Request) { 79 | ctx := r.Context() 80 | u := middleware.UserFromContext(ctx) 81 | vars := mux.Vars(r) 82 | userID := vars["userID"] 83 | 84 | userToBeRemoved, err := c.UserStore.GetUserByID(ctx, userID) 85 | if err != nil { 86 | bjson.HandleError(w, err) 87 | return 88 | } 89 | 90 | if err := u.RemoveContact(userToBeRemoved); err != nil { 91 | bjson.HandleError(w, err) 92 | return 93 | } 94 | 95 | if err := c.UserStore.Commit(ctx, u); err != nil { 96 | bjson.HandleError(w, err) 97 | return 98 | } 99 | 100 | bjson.WriteJSON(w, model.MapUserToUserPartial(userToBeRemoved), http.StatusOK) 101 | } 102 | -------------------------------------------------------------------------------- /handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | 8 | "github.com/hiconvo/api/bjson" 9 | "github.com/hiconvo/api/clients/db" 10 | "github.com/hiconvo/api/clients/magic" 11 | notif "github.com/hiconvo/api/clients/notification" 12 | "github.com/hiconvo/api/clients/oauth" 13 | "github.com/hiconvo/api/clients/opengraph" 14 | "github.com/hiconvo/api/clients/places" 15 | "github.com/hiconvo/api/clients/pluck" 16 | "github.com/hiconvo/api/clients/queue" 17 | "github.com/hiconvo/api/clients/storage" 18 | "github.com/hiconvo/api/handler/contact" 19 | "github.com/hiconvo/api/handler/event" 20 | "github.com/hiconvo/api/handler/inbound" 21 | "github.com/hiconvo/api/handler/middleware" 22 | "github.com/hiconvo/api/handler/note" 23 | "github.com/hiconvo/api/handler/task" 24 | "github.com/hiconvo/api/handler/thread" 25 | "github.com/hiconvo/api/handler/user" 26 | "github.com/hiconvo/api/mail" 27 | "github.com/hiconvo/api/model" 28 | ) 29 | 30 | type Config struct { 31 | DB db.Client 32 | Transacter db.Transacter 33 | UserStore model.UserStore 34 | ThreadStore model.ThreadStore 35 | EventStore model.EventStore 36 | MessageStore model.MessageStore 37 | NoteStore model.NoteStore 38 | Welcome model.Welcomer 39 | TxnMiddleware mux.MiddlewareFunc 40 | Mail *mail.Client 41 | Magic magic.Client 42 | OAuth oauth.Client 43 | Storage *storage.Client 44 | Notif notif.Client 45 | OG opengraph.Client 46 | Places places.Client 47 | Queue queue.Client 48 | } 49 | 50 | func New(c *Config) http.Handler { 51 | router := mux.NewRouter() 52 | 53 | router.NotFoundHandler = http.HandlerFunc(notFound) 54 | router.HandleFunc("/_ah/warmup", warmup) 55 | 56 | s := router.NewRoute().Subrouter() 57 | s.PathPrefix("/inbound").Handler(inbound.NewHandler(&inbound.Config{ 58 | Pluck: pluck.NewClient(), 59 | UserStore: c.UserStore, 60 | ThreadStore: c.ThreadStore, 61 | MessageStore: c.MessageStore, 62 | Mail: c.Mail, 63 | Magic: c.Magic, 64 | OG: c.OG, 65 | Storage: c.Storage, 66 | })) 67 | s.PathPrefix("/tasks").Handler(task.NewHandler(&task.Config{ 68 | DB: c.DB, 69 | UserStore: c.UserStore, 70 | ThreadStore: c.ThreadStore, 71 | EventStore: c.EventStore, 72 | MessageStore: c.MessageStore, 73 | Welcome: c.Welcome, 74 | Mail: c.Mail, 75 | Magic: c.Magic, 76 | Storage: c.Storage, 77 | })) 78 | 79 | t := router.NewRoute().Subrouter() 80 | t.Use(middleware.WithJSONRequests) 81 | 82 | t.PathPrefix("/users").Handler(user.NewHandler(&user.Config{ 83 | Transacter: c.Transacter, 84 | UserStore: c.UserStore, 85 | ThreadStore: c.ThreadStore, 86 | EventStore: c.EventStore, 87 | MessageStore: c.MessageStore, 88 | NoteStore: c.NoteStore, 89 | Mail: c.Mail, 90 | Magic: c.Magic, 91 | OA: c.OAuth, 92 | Storage: c.Storage, 93 | Welcome: c.Welcome, 94 | })) 95 | t.PathPrefix("/contacts").Handler(contact.NewHandler(&contact.Config{ 96 | UserStore: c.UserStore, 97 | })) 98 | t.PathPrefix("/threads").Handler(thread.NewHandler(&thread.Config{ 99 | UserStore: c.UserStore, 100 | ThreadStore: c.ThreadStore, 101 | MessageStore: c.MessageStore, 102 | TxnMiddleware: c.TxnMiddleware, 103 | Mail: c.Mail, 104 | Magic: c.Magic, 105 | Storage: c.Storage, 106 | Notif: c.Notif, 107 | OG: c.OG, 108 | Queue: c.Queue, 109 | })) 110 | t.PathPrefix("/events").Handler(event.NewHandler(&event.Config{ 111 | UserStore: c.UserStore, 112 | EventStore: c.EventStore, 113 | MessageStore: c.MessageStore, 114 | TxnMiddleware: c.TxnMiddleware, 115 | Mail: c.Mail, 116 | Magic: c.Magic, 117 | Storage: c.Storage, 118 | Notif: c.Notif, 119 | OG: c.OG, 120 | Places: c.Places, 121 | Queue: c.Queue, 122 | })) 123 | t.PathPrefix("/notes").Handler(note.NewHandler(¬e.Config{ 124 | UserStore: c.UserStore, 125 | NoteStore: c.NoteStore, 126 | OG: c.OG, 127 | })) 128 | 129 | h := middleware.WithCORS(router) 130 | h = middleware.WithLogging(h) 131 | h = middleware.WithErrorReporting(h) 132 | 133 | return h 134 | } 135 | 136 | func notFound(w http.ResponseWriter, r *http.Request) { 137 | bjson.WriteJSON(w, map[string]string{"message": "Not found"}, http.StatusNotFound) 138 | } 139 | 140 | func warmup(w http.ResponseWriter, r *http.Request) { 141 | w.WriteHeader(http.StatusOK) 142 | w.Write([]byte("Shall I by justice reach the higher stronghold, or by deceit? -Pindar")) 143 | } 144 | -------------------------------------------------------------------------------- /handler/inbound/inbound.go: -------------------------------------------------------------------------------- 1 | package inbound 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | 10 | "github.com/hiconvo/api/clients/magic" 11 | "github.com/hiconvo/api/clients/opengraph" 12 | "github.com/hiconvo/api/clients/pluck" 13 | "github.com/hiconvo/api/clients/storage" 14 | "github.com/hiconvo/api/errors" 15 | "github.com/hiconvo/api/log" 16 | "github.com/hiconvo/api/mail" 17 | "github.com/hiconvo/api/model" 18 | "github.com/hiconvo/api/valid" 19 | ) 20 | 21 | type Config struct { 22 | Pluck pluck.Client 23 | UserStore model.UserStore 24 | ThreadStore model.ThreadStore 25 | MessageStore model.MessageStore 26 | Magic magic.Client 27 | Mail *mail.Client 28 | OG opengraph.Client 29 | Storage *storage.Client 30 | } 31 | 32 | func NewHandler(c *Config) *mux.Router { 33 | r := mux.NewRouter() 34 | 35 | r.HandleFunc("/inbound", c.Inbound).Methods("POST") 36 | 37 | return r 38 | } 39 | 40 | type inboundMessagePayload struct { 41 | Body string `validate:"nonzero"` 42 | } 43 | 44 | func (c *Config) Inbound(w http.ResponseWriter, r *http.Request) { 45 | op := errors.Op("handler.Inbound") 46 | ctx := r.Context() 47 | 48 | if err := r.ParseMultipartForm(10485760); err != nil { 49 | handleClientErrorResponse(w, err) 50 | return 51 | } 52 | 53 | encodedEnvelope := r.PostFormValue("envelope") 54 | to, from, err := c.Pluck.AddressesFromEnvelope(encodedEnvelope) 55 | 56 | if err != nil { 57 | handleClientErrorResponse(w, err) 58 | return 59 | } 60 | 61 | // Get thread id from address 62 | threadID, err := c.Pluck.ThreadInt64IDFromAddress(to) 63 | if err != nil { 64 | handleClientErrorResponse(w, err) 65 | return 66 | } 67 | 68 | // Get the thread 69 | thread, err := c.ThreadStore.GetThreadByInt64ID(ctx, threadID) 70 | if err != nil { 71 | if err := c.Mail.SendInboundTryAgainEmail(from); err != nil { 72 | log.Alarm(errors.E(op, err)) 73 | } 74 | 75 | handleClientErrorResponse(w, err) 76 | 77 | return 78 | } 79 | 80 | // Get user from from address 81 | user, found, err := c.UserStore.GetUserByEmail(ctx, from) 82 | if !found { 83 | if err := c.Mail.SendInboundErrorEmail(from); err != nil { 84 | log.Alarm(errors.E(op, err)) 85 | } 86 | 87 | handleClientErrorResponse(w, errors.E(op, errors.Str("Email not recognized"))) 88 | 89 | return 90 | } else if err != nil { 91 | handleClientErrorResponse(w, errors.E(op, err)) 92 | return 93 | } 94 | 95 | // Verify that the user is a particiapant of the thread 96 | if !(thread.OwnerIs(user) || thread.HasUser(user)) { 97 | if err := c.Mail.SendInboundErrorEmail(from); err != nil { 98 | log.Alarm(errors.E(op, err)) 99 | } 100 | 101 | handleClientErrorResponse(w, errors.E(op, errors.Str("permission denied"))) 102 | 103 | return 104 | } 105 | 106 | // Pluck the new message 107 | htmlMessage := html.UnescapeString(r.FormValue("html")) 108 | textMessage := r.FormValue("text") 109 | 110 | messageText, err := c.Pluck.MessageText(htmlMessage, textMessage, from, to) 111 | if err != nil { 112 | handleClientErrorResponse(w, errors.E(op, err)) 113 | return 114 | } 115 | 116 | // Validate and sanitize 117 | var payload = inboundMessagePayload{Body: messageText} 118 | if err := valid.Raw(&payload); err != nil { 119 | handleClientErrorResponse(w, errors.E(op, err)) 120 | return 121 | } 122 | 123 | // Create the new message 124 | message, err := model.NewMessage( 125 | ctx, 126 | c.Storage, 127 | c.OG, 128 | &model.NewMessageInput{ 129 | User: user, 130 | Parent: thread.Key, 131 | Body: html.UnescapeString(payload.Body), 132 | }) 133 | if err != nil { 134 | handleServerErrorResponse(w, err) 135 | return 136 | } 137 | 138 | model.ClearReads(thread) 139 | model.MarkAsRead(thread, user.Key) 140 | 141 | if err := c.MessageStore.Commit(ctx, message); err != nil { 142 | handleServerErrorResponse(w, err) 143 | return 144 | } 145 | 146 | if err := c.ThreadStore.Commit(ctx, thread); err != nil { 147 | handleServerErrorResponse(w, err) 148 | return 149 | } 150 | 151 | messages, err := c.MessageStore.GetMessagesByThread(ctx, thread, &model.Pagination{Size: 5}) 152 | if err != nil { 153 | handleServerErrorResponse(w, err) 154 | return 155 | } 156 | 157 | if err := c.Mail.SendThread(c.Magic, thread, messages); err != nil { 158 | handleServerErrorResponse(w, err) 159 | return 160 | } 161 | 162 | w.WriteHeader(http.StatusOK) 163 | w.Write([]byte(fmt.Sprintf("PASS: message %s created", message.ID))) 164 | } 165 | 166 | func handleClientErrorResponse(w http.ResponseWriter, err error) { 167 | log.Alarm(errors.E(errors.Op("inboundClientError"), err)) 168 | w.WriteHeader(http.StatusOK) 169 | } 170 | 171 | func handleServerErrorResponse(w http.ResponseWriter, err error) { 172 | log.Alarm(errors.E(errors.Op("inboundInternalError"), err)) 173 | w.WriteHeader(http.StatusInternalServerError) 174 | } 175 | -------------------------------------------------------------------------------- /handler/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | "github.com/getsentry/raven-go" 10 | "github.com/gorilla/handlers" 11 | "github.com/gorilla/mux" 12 | 13 | "github.com/hiconvo/api/bjson" 14 | "github.com/hiconvo/api/clients/db" 15 | "github.com/hiconvo/api/errors" 16 | "github.com/hiconvo/api/model" 17 | ) 18 | 19 | type contextKey int 20 | 21 | const ( 22 | userKey contextKey = iota 23 | threadKey 24 | eventKey 25 | noteKey 26 | ) 27 | 28 | // WithLogging logs requests to stdout. 29 | func WithLogging(next http.Handler) http.Handler { 30 | return handlers.LoggingHandler(os.Stdout, next) 31 | } 32 | 33 | // WithErrorReporting reports errors to Sentry. 34 | func WithErrorReporting(next http.Handler) http.Handler { 35 | return raven.Recoverer(next) 36 | } 37 | 38 | // nolint 39 | var corsHandler = handlers.CORS( 40 | handlers.AllowedOrigins([]string{"*"}), 41 | handlers.AllowedMethods([]string{"GET", "PATCH", "POST", "DELETE"}), 42 | handlers.AllowedHeaders([]string{"Content-Type", "Authorization"}), 43 | ) 44 | 45 | // WithCORS adds OPTIONS endpoints and validates CORS permissions and validation. 46 | func WithCORS(next http.Handler) http.Handler { 47 | return corsHandler(next) 48 | } 49 | 50 | // WithJSONRequests is middleware that ensures that a content-type of "application/json" 51 | // is set on all write POST, PUT, and PATCH requets. 52 | func WithJSONRequests(next http.Handler) http.Handler { 53 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | if isWriteRequest(r.Method) { 55 | if ct := r.Header.Get("Content-Type"); ct != "application/json" { 56 | bjson.HandleError(w, errors.E( 57 | errors.Op("bjson.WithJSONRequests"), 58 | errors.Str("correct header not present"), 59 | http.StatusUnsupportedMediaType)) 60 | return 61 | } 62 | } 63 | 64 | next.ServeHTTP(w, r) 65 | }) 66 | } 67 | 68 | func isWriteRequest(method string) bool { 69 | return method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete 70 | } 71 | 72 | // UserFromContext returns the User object that was added to the context via 73 | // WithUser middleware. 74 | func UserFromContext(ctx context.Context) *model.User { 75 | return ctx.Value(userKey).(*model.User) 76 | } 77 | 78 | // WithUser adds the authenticated user to the context. If the user cannot be 79 | // found, then a 401 unauthorized response is returned. 80 | func WithUser(s model.UserStore) func(http.Handler) http.Handler { 81 | return func(next http.Handler) http.Handler { 82 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 | var op errors.Op = "middleware.WithUser" 84 | 85 | if token, ok := GetAuthToken(r.Header); ok { 86 | ctx := r.Context() 87 | user, ok, err := s.GetUserByToken(ctx, token) 88 | if err != nil { 89 | bjson.HandleError(w, errors.E(op, err)) 90 | return 91 | } 92 | 93 | if ok { 94 | next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, userKey, user))) 95 | return 96 | } 97 | } 98 | 99 | bjson.HandleError(w, errors.E(op, http.StatusUnauthorized, errors.Str("no token"))) 100 | }) 101 | } 102 | } 103 | 104 | // ThreadFromContext returns the Thread object that was added to the context via 105 | // WithThread middleware. 106 | func ThreadFromContext(ctx context.Context) *model.Thread { 107 | return ctx.Value(threadKey).(*model.Thread) 108 | } 109 | 110 | // WithThread adds the thread indicated in the url to the context. If the thread 111 | // cannot be found, then a 404 response is returned. 112 | func WithThread(s model.ThreadStore) func(http.Handler) http.Handler { 113 | return func(next http.Handler) http.Handler { 114 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 115 | ctx := r.Context() 116 | vars := mux.Vars(r) 117 | id := vars["threadID"] 118 | 119 | thread, err := s.GetThreadByID(ctx, id) 120 | if err != nil { 121 | bjson.HandleError(w, errors.E(errors.Op("middleware.WithThread"), http.StatusNotFound, err)) 122 | return 123 | } 124 | 125 | next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, threadKey, thread))) 126 | }) 127 | } 128 | } 129 | 130 | // EventFromContext returns the Event object that was added to the context via 131 | // WithEvent middleware. 132 | func EventFromContext(ctx context.Context) *model.Event { 133 | return ctx.Value(eventKey).(*model.Event) 134 | } 135 | 136 | // WithEvent adds the thread indicated in the url to the context. If the thread 137 | // cannot be found, then a 404 response is returned. 138 | func WithEvent(s model.EventStore) func(http.Handler) http.Handler { 139 | return func(next http.Handler) http.Handler { 140 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 141 | ctx := r.Context() 142 | vars := mux.Vars(r) 143 | id := vars["eventID"] 144 | 145 | event, err := s.GetEventByID(ctx, id) 146 | if err != nil { 147 | bjson.HandleError(w, errors.E(errors.Op("middleware.WithEvent"), http.StatusNotFound, err)) 148 | return 149 | } 150 | 151 | next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, eventKey, event))) 152 | }) 153 | } 154 | } 155 | 156 | // NoteFromContext returns the Note object that was added to the context via 157 | // WithNote middleware. 158 | func NoteFromContext(ctx context.Context) *model.Note { 159 | return ctx.Value(noteKey).(*model.Note) 160 | } 161 | 162 | // WithEvent adds the thread indicated in the url to the context. If the thread 163 | // cannot be found, then a 404 response is returned. 164 | func WithNote(s model.NoteStore) func(http.Handler) http.Handler { 165 | return func(next http.Handler) http.Handler { 166 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 | op := errors.Op("middleware.WithNote") 168 | ctx := r.Context() 169 | u := UserFromContext(ctx) 170 | vars := mux.Vars(r) 171 | id := vars["noteID"] 172 | 173 | n, err := s.GetNoteByID(ctx, id) 174 | if err != nil { 175 | bjson.HandleError(w, errors.E(op, http.StatusNotFound, err)) 176 | return 177 | } 178 | 179 | if !n.OwnerKey.Equal(u.Key) { 180 | bjson.HandleError(w, errors.E( 181 | op, errors.Str("no permission"), http.StatusNotFound)) 182 | return 183 | } 184 | 185 | next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, noteKey, n))) 186 | }) 187 | } 188 | } 189 | 190 | // TransactionFromContext extracts a transaction from the given 191 | // context is one is present. 192 | func TransactionFromContext(ctx context.Context) (db.Transaction, bool) { 193 | return db.TransactionFromContext(ctx) 194 | } 195 | 196 | // AddTransactionToContext returns a new context with a transaction added. 197 | func AddTransactionToContext(ctx context.Context, c db.Client) (context.Context, db.Transaction, error) { 198 | return db.AddTransactionToContext(ctx, c) 199 | } 200 | 201 | // WithTransaction is middleware that adds a transaction to the request context. 202 | func WithTransaction(c db.Client) func(http.Handler) http.Handler { 203 | return db.WithTransaction(c) 204 | } 205 | 206 | // GetAuthToken extracts the Authorization Bearer token from request 207 | // headers if present. 208 | func GetAuthToken(h http.Header) (string, bool) { 209 | if val := h.Get("Authorization"); val != "" && len(val) >= 7 { 210 | if strings.ToLower(val[:7]) == "bearer " { 211 | return val[7:], true 212 | } 213 | } 214 | 215 | return "", false 216 | } 217 | -------------------------------------------------------------------------------- /handler/note/note.go: -------------------------------------------------------------------------------- 1 | package note 2 | 3 | import ( 4 | "html" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | "github.com/hiconvo/api/bjson" 10 | "github.com/hiconvo/api/clients/opengraph" 11 | "github.com/hiconvo/api/db" 12 | "github.com/hiconvo/api/errors" 13 | "github.com/hiconvo/api/handler/middleware" 14 | "github.com/hiconvo/api/log" 15 | "github.com/hiconvo/api/model" 16 | "github.com/hiconvo/api/valid" 17 | ) 18 | 19 | type Config struct { 20 | UserStore model.UserStore 21 | NoteStore model.NoteStore 22 | OG opengraph.Client 23 | } 24 | 25 | func NewHandler(c *Config) *mux.Router { 26 | r := mux.NewRouter() 27 | 28 | r.Use(middleware.WithUser(c.UserStore)) 29 | r.HandleFunc("/notes", c.CreateNote).Methods("POST") 30 | r.HandleFunc("/notes", c.GetNotes).Methods("GET") 31 | 32 | s := r.NewRoute().Subrouter() 33 | s.Use(middleware.WithNote(c.NoteStore)) 34 | s.HandleFunc("/notes/{noteID}", c.GetNote).Methods("GET") 35 | s.HandleFunc("/notes/{noteID}", c.UpdateNote).Methods("PATCH") 36 | s.HandleFunc("/notes/{noteID}", c.DeleteNote).Methods("DELETE") 37 | 38 | return r 39 | } 40 | 41 | type createNotePayload struct { 42 | Name string `validate:"max=511"` 43 | Favicon string `validate:"max=1023"` 44 | URL string `validate:"max=1023"` 45 | Tags []string 46 | Body string `validate:"max=8191"` 47 | } 48 | 49 | func (c *Config) CreateNote(w http.ResponseWriter, r *http.Request) { 50 | op := errors.Op("handlers.CreateNote") 51 | ctx := r.Context() 52 | u := middleware.UserFromContext(ctx) 53 | 54 | var payload createNotePayload 55 | if err := bjson.ReadJSON(&payload, r); err != nil { 56 | bjson.HandleError(w, errors.E(op, err)) 57 | return 58 | } 59 | 60 | if err := valid.Raw(&payload); err != nil { 61 | bjson.HandleError(w, errors.E(op, err)) 62 | return 63 | } 64 | 65 | n, err := model.NewNote( 66 | u, 67 | html.UnescapeString(payload.Name), 68 | payload.URL, 69 | payload.Favicon, 70 | html.UnescapeString(payload.Body), 71 | ) 72 | if err != nil { 73 | bjson.HandleError(w, errors.E(op, err)) 74 | return 75 | } 76 | 77 | if err := c.NoteStore.Commit(ctx, n); err != nil { 78 | bjson.HandleError(w, errors.E(op, err)) 79 | return 80 | } 81 | 82 | bjson.WriteJSON(w, n, http.StatusCreated) 83 | } 84 | 85 | func (c *Config) GetNotes(w http.ResponseWriter, r *http.Request) { 86 | op := errors.Op("handlers.GetNotes") 87 | ctx := r.Context() 88 | u := middleware.UserFromContext(ctx) 89 | p := model.GetPagination(r) 90 | 91 | notes, err := c.NoteStore.GetNotesByUser(ctx, u, p, 92 | db.GetNotesFilter(r.URL.Query().Get("filter")), 93 | db.GetNotesSearch(r.URL.Query().Get("search")), 94 | db.GetNotesTags(r.URL.Query().Get("tag"))) 95 | if err != nil { 96 | bjson.HandleError(w, errors.E(op, err)) 97 | return 98 | } 99 | 100 | resp := map[string]interface{}{"notes": notes} 101 | 102 | if p.Page == 0 { 103 | pins, err := c.NoteStore.GetNotesByUser(ctx, u, 104 | &model.Pagination{Size: -1}, db.GetNotesPins()) 105 | if err != nil { 106 | bjson.HandleError(w, errors.E(op, err)) 107 | return 108 | } 109 | 110 | resp["pins"] = pins 111 | } 112 | 113 | bjson.WriteJSON(w, resp, http.StatusOK) 114 | } 115 | 116 | func (c *Config) GetNote(w http.ResponseWriter, r *http.Request) { 117 | n := middleware.NoteFromContext(r.Context()) 118 | bjson.WriteJSON(w, n, http.StatusOK) 119 | } 120 | 121 | type updateNotePayload struct { 122 | Name string `validate:"max=511"` 123 | Favicon string `validate:"max=1023"` 124 | URL string `validate:"max=1023"` 125 | Tags *[]string 126 | Body *string `validate:"max=8191"` 127 | Pin *bool 128 | } 129 | 130 | func (c *Config) UpdateNote(w http.ResponseWriter, r *http.Request) { 131 | op := errors.Op("handlers.UpdateNote") 132 | ctx := r.Context() 133 | n := middleware.NoteFromContext(ctx) 134 | u := middleware.UserFromContext(ctx) 135 | 136 | var payload updateNotePayload 137 | if err := bjson.ReadJSON(&payload, r); err != nil { 138 | bjson.HandleError(w, errors.E(op, err)) 139 | return 140 | } 141 | 142 | if err := valid.Raw(&payload); err != nil { 143 | bjson.HandleError(w, errors.E(op, err)) 144 | return 145 | } 146 | 147 | if len(payload.Favicon) > 0 { 148 | if url, err := valid.URL(payload.Favicon); err == nil { 149 | n.Favicon = url 150 | } else { 151 | bjson.HandleError(w, errors.E(op, err, http.StatusBadRequest)) 152 | return 153 | } 154 | } 155 | 156 | if n.Variant == "note" && len(payload.URL) > 0 { 157 | bjson.HandleError(w, errors.E(op, 158 | errors.Str("url cannot be added to note"), 159 | map[string]string{"message": "URL cannot be added to note of variant note"}, 160 | http.StatusBadRequest)) 161 | return 162 | } 163 | 164 | if len(payload.URL) > 0 { 165 | if url, err := valid.URL(payload.URL); err == nil { 166 | n.URL = url 167 | } else { 168 | bjson.HandleError(w, errors.E(op, err, http.StatusBadRequest)) 169 | return 170 | } 171 | } 172 | 173 | if payload.Body != nil { 174 | if body := html.UnescapeString(*payload.Body); body != n.Body { 175 | n.Body = body 176 | 177 | if n.Variant == "note" { 178 | n.UpdateNameFromBlurb(body) 179 | } 180 | } 181 | } 182 | 183 | if name := html.UnescapeString(payload.Name); len(name) > 0 { 184 | n.UpdateNameFromBlurb(name) 185 | } 186 | 187 | if payload.Pin != nil && *payload.Pin != n.Pin { 188 | n.Pin = *payload.Pin 189 | } 190 | 191 | if payload.Tags != nil { 192 | userChanged, err := model.TabulateNoteTags(u, n, *payload.Tags) 193 | if err != nil { 194 | bjson.HandleError(w, errors.E(op, err)) 195 | return 196 | } 197 | 198 | if userChanged { 199 | if err := c.UserStore.Commit(ctx, u); err != nil { 200 | bjson.HandleError(w, errors.E(op, err)) 201 | return 202 | } 203 | } 204 | } 205 | 206 | if err := c.NoteStore.Commit(ctx, n); err != nil { 207 | log.Alarm(errors.Errorf("Inconsistent data detected for u=%s note=%v", u.Email, n.Key.ID)) 208 | bjson.HandleError(w, errors.E(op, err)) 209 | return 210 | } 211 | 212 | bjson.WriteJSON(w, n, http.StatusOK) 213 | } 214 | 215 | func (c *Config) DeleteNote(w http.ResponseWriter, r *http.Request) { 216 | op := errors.Op("handlers.DeleteNote") 217 | ctx := r.Context() 218 | n := middleware.NoteFromContext(ctx) 219 | u := middleware.UserFromContext(ctx) 220 | 221 | userChanged, err := model.TabulateNoteTags(u, n, []string{}) 222 | if err != nil { 223 | bjson.HandleError(w, errors.E(op, err)) 224 | return 225 | } 226 | 227 | if userChanged { 228 | if err := c.UserStore.Commit(ctx, u); err != nil { 229 | bjson.HandleError(w, errors.E(op, err)) 230 | return 231 | } 232 | } 233 | 234 | if err := c.NoteStore.Delete(ctx, n); err != nil { 235 | bjson.HandleError(w, errors.E(op, err)) 236 | return 237 | } 238 | 239 | bjson.WriteJSON(w, n, http.StatusOK) 240 | } 241 | -------------------------------------------------------------------------------- /handler/task/task.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | 8 | "github.com/hiconvo/api/bjson" 9 | "github.com/hiconvo/api/clients/db" 10 | "github.com/hiconvo/api/clients/magic" 11 | "github.com/hiconvo/api/clients/queue" 12 | "github.com/hiconvo/api/clients/storage" 13 | "github.com/hiconvo/api/digest" 14 | "github.com/hiconvo/api/errors" 15 | "github.com/hiconvo/api/log" 16 | "github.com/hiconvo/api/mail" 17 | "github.com/hiconvo/api/model" 18 | ) 19 | 20 | type Config struct { 21 | DB db.Client 22 | UserStore model.UserStore 23 | ThreadStore model.ThreadStore 24 | EventStore model.EventStore 25 | MessageStore model.MessageStore 26 | Welcome model.Welcomer 27 | Mail *mail.Client 28 | Magic magic.Client 29 | Storage *storage.Client 30 | } 31 | 32 | func NewHandler(c *Config) *mux.Router { 33 | r := mux.NewRouter() 34 | 35 | r.HandleFunc("/tasks/digest", c.CreateDigest) 36 | r.HandleFunc("/tasks/emails", c.SendEmailsAsync) 37 | 38 | return r 39 | } 40 | 41 | func (c *Config) CreateDigest(w http.ResponseWriter, r *http.Request) { 42 | if val := r.Header.Get("X-Appengine-Cron"); val != "true" { 43 | bjson.WriteJSON(w, map[string]string{ 44 | "message": "Not found", 45 | }, http.StatusNotFound) 46 | 47 | return 48 | } 49 | 50 | d := digest.New(&digest.Config{ 51 | DB: c.DB, 52 | UserStore: c.UserStore, 53 | EventStore: c.EventStore, 54 | ThreadStore: c.ThreadStore, 55 | MessageStore: c.MessageStore, 56 | Magic: c.Magic, 57 | Mail: c.Mail, 58 | }) 59 | 60 | if err := d.Digest(r.Context()); err != nil { 61 | bjson.HandleError(w, err) 62 | return 63 | } 64 | 65 | bjson.WriteJSON(w, map[string]string{"message": "pass"}, http.StatusOK) 66 | } 67 | 68 | func (c *Config) SendEmailsAsync(w http.ResponseWriter, r *http.Request) { 69 | var ( 70 | op errors.Op = "handlers.SendEmailsAsync" 71 | ctx = r.Context() 72 | payload queue.EmailPayload 73 | ) 74 | 75 | if val := r.Header.Get("X-Appengine-QueueName"); val != "convo-emails" { 76 | bjson.WriteJSON(w, map[string]string{ 77 | "message": "Not found", 78 | }, http.StatusNotFound) 79 | 80 | return 81 | } 82 | 83 | if err := bjson.ReadJSON(&payload, r); err != nil { 84 | bjson.HandleError(w, err) 85 | return 86 | } 87 | 88 | for i := range payload.IDs { 89 | switch payload.Type { 90 | case queue.User: 91 | u, err := c.UserStore.GetUserByID(ctx, payload.IDs[i]) 92 | if err != nil { 93 | log.Alarm(errors.E(op, err)) 94 | break 95 | } 96 | 97 | if payload.Action == queue.SendWelcome { 98 | err = c.Welcome.Welcome(ctx, c.ThreadStore, c.Storage, u) 99 | } 100 | 101 | if err != nil { 102 | log.Alarm(errors.E(op, err)) 103 | } 104 | case queue.Event: 105 | e, err := c.EventStore.GetEventByID(ctx, payload.IDs[i]) 106 | if err != nil { 107 | log.Alarm(errors.E(op, err)) 108 | break 109 | } 110 | 111 | if payload.Action == queue.SendInvites { 112 | err = c.Mail.SendEventInvites(c.Magic, e, false) 113 | } else if payload.Action == queue.SendUpdatedInvites { 114 | err = c.Mail.SendEventInvites(c.Magic, e, true) 115 | } 116 | 117 | if err != nil { 118 | log.Alarm(errors.E(op, err)) 119 | break 120 | } 121 | case queue.Thread: 122 | thread, err := c.ThreadStore.GetThreadByID(ctx, payload.IDs[i]) 123 | if err != nil { 124 | log.Alarm(errors.E(op, err)) 125 | break 126 | } 127 | 128 | messages, err := c.MessageStore.GetMessagesByThread(ctx, thread, &model.Pagination{Size: 5}) 129 | if err != nil { 130 | log.Alarm(errors.E(op, err)) 131 | break 132 | } 133 | 134 | if payload.Action == queue.SendThread { 135 | if err := c.Mail.SendThread(c.Magic, thread, messages); err != nil { 136 | log.Alarm(errors.E(op, err)) 137 | break 138 | } 139 | 140 | // SendThread only sends threads to non-registered users. In order not to spam 141 | // such users with a digest, we mark the thread as read for these users. 142 | for i := range thread.Users { 143 | if !thread.Users[i].IsRegistered() { 144 | model.MarkAsRead(thread, thread.Users[i].Key) 145 | } 146 | } 147 | 148 | if err := c.ThreadStore.Commit(ctx, thread); err != nil { 149 | log.Alarm(errors.E(op, err)) 150 | break 151 | } 152 | } 153 | } 154 | } 155 | 156 | bjson.WriteJSON(w, map[string]string{"message": "pass"}, http.StatusOK) 157 | } 158 | -------------------------------------------------------------------------------- /index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | - kind: Event 3 | properties: 4 | - name: UserKeys 5 | - name: CreatedAt 6 | direction: desc 7 | 8 | - kind: Thread 9 | properties: 10 | - name: UserKeys 11 | - name: CreatedAt 12 | direction: desc 13 | 14 | - kind: Thread 15 | properties: 16 | - name: UserKeys 17 | - name: UpdatedAt 18 | direction: desc 19 | 20 | - kind: Note 21 | properties: 22 | - name: OwnerKey 23 | - name: CreatedAt 24 | direction: desc 25 | 26 | - kind: Note 27 | properties: 28 | - name: Pin 29 | - name: CreatedAt 30 | direction: desc 31 | 32 | - kind: Note 33 | properties: 34 | - name: Tags 35 | - name: CreatedAt 36 | direction: desc 37 | 38 | - kind: Note 39 | properties: 40 | - name: Variant 41 | - name: CreatedAt 42 | direction: desc 43 | 44 | - kind: Message 45 | properties: 46 | - name: ParentKey 47 | - name: CreatedAt 48 | 49 | - kind: Message 50 | properties: 51 | - name: ParentKey 52 | - name: CreatedAt 53 | direction: desc 54 | -------------------------------------------------------------------------------- /integ/contact_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/steinfletcher/apitest" 9 | jsonpath "github.com/steinfletcher/apitest-jsonpath" 10 | 11 | "github.com/hiconvo/api/testutil" 12 | ) 13 | 14 | func TestGetContacts(t *testing.T) { 15 | user, _ := _mock.NewUser(_ctx, t) 16 | contact1, _ := _mock.NewUser(_ctx, t) 17 | contact2, _ := _mock.NewUser(_ctx, t) 18 | 19 | if err := user.AddContact(contact1); err != nil { 20 | t.Fatal(err) 21 | } 22 | if err := user.AddContact(contact2); err != nil { 23 | t.Fatal(err) 24 | } 25 | if _, err := _dbClient.Put(_ctx, user.Key, user); err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | t.Log(contact1.ID, contact2.ID) 30 | t.Log(contact1.FullName, contact2.FullName) 31 | 32 | tests := []struct { 33 | Name string 34 | AuthHeader map[string]string 35 | ExpectStatus int 36 | ExpectContactIDs []string 37 | ExpectContactNames []string 38 | }{ 39 | { 40 | AuthHeader: testutil.GetAuthHeader(user.Token), 41 | ExpectStatus: http.StatusOK, 42 | ExpectContactIDs: []string{contact1.ID, contact2.ID}, 43 | ExpectContactNames: []string{contact1.FullName, contact2.FullName}, 44 | }, 45 | { 46 | AuthHeader: testutil.GetAuthHeader(contact1.Token), 47 | ExpectStatus: http.StatusOK, 48 | ExpectContactIDs: []string{}, 49 | ExpectContactNames: []string{}, 50 | }, 51 | { 52 | AuthHeader: nil, 53 | ExpectStatus: http.StatusUnauthorized, 54 | }, 55 | } 56 | 57 | for _, tcase := range tests { 58 | t.Run(tcase.Name, func(t *testing.T) { 59 | tt := apitest.New(tcase.Name). 60 | Handler(_handler). 61 | Get("/contacts"). 62 | Headers(tcase.AuthHeader). 63 | Expect(t). 64 | Status(tcase.ExpectStatus) 65 | 66 | if tcase.ExpectStatus < 400 { 67 | tt.Assert(jsonpath.Len("$.contacts[*].id", len(tcase.ExpectContactNames))) 68 | 69 | for _, name := range tcase.ExpectContactNames { 70 | tt.Assert(jsonpath.Contains("$.contacts[*].fullName", name)) 71 | } 72 | 73 | for _, id := range tcase.ExpectContactIDs { 74 | tt.Assert(jsonpath.Contains("$.contacts[*].id", id)) 75 | } 76 | } 77 | 78 | tt.End() 79 | }) 80 | } 81 | } 82 | 83 | func TestCreateContact(t *testing.T) { 84 | user, _ := _mock.NewUser(_ctx, t) 85 | contact1, _ := _mock.NewUser(_ctx, t) 86 | 87 | tests := []struct { 88 | Name string 89 | AuthHeader map[string]string 90 | URL string 91 | ExpectStatus int 92 | }{ 93 | { 94 | AuthHeader: testutil.GetAuthHeader(user.Token), 95 | URL: fmt.Sprintf("/contacts/%s", contact1.ID), 96 | ExpectStatus: http.StatusCreated, 97 | }, 98 | { 99 | AuthHeader: testutil.GetAuthHeader(user.Token), 100 | URL: fmt.Sprintf("/contacts/%s", contact1.ID), 101 | ExpectStatus: http.StatusBadRequest, 102 | }, 103 | { 104 | AuthHeader: testutil.GetAuthHeader(user.Token), 105 | URL: fmt.Sprintf("/contacts/%s", user.ID), 106 | ExpectStatus: http.StatusBadRequest, 107 | }, 108 | { 109 | AuthHeader: nil, 110 | URL: fmt.Sprintf("/contacts/%s", contact1.ID), 111 | ExpectStatus: http.StatusUnauthorized, 112 | }, 113 | } 114 | 115 | for _, tcase := range tests { 116 | t.Run(tcase.Name, func(t *testing.T) { 117 | tt := apitest.New(tcase.Name). 118 | Handler(_handler). 119 | Post(tcase.URL). 120 | JSON(`{}`). 121 | Headers(tcase.AuthHeader). 122 | Expect(t). 123 | Status(tcase.ExpectStatus) 124 | 125 | if tcase.ExpectStatus < 400 { 126 | tt.Assert(jsonpath.Equal("$.id", contact1.ID)) 127 | tt.Assert(jsonpath.Equal("$.fullName", contact1.FullName)) 128 | tt.Assert(jsonpath.NotPresent("$.email")) 129 | tt.Assert(jsonpath.NotPresent("$.token")) 130 | } 131 | 132 | tt.End() 133 | }) 134 | } 135 | } 136 | 137 | //////////////////////////////////// 138 | // DELETE /contacts/{id} Tests 139 | //////////////////////////////////// 140 | 141 | func TestDeleteContact(t *testing.T) { 142 | user, _ := _mock.NewUser(_ctx, t) 143 | contact1, _ := _mock.NewUser(_ctx, t) 144 | 145 | user.AddContact(contact1) 146 | 147 | if _, err := _dbClient.Put(_ctx, user.Key, user); err != nil { 148 | t.Fatal(err) 149 | } 150 | 151 | tests := []struct { 152 | Name string 153 | AuthHeader map[string]string 154 | URL string 155 | ExpectStatus int 156 | }{ 157 | { 158 | AuthHeader: testutil.GetAuthHeader(user.Token), 159 | URL: fmt.Sprintf("/contacts/%s", contact1.ID), 160 | ExpectStatus: http.StatusOK, 161 | }, 162 | { 163 | AuthHeader: testutil.GetAuthHeader(user.Token), 164 | URL: fmt.Sprintf("/contacts/%s", contact1.ID), 165 | ExpectStatus: http.StatusBadRequest, 166 | }, 167 | { 168 | AuthHeader: testutil.GetAuthHeader(contact1.Token), 169 | URL: fmt.Sprintf("/contacts/%s", contact1.ID), 170 | ExpectStatus: http.StatusBadRequest, 171 | }, 172 | { 173 | AuthHeader: testutil.GetAuthHeader(contact1.Token), 174 | URL: fmt.Sprintf("/contacts/%s", user.ID), 175 | ExpectStatus: http.StatusBadRequest, 176 | }, 177 | { 178 | AuthHeader: nil, 179 | URL: fmt.Sprintf("/contacts/%s", contact1.ID), 180 | ExpectStatus: http.StatusUnauthorized, 181 | }, 182 | } 183 | 184 | for _, tcase := range tests { 185 | t.Run(tcase.Name, func(t *testing.T) { 186 | tt := apitest.New(tcase.Name). 187 | Handler(_handler). 188 | Delete(tcase.URL). 189 | JSON(`{}`). 190 | Headers(tcase.AuthHeader). 191 | Expect(t). 192 | Status(tcase.ExpectStatus) 193 | 194 | if tcase.ExpectStatus < 400 { 195 | tt.Assert(jsonpath.Equal("$.id", contact1.ID)) 196 | tt.Assert(jsonpath.Equal("$.fullName", contact1.FullName)) 197 | tt.Assert(jsonpath.NotPresent("$.email")) 198 | tt.Assert(jsonpath.NotPresent("$.token")) 199 | } 200 | 201 | tt.End() 202 | }) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /integ/inbound_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "mime/multipart" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/hiconvo/api/model" 14 | ) 15 | 16 | func TestInbound(t *testing.T) { 17 | u1, _ := _mock.NewUser(_ctx, t) 18 | u2, _ := _mock.NewUser(_ctx, t) 19 | u3, _ := _mock.NewUser(_ctx, t) 20 | thread := _mock.NewThread(_ctx, t, u1, []*model.User{u2, u3}) 21 | 22 | messages, err := _mock.MessageStore.GetMessagesByThread(_ctx, thread, &model.Pagination{Size: -1}) 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | initalMessageCount := len(messages) 27 | 28 | var b bytes.Buffer 29 | form := multipart.NewWriter(&b) 30 | 31 | form.WriteField("dkim", "{@sendgrid.com : pass}") 32 | form.WriteField("to", thread.GetEmail()) 33 | form.WriteField("html", "

Hello, does this work?

") 34 | form.WriteField("from", fmt.Sprintf("%s <%s>", u1.FullName, u1.Email)) 35 | form.WriteField("text", "Hello, does this work?") 36 | form.WriteField("sender_ip", "0.0.0.0") 37 | form.WriteField("envelope", fmt.Sprintf(`{"to":["%s"],"from":"%s"}`, thread.GetEmail(), u1.Email)) 38 | form.WriteField("attachments", "0") 39 | form.WriteField("subject", thread.Subject) 40 | form.WriteField("charsets", `{"to":"UTF-8","html":"UTF-8","subject":"UTF-8","from":"UTF-8","text":"UTF-8"}`) 41 | form.WriteField("SPF", "pass") 42 | 43 | form.Close() 44 | 45 | req, err := http.NewRequest("POST", "/inbound", &b) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | req.Header.Add("Content-Type", form.FormDataContentType()) 50 | req.WithContext(_ctx) 51 | 52 | rr := httptest.NewRecorder() 53 | _handler.ServeHTTP(rr, req) 54 | 55 | newMessages, err := _mock.MessageStore.GetMessagesByThread(_ctx, thread, &model.Pagination{Size: -1}) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | finalMessageCount := len(newMessages) 60 | 61 | assert.Equal(t, rr.Code, http.StatusOK) 62 | assert.Equal(t, finalMessageCount > initalMessageCount, true) 63 | } 64 | -------------------------------------------------------------------------------- /integ/main_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "testing" 9 | 10 | "github.com/steinfletcher/apitest" 11 | 12 | "github.com/hiconvo/api/clients/db" 13 | "github.com/hiconvo/api/clients/search" 14 | "github.com/hiconvo/api/random" 15 | "github.com/hiconvo/api/testutil" 16 | ) 17 | 18 | var ( 19 | _ctx context.Context 20 | _handler http.Handler 21 | _dbClient db.Client 22 | _searchClient search.Client 23 | _mock *testutil.Mock 24 | ) 25 | 26 | func TestMain(m *testing.M) { 27 | _ctx = context.Background() 28 | _dbClient = testutil.NewDBClient(_ctx) 29 | _searchClient = testutil.NewSearchClient() 30 | _handler, _mock = testutil.Handler(_dbClient, _searchClient) 31 | 32 | testutil.ClearDB(_ctx, _dbClient) 33 | 34 | result := m.Run() 35 | 36 | testutil.ClearDB(_ctx, _dbClient) 37 | 38 | _dbClient.Close() 39 | 40 | os.Exit(result) 41 | } 42 | 43 | func Test404(t *testing.T) { 44 | apitest.New(). 45 | Handler(_handler). 46 | Get(fmt.Sprintf("/%s", random.String(10))). 47 | Expect(t). 48 | Status(http.StatusNotFound). 49 | Body(`{"message":"Not found"}`). 50 | End() 51 | } 52 | 53 | func Test415(t *testing.T) { 54 | apitest.New(). 55 | Handler(_handler). 56 | Post("/users"). 57 | Expect(t). 58 | Status(http.StatusUnsupportedMediaType). 59 | End() 60 | } 61 | 62 | func TestWarmup(t *testing.T) { 63 | apitest.New(). 64 | Handler(_handler). 65 | Get("/_ah/warmup"). 66 | Expect(t). 67 | Status(http.StatusOK). 68 | End() 69 | } 70 | -------------------------------------------------------------------------------- /integ/task_test.go: -------------------------------------------------------------------------------- 1 | package handler_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/steinfletcher/apitest" 9 | 10 | "github.com/hiconvo/api/model" 11 | ) 12 | 13 | func TestSendEmailsAsync(t *testing.T) { 14 | owner, _ := _mock.NewUser(_ctx, t) 15 | member1, _ := _mock.NewUser(_ctx, t) 16 | member2, _ := _mock.NewUser(_ctx, t) 17 | event := _mock.NewEvent(_ctx, t, owner, []*model.User{}, []*model.User{member1, member2}) 18 | thread := _mock.NewThread(_ctx, t, owner, []*model.User{member1, member2}) 19 | _ = _mock.NewThreadMessage(_ctx, t, owner, thread) 20 | 21 | tests := []struct { 22 | Name string 23 | GivenBody string 24 | GivenHeaders map[string]string 25 | ExpectStatus int 26 | }{ 27 | { 28 | GivenBody: fmt.Sprintf(`{ "ids": ["%v"], "type": "Event", "action": "SendInvites" }`, event.ID), 29 | GivenHeaders: map[string]string{ 30 | "Content-Type": "application/json", 31 | "X-Appengine-Queuename": "convo-emails", 32 | }, 33 | ExpectStatus: 200, 34 | }, 35 | { 36 | GivenBody: fmt.Sprintf(`{ "ids": ["%v"], "type": "Event", "action": "SendUpdatedInvites" }`, event.ID), 37 | GivenHeaders: map[string]string{ 38 | "Content-Type": "application/json", 39 | "X-Appengine-Queuename": "convo-emails", 40 | }, 41 | ExpectStatus: 200, 42 | }, 43 | { 44 | GivenBody: fmt.Sprintf(`{ "ids": ["%v"], "type": "Thread", "action": "SendThread" }`, thread.ID), 45 | GivenHeaders: map[string]string{ 46 | "Content-Type": "application/json", 47 | "X-Appengine-Queuename": "convo-emails", 48 | }, 49 | ExpectStatus: 200, 50 | }, 51 | { 52 | GivenBody: fmt.Sprintf(`{ "ids": ["%v", "%v", "%v"], "type": "User", "action": "SendWelcome" }`, owner.ID, member1.ID, member2.ID), 53 | GivenHeaders: map[string]string{ 54 | "Content-Type": "application/json", 55 | "X-Appengine-Queuename": "convo-emails", 56 | }, 57 | ExpectStatus: 200, 58 | }, 59 | // Invalid payload 60 | { 61 | GivenBody: fmt.Sprintf(`{ "ids": ["%v"], "type": "Thread", "action": "SendInvites" }`, event.ID), 62 | ExpectStatus: 404, 63 | }, 64 | // Missing header 65 | { 66 | GivenBody: fmt.Sprintf(`{ "ids": ["%v"], "type": "Event", "action": "SendUpdatedInvites" }`, event.ID), 67 | GivenHeaders: map[string]string{ 68 | "Content-Type": "application/json", 69 | }, 70 | ExpectStatus: 404, 71 | }, 72 | } 73 | 74 | for _, testCase := range tests { 75 | apitest.New(testCase.Name). 76 | Handler(_handler). 77 | Post("/tasks/emails"). 78 | Headers(testCase.GivenHeaders). 79 | Body(testCase.GivenBody). 80 | Expect(t). 81 | Status(testCase.ExpectStatus). 82 | End() 83 | } 84 | } 85 | 86 | func TestDigest(t *testing.T) { 87 | apitest.New("Digest"). 88 | Handler(_handler). 89 | Get("/tasks/digest"). 90 | Headers(map[string]string{"X-Appengine-Cron": "true"}). 91 | Expect(t). 92 | Status(http.StatusOK). 93 | End() 94 | } 95 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | // Package log provides helpers for logging and error reporting. 2 | package log 3 | 4 | import ( 5 | "log" 6 | "os" 7 | 8 | "github.com/getsentry/raven-go" 9 | ) 10 | 11 | var _logger = log.New(os.Stderr, "", log.LstdFlags) 12 | 13 | // Print logs to stderr. 14 | func Print(i ...interface{}) { 15 | _logger.Println(i...) 16 | } 17 | 18 | // Printf logs to stderr with a format string. 19 | func Printf(format string, i ...interface{}) { 20 | _logger.Printf(format, i...) 21 | } 22 | 23 | func Panicf(format string, i ...interface{}) { 24 | _logger.Panicf(format, i...) 25 | } 26 | 27 | // Alarm logs the error to stderr and triggers an alarm. 28 | func Alarm(err error) { 29 | raven.CaptureError(err, nil) 30 | _logger.Printf("Internal Server Error: %v", err.Error()) 31 | } 32 | -------------------------------------------------------------------------------- /mail/content/merge-accounts.txt: -------------------------------------------------------------------------------- 1 | Please click the link below to verify your email address. This will merge your account with %s into your account with %s. If you did not attempt to add a new email to your account, it might be a good idea to notifiy support@convo.events. 2 | -------------------------------------------------------------------------------- /mail/content/password-reset.txt: -------------------------------------------------------------------------------- 1 | Please click the link below to set your password. 2 | -------------------------------------------------------------------------------- /mail/content/verify-email.txt: -------------------------------------------------------------------------------- 1 | Please click the link below to verify your email address. 2 | -------------------------------------------------------------------------------- /model/digest.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "cloud.google.com/go/datastore" 4 | 5 | type DigestItem struct { 6 | ParentID *datastore.Key 7 | Name string 8 | Messages []*Message 9 | } 10 | 11 | type Digestable interface { 12 | GetKey() *datastore.Key 13 | GetName() string 14 | } 15 | -------------------------------------------------------------------------------- /model/merge.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | 6 | "cloud.google.com/go/datastore" 7 | 8 | "github.com/hiconvo/api/clients/db" 9 | ) 10 | 11 | func swapKeys(keyList []*datastore.Key, oldKey, newKey *datastore.Key) []*datastore.Key { 12 | for i := range keyList { 13 | if keyList[i].Equal(oldKey) { 14 | keyList[i] = newKey 15 | } 16 | } 17 | 18 | // Remove duplicates 19 | var clean []*datastore.Key 20 | seen := map[string]struct{}{} 21 | for i := range keyList { 22 | keyString := keyList[i].String() 23 | if _, hasVal := seen[keyString]; !hasVal { 24 | seen[keyString] = struct{}{} 25 | clean = append(clean, keyList[i]) 26 | } 27 | } 28 | 29 | return clean 30 | } 31 | 32 | func swapReadUserKeys(readList []*Read, oldKey, newKey *datastore.Key) []*Read { 33 | var clean []*Read 34 | seen := map[string]struct{}{} 35 | for i := range readList { 36 | keyString := readList[i].UserKey.String() 37 | if _, isSeen := seen[keyString]; !isSeen { 38 | seen[keyString] = struct{}{} 39 | 40 | if readList[i].UserKey.Equal(oldKey) { 41 | readList[i].UserKey = newKey 42 | } 43 | 44 | clean = append(clean, readList[i]) 45 | } 46 | } 47 | 48 | return clean 49 | } 50 | 51 | func mergeContacts(a, b []*datastore.Key) []*datastore.Key { 52 | var all []*datastore.Key 53 | all = append(all, a...) 54 | all = append(all, b...) 55 | 56 | var merged []*datastore.Key 57 | seen := make(map[string]struct{}) 58 | 59 | for i := range all { 60 | keyString := all[i].String() 61 | 62 | if _, isSeen := seen[keyString]; isSeen { 63 | continue 64 | } 65 | 66 | seen[keyString] = struct{}{} 67 | merged = append(merged, all[i]) 68 | } 69 | 70 | return merged 71 | } 72 | 73 | func reassignContacts( 74 | ctx context.Context, 75 | tx db.Transaction, 76 | us UserStore, 77 | oldUser, newUser *User, 78 | ) error { 79 | users, err := us.GetUsersByContact(ctx, oldUser) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | for i := range users { 85 | users[i].ContactKeys = swapKeys(users[i].ContactKeys, oldUser.Key, newUser.Key) 86 | } 87 | 88 | _, err = tx.PutMulti(MapUsersToKeys(users), users) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func reassignMessageUsers( 97 | ctx context.Context, 98 | tx db.Transaction, 99 | ms MessageStore, 100 | old, newUser *User, 101 | ) error { 102 | userMessages, err := ms.GetUnhydratedMessagesByUser(ctx, old, &Pagination{Size: -1}) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | // Reassign ownership of messages and save keys to oldUserMessageKeys slice 108 | userMessageKeys := make([]*datastore.Key, len(userMessages)) 109 | for i := range userMessages { 110 | userMessages[i].UserKey = newUser.Key 111 | userMessages[i].Reads = swapReadUserKeys(userMessages[i].Reads, old.Key, newUser.Key) 112 | userMessageKeys[i] = userMessages[i].Key 113 | } 114 | 115 | // Save the messages 116 | _, err = tx.PutMulti(userMessageKeys, userMessages) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func reassignThreadUsers( 125 | ctx context.Context, 126 | tx db.Transaction, 127 | ts ThreadStore, 128 | old, newUser *User, 129 | ) error { 130 | userThreads, err := ts.GetUnhydratedThreadsByUser(ctx, old, &Pagination{Size: -1}) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | // Reassign ownership of threads and save keys to oldUserThreadKeys slice 136 | userThreadKeys := make([]*datastore.Key, len(userThreads)) 137 | for i := range userThreads { 138 | userThreads[i].UserKeys = swapKeys(userThreads[i].UserKeys, old.Key, newUser.Key) 139 | userThreads[i].Reads = swapReadUserKeys(userThreads[i].Reads, old.Key, newUser.Key) 140 | 141 | if userThreads[i].OwnerKey.Equal(old.Key) { 142 | userThreads[i].OwnerKey = newUser.Key 143 | } 144 | 145 | userThreadKeys[i] = userThreads[i].Key 146 | } 147 | 148 | // Save the threads 149 | _, err = tx.PutMulti(userThreadKeys, userThreads) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return nil 155 | } 156 | 157 | func reassignEventUsers( 158 | ctx context.Context, 159 | tx db.Transaction, 160 | es EventStore, 161 | old, newUser *User, 162 | ) error { 163 | userEvents, err := es.GetUnhydratedEventsByUser(ctx, old, &Pagination{Size: -1}) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | // Reassign ownership of events and save keys to userEvetKeys slice 169 | userEventKeys := make([]*datastore.Key, len(userEvents)) 170 | for i := range userEvents { 171 | userEvents[i].UserKeys = swapKeys(userEvents[i].UserKeys, old.Key, newUser.Key) 172 | userEvents[i].RSVPKeys = swapKeys(userEvents[i].RSVPKeys, old.Key, newUser.Key) 173 | userEvents[i].Reads = swapReadUserKeys(userEvents[i].Reads, old.Key, newUser.Key) 174 | 175 | if userEvents[i].OwnerKey.Equal(old.Key) { 176 | userEvents[i].OwnerKey = newUser.Key 177 | } 178 | 179 | userEventKeys[i] = userEvents[i].Key 180 | } 181 | 182 | // Save the events 183 | _, err = tx.PutMulti(userEventKeys, userEvents) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | return nil 189 | } 190 | 191 | func reassignNoteUsers( 192 | ctx context.Context, 193 | tx db.Transaction, 194 | ns NoteStore, 195 | old, newUser *User, 196 | ) error { 197 | notes, err := ns.GetNotesByUser(ctx, old, &Pagination{Size: -1}) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | keys := make([]*datastore.Key, len(notes)) 203 | for i := range notes { 204 | notes[i].OwnerKey = newUser.Key 205 | keys[i] = notes[i].Key 206 | } 207 | 208 | _, err = tx.PutMulti(keys, notes) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | return nil 214 | } 215 | -------------------------------------------------------------------------------- /model/message.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "cloud.google.com/go/datastore" 10 | 11 | og "github.com/hiconvo/api/clients/opengraph" 12 | "github.com/hiconvo/api/clients/storage" 13 | "github.com/hiconvo/api/errors" 14 | ) 15 | 16 | type Message struct { 17 | Key *datastore.Key `json:"-" datastore:"__key__"` 18 | ID string `json:"id" datastore:"-"` 19 | UserKey *datastore.Key `json:"-"` 20 | User *UserPartial `json:"user" datastore:"-"` 21 | ParentKey *datastore.Key `json:"-"` 22 | ParentID string `json:"parentId" datastore:"-"` 23 | Body string `json:"body" datastore:",noindex"` 24 | CreatedAt time.Time `json:"createdAt"` 25 | Reads []*Read `json:"-" datastore:",noindex"` 26 | PhotoKeys []string `json:"photos"` 27 | Link *og.LinkData `json:"link" datastore:",noindex"` 28 | } 29 | 30 | type GetMessagesOption func(m map[string]interface{}) 31 | 32 | type MessageStore interface { 33 | GetMessageByID(ctx context.Context, id string) (*Message, error) 34 | GetMessagesByKey(ctx context.Context, 35 | k *datastore.Key, p *Pagination, o ...GetMessagesOption) ([]*Message, error) 36 | GetMessagesByThread(ctx context.Context, 37 | t *Thread, p *Pagination, o ...GetMessagesOption) ([]*Message, error) 38 | GetMessagesByEvent(ctx context.Context, 39 | t *Event, p *Pagination, o ...GetMessagesOption) ([]*Message, error) 40 | GetUnhydratedMessagesByUser(ctx context.Context, 41 | u *User, p *Pagination, o ...GetMessagesOption) ([]*Message, error) 42 | Commit(ctx context.Context, t *Message) error 43 | CommitMulti(ctx context.Context, messages []*Message) error 44 | Delete(ctx context.Context, t *Message) error 45 | } 46 | 47 | type NewMessageInput struct { 48 | User *User 49 | Parent *datastore.Key 50 | Body string 51 | Blob string 52 | } 53 | 54 | func NewMessage( 55 | ctx context.Context, 56 | sclient *storage.Client, 57 | ogclient og.Client, 58 | input *NewMessageInput, 59 | ) (*Message, error) { 60 | var ( 61 | op = errors.Op("model.NewThreadMessage") 62 | ts = time.Now() 63 | photoURL string 64 | err error 65 | ) 66 | 67 | link, photoURL, err := handleLinkAndPhoto( 68 | ctx, sclient, ogclient, input.Parent, input.Body, input.Blob) 69 | if err != nil { 70 | return nil, errors.E(op, err) 71 | } 72 | 73 | message := Message{ 74 | Key: datastore.IncompleteKey("Message", nil), 75 | UserKey: input.User.Key, 76 | User: MapUserToUserPartial(input.User), 77 | ParentKey: input.Parent, 78 | ParentID: input.Parent.Encode(), 79 | Body: removeLink(input.Body, link), 80 | CreatedAt: ts, 81 | Link: link, 82 | } 83 | 84 | if photoURL != "" { 85 | message.PhotoKeys = []string{photoURL} 86 | } 87 | 88 | MarkAsRead(&message, input.User.Key) 89 | 90 | return &message, nil 91 | } 92 | 93 | func (m *Message) LoadKey(k *datastore.Key) error { 94 | m.Key = k 95 | 96 | // Add URL safe key 97 | if k != nil { 98 | m.ID = k.Encode() 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func (m *Message) Save() ([]datastore.Property, error) { 105 | return datastore.SaveStruct(m) 106 | } 107 | 108 | func (m *Message) Load(ps []datastore.Property) error { 109 | op := errors.Op("message.Load") 110 | 111 | if err := datastore.LoadStruct(m, ps); err != nil { 112 | return errors.E(op, err) 113 | } 114 | 115 | for _, p := range ps { 116 | if p.Name == "ParentKey" { 117 | k, ok := p.Value.(*datastore.Key) 118 | if !ok { 119 | return errors.E(op, errors.Errorf("could not load parent key into message='%v'", m.Key.ID)) 120 | } 121 | m.ParentKey = k 122 | m.ParentID = k.Encode() 123 | } 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func (m *Message) GetReads() []*Read { 130 | return m.Reads 131 | } 132 | 133 | func (m *Message) SetReads(newReads []*Read) { 134 | m.Reads = newReads 135 | } 136 | 137 | func (m *Message) HasPhoto() bool { 138 | return len(m.PhotoKeys) > 0 139 | } 140 | 141 | func (m *Message) HasLink() bool { 142 | return m.Link != nil 143 | } 144 | 145 | func (m *Message) OwnerIs(u *User) bool { 146 | return m.UserKey.Equal(u.Key) 147 | } 148 | 149 | func (m *Message) HasPhotoKey(key string) bool { 150 | for i := range m.PhotoKeys { 151 | if m.PhotoKeys[i] == key { 152 | return true 153 | } 154 | } 155 | 156 | return false 157 | } 158 | 159 | func MarkMessagesAsRead( 160 | ctx context.Context, 161 | s MessageStore, 162 | u *User, 163 | parentKey *datastore.Key, 164 | ) error { 165 | op := errors.Op("model.MarkMessagesAsRead") 166 | 167 | messages, err := s.GetMessagesByKey(ctx, parentKey, &Pagination{Size: 50}) 168 | if err != nil { 169 | return errors.E(op, err) 170 | } 171 | 172 | for i := range messages { 173 | MarkAsRead(messages[i], u.Key) 174 | } 175 | 176 | err = s.CommitMulti(ctx, messages) 177 | if err != nil { 178 | return errors.E(op, err) 179 | } 180 | 181 | return nil 182 | } 183 | 184 | func removeLink(body string, linkPtr *og.LinkData) string { 185 | if linkPtr == nil { 186 | return body 187 | } 188 | 189 | // If this is a markdown formatted link, leave it. Otherwise, remove the link. 190 | // This isn't a perfect test, but it gets the job done and I'm lazy. 191 | if strings.Contains(body, fmt.Sprintf("[%s]", linkPtr.Original)) { 192 | return body 193 | } 194 | 195 | return strings.Replace(body, linkPtr.Original, "", 1) 196 | } 197 | 198 | func handleLinkAndPhoto( 199 | ctx context.Context, 200 | sclient *storage.Client, 201 | ogclient og.Client, 202 | key *datastore.Key, 203 | body, blob string, 204 | ) (*og.LinkData, string, error) { 205 | var ( 206 | op = errors.Op("model.handleLinkAndPhoto") 207 | photoURL string 208 | err error 209 | ) 210 | 211 | if blob != "" { 212 | photoURL, err = sclient.PutPhotoFromBlob(ctx, key.Encode(), blob) 213 | if err != nil { 214 | return nil, "", errors.E(op, err) 215 | } 216 | } 217 | 218 | link := ogclient.Extract(ctx, body) 219 | 220 | return link, photoURL, nil 221 | } 222 | -------------------------------------------------------------------------------- /model/note.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "cloud.google.com/go/datastore" 10 | 11 | "github.com/hiconvo/api/errors" 12 | "github.com/hiconvo/api/valid" 13 | ) 14 | 15 | type Note struct { 16 | Key *datastore.Key `json:"-" datastore:"__key__"` 17 | ID string `json:"id" datastore:"-"` 18 | OwnerKey *datastore.Key `json:"-"` 19 | Body string `json:"body" datastore:",noindex"` 20 | Tags []string `json:"tags"` 21 | URL string `json:"url"` 22 | Favicon string `json:"favicon" datastore:",noindex"` 23 | Name string `json:"name"` 24 | Pin bool `json:"pin"` 25 | Variant string `json:"variant"` 26 | CreatedAt time.Time `json:"createdAt"` 27 | UpdatedAt time.Time `json:"updatedAt"` 28 | } 29 | 30 | type GetNotesOption func(m map[string]interface{}) 31 | 32 | type NoteStore interface { 33 | GetNoteByID(ctx context.Context, id string) (*Note, error) 34 | GetNotesByUser(ctx context.Context, u *User, p *Pagination, o ...GetNotesOption) ([]*Note, error) 35 | Commit(ctx context.Context, n *Note) error 36 | Delete(ctx context.Context, n *Note) error 37 | } 38 | 39 | func NewNote(u *User, name, url, favicon, body string) (*Note, error) { 40 | op := errors.Op("model.NewNote") 41 | 42 | errMap := map[string]string{} 43 | var err error 44 | 45 | if len(url) == 0 && len(body) == 0 { 46 | errMap["body"] = "body cannot be empty without a url" 47 | } 48 | 49 | if len(url) > 0 { 50 | url, err = valid.URL(url) 51 | if err != nil { 52 | errMap["url"] = "invalid url" 53 | } 54 | } 55 | 56 | if len(favicon) > 0 { 57 | favicon, err = valid.URL(favicon) 58 | if err != nil { 59 | errMap["favicon"] = "invalid url" 60 | } 61 | } 62 | 63 | if len(errMap) > 0 { 64 | return nil, errors.E(op, errMap, 65 | errors.Str("failed validation"), http.StatusBadRequest) 66 | } 67 | 68 | if len(name) == 0 && len(body) > 0 { 69 | name = getNameFromBlurb(body) 70 | } else { 71 | name = getNameFromBlurb(name) 72 | } 73 | 74 | var variant string 75 | if len(url) > 0 { 76 | variant = "link" 77 | } else { 78 | variant = "note" 79 | } 80 | 81 | return &Note{ 82 | Key: datastore.IncompleteKey("Note", nil), 83 | OwnerKey: u.Key, 84 | Name: name, 85 | URL: url, 86 | Favicon: favicon, 87 | Body: body, 88 | Variant: variant, 89 | CreatedAt: time.Now(), 90 | }, nil 91 | } 92 | 93 | func (n *Note) LoadKey(k *datastore.Key) error { 94 | n.Key = k 95 | 96 | // Add URL safe key 97 | n.ID = k.Encode() 98 | 99 | return nil 100 | } 101 | 102 | func (n *Note) Save() ([]datastore.Property, error) { 103 | return datastore.SaveStruct(n) 104 | } 105 | 106 | func (n *Note) Load(ps []datastore.Property) error { 107 | return datastore.LoadStruct(n, ps) 108 | } 109 | 110 | func (n *Note) AddTag(tag string) { 111 | for i := range n.Tags { 112 | if n.Tags[i] == tag { 113 | return 114 | } 115 | } 116 | 117 | n.Tags = append(n.Tags, tag) 118 | } 119 | 120 | func (n *Note) RemoveTag(tag string) { 121 | for i := range n.Tags { 122 | if n.Tags[i] == tag { 123 | n.Tags[i] = n.Tags[len(n.Tags)-1] 124 | n.Tags = n.Tags[:len(n.Tags)-1] 125 | } 126 | } 127 | } 128 | 129 | func (n *Note) UpdateNameFromBlurb(body string) { 130 | n.Name = getNameFromBlurb(body) 131 | } 132 | 133 | func getNameFromBlurb(body string) string { 134 | var name string 135 | 136 | trimmed := strings.TrimLeft(body, "#") 137 | trimmed = strings.TrimSpace(trimmed) 138 | split := strings.SplitAfterN(trimmed, "\n", 2) 139 | 140 | if len(split) > 0 { 141 | if len(split[0]) > 128 { 142 | name = split[0][:128] 143 | } else { 144 | name = split[0] 145 | } 146 | } else { 147 | if len(body) > 128 { 148 | name = body[:128] 149 | } else { 150 | name = body 151 | } 152 | } 153 | 154 | name = strings.TrimSpace(name) 155 | 156 | if len(name) == 0 { 157 | return "Everything is what it is and not another thing" 158 | } 159 | 160 | return name 161 | } 162 | -------------------------------------------------------------------------------- /model/pagination.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | ) 7 | 8 | const ( 9 | _defaultPageNum = 0 10 | _defaultPageSize = 10 11 | ) 12 | 13 | // Pagination captures all info needed for pagination. 14 | // If Size is negative, the result is an unlimited size. 15 | type Pagination struct { 16 | Page int 17 | Size int 18 | } 19 | 20 | func (p *Pagination) getSize() int { 21 | size := p.Size 22 | if size == 0 { 23 | size = _defaultPageSize 24 | } 25 | 26 | return size 27 | } 28 | 29 | func (p *Pagination) Offset() int { 30 | if p.getSize() < 0 { 31 | return p.Page 32 | } 33 | 34 | return p.Page * p.getSize() 35 | } 36 | 37 | func (p *Pagination) Limit() int { 38 | return p.getSize() 39 | } 40 | 41 | func GetPagination(r *http.Request) *Pagination { 42 | pageNum, _ := strconv.Atoi(r.URL.Query().Get("page")) 43 | pageSize, _ := strconv.Atoi(r.URL.Query().Get("size")) 44 | return &Pagination{Page: pageNum, Size: pageSize} 45 | } 46 | -------------------------------------------------------------------------------- /model/read.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "cloud.google.com/go/datastore" 7 | ) 8 | 9 | type Read struct { 10 | UserKey *datastore.Key 11 | Timestamp time.Time 12 | } 13 | 14 | type Readable interface { 15 | GetReads() []*Read 16 | SetReads([]*Read) 17 | } 18 | 19 | func NewRead(userKey *datastore.Key) *Read { 20 | return &Read{ 21 | UserKey: userKey, 22 | Timestamp: time.Now(), 23 | } 24 | } 25 | 26 | func MarkAsRead(r Readable, userKey *datastore.Key) { 27 | reads := r.GetReads() 28 | 29 | // If this has already been read, skip it 30 | if IsRead(r, userKey) { 31 | return 32 | } 33 | 34 | reads = append(reads, NewRead(userKey)) 35 | 36 | r.SetReads(reads) 37 | } 38 | 39 | func ClearReads(r Readable) { 40 | r.SetReads([]*Read{}) 41 | } 42 | 43 | func IsRead(r Readable, userKey *datastore.Key) bool { 44 | reads := r.GetReads() 45 | 46 | for i := range reads { 47 | if reads[i].UserKey.Equal(userKey) { 48 | return true 49 | } 50 | } 51 | 52 | return false 53 | } 54 | 55 | func MapReadsToUserPartials(r Readable, users []*User) []*UserPartial { 56 | reads := r.GetReads() 57 | var userPartials []*UserPartial 58 | for i := range reads { 59 | for j := range users { 60 | if users[j].Key.Equal(reads[i].UserKey) { 61 | userPartials = append(userPartials, MapUserToUserPartial(users[j])) 62 | break 63 | } 64 | } 65 | } 66 | 67 | return userPartials 68 | } 69 | -------------------------------------------------------------------------------- /model/tag.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/hiconvo/api/errors" 9 | ) 10 | 11 | type TagPair struct { 12 | Name string 13 | Count int 14 | } 15 | 16 | type TagList []TagPair 17 | 18 | func (t TagList) MarshalJSON() ([]byte, error) { 19 | tags := make([]string, len(t)) 20 | 21 | for i, pair := range t { 22 | tags[i] = pair.Name 23 | } 24 | 25 | return json.Marshal(tags) 26 | } 27 | 28 | func (t *TagList) Add(tag string) { 29 | list := *t 30 | 31 | for i := range list { 32 | if list[i].Name == tag { 33 | list[i].Count++ 34 | 35 | *t = list 36 | 37 | return 38 | } 39 | } 40 | 41 | list = append(list, TagPair{Name: tag, Count: 1}) 42 | 43 | *t = list 44 | } 45 | 46 | func (t *TagList) Remove(tag string) { 47 | list := *t 48 | 49 | for i := range list { 50 | if list[i].Name == tag { 51 | list[i].Count-- 52 | 53 | if list[i].Count > 0 { 54 | *t = list 55 | 56 | return 57 | } 58 | 59 | list[i] = list[len(list)-1] 60 | list = list[:len(list)-1] 61 | 62 | *t = list 63 | 64 | return 65 | } 66 | } 67 | } 68 | 69 | func TabulateNoteTags(u *User, n *Note, dirtyTags []string) (userChanged bool, err error) { 70 | op := errors.Op("model.TabulateNoteTags") 71 | 72 | newTags := make([]string, 0) 73 | for i := range dirtyTags { 74 | if i > 3 { 75 | return false, errors.E(op, errors.Str("too many tags"), 76 | map[string]string{"message": "only four tags are supported at this time"}, 77 | http.StatusBadRequest) 78 | } 79 | 80 | if len(dirtyTags[i]) > 12 { 81 | return false, errors.E(op, errors.Str("tag too long"), 82 | map[string]string{"message": "tags can only be 12 characters long"}, 83 | http.StatusBadRequest) 84 | } 85 | 86 | newTags = append(newTags, strings.ToLower(dirtyTags[i])) 87 | } 88 | 89 | added := make([]string, 0) 90 | for i := range newTags { 91 | found := false 92 | for j := range n.Tags { 93 | if newTags[i] == n.Tags[j] { 94 | found = true 95 | break 96 | } 97 | } 98 | 99 | if !found { 100 | added = append(added, newTags[i]) 101 | } 102 | } 103 | 104 | removed := make([]string, 0) 105 | for i := range n.Tags { 106 | found := false 107 | for j := range newTags { 108 | if n.Tags[i] == newTags[j] { 109 | found = true 110 | break 111 | } 112 | } 113 | 114 | if !found { 115 | removed = append(removed, n.Tags[i]) 116 | } 117 | } 118 | 119 | for i := range added { 120 | u.Tags.Add(added[i]) 121 | } 122 | 123 | for i := range removed { 124 | u.Tags.Remove(removed[i]) 125 | } 126 | 127 | if len(u.Tags) > 60 { 128 | return false, errors.E(op, errors.Str("too many unique tags"), 129 | map[string]string{"message": "you have too many unique tags"}, 130 | http.StatusBadRequest) 131 | } 132 | 133 | n.Tags = newTags 134 | 135 | if len(added) > 0 || len(removed) > 0 { 136 | return true, nil 137 | } 138 | 139 | return false, nil 140 | } 141 | -------------------------------------------------------------------------------- /model/thread.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "cloud.google.com/go/datastore" 10 | "github.com/gosimple/slug" 11 | 12 | "github.com/hiconvo/api/clients/db" 13 | og "github.com/hiconvo/api/clients/opengraph" 14 | "github.com/hiconvo/api/clients/storage" 15 | "github.com/hiconvo/api/errors" 16 | ) 17 | 18 | type Thread struct { 19 | Key *datastore.Key `json:"-" datastore:"__key__"` 20 | ID string `json:"id" datastore:"-"` 21 | OwnerKey *datastore.Key `json:"-"` 22 | Owner *UserPartial `json:"owner" datastore:"-"` 23 | UserKeys []*datastore.Key `json:"-"` 24 | Users []*User `json:"-" datastore:"-"` 25 | UserPartials []*UserPartial `json:"users" datastore:"-"` 26 | Subject string `json:"subject" datastore:",noindex"` 27 | Body string `json:"body" datastore:",noindex"` 28 | Photos []string `json:"photos" datastore:",noindex"` 29 | Link *og.LinkData `json:"link"` 30 | UserReads []*UserPartial `json:"reads" datastore:"-"` 31 | Reads []*Read `json:"-" datastore:",noindex"` 32 | CreatedAt time.Time `json:"createdAt"` 33 | UpdatedAt time.Time `json:"updatedAt"` 34 | ResponseCount int `json:"responseCount"` 35 | } 36 | 37 | type ThreadStore interface { 38 | GetThreadByID(ctx context.Context, id string) (*Thread, error) 39 | GetThreadByInt64ID(ctx context.Context, id int64) (*Thread, error) 40 | GetUnhydratedThreadsByUser(ctx context.Context, u *User, p *Pagination) ([]*Thread, error) 41 | GetThreadsByUser(ctx context.Context, u *User, p *Pagination) ([]*Thread, error) 42 | Commit(ctx context.Context, t *Thread) error 43 | CommitMulti(ctx context.Context, threads []*Thread) error 44 | CommitWithTransaction(tx db.Transaction, t *Thread) (*datastore.PendingKey, error) 45 | Delete(ctx context.Context, t *Thread) error 46 | AllocateKey(ctx context.Context) (*datastore.Key, error) 47 | } 48 | 49 | type NewThreadInput struct { 50 | Owner *User 51 | Users []*User 52 | Subject string 53 | Body string 54 | Blob string 55 | } 56 | 57 | func NewThread( 58 | ctx context.Context, 59 | tstore ThreadStore, 60 | sclient *storage.Client, 61 | ogclient og.Client, 62 | input *NewThreadInput, 63 | ) (*Thread, error) { 64 | op := errors.Op("NewThread") 65 | 66 | if len(input.Users) > 11 { 67 | return nil, errors.E(op, http.StatusBadRequest, map[string]string{ 68 | "message": "Convos have a maximum of 11 members", 69 | }) 70 | } 71 | 72 | // Get all of the users' keys, remove duplicates, and check whether 73 | // the owner was included in the users slice 74 | userKeys := make([]*datastore.Key, 0) 75 | seen := make(map[string]struct{}) 76 | hasOwner := false 77 | for _, u := range input.Users { 78 | if _, alreadySeen := seen[u.ID]; alreadySeen { 79 | continue 80 | } 81 | seen[u.ID] = struct{}{} 82 | if u.Key.Equal(input.Owner.Key) { 83 | hasOwner = true 84 | } 85 | userKeys = append(userKeys, u.Key) 86 | } 87 | 88 | // Add the owner to the users if not already present 89 | if !hasOwner { 90 | userKeys = append(userKeys, input.Owner.Key) 91 | input.Users = append(input.Users, input.Owner) 92 | } 93 | 94 | key, err := tstore.AllocateKey(ctx) 95 | if err != nil { 96 | return nil, errors.E(op, err) 97 | } 98 | 99 | link, photoURL, err := handleLinkAndPhoto( 100 | ctx, sclient, ogclient, key, input.Body, input.Blob) 101 | if err != nil { 102 | return nil, errors.E(op, err) 103 | } 104 | 105 | var photos []string 106 | if photoURL != "" { 107 | photos = []string{photoURL} 108 | } 109 | 110 | if input.Subject == "" && link != nil && link.Title != "" { 111 | input.Subject = link.Title 112 | } 113 | 114 | // If a subject wasn't given, create one that is a list of the participants' 115 | // names. 116 | if input.Subject == "" { 117 | if len(input.Users) == 1 { 118 | input.Subject = input.Owner.FirstName + "'s Private Convo" 119 | } else { 120 | for i, u := range input.Users { 121 | if i == len(input.Users)-1 { 122 | input.Subject += "and " + u.FirstName 123 | } else if i == len(input.Users)-2 { 124 | input.Subject += u.FirstName + " " 125 | } else { 126 | input.Subject += u.FirstName + ", " 127 | } 128 | } 129 | } 130 | } 131 | 132 | t := &Thread{ 133 | Key: key, 134 | OwnerKey: input.Owner.Key, 135 | Owner: MapUserToUserPartial(input.Owner), 136 | UserKeys: userKeys, 137 | Users: input.Users, 138 | UserPartials: MapUsersToUserPartials(input.Users), 139 | Subject: input.Subject, 140 | Body: removeLink(input.Body, link), 141 | Photos: photos, 142 | Link: link, 143 | } 144 | 145 | MarkAsRead(t, input.Owner.Key) 146 | 147 | return t, nil 148 | } 149 | 150 | func (t *Thread) LoadKey(k *datastore.Key) error { 151 | t.Key = k 152 | 153 | // Add URL safe key 154 | t.ID = k.Encode() 155 | 156 | return nil 157 | } 158 | 159 | func (t *Thread) Save() ([]datastore.Property, error) { 160 | return datastore.SaveStruct(t) 161 | } 162 | 163 | func (t *Thread) Load(ps []datastore.Property) error { 164 | return datastore.LoadStruct(t, ps) 165 | } 166 | 167 | func (t *Thread) GetReads() []*Read { 168 | return t.Reads 169 | } 170 | 171 | func (t *Thread) SetReads(newReads []*Read) { 172 | t.Reads = newReads 173 | } 174 | 175 | func (t *Thread) GetKey() *datastore.Key { 176 | return t.Key 177 | } 178 | 179 | func (t *Thread) GetName() string { 180 | return t.Subject 181 | } 182 | 183 | func (t *Thread) GetEmail() string { 184 | slugified := slug.Make(t.Subject) 185 | if len(slugified) > 20 { 186 | slugified = slugified[:20] 187 | } 188 | return fmt.Sprintf("%s-%d@mail.convo.events", slugified, t.Key.ID) 189 | } 190 | 191 | func (t *Thread) HasUser(u *User) bool { 192 | for _, k := range t.UserKeys { 193 | if k.Equal(u.Key) { 194 | return true 195 | } 196 | } 197 | 198 | return false 199 | } 200 | 201 | func (t *Thread) IsSendable() bool { 202 | for i := range t.Users { 203 | if !t.Users[i].IsRegistered() && !IsRead(t, t.Users[i].Key) { 204 | return true 205 | } 206 | } 207 | 208 | return false 209 | } 210 | 211 | func (t *Thread) OwnerIs(u *User) bool { 212 | return t.OwnerKey.Equal(u.Key) 213 | } 214 | 215 | // AddUser adds a user to the thread. 216 | func (t *Thread) AddUser(u *User) error { 217 | op := errors.Op("thread.AddUser") 218 | 219 | if u.Key.Incomplete() { 220 | return errors.E(op, errors.Str("incomplete key")) 221 | } 222 | 223 | // Cannot add owner or duplicate. 224 | if t.OwnerIs(u) || t.HasUser(u) { 225 | return errors.E(op, 226 | map[string]string{"message": "This user is already a member of this Convo"}, 227 | http.StatusBadRequest, 228 | errors.Str("already has user")) 229 | } 230 | 231 | if len(t.UserKeys) >= 11 { 232 | return errors.E(op, 233 | map[string]string{"message": "This Convo has the maximum number of users"}, 234 | http.StatusBadRequest, 235 | errors.Str("user count limit")) 236 | } 237 | 238 | t.UserKeys = append(t.UserKeys, u.Key) 239 | t.Users = append(t.Users, u) 240 | t.UserPartials = append(t.UserPartials, MapUserToUserPartial(u)) 241 | 242 | return nil 243 | } 244 | 245 | func (t *Thread) RemoveUser(u *User) { 246 | // Remove from keys. 247 | for i, k := range t.UserKeys { 248 | if k.Equal(u.Key) { 249 | t.UserKeys[i] = t.UserKeys[len(t.UserKeys)-1] 250 | t.UserKeys = t.UserKeys[:len(t.UserKeys)-1] 251 | break 252 | } 253 | } 254 | // Remove from users. 255 | for i, c := range t.Users { 256 | if c.ID == u.ID { 257 | t.Users[i] = t.Users[len(t.Users)-1] 258 | t.Users = t.Users[:len(t.Users)-1] 259 | break 260 | } 261 | } 262 | // Remove from contacts. 263 | for i, c := range t.UserPartials { 264 | if c.ID == u.ID { 265 | t.UserPartials[i] = t.UserPartials[len(t.UserPartials)-1] 266 | t.UserPartials = t.UserPartials[:len(t.UserPartials)-1] 267 | break 268 | } 269 | } 270 | } 271 | 272 | func (t *Thread) IncRespCount() error { 273 | t.ResponseCount++ 274 | return nil 275 | } 276 | -------------------------------------------------------------------------------- /random/random.go: -------------------------------------------------------------------------------- 1 | package random 2 | 3 | import ( 4 | crand "crypto/rand" 5 | "encoding/base64" 6 | mrand "math/rand" 7 | ) 8 | 9 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 10 | 11 | // String returns a string of the given length of random upper and lowercase 12 | // letters. 13 | func String(size int) string { 14 | bytes := make([]byte, size) 15 | 16 | for i := range bytes { 17 | bytes[i] = letterBytes[mrand.Intn(len(letterBytes))] 18 | } 19 | 20 | return string(bytes) 21 | } 22 | 23 | // Token returns a 32 character length string derived from a 24 | // cryptographically secure random number generator. 25 | func Token() string { 26 | randomBytes := make([]byte, 32) 27 | 28 | _, err := crand.Read(randomBytes) 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | return base64.StdEncoding.EncodeToString(randomBytes)[:32] 34 | } 35 | -------------------------------------------------------------------------------- /template/includes/button.html: -------------------------------------------------------------------------------- 1 | {{ define "button" }} 2 | 9 | 10 | 11 | 24 | 25 | 26 | 27 | {{ end }} 28 | -------------------------------------------------------------------------------- /template/includes/message.html: -------------------------------------------------------------------------------- 1 | {{ define "message" }} {{if eq (.FromID) (.ToID)}} 2 | 3 | {{- else}} 4 | 5 | {{- end}} 6 | 7 | 8 | 9 | 45 | 46 | 47 | 48 | 49 | 50 | {{ end }} 51 | 52 | -------------------------------------------------------------------------------- /template/includes/profile.html: -------------------------------------------------------------------------------- 1 | {{ define "profile" }} 2 | 9 | 10 | 11 | 22 | 23 | 24 | 25 | {{ end }} 26 | -------------------------------------------------------------------------------- /template/layouts/admin.html: -------------------------------------------------------------------------------- 1 | 2 | {{ define "title" }} 3 | Action required on Convo 4 | {{ end }} 5 | 6 | 7 | 8 | {{ define "content" }} 9 | 10 | 11 | 24 | 25 |
12 | 13 | 14 | 21 | 22 |
15 |

Hello,

16 | 17 | {{ .RenderedBody }} {{ template "button" .}} 18 | 19 |

Thanks,
Convo Support

20 |
23 |
26 | 27 | {{ end }} 28 | 29 | 30 | 31 | {{ define "footer" }} 32 |

33 | Login to Convo 34 |

35 | {{ end }} 36 | 37 | -------------------------------------------------------------------------------- /template/layouts/cancellation.html: -------------------------------------------------------------------------------- 1 | 2 | {{ define "title" }} 3 | Cancelled: {{ .Name }} 4 | {{ end }} 5 | 6 | 7 | 8 | {{ define "content" }} 9 | 10 | 11 | 21 | 22 |
12 | 13 | 14 | 18 | 19 |
15 |

Hello,

16 |

The following event has been cancelled:

17 |
20 |
23 | 24 | 25 | 26 | 41 | 42 | 43 | 44 | 45 | 46 | 55 | 56 |
47 | 48 | 49 | 52 | 53 |
50 | {{ .RenderedBody }} 51 |
54 |
57 | 58 | {{ end }} 59 | 60 | 61 | 62 | {{ define "footer" }} 63 |

64 | Login to Convo. 65 | Unsubscribe. 66 |

67 | {{ end }} 68 | 69 | -------------------------------------------------------------------------------- /template/layouts/digest.html: -------------------------------------------------------------------------------- 1 | 2 | {{ define "title" }} 3 | Convo Digest 4 | {{ end }} 5 | 6 | 7 | 8 | {{ define "content" }} 9 |

Convo Digest

10 |

11 | {{ if and .Events .Items }} 12 | You have unread messages and upcoming events on Convo. 13 | {{ else }} 14 | {{ if .Events }} 15 | You have upcoming events on Convo. 16 | {{ else }} 17 | You have unread messages on Convo. 18 | {{ end }} 19 | {{ end }} 20 | Login to Convo to respond. 21 |

22 | 23 | 24 | {{ if .Events }} 25 |

Upcoming Events

26 | 27 | {{ range .Events }} 28 | 29 | 30 | 45 | 46 | 47 | {{ end }} 48 | {{ else }} 49 | {{ end }} 50 | 51 | 52 | 53 | {{ if .Items }} 54 |

Unread Messages

55 | 56 | {{ range .Items }} 57 |
58 | 59 | 60 | 61 | 67 | 68 | 69 |
62 |

{{ .Subject }}

63 | {{ range .Messages }} 64 | {{ template "message" .}} 65 | {{ end }} 66 |
70 |
71 | {{ end }} 72 | {{ else }} 73 | {{ end }} 74 | 75 | 76 | {{ end }} 77 | 78 | 79 | 80 | {{ define "footer" }} 81 |

82 | Login to Convo. 83 | Unsubscribe. 84 |

85 | {{ end }} 86 | 87 | -------------------------------------------------------------------------------- /template/layouts/event.html: -------------------------------------------------------------------------------- 1 | 2 | {{ define "title" }} 3 | {{ .Name }} 4 | {{ end }} 5 | 6 | 7 | 8 | {{ define "content" }} 9 | 10 | 11 | 24 | 25 |
12 | 13 | 14 | 21 | 22 |
15 |

Hello,

16 |

17 | {{ .FromName }} has invited you to the following event. Click the 18 | button below to RSVP or to send a message to the group. 19 |

20 |
23 |
26 | 27 | 28 | 29 | 46 | 47 | 48 | 49 | 50 | 51 | 60 | 61 |
52 | 53 | 54 | 57 | 58 |
55 | {{ .RenderedBody }} 56 |
59 |
62 | 63 | {{ end }} 64 | 65 | 66 | 67 | {{ define "footer" }} 68 |

69 | Login to Convo. 70 | Unsubscribe. 71 |

72 | {{ end }} 73 | 74 | -------------------------------------------------------------------------------- /template/layouts/thread.html: -------------------------------------------------------------------------------- 1 | 2 | {{ define "title" }} 3 | {{ .Subject }} 4 | {{ end }} 5 | 6 | 7 | 8 | {{ define "content" }} 9 |

Hello,

10 |

11 | {{ .FromName }} shared something with you on Convo. Respond by replying to 12 | this email directly or by 13 | creating an account on Convo with the 14 | email to which this messages is addressed. 15 |

16 | 17 | {{ range .Messages }} {{ template "message" .}} {{ end }} {{ end }} 18 | 19 | 20 | 21 | {{ define "footer" }} 22 |

23 | This email is a Convo. You can reply to 24 | this email as you would any other. 25 | Unsubscribe. 26 |

27 | {{ end }} 28 | 29 | -------------------------------------------------------------------------------- /template/renderable.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | htmltpl "html/template" 6 | 7 | "github.com/aymerick/douceur/inliner" 8 | "github.com/russross/blackfriday/v2" 9 | 10 | "github.com/hiconvo/api/errors" 11 | ) 12 | 13 | type renderable struct { 14 | RenderedBody htmltpl.HTML 15 | } 16 | 17 | func (r *renderable) RenderMarkdown(data string) { 18 | r.RenderedBody = htmltpl.HTML(blackfriday.Run([]byte(data))) 19 | } 20 | 21 | func (r *renderable) RenderHTML(tpl *htmltpl.Template, data interface{}) (string, error) { 22 | var op errors.Op = "renderable.RenderHTML" 23 | 24 | var buf bytes.Buffer 25 | if err := tpl.ExecuteTemplate(&buf, "base.html", data); err != nil { 26 | return "", errors.E(op, err) 27 | } 28 | 29 | html, err := inliner.Inline(buf.String()) 30 | if err != nil { 31 | return html, errors.E(op, err) 32 | } 33 | 34 | return html, nil 35 | } 36 | -------------------------------------------------------------------------------- /template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | htmltpl "html/template" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/hiconvo/api/clients/opengraph" 11 | ) 12 | 13 | const ( 14 | _tplStrMessage = "%s said:\n\n%s\n\n" 15 | _tplStrEvent = "%s invited you to:\n\n%s\n\n%s\n\n%s\n\n%s\n" 16 | _tplStrCancellation = "%s has cancelled:\n\n%s\n\n%s\n\n%s\n\n%s" 17 | ) 18 | 19 | // Message is a renderable message. It is always a constituent of a 20 | // Thread. The Body field accepts markdown. XML is not allowed. 21 | type Message struct { 22 | renderable 23 | Body string 24 | Name string 25 | FromID string 26 | ToID string 27 | HasPhoto bool 28 | HasLink bool 29 | Link *opengraph.LinkData 30 | MagicLink string 31 | } 32 | 33 | // Thread is a representation of a renderable email thread. 34 | type Thread struct { 35 | renderable 36 | Subject string 37 | FromName string 38 | Messages []Message 39 | Preview string 40 | UnsubscribeMagicLink string 41 | } 42 | 43 | // Event is a representation of a renderable email event. 44 | type Event struct { 45 | renderable 46 | Name string 47 | Address string 48 | Time string 49 | Description string 50 | Preview string 51 | FromName string 52 | MagicLink string 53 | ButtonText string 54 | Message string 55 | UnsubscribeMagicLink string 56 | } 57 | 58 | // Digest is a representation of a renderable email digest. 59 | type Digest struct { 60 | renderable 61 | Items []Thread 62 | Preview string 63 | Events []Event 64 | MagicLink string 65 | UnsubscribeMagicLink string 66 | } 67 | 68 | // AdminEmail is a representation of a renderable administrative 69 | // email which includes a call to action button. 70 | type AdminEmail struct { 71 | renderable 72 | Body string 73 | ButtonText string 74 | MagicLink string 75 | Fargs []interface{} 76 | Preview string 77 | } 78 | 79 | type Client struct { 80 | templates map[string]*htmltpl.Template 81 | } 82 | 83 | func NewClient() *Client { 84 | templates := make(map[string]*htmltpl.Template) 85 | 86 | wd, err := os.Getwd() 87 | if err != nil { 88 | panic(err) 89 | } 90 | 91 | var basePath string 92 | if strings.HasSuffix(wd, "template") || strings.HasSuffix(wd, "integ") { 93 | // This package is the cwd, so we need to go up one dir to resolve the 94 | // layouts and includes dirs consistently. 95 | basePath = "../template" 96 | } else { 97 | basePath = "./template" 98 | } 99 | 100 | layouts, err := filepath.Glob(basePath + "/layouts/*.html") 101 | if err != nil { 102 | panic(err) 103 | } 104 | 105 | includes, err := filepath.Glob(basePath + "/includes/*.html") 106 | if err != nil { 107 | panic(err) 108 | } 109 | 110 | // Generate our templates map from our layouts/ and includes/ directories 111 | for _, layout := range layouts { 112 | files := append(includes, layout) 113 | templates[filepath.Base(layout)] = htmltpl.Must(htmltpl.ParseFiles(files...)) 114 | } 115 | 116 | // Make sure the expected templates are there 117 | for _, tplName := range []string{ 118 | "thread.html", 119 | "event.html", 120 | "cancellation.html", 121 | "digest.html", 122 | "admin.html", 123 | } { 124 | _, ok := templates[tplName] 125 | 126 | if !ok { 127 | panic(fmt.Sprintf("Template '%v' not found", tplName)) 128 | } 129 | } 130 | 131 | return &Client{ 132 | templates: templates, 133 | } 134 | } 135 | 136 | // RenderThread returns a rendered thread email. 137 | func (c *Client) RenderThread(t *Thread) (string, string, error) { 138 | var builder strings.Builder 139 | 140 | for i, m := range t.Messages { 141 | fmt.Fprintf(&builder, _tplStrMessage, m.Name, m.Body) 142 | t.Messages[i].RenderMarkdown(t.Messages[i].Body) 143 | } 144 | 145 | plainText := builder.String() 146 | preview := getPreview(plainText) 147 | 148 | t.Preview = preview 149 | 150 | html, err := t.RenderHTML(c.templates["thread.html"], t) 151 | 152 | return plainText, html, err 153 | } 154 | 155 | // RenderEvent returns a rendered event invitation email. 156 | func (c *Client) RenderEvent(e *Event) (string, string, error) { 157 | e.RenderMarkdown(e.Description) 158 | 159 | var builder strings.Builder 160 | fmt.Fprintf(&builder, _tplStrEvent, 161 | e.FromName, 162 | e.Name, 163 | e.Address, 164 | e.Time, 165 | e.Description) 166 | plainText := builder.String() 167 | preview := getPreview(plainText) 168 | 169 | e.Preview = preview 170 | 171 | html, err := e.RenderHTML(c.templates["event.html"], e) 172 | 173 | return plainText, html, err 174 | } 175 | 176 | // RenderCancellation returns a rendered event cancellation email. 177 | func (c *Client) RenderCancellation(e *Event) (string, string, error) { 178 | e.RenderMarkdown(e.Message) 179 | 180 | var builder strings.Builder 181 | fmt.Fprintf(&builder, _tplStrCancellation, 182 | e.FromName, 183 | e.Name, 184 | e.Address, 185 | e.Time, 186 | e.Message) 187 | plainText := builder.String() 188 | preview := getPreview(plainText) 189 | 190 | e.Preview = preview 191 | 192 | html, err := e.RenderHTML(c.templates["cancellation.html"], e) 193 | 194 | return plainText, html, err 195 | } 196 | 197 | // RenderDigest returns a rendered digest email. 198 | func (c *Client) RenderDigest(d *Digest) (string, string, error) { 199 | for i := range d.Items { 200 | for j := range d.Items[i].Messages { 201 | d.Items[i].Messages[j].RenderMarkdown(d.Items[i].Messages[j].Body) 202 | } 203 | } 204 | 205 | plainText := "You have notifications on Convo." 206 | d.Preview = plainText 207 | 208 | html, err := d.RenderHTML(c.templates["digest.html"], d) 209 | 210 | return plainText, html, err 211 | } 212 | 213 | // RenderAdminEmail returns a rendered admin email. 214 | func (c *Client) RenderAdminEmail(a *AdminEmail) (string, string, error) { 215 | var builder strings.Builder 216 | fmt.Fprintf(&builder, a.Body, a.Fargs...) 217 | plainText := builder.String() 218 | preview := getPreview(plainText) 219 | 220 | a.Preview = preview 221 | 222 | a.RenderMarkdown(plainText) 223 | html, err := a.RenderHTML(c.templates["admin.html"], a) 224 | 225 | return plainText, html, err 226 | } 227 | 228 | func getPreview(plainText string) string { 229 | if len(plainText) > 200 { 230 | return plainText[:200] + "..." 231 | } 232 | 233 | return plainText 234 | } 235 | -------------------------------------------------------------------------------- /testutil/handler.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "cloud.google.com/go/datastore" 13 | "github.com/icrowley/fake" 14 | "go.mongodb.org/mongo-driver/mongo" 15 | 16 | dbc "github.com/hiconvo/api/clients/db" 17 | "github.com/hiconvo/api/clients/magic" 18 | sender "github.com/hiconvo/api/clients/mail" 19 | mgc "github.com/hiconvo/api/clients/mongo" 20 | "github.com/hiconvo/api/clients/notification" 21 | "github.com/hiconvo/api/clients/oauth" 22 | "github.com/hiconvo/api/clients/opengraph" 23 | "github.com/hiconvo/api/clients/places" 24 | "github.com/hiconvo/api/clients/queue" 25 | "github.com/hiconvo/api/clients/search" 26 | "github.com/hiconvo/api/clients/storage" 27 | "github.com/hiconvo/api/db" 28 | "github.com/hiconvo/api/handler" 29 | "github.com/hiconvo/api/mail" 30 | "github.com/hiconvo/api/model" 31 | "github.com/hiconvo/api/template" 32 | "github.com/hiconvo/api/welcome" 33 | ) 34 | 35 | type Mock struct { 36 | UserStore model.UserStore 37 | ThreadStore model.ThreadStore 38 | EventStore model.EventStore 39 | MessageStore model.MessageStore 40 | NoteStore model.NoteStore 41 | Welcome model.Welcomer 42 | Mail *mail.Client 43 | Magic magic.Client 44 | OAuth oauth.Client 45 | Storage *storage.Client 46 | OG opengraph.Client 47 | Places places.Client 48 | Queue queue.Client 49 | } 50 | 51 | func Handler(dbClient dbc.Client, searchClient search.Client) (http.Handler, *Mock) { 52 | mailClient := mail.New(sender.NewLogger(), template.NewClient()) 53 | magicClient := magic.NewClient("") 54 | storageClient := storage.NewClient("", "") 55 | userStore := &db.UserStore{DB: dbClient, Notif: notification.NewLogger(), S: searchClient, Queue: queue.NewLogger()} 56 | threadStore := &db.ThreadStore{DB: dbClient} 57 | eventStore := &db.EventStore{DB: dbClient} 58 | messageStore := &db.MessageStore{DB: dbClient} 59 | noteStore := &db.NoteStore{DB: dbClient, S: searchClient} 60 | welcomer := welcome.New(context.Background(), userStore, "support") 61 | 62 | h := handler.New(&handler.Config{ 63 | DB: dbClient, 64 | Transacter: dbClient, 65 | UserStore: userStore, 66 | ThreadStore: threadStore, 67 | EventStore: eventStore, 68 | MessageStore: messageStore, 69 | NoteStore: noteStore, 70 | Welcome: welcomer, 71 | TxnMiddleware: dbc.WithTransaction(dbClient), 72 | Mail: mailClient, 73 | Magic: magicClient, 74 | Storage: storageClient, 75 | OAuth: oauth.NewClient(""), 76 | Notif: notification.NewLogger(), 77 | OG: opengraph.NewClient(), 78 | Places: places.NewLogger(), 79 | Queue: queue.NewLogger(), 80 | }) 81 | 82 | m := &Mock{ 83 | UserStore: userStore, 84 | ThreadStore: threadStore, 85 | EventStore: eventStore, 86 | MessageStore: messageStore, 87 | NoteStore: noteStore, 88 | Welcome: welcomer, 89 | Mail: mailClient, 90 | Magic: magicClient, 91 | Storage: storageClient, 92 | OAuth: oauth.NewClient(""), 93 | OG: opengraph.NewClient(), 94 | Places: places.NewLogger(), 95 | Queue: queue.NewLogger(), 96 | } 97 | 98 | return h, m 99 | } 100 | 101 | func (m *Mock) NewUser(ctx context.Context, t *testing.T) (*model.User, string) { 102 | t.Helper() 103 | 104 | email := fake.EmailAddress() 105 | pw := fake.SimplePassword() 106 | 107 | u, err := model.NewUserWithPassword( 108 | email, 109 | fake.FirstName(), 110 | fake.LastName(), 111 | pw) 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | u.Verified = true 117 | 118 | err = m.UserStore.Commit(ctx, u) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | return u, pw 124 | } 125 | 126 | func (m *Mock) NewIncompleteUser(ctx context.Context, t *testing.T) *model.User { 127 | t.Helper() 128 | 129 | u, err := model.NewIncompleteUser(fake.EmailAddress()) 130 | if err != nil { 131 | t.Fatal(err) 132 | } 133 | 134 | err = m.UserStore.Commit(ctx, u) 135 | if err != nil { 136 | t.Fatal(err) 137 | } 138 | 139 | return u 140 | } 141 | 142 | func (m *Mock) NewThread( 143 | ctx context.Context, 144 | t *testing.T, 145 | owner *model.User, 146 | users []*model.User, 147 | ) *model.Thread { 148 | t.Helper() 149 | 150 | th, err := model.NewThread(ctx, m.ThreadStore, m.Storage, m.OG, &model.NewThreadInput{ 151 | Owner: owner, 152 | Users: users, 153 | Subject: fake.Title(), 154 | Body: fake.Paragraph(), 155 | }) 156 | if err != nil { 157 | t.Fatal(err) 158 | } 159 | 160 | err = m.ThreadStore.Commit(ctx, th) 161 | if err != nil { 162 | t.Fatal(err) 163 | } 164 | 165 | return th 166 | } 167 | 168 | func (m *Mock) NewEvent( 169 | ctx context.Context, 170 | t *testing.T, 171 | owner *model.User, 172 | hosts []*model.User, 173 | users []*model.User, 174 | ) *model.Event { 175 | t.Helper() 176 | 177 | ev, err := model.NewEvent( 178 | fake.Title(), 179 | fake.Paragraph(), 180 | fake.CharactersN(32), 181 | fake.StreetAddress(), 182 | 0.0, 0.0, 183 | time.Date(2030, 6, 5, 4, 3, 2, 1, time.Local), 184 | 0, 185 | owner, 186 | hosts, 187 | users, 188 | false) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | err = m.EventStore.Commit(ctx, ev) 194 | if err != nil { 195 | t.Fatal(err) 196 | } 197 | 198 | return ev 199 | } 200 | 201 | func (m *Mock) NewThreadMessage( 202 | ctx context.Context, 203 | t *testing.T, 204 | owner *model.User, 205 | thread *model.Thread, 206 | ) *model.Message { 207 | t.Helper() 208 | 209 | mess, err := model.NewMessage( 210 | ctx, 211 | m.Storage, 212 | m.OG, 213 | &model.NewMessageInput{ 214 | User: owner, 215 | Parent: thread.Key, 216 | Body: fake.Paragraph(), 217 | Blob: "", 218 | }) 219 | if err != nil { 220 | t.Fatal(err) 221 | } 222 | 223 | err = m.MessageStore.Commit(ctx, mess) 224 | if err != nil { 225 | t.Fatal(err) 226 | } 227 | 228 | return mess 229 | } 230 | 231 | func (m *Mock) NewEventMessage( 232 | ctx context.Context, 233 | t *testing.T, 234 | owner *model.User, 235 | event *model.Event, 236 | ) *model.Message { 237 | t.Helper() 238 | 239 | mess, err := model.NewMessage( 240 | ctx, 241 | m.Storage, 242 | m.OG, 243 | &model.NewMessageInput{ 244 | User: owner, 245 | Parent: event.Key, 246 | Body: fake.Paragraph(), 247 | Blob: "", 248 | }) 249 | if err != nil { 250 | t.Fatal(err) 251 | } 252 | 253 | err = m.MessageStore.Commit(ctx, mess) 254 | if err != nil { 255 | t.Fatal(err) 256 | } 257 | 258 | return mess 259 | } 260 | 261 | func (m *Mock) NewNote(ctx context.Context, t *testing.T, u *model.User) *model.Note { 262 | t.Helper() 263 | 264 | n, err := model.NewNote(u, fake.Title(), "", "", fake.Paragraph()) 265 | if err != nil { 266 | t.Fatal(err) 267 | } 268 | 269 | err = m.NoteStore.Commit(ctx, n) 270 | if err != nil { 271 | t.Fatal(err) 272 | } 273 | 274 | return n 275 | } 276 | 277 | func NewNotifClient(t *testing.T) notification.Client { 278 | t.Helper() 279 | return notification.NewLogger() 280 | } 281 | 282 | func NewUserStore(ctx context.Context, t *testing.T, dbClient dbc.Client, searchClient search.Client) model.UserStore { 283 | t.Helper() 284 | return &db.UserStore{DB: dbClient, Notif: notification.NewLogger(), S: searchClient} 285 | } 286 | 287 | func NewThreadStore(ctx context.Context, t *testing.T, dbClient dbc.Client) model.ThreadStore { 288 | t.Helper() 289 | return &db.ThreadStore{DB: dbClient} 290 | } 291 | 292 | func NewMessageStore(ctx context.Context, t *testing.T, dbClient dbc.Client) model.MessageStore { 293 | t.Helper() 294 | return &db.MessageStore{DB: dbClient} 295 | } 296 | 297 | func NewEventStore(ctx context.Context, t *testing.T, dbClient dbc.Client) model.EventStore { 298 | t.Helper() 299 | return &db.EventStore{DB: dbClient} 300 | } 301 | 302 | func NewNoteStore(ctx context.Context, t *testing.T, dbClient dbc.Client, searchClient search.Client) model.NoteStore { 303 | t.Helper() 304 | return &db.NoteStore{DB: dbClient, S: searchClient} 305 | } 306 | 307 | func NewSearchClient() search.Client { 308 | esh := os.Getenv("ELASTICSEARCH_HOST") 309 | if esh == "" { 310 | esh = "elasticsearch" 311 | } 312 | 313 | return search.NewClient(esh) 314 | } 315 | 316 | func NewDBClient(ctx context.Context) dbc.Client { 317 | return dbc.NewClient(ctx, "local-convo-api") 318 | } 319 | 320 | func NewMongoClient(ctx context.Context) (*mongo.Client, func()) { 321 | conn := os.Getenv("MONGO_CONNECTION") 322 | if conn == "" { 323 | conn = "mongo" 324 | } 325 | c, closer := mgc.NewClient(ctx, conn) 326 | return c, closer 327 | } 328 | 329 | func ClearDB(ctx context.Context, client dbc.Client) { 330 | for _, tp := range []string{"User", "Thread", "Event", "Message", "Note"} { 331 | q := datastore.NewQuery(tp).KeysOnly() 332 | 333 | keys, err := client.GetAll(ctx, q, nil) 334 | if err != nil { 335 | panic(err) 336 | } 337 | 338 | err = client.DeleteMulti(ctx, keys) 339 | if err != nil { 340 | panic(err) 341 | } 342 | } 343 | } 344 | 345 | func GetMagicLinkParts(link string) (string, string, string) { 346 | split := strings.Split(link, "/") 347 | kenc := split[len(split)-3] 348 | b64ts := split[len(split)-2] 349 | sig := split[len(split)-1] 350 | return kenc, b64ts, sig 351 | } 352 | 353 | func GetAuthHeader(token string) map[string]string { 354 | return map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)} 355 | } 356 | -------------------------------------------------------------------------------- /valid/valid.go: -------------------------------------------------------------------------------- 1 | package valid 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "reflect" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/microcosm-cc/bluemonday" 11 | "gopkg.in/validator.v2" 12 | 13 | "github.com/hiconvo/api/errors" 14 | ) 15 | 16 | // nolint 17 | var _emailRe = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,14}$`) 18 | 19 | func Email(email string) (string, error) { 20 | email = strings.TrimSpace(strings.ToLower(email)) 21 | 22 | if _emailRe.MatchString(email) { 23 | return email, nil 24 | } 25 | 26 | return "", errors.E( 27 | errors.Opf("valid.Email(%q)", email), 28 | errors.Str("invalid email"), 29 | http.StatusBadRequest) 30 | } 31 | 32 | func URL(in string) (string, error) { 33 | parsed, err := url.ParseRequestURI(in) 34 | if err != nil { 35 | return "", errors.Str("invalid url") 36 | } 37 | 38 | return parsed.String(), nil 39 | } 40 | 41 | func Raw(in interface{}) error { 42 | v := reflect.ValueOf(in).Elem() 43 | 44 | for i := 0; i < v.NumField(); i++ { 45 | val, ok := v.Field(i).Interface().(string) 46 | if !ok { 47 | continue 48 | } 49 | 50 | // if it's an email address, lower case it 51 | if _emailRe.MatchString(strings.ToLower(val)) { 52 | val = strings.ToLower(val) 53 | } 54 | 55 | val = strings.TrimSpace(val) 56 | val = bluemonday.StrictPolicy().Sanitize(val) 57 | 58 | if v.Field(i).CanSet() { 59 | v.Field(i).SetString(val) 60 | } 61 | } 62 | 63 | if err := validator.Validate(in); err != nil { 64 | return errors.E(errors.Op("valid.Raw"), normalizeErrors(err), http.StatusBadRequest, err) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func normalizeErrors(e interface{}) map[string]string { 71 | normalized := make(map[string]string) 72 | 73 | errs := e.(validator.ErrorMap) 74 | 75 | for field, errs := range errs { 76 | err := errs[0] // Take just the first error 77 | 78 | switch err { 79 | case validator.ErrZeroValue: 80 | normalized[lowerFirstLetter(field)] = "This field is required" 81 | case validator.ErrMin: 82 | if field == "Password" { 83 | normalized[lowerFirstLetter(field)] = "Must be at least 8 characters long" 84 | } else { 85 | normalized[lowerFirstLetter(field)] = "This is too short" 86 | } 87 | case validator.ErrMax: 88 | normalized[lowerFirstLetter(field)] = "This is too long" 89 | case validator.ErrRegexp: 90 | if field == "Email" { 91 | normalized[lowerFirstLetter(field)] = "This is not a valid email" 92 | } else { 93 | normalized[lowerFirstLetter(field)] = "Nope" 94 | } 95 | default: 96 | normalized[lowerFirstLetter(field)] = "Nope" 97 | } 98 | } 99 | 100 | return normalized 101 | } 102 | 103 | func lowerFirstLetter(s string) string { 104 | if r := rune(s[0]); r >= 'A' && r <= 'Z' { 105 | s = strings.ToLower(string(r)) + s[1:] 106 | } 107 | 108 | if s[len(s)-2:] == "ID" { 109 | s = s[:len(s)-2] + "Id" 110 | } 111 | 112 | return s 113 | } 114 | 115 | func upperFirstLetter(s string) string { 116 | if r := rune(s[0]); r >= 'A' && r <= 'Z' { 117 | s = strings.ToUpper(string(r)) + s[1:] 118 | } 119 | 120 | if len(s) >= 2 && s[len(s)-2:] == "Id" { 121 | s = s[:len(s)-2] + "ID" 122 | } 123 | 124 | return s 125 | } 126 | -------------------------------------------------------------------------------- /welcome/content/welcome.md: -------------------------------------------------------------------------------- 1 | Hello, 👋 2 | 3 | Welcome to Convo! Convo has two main features, **events** and **Convos**. 4 | 5 | Convo events make it easy to plan events with real people. Invite your guests by name or email and they can RSVP in one click without having to create accounts of their own. 6 | 7 | Convo also allows you to share content with people directly via *Convos*. A Convo is like a Facebook post except that it's private and only visible to the people you choose. 8 | 9 | Read more about Convo and why I built it on [the blog](https://blog.convo.events/hello-world). 10 | 11 | If you have any suggestions or feedback, please respond to this Convo directly and I'll get back to you. 12 | 13 | Thanks, 14 | 15 | Alex 16 | -------------------------------------------------------------------------------- /welcome/welcome.go: -------------------------------------------------------------------------------- 1 | package welcome 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/hiconvo/api/clients/opengraph" 11 | "github.com/hiconvo/api/clients/storage" 12 | "github.com/hiconvo/api/errors" 13 | "github.com/hiconvo/api/log" 14 | "github.com/hiconvo/api/model" 15 | ) 16 | 17 | var _ model.Welcomer = (*Welcomer)(nil) 18 | 19 | type Welcomer struct { 20 | supportUser *model.User 21 | welcomeMessage string 22 | nullOG opengraph.Client 23 | } 24 | 25 | func New(ctx context.Context, us model.UserStore, supportPassword string) *Welcomer { 26 | op := errors.Op("welcome.New") 27 | 28 | spuser, found, err := us.GetUserByEmail(ctx, "support@convo.events") 29 | if err != nil { 30 | panic(errors.E(op, err)) 31 | } 32 | 33 | if !found { 34 | spuser, err = model.NewUserWithPassword( 35 | "support@convo.events", "Convo Support", "", supportPassword) 36 | if err != nil { 37 | panic(errors.E(op, err)) 38 | } 39 | 40 | err = us.Commit(ctx, spuser) 41 | if err != nil { 42 | panic(errors.E(op, err)) 43 | } 44 | 45 | log.Print("welcome.New: Created new support user") 46 | } 47 | 48 | return &Welcomer{ 49 | supportUser: spuser, 50 | welcomeMessage: readStringFromFile("welcome.md"), 51 | nullOG: opengraph.NewNullClient(), 52 | } 53 | } 54 | 55 | func (w *Welcomer) Welcome( 56 | ctx context.Context, 57 | ts model.ThreadStore, 58 | sclient *storage.Client, 59 | u *model.User, 60 | ) error { 61 | var op errors.Op = "user.Welcome" 62 | 63 | thread, err := model.NewThread( 64 | ctx, 65 | ts, 66 | sclient, 67 | w.nullOG, 68 | &model.NewThreadInput{ 69 | Owner: w.supportUser, 70 | Users: []*model.User{u}, 71 | Subject: "Welcome", 72 | Body: w.welcomeMessage, 73 | }) 74 | if err != nil { 75 | return errors.E(op, err) 76 | } 77 | 78 | // Don't spam users with this welcome message in their digests 79 | model.MarkAsRead(thread, u.Key) 80 | 81 | if err := ts.Commit(ctx, thread); err != nil { 82 | return errors.E(op, err) 83 | } 84 | 85 | log.Printf("welcome.Welcome: created welcome thread for %q", u.Email) 86 | 87 | return nil 88 | } 89 | 90 | func readStringFromFile(file string) string { 91 | op := errors.Opf("welcome.readStringFromFile(file=%s)", file) 92 | 93 | wd, err := os.Getwd() 94 | if err != nil { 95 | // This function should only be run at startup time, so we 96 | // panic if it fails. 97 | panic(errors.E(op, err)) 98 | } 99 | 100 | var basePath string 101 | if strings.HasSuffix(wd, "welcome") || strings.HasSuffix(wd, "integ") { 102 | // This package is the cwd, so we need to go up one dir to resolve the 103 | // content. 104 | basePath = "../welcome/content" 105 | } else { 106 | basePath = "./welcome/content" 107 | } 108 | 109 | b, err := ioutil.ReadFile(path.Join(basePath, file)) 110 | if err != nil { 111 | panic(err) 112 | } 113 | 114 | return string(b) 115 | } 116 | --------------------------------------------------------------------------------