├── .deepsource.toml ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── buf.gen.yaml ├── cmd ├── accounts │ ├── cmd │ │ ├── root.go │ │ └── twitter.go │ └── main.go ├── api │ └── main.go ├── indexer │ ├── cmd │ │ ├── root.go │ │ ├── worker.go │ │ └── writer.go │ └── main.go ├── metadata │ └── main.go ├── notifications │ ├── cmd │ │ ├── root.go │ │ ├── send.go │ │ └── worker.go │ └── main.go ├── recommendations │ ├── cmd │ │ ├── follows.go │ │ └── root.go │ └── main.go ├── rooms │ ├── cmd │ │ ├── close.go │ │ ├── list.go │ │ ├── root.go │ │ └── server.go │ └── main.go ├── stories │ └── main.go └── tracking │ └── main.go ├── conf ├── elasticsearch │ └── users.json └── services │ ├── accounts.toml │ ├── indexer.toml │ ├── metadata.toml │ ├── notifications.toml │ ├── recommendations.toml │ ├── rooms.toml │ ├── soapbox.toml │ ├── stories.toml │ └── tracking.toml ├── db ├── database.sql └── tables.sql ├── go.mod ├── go.sum ├── main.go ├── mocks ├── apns_mock.go ├── roomserviceclient_mock.go └── signinwithapple_mock.go ├── pkg ├── account │ ├── backend.go │ ├── endpoint.go │ └── endpoint_test.go ├── activeusers │ ├── backend.go │ └── types.go ├── analytics │ ├── backend.go │ ├── endpoint.go │ ├── endpoint_test.go │ └── types.go ├── apple │ ├── apns.go │ └── signinwithapple.go ├── blocks │ ├── backend.go │ └── endpoint.go ├── conf │ ├── conf.go │ ├── conf_test.go │ └── testdata │ │ ├── invalid.toml │ │ └── redis.toml ├── devices │ ├── backend.go │ ├── endpoint.go │ └── endpoint_test.go ├── followers │ └── backend.go ├── http │ ├── context.go │ ├── context_test.go │ ├── cors.go │ ├── http.go │ ├── http_test.go │ └── middlewares │ │ ├── authentication.go │ │ └── authentication_test.go ├── images │ ├── backend.go │ └── utils.go ├── linkedaccounts │ └── backend.go ├── login │ ├── endpoint.go │ ├── endpoint_test.go │ ├── internal │ │ └── utils.go │ └── state_manager.go ├── mail │ └── service.go ├── me │ └── endpoint.go ├── metadata │ └── endpoint.go ├── minis │ ├── backend.go │ ├── backend_test.go │ ├── endpoint.go │ ├── endpoint_test.go │ └── types.go ├── notifications │ ├── apns.go │ ├── errors.go │ ├── grpc │ │ └── service.go │ ├── handlers │ │ ├── errors.go │ │ ├── followernotificationhandler.go │ │ ├── followernotificationhandler_test.go │ │ ├── followrecommendationsnotificationhandler.go │ │ ├── followrecommendationsnotificationhandler_test.go │ │ ├── roomcreationnotificationhandler.go │ │ ├── roomcreationnotificationhandler_test.go │ │ ├── roominvitenotificationhandler.go │ │ ├── roominvitenotificationhandler_test.go │ │ ├── roomjoinnotificationhandler.go │ │ ├── roomjoinnotificationhandler_test.go │ │ ├── types.go │ │ ├── util_test.go │ │ ├── welcomeroomnotificationhandler.go │ │ └── welcomeroomnotificationhandler_test.go │ ├── limiter.go │ ├── pb │ │ ├── notifications.pb.go │ │ ├── notifications_api.pb.go │ │ ├── notifications_api_grpc.pb.go │ │ └── utils.go │ ├── settings.go │ ├── storage.go │ ├── types.go │ └── worker │ │ ├── dispatcher.go │ │ ├── types.go │ │ ├── worker.go │ │ └── worker_test.go ├── pubsub │ ├── events.go │ └── queue.go ├── recommendations │ └── follows │ │ ├── backend.go │ │ ├── providers │ │ ├── twitter.go │ │ └── types.go │ │ └── worker │ │ ├── dispatcher.go │ │ ├── dispatcher_test.go │ │ ├── types.go │ │ ├── worker.go │ │ └── worker_test.go ├── redis │ ├── redis.go │ ├── redis_test.go │ ├── timeoutstore.go │ └── timeoutstore_test.go ├── rooms │ ├── auth.go │ ├── auth_test.go │ ├── buffereddatachannel.go │ ├── currentroombackend.go │ ├── currentroombackend_test.go │ ├── endpoint.go │ ├── grpc │ │ ├── service.go │ │ └── service_test.go │ ├── internal │ │ ├── utils.go │ │ └── utils_test.go │ ├── member.go │ ├── pb │ │ ├── room.pb.go │ │ ├── room_api.pb.go │ │ ├── room_api_grpc.pb.go │ │ └── signal.pb.go │ ├── repository.go │ ├── room.go │ ├── server.go │ ├── signal │ │ └── transport.go │ ├── welcomestore.go │ └── welcomestore_test.go ├── search │ ├── endpoint.go │ └── internal │ │ └── types.go ├── sessions │ └── manager.go ├── sql │ └── sql.go ├── stories │ ├── backend.go │ ├── endpoint.go │ ├── filebackend.go │ ├── types.go │ └── utils.go ├── tracking │ ├── backends │ │ └── userroomlogbackend.go │ ├── event.go │ └── trackers │ │ ├── mixpaneltracker.go │ │ ├── mixpaneltracker_test.go │ │ ├── recentlyactivetracker.go │ │ ├── recentlyactivetracker_test.go │ │ ├── tracker.go │ │ ├── userroomlogtracker.go │ │ └── userroomlogtracker_test.go └── users │ ├── backend.go │ ├── endpoint.go │ └── types │ └── types.go └── vagrant ├── Vagrantfile ├── bin ├── boot.sh └── provision.sh └── conf ├── crontab ├── nginx.conf ├── pg_hba.conf ├── supervisord.conf └── supervisord ├── indexer.conf ├── metadata.conf ├── notifications.conf ├── rooms.conf ├── soapbox.conf └── tracking.conf /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | exclude_patterns = [ 4 | "pkg/rooms/pb/**", 5 | ] 6 | 7 | [[analyzers]] 8 | name = "go" 9 | enabled = true 10 | 11 | [analyzers.meta] 12 | import_paths = ["github.com/soapboxsocial/soapbox"] 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pb.go linguist-generated=true 2 | *_mock.go linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | go-version: [1.16.x] 14 | platform: [ubuntu-latest] 15 | runs-on: ${{ matrix.platform }} 16 | steps: 17 | - name: Install Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | - name: Checkout code 22 | uses: actions/checkout@v2 23 | - name: Test 24 | run: go test ./... 25 | lint: 26 | strategy: 27 | matrix: 28 | go-version: [1.15.x, 1.16.x] 29 | platform: [ubuntu-latest] 30 | runs-on: ${{ matrix.platform }} 31 | steps: 32 | - uses: actions/checkout@master 33 | - uses: reviewdog/action-golangci-lint@v1 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | 17 | vagrant/.vagrant 18 | vagrant/provisioned 19 | 20 | # idea 21 | .idea/ 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: protobuf 2 | 3 | protobuf: 4 | ifdef BRANCH 5 | buf generate https://github.com/soapboxsocial/protobufs.git#branch=$(BRANCH) 6 | else 7 | buf generate https://github.com/soapboxsocial/protobufs.git 8 | endif 9 | 10 | mock: 11 | mockgen -package=mocks -destination=mocks/signinwithapple_mock.go -source=pkg/apple/signinwithapple.go 12 | mockgen -package=mocks -destination=mocks/apns_mock.go -source=pkg/notifications/apns.go 13 | mockgen -package=mocks -destination=mocks/roomserviceclient_mock.go -source=pkg/rooms/pb/room_api_grpc.pb.go RoomServiceClient 14 | .PHONY: mock 15 | 16 | cover: 17 | go test ./... -coverprofile cover.out 18 | go tool cover -func cover.out 19 | rm -f cover.out 20 | .PHONY: cover 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Soapbox 2 | 3 | ## Running 4 | 5 | To run the Soapbox server locally, it is recommended to use `vagrant`. This can be done by running the following in the `vagrant` directory. 6 | 7 | ```console 8 | vagrant up 9 | vagrant reload 10 | ``` 11 | 12 | The API is then available under the IP address: `192.168.33.16` 13 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1beta1 2 | plugins: 3 | - name: go 4 | out: . 5 | - name: go-grpc 6 | out: . 7 | -------------------------------------------------------------------------------- /cmd/accounts/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/soapboxsocial/soapbox/pkg/conf" 9 | ) 10 | 11 | var ( 12 | file string 13 | config *Conf 14 | 15 | rootCmd = &cobra.Command{ 16 | Use: "accounts", 17 | Short: "Soapbox Third-Party Accounts Management", 18 | Long: "", 19 | } 20 | ) 21 | 22 | type Conf struct { 23 | DB conf.PostgresConf `mapstructure:"db"` 24 | Twitter struct { 25 | Key string `mapstructure:"key"` 26 | Secret string `mapstructure:"secret"` 27 | } `mapstructure:"twitter"` 28 | } 29 | 30 | func init() { 31 | cobra.OnInitialize(initConfig) 32 | rootCmd.PersistentFlags().StringVarP(&file, "config", "c", "config.toml", "config file") 33 | 34 | rootCmd.AddCommand(twitterCmd) 35 | } 36 | 37 | // Execute executes the root command. 38 | func Execute() error { 39 | return rootCmd.Execute() 40 | } 41 | 42 | func initConfig() { 43 | config = &Conf{} 44 | err := conf.Load(file, config) 45 | if err != nil { 46 | log.Fatalf("failed to load config: %s", err) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /cmd/accounts/cmd/twitter.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/dghubble/go-twitter/twitter" 8 | "github.com/dghubble/oauth1" 9 | "github.com/pkg/errors" 10 | "github.com/spf13/cobra" 11 | "google.golang.org/grpc" 12 | 13 | "github.com/soapboxsocial/soapbox/pkg/linkedaccounts" 14 | "github.com/soapboxsocial/soapbox/pkg/notifications/pb" 15 | "github.com/soapboxsocial/soapbox/pkg/sql" 16 | ) 17 | 18 | const errExpiredTokenCode = 89 19 | 20 | var ( 21 | accounts *linkedaccounts.Backend 22 | notifications pb.NotificationServiceClient 23 | 24 | addr string 25 | 26 | twitterCmd = &cobra.Command{ 27 | Use: "twitter", 28 | Short: "twitter account management used for deleting and updating out-of-date twitter accounts", 29 | RunE: runTwitter, 30 | } 31 | ) 32 | 33 | func init() { 34 | twitterCmd.Flags().StringVarP(&addr, "addr", "a", "127.0.0.1:50053", "grpc address") 35 | } 36 | 37 | func runTwitter(*cobra.Command, []string) error { 38 | db, err := sql.Open(config.DB) 39 | if err != nil { 40 | return errors.Wrap(err, "failed to open db") 41 | } 42 | 43 | conn, err := grpc.Dial(addr, grpc.WithInsecure()) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | defer conn.Close() 49 | 50 | notifications = pb.NewNotificationServiceClient(conn) 51 | 52 | oauth := oauth1.NewConfig( 53 | config.Twitter.Key, 54 | config.Twitter.Secret, 55 | ) 56 | 57 | accounts = linkedaccounts.NewLinkedAccountsBackend(db) 58 | 59 | rows, err := db.Query("SELECT user_id, token, secret FROM linked_accounts") 60 | if err != nil { 61 | return err 62 | } 63 | 64 | for rows.Next() { 65 | var ( 66 | id int 67 | token string 68 | secret string 69 | ) 70 | 71 | err := rows.Scan(&id, &token, &secret) 72 | if err != nil { 73 | log.Println(err) 74 | continue 75 | } 76 | 77 | update(oauth, id, token, secret) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func update(oauth *oauth1.Config, user int, token, secret string) { 84 | access := oauth1.NewToken(token, secret) 85 | httpClient := oauth.Client(oauth1.NoContext, access) 86 | 87 | client := twitter.NewClient(httpClient) 88 | profile, _, err := client.Accounts.VerifyCredentials(nil) 89 | if err != nil { 90 | aerr, ok := err.(twitter.APIError) 91 | if !ok { 92 | return 93 | } 94 | 95 | if aerr.Contains(errExpiredTokenCode) { 96 | unlink(user) 97 | return 98 | } 99 | 100 | log.Printf("accounts.VerifyCredentials failed err %s\n", err) 101 | } 102 | 103 | err = accounts.UpdateTwitterUsernameFor(user, profile.ScreenName) 104 | if err != nil { 105 | log.Printf("accounts.UpdateTwitterUsernameFor err: %s", err) 106 | } 107 | } 108 | 109 | func unlink(user int) { 110 | log.Printf("removing twitter for %d\n", user) 111 | 112 | err := accounts.UnlinkTwitterProfile(user) 113 | if err != nil { 114 | log.Printf("accounts.UnlinkTwitterProfile err %s\n", err) 115 | } 116 | 117 | _, err = notifications.SendNotification(context.Background(), &pb.SendNotificationRequest{ 118 | Targets: []int64{int64(user)}, 119 | Notification: &pb.Notification{ 120 | Category: "INFO", 121 | Alert: &pb.Notification_Alert{ 122 | Body: "There was an issue with your twitter account. Please reconnect it.", 123 | }, 124 | }, 125 | }) 126 | 127 | if err != nil { 128 | log.Printf("notifications.SendNotification err: %s\n", err) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /cmd/accounts/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/soapboxsocial/soapbox/cmd/accounts/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cmd/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /cmd/indexer/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/elastic/go-elasticsearch/v7" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/conf" 10 | "github.com/soapboxsocial/soapbox/pkg/users" 11 | ) 12 | 13 | var ( 14 | file string 15 | config *Conf 16 | 17 | // Used by some of the commands. 18 | client *elasticsearch.Client 19 | userBackend *users.Backend 20 | 21 | rootCmd = &cobra.Command{ 22 | Use: "indexer", 23 | Short: "Soapbox Search Indexing", 24 | Long: "", 25 | } 26 | ) 27 | 28 | type Conf struct { 29 | Redis conf.RedisConf `mapstructure:"redis"` 30 | DB conf.PostgresConf `mapstructure:"db"` 31 | } 32 | 33 | func init() { 34 | cobra.OnInitialize(initConfig) 35 | rootCmd.PersistentFlags().StringVarP(&file, "config", "c", "config.toml", "config file") 36 | 37 | rootCmd.AddCommand(worker) 38 | rootCmd.AddCommand(writer) 39 | } 40 | 41 | // Execute executes the root command. 42 | func Execute() error { 43 | return rootCmd.Execute() 44 | } 45 | 46 | func initConfig() { 47 | config = &Conf{} 48 | err := conf.Load(file, config) 49 | if err != nil { 50 | log.Fatalf("failed to load config: %s", err) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/indexer/cmd/worker.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "log" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/elastic/go-elasticsearch/v7" 12 | "github.com/elastic/go-elasticsearch/v7/esapi" 13 | "github.com/spf13/cobra" 14 | 15 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 16 | "github.com/soapboxsocial/soapbox/pkg/redis" 17 | "github.com/soapboxsocial/soapbox/pkg/sql" 18 | "github.com/soapboxsocial/soapbox/pkg/users" 19 | ) 20 | 21 | var worker = &cobra.Command{ 22 | Use: "worker", 23 | Short: "runs a index worker", 24 | RunE: runWorker, 25 | } 26 | 27 | var errNoRequestHandler = errors.New("no request handler for event") 28 | 29 | // @TODO OPTIMIZE SO WORKER ONLY UPDATES ROOM TIME. 30 | 31 | func runWorker(*cobra.Command, []string) error { 32 | rdb := redis.NewRedis(config.Redis) 33 | 34 | db, err := sql.Open(config.DB) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | client, err = elasticsearch.NewDefaultClient() 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | queue := pubsub.NewQueue(rdb) 45 | events := queue.Subscribe(pubsub.UserTopic) 46 | 47 | userBackend = users.NewBackend(db) 48 | 49 | for event := range events { 50 | go handleEvent(event) 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func handleEvent(event *pubsub.Event) { 57 | request, err := requestFor(event) 58 | if err != nil { 59 | if err == errNoRequestHandler { 60 | return 61 | } 62 | 63 | log.Printf("failed to create request: %v\n", err) 64 | return 65 | } 66 | 67 | res, err := request.Do(context.Background(), client) 68 | if err != nil { 69 | log.Printf("failed to execute request: %v\n", err) 70 | } 71 | 72 | _ = res.Body.Close() 73 | } 74 | 75 | func requestFor(event *pubsub.Event) (esapi.Request, error) { 76 | switch event.Type { 77 | case pubsub.EventTypeUserUpdate, pubsub.EventTypeNewUser, pubsub.EventTypeNewFollower: // @TODO think about unfollows 78 | return userUpdateRequest(event) 79 | case pubsub.EventTypeDeleteUser: 80 | return userDeleteRequest(event) 81 | default: 82 | return nil, errNoRequestHandler 83 | } 84 | } 85 | 86 | func userUpdateRequest(event *pubsub.Event) (esapi.Request, error) { 87 | id, ok := event.Params["id"].(float64) 88 | if !ok { 89 | return nil, errors.New("failed to recover user ID") 90 | } 91 | 92 | user, err := userBackend.GetUserForSearchEngine(int(id)) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | body, err := json.Marshal(user) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return esapi.IndexRequest{ 103 | Index: "users", 104 | DocumentID: strconv.Itoa(user.ID), 105 | Body: strings.NewReader(string(body)), 106 | Refresh: "true", 107 | }, nil 108 | } 109 | 110 | func userDeleteRequest(event *pubsub.Event) (esapi.Request, error) { 111 | id, err := event.GetInt("id") 112 | if err != nil { 113 | return nil, errors.New("failed to recover user ID") 114 | } 115 | 116 | return esapi.DeleteRequest{ 117 | Index: "users", 118 | DocumentID: strconv.Itoa(id), 119 | Refresh: "true", 120 | }, nil 121 | } 122 | -------------------------------------------------------------------------------- /cmd/indexer/cmd/writer.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 10 | "github.com/soapboxsocial/soapbox/pkg/redis" 11 | "github.com/soapboxsocial/soapbox/pkg/sql" 12 | "github.com/soapboxsocial/soapbox/pkg/users" 13 | ) 14 | 15 | var writer = &cobra.Command{ 16 | Use: "writer", 17 | Short: "runs a index writer, used to reindex", 18 | RunE: runWriter, 19 | } 20 | 21 | func runWriter(*cobra.Command, []string) error { 22 | rdb := redis.NewRedis(config.Redis) 23 | 24 | db, err := sql.Open(config.DB) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | queue := pubsub.NewQueue(rdb) 30 | userBackend = users.NewBackend(db) 31 | 32 | rows, err := db.Query("SELECT id FROM users;") 33 | if err != nil { 34 | return err 35 | } 36 | 37 | index := 0 38 | for rows.Next() { 39 | if index%10 == 0 && index != 0 { 40 | log.Printf("indexed %d users, sleeping for 5s", index) 41 | time.Sleep(5 * time.Second) 42 | } 43 | 44 | var id int 45 | err := rows.Scan(&id) 46 | if err != nil { 47 | log.Printf("error encountered %s", err) 48 | } 49 | 50 | err = queue.Publish(pubsub.UserTopic, pubsub.NewUserUpdateEvent(id)) 51 | if err != nil { 52 | log.Printf("error encountered %s", err) 53 | } 54 | 55 | index++ 56 | } 57 | 58 | log.Printf("finished, total indexed: %d", index) 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /cmd/indexer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/soapboxsocial/soapbox/cmd/indexer/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cmd/metadata/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "google.golang.org/grpc" 10 | 11 | "github.com/soapboxsocial/soapbox/pkg/conf" 12 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 13 | "github.com/soapboxsocial/soapbox/pkg/metadata" 14 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 15 | "github.com/soapboxsocial/soapbox/pkg/sql" 16 | "github.com/soapboxsocial/soapbox/pkg/users" 17 | ) 18 | 19 | type Conf struct { 20 | DB conf.PostgresConf `mapstructure:"db"` 21 | GRPC conf.AddrConf `mapstructure:"grpc"` 22 | Listen conf.AddrConf `mapstructure:"listen"` 23 | } 24 | 25 | func parse() (*Conf, error) { 26 | var file string 27 | flag.StringVar(&file, "c", "config.toml", "config file") 28 | flag.Parse() 29 | 30 | config := &Conf{} 31 | err := conf.Load(file, config) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return config, nil 37 | } 38 | 39 | func main() { 40 | config, err := parse() 41 | if err != nil { 42 | log.Fatal("failed to parse config") 43 | } 44 | 45 | db, err := sql.Open(config.DB) 46 | if err != nil { 47 | log.Fatalf("failed to open db: %s", err) 48 | } 49 | 50 | usersBackend := users.NewBackend(db) 51 | 52 | conn, err := grpc.Dial(fmt.Sprintf("%s:%d", config.GRPC.Host, config.GRPC.Port), grpc.WithInsecure()) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | 57 | defer conn.Close() 58 | 59 | client := pb.NewRoomServiceClient(conn) 60 | endpoint := metadata.NewEndpoint(usersBackend, client) 61 | 62 | router := endpoint.Router() 63 | 64 | log.Print(http.ListenAndServe(fmt.Sprintf(":%d", config.Listen.Port), httputil.CORS(router))) 65 | } 66 | -------------------------------------------------------------------------------- /cmd/notifications/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/soapboxsocial/soapbox/pkg/conf" 9 | ) 10 | 11 | var ( 12 | file string 13 | config *Conf 14 | 15 | rootCmd = &cobra.Command{ 16 | Use: "notifications", 17 | Short: "Soapbox Notifications", 18 | Long: "", 19 | } 20 | ) 21 | 22 | func init() { 23 | cobra.OnInitialize(initConfig) 24 | rootCmd.PersistentFlags().StringVarP(&file, "config", "c", "config.toml", "config file") 25 | 26 | rootCmd.AddCommand(workerCmd) 27 | rootCmd.AddCommand(send) 28 | } 29 | 30 | // Execute executes the root command. 31 | func Execute() error { 32 | return rootCmd.Execute() 33 | } 34 | 35 | func initConfig() { 36 | config = &Conf{} 37 | err := conf.Load(file, config) 38 | if err != nil { 39 | log.Fatalf("failed to load config: %s", err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cmd/notifications/cmd/send.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/spf13/cobra" 8 | "google.golang.org/grpc" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/notifications/pb" 11 | "github.com/soapboxsocial/soapbox/pkg/sql" 12 | ) 13 | 14 | var send = &cobra.Command{ 15 | Use: "send", 16 | Short: "sends a notification", 17 | RunE: runSend, 18 | } 19 | 20 | var ( 21 | addr string 22 | 23 | // Related to who to send the notification to 24 | targets []int64 25 | query string 26 | 27 | // The actual notification data 28 | body string 29 | category string 30 | ) 31 | 32 | func init() { 33 | send.Flags().StringVarP(&addr, "addr", "a", "127.0.0.1:50053", "grpc address") 34 | send.Flags().Int64SliceVarP(&targets, "targets", "t", []int64{}, "target user IDs") 35 | send.Flags().StringVarP(&query, "query", "q", "", "a query for target users") 36 | 37 | send.Flags().StringVarP(&body, "body", "", "", "notification body") 38 | send.Flags().StringVarP(&category, "category", "", "", "notification category") 39 | } 40 | 41 | func runSend(*cobra.Command, []string) error { 42 | if body == "" { 43 | return errors.New("body cannot be empty") 44 | } 45 | 46 | if category == "" { 47 | return errors.New("category cannot be empty") 48 | } 49 | 50 | notification := &pb.Notification{ 51 | Category: category, 52 | Alert: &pb.Notification_Alert{ 53 | Body: body, 54 | }, 55 | } 56 | 57 | ids, err := getTargets() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | if len(ids) == 0 { 63 | return errors.New("no targets") 64 | } 65 | 66 | conn, err := grpc.Dial(addr, grpc.WithInsecure()) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | defer conn.Close() 72 | 73 | client := pb.NewNotificationServiceClient(conn) 74 | 75 | _, err = client.SendNotification(context.TODO(), &pb.SendNotificationRequest{Targets: ids, Notification: notification}) 76 | return err 77 | } 78 | 79 | func getTargets() ([]int64, error) { 80 | if len(targets) > 0 { 81 | return targets, nil 82 | } 83 | 84 | if query == "" { 85 | return nil, errors.New("query not supplied") 86 | } 87 | 88 | db, err := sql.Open(config.DB) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | rows, err := db.Query(query) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | result := make([]int64, 0) 99 | 100 | for rows.Next() { 101 | var id int64 102 | err := rows.Scan(&id) 103 | if err != nil { 104 | continue 105 | } 106 | 107 | result = append(result, id) 108 | } 109 | 110 | return result, nil 111 | } 112 | -------------------------------------------------------------------------------- /cmd/notifications/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/soapboxsocial/soapbox/cmd/notifications/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cmd/recommendations/cmd/follows.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/dghubble/oauth1" 7 | "github.com/pkg/errors" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/linkedaccounts" 11 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 12 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows" 13 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows/providers" 14 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows/worker" 15 | "github.com/soapboxsocial/soapbox/pkg/redis" 16 | "github.com/soapboxsocial/soapbox/pkg/sql" 17 | ) 18 | 19 | var followscmd = &cobra.Command{ 20 | Use: "follows", 21 | Short: "created recommendations for users to follow", 22 | RunE: runFollows, 23 | } 24 | 25 | func runFollows(*cobra.Command, []string) error { 26 | rdb := redis.NewRedis(config.Redis) 27 | 28 | db, err := sql.Open(config.DB) 29 | if err != nil { 30 | return errors.Wrap(err, "failed to open db") 31 | } 32 | 33 | oauth := oauth1.NewConfig( 34 | config.Twitter.Key, 35 | config.Twitter.Secret, 36 | ) 37 | 38 | dispatch := worker.NewDispatcher(2, &worker.Config{ 39 | Twitter: providers.NewTwitter(oauth, linkedaccounts.NewLinkedAccountsBackend(db), nil), 40 | Recommendations: follows.NewBackend(db), 41 | Queue: pubsub.NewQueue(rdb), 42 | }) 43 | dispatch.Run() 44 | 45 | row := db.QueryRow("SELECT COUNT(user_id) FROM linked_accounts") 46 | 47 | var count int 48 | err = row.Scan(&count) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | 53 | log.Printf("found %d accounts\n", count) 54 | 55 | rows, err := db.Query("SELECT user_id FROM linked_accounts") 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | for rows.Next() { 61 | var id int 62 | err := rows.Scan(&id) 63 | if err != nil { 64 | log.Println(err) 65 | continue 66 | } 67 | 68 | dispatch.Dispatch(id) 69 | } 70 | 71 | dispatch.Wait() 72 | dispatch.Stop() 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /cmd/recommendations/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/soapboxsocial/soapbox/pkg/conf" 9 | ) 10 | 11 | var ( 12 | file string 13 | config *Conf 14 | 15 | rootCmd = &cobra.Command{ 16 | Use: "recommendations", 17 | Short: "Soapbox Recommendations Engine", 18 | Long: "", 19 | } 20 | ) 21 | 22 | type Conf struct { 23 | Redis conf.RedisConf `mapstructure:"redis"` 24 | DB conf.PostgresConf `mapstructure:"db"` 25 | Twitter struct { 26 | Key string `mapstructure:"key"` 27 | Secret string `mapstructure:"secret"` 28 | } `mapstructure:"twitter"` 29 | } 30 | 31 | func init() { 32 | cobra.OnInitialize(initConfig) 33 | rootCmd.PersistentFlags().StringVarP(&file, "config", "c", "config.toml", "config file") 34 | 35 | rootCmd.AddCommand(followscmd) 36 | } 37 | 38 | // Execute executes the root command. 39 | func Execute() error { 40 | return rootCmd.Execute() 41 | } 42 | 43 | func initConfig() { 44 | config = &Conf{} 45 | err := conf.Load(file, config) 46 | if err != nil { 47 | log.Fatalf("failed to load config: %s", err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cmd/recommendations/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/soapboxsocial/soapbox/cmd/recommendations/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cmd/rooms/cmd/close.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/spf13/cobra" 9 | "google.golang.org/grpc" 10 | 11 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 12 | ) 13 | 14 | var close = &cobra.Command{ 15 | Use: "close", 16 | Short: "close an active room", 17 | RunE: runClose, 18 | } 19 | 20 | var room string 21 | 22 | func init() { 23 | close.Flags().StringVarP(&room, "room", "r", "", "room id") 24 | close.Flags().StringVarP(&addr, "addr", "a", "127.0.0.1:50052", "grpc address") 25 | } 26 | 27 | func runClose(*cobra.Command, []string) error { 28 | if room == "" { 29 | return errors.New("room is empty") 30 | } 31 | 32 | conn, err := grpc.Dial(addr, grpc.WithInsecure()) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | defer conn.Close() 38 | 39 | client := pb.NewRoomServiceClient(conn) 40 | 41 | resp, err := client.CloseRoom(context.TODO(), &pb.CloseRoomRequest{Id: room}) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | if !resp.Success { 47 | return errors.New("failed to close room") 48 | } 49 | 50 | fmt.Printf("Closed %s\n", room) 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /cmd/rooms/cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "google.golang.org/grpc" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 11 | ) 12 | 13 | var list = &cobra.Command{ 14 | Use: "list", 15 | Short: "list all active rooms", 16 | RunE: runList, 17 | } 18 | 19 | func init() { 20 | list.Flags().StringVarP(&addr, "addr", "a", "127.0.0.1:50052", "grpc address") 21 | } 22 | 23 | func runList(*cobra.Command, []string) error { 24 | conn, err := grpc.Dial(addr, grpc.WithInsecure()) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | defer conn.Close() 30 | 31 | client := pb.NewRoomServiceClient(conn) 32 | 33 | resp, err := client.ListRooms(context.TODO(), &pb.ListRoomsRequest{}) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | for _, room := range resp.Rooms { 39 | fmt.Printf("Room (%s) Peers = %d Visibility = %s\n", room.Id, len(room.Members), room.Visibility) 40 | } 41 | 42 | fmt.Printf("Total Rooms %d\n", len(resp.Rooms)) 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /cmd/rooms/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | rootCmd = &cobra.Command{ 9 | Use: "rooms", 10 | Short: "Soapbox Room Servers", 11 | Long: "", 12 | } 13 | ) 14 | 15 | var addr string 16 | 17 | func init() { 18 | rootCmd.AddCommand(server) 19 | rootCmd.AddCommand(list) 20 | rootCmd.AddCommand(close) 21 | } 22 | 23 | // Execute executes the root command. 24 | func Execute() error { 25 | return rootCmd.Execute() 26 | } 27 | -------------------------------------------------------------------------------- /cmd/rooms/cmd/server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/http" 8 | 9 | plog "github.com/pion/ion-sfu/pkg/logger" 10 | "github.com/pion/ion-sfu/pkg/middlewares/datachannel" 11 | "github.com/pion/ion-sfu/pkg/sfu" 12 | "github.com/pkg/errors" 13 | "github.com/spf13/cobra" 14 | "google.golang.org/grpc" 15 | 16 | "github.com/soapboxsocial/soapbox/pkg/blocks" 17 | "github.com/soapboxsocial/soapbox/pkg/conf" 18 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 19 | "github.com/soapboxsocial/soapbox/pkg/http/middlewares" 20 | "github.com/soapboxsocial/soapbox/pkg/minis" 21 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 22 | "github.com/soapboxsocial/soapbox/pkg/redis" 23 | "github.com/soapboxsocial/soapbox/pkg/rooms" 24 | roomGRPC "github.com/soapboxsocial/soapbox/pkg/rooms/grpc" 25 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 26 | "github.com/soapboxsocial/soapbox/pkg/sessions" 27 | "github.com/soapboxsocial/soapbox/pkg/sql" 28 | "github.com/soapboxsocial/soapbox/pkg/users" 29 | ) 30 | 31 | type Conf struct { 32 | SFU sfu.Config `mapstructure:"sfu"` 33 | Redis conf.RedisConf `mapstructure:"redis"` 34 | DB conf.PostgresConf `mapstructure:"db"` 35 | GRPC conf.AddrConf `mapstructure:"grpc"` 36 | API conf.AddrConf `mapstructure:"api"` 37 | } 38 | 39 | var server = &cobra.Command{ 40 | Use: "server", 41 | Short: "runs a room server", 42 | RunE: runServer, 43 | } 44 | 45 | var file string 46 | 47 | func init() { 48 | server.Flags().StringVarP(&file, "config", "c", "config.toml", "config file") 49 | } 50 | 51 | func runServer(*cobra.Command, []string) error { 52 | config := &Conf{} 53 | err := conf.Load(file, config) 54 | if err != nil { 55 | return errors.Wrap(err, "failed to parse config") 56 | } 57 | 58 | rdb := redis.NewRedis(config.Redis) 59 | 60 | db, err := sql.Open(config.DB) 61 | if err != nil { 62 | return errors.Wrap(err, "failed to open db") 63 | } 64 | 65 | repository := rooms.NewRepository() 66 | sm := sessions.NewSessionManager(rdb) 67 | ws := rooms.NewWelcomeStore(rdb) 68 | auth := rooms.NewAuth(repository, blocks.NewBackend(db)) 69 | 70 | lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", config.GRPC.Host, config.GRPC.Port)) 71 | if err != nil { 72 | return errors.Wrap(err, "failed to listen") 73 | } 74 | 75 | gs := grpc.NewServer() 76 | pb.RegisterRoomServiceServer( 77 | gs, 78 | roomGRPC.NewService(repository, ws, auth), 79 | ) 80 | 81 | go func() { 82 | err = gs.Serve(lis) 83 | if err != nil { 84 | log.Panicf("failed to serve: %v", err) 85 | } 86 | }() 87 | 88 | // @TODO ADD LOG 89 | plog.SetGlobalOptions(plog.GlobalConfig{V: 1}) 90 | logger := plog.New() 91 | 92 | // SFU instance needs to be created with logr implementation 93 | sfu.Logger = logger 94 | 95 | s := sfu.NewSFU(config.SFU) 96 | dc := s.NewDatachannel(sfu.APIChannelLabel) 97 | dc.Use(datachannel.SubscriberAPI) 98 | 99 | server := rooms.NewServer( 100 | s, 101 | sm, 102 | users.NewBackend(db), 103 | pubsub.NewQueue(rdb), 104 | rooms.NewCurrentRoomBackend(db), 105 | ws, 106 | repository, 107 | minis.NewBackend(db), 108 | auth, 109 | ) 110 | 111 | endpoint := rooms.NewEndpoint(repository, server, auth) 112 | router := endpoint.Router() 113 | 114 | amw := middlewares.NewAuthenticationMiddleware(sm) 115 | router.Use(amw.Middleware) 116 | 117 | return http.ListenAndServe(fmt.Sprintf(":%d", config.API.Port), httputil.CORS(router)) 118 | } 119 | -------------------------------------------------------------------------------- /cmd/rooms/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/soapboxsocial/soapbox/cmd/rooms/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cmd/stories/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "time" 7 | 8 | _ "github.com/lib/pq" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/conf" 11 | "github.com/soapboxsocial/soapbox/pkg/sql" 12 | "github.com/soapboxsocial/soapbox/pkg/stories" 13 | ) 14 | 15 | type Conf struct { 16 | Data struct { 17 | Path string `mapstructure:"path"` 18 | } `mapstructure:"data"` 19 | DB conf.PostgresConf `mapstructure:"db"` 20 | } 21 | 22 | func parse() (*Conf, error) { 23 | var file string 24 | flag.StringVar(&file, "c", "config.toml", "config file") 25 | flag.Parse() 26 | 27 | config := &Conf{} 28 | err := conf.Load(file, config) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return config, nil 34 | } 35 | 36 | func main() { 37 | config, err := parse() 38 | if err != nil { 39 | log.Fatal("failed to parse config") 40 | } 41 | 42 | db, err := sql.Open(config.DB) 43 | if err != nil { 44 | log.Fatalf("failed to open db: %s", err) 45 | } 46 | 47 | backend := stories.NewBackend(db) 48 | files := stories.NewFileBackend(config.Data.Path) 49 | 50 | now := time.Now().Unix() 51 | 52 | ids, err := backend.DeleteExpired(now) 53 | if err != nil { 54 | panic(err) 55 | } 56 | 57 | for _, id := range ids { 58 | err := files.Remove(id + ".aac") 59 | if err != nil { 60 | log.Printf("files.Remove err: %v\n", err) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/tracking/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/dukex/mixpanel" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/activeusers" 10 | "github.com/soapboxsocial/soapbox/pkg/conf" 11 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 12 | "github.com/soapboxsocial/soapbox/pkg/redis" 13 | "github.com/soapboxsocial/soapbox/pkg/sql" 14 | "github.com/soapboxsocial/soapbox/pkg/tracking/backends" 15 | "github.com/soapboxsocial/soapbox/pkg/tracking/trackers" 16 | ) 17 | 18 | type Conf struct { 19 | Trackers struct { 20 | RoomTimeLog bool `mapstructure:"roomtimelog"` 21 | Mixpanel bool `mapstructure:"mixpanel"` 22 | LastActive bool `mapstructure:"lastactive"` 23 | } `mapstructure:"trackers"` 24 | Mixpanel struct { 25 | Token string `mapstructure:"token"` 26 | URL string `mapstructure:"url"` 27 | } `mapstructure:"mixpanel"` 28 | Redis conf.RedisConf `mapstructure:"redis"` 29 | DB conf.PostgresConf `mapstructure:"db"` 30 | } 31 | 32 | func parse() (*Conf, error) { 33 | var file string 34 | flag.StringVar(&file, "c", "config.toml", "config file") 35 | flag.Parse() 36 | 37 | config := &Conf{} 38 | err := conf.Load(file, config) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return config, nil 44 | } 45 | 46 | func main() { 47 | config, err := parse() 48 | if err != nil { 49 | log.Fatal("failed to parse config") 50 | } 51 | 52 | rdb := redis.NewRedis(config.Redis) 53 | queue := pubsub.NewQueue(rdb) 54 | 55 | db, err := sql.Open(config.DB) 56 | if err != nil { 57 | log.Fatalf("failed to open db: %s", err) 58 | } 59 | 60 | t := make([]trackers.Tracker, 0) 61 | 62 | if config.Trackers.Mixpanel { 63 | client := mixpanel.New(config.Mixpanel.Token, config.Mixpanel.URL) 64 | mt := trackers.NewMixpanelTracker(client) 65 | t = append(t, mt) 66 | } 67 | 68 | if config.Trackers.RoomTimeLog { 69 | backend := backends.NewUserRoomLogBackend(db) 70 | rt := trackers.NewUserRoomLogTracker(backend, queue) 71 | t = append(t, rt) 72 | } 73 | 74 | if config.Trackers.LastActive { 75 | backend := activeusers.NewBackend(db) 76 | at := trackers.NewRecentlyActiveTracker(backend, redis.NewTimeoutStore(rdb)) 77 | t = append(t, at) 78 | } 79 | 80 | events := queue.Subscribe(pubsub.RoomTopic, pubsub.UserTopic, pubsub.StoryTopic) 81 | 82 | for evt := range events { 83 | for _, tracker := range t { 84 | if !tracker.CanTrack(evt) { 85 | continue 86 | } 87 | 88 | err := tracker.Track(evt) 89 | if err != nil { 90 | log.Printf("tacker.Track err %v", err) 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /conf/elasticsearch/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "index": { 4 | "sort.field": ["room_time", "followers"], 5 | "sort.order": ["desc", "desc"] 6 | }, 7 | "analysis": { 8 | "filter": { 9 | "ngram_filter": { 10 | "type": "edge_ngram", 11 | "min_gram": 1, 12 | "max_gram": 15 13 | } 14 | }, 15 | "analyzer": { 16 | "ngram_analyzer": { 17 | "type": "custom", 18 | "tokenizer": "standard", 19 | "filter": [ 20 | "lowercase", 21 | "ngram_filter" 22 | ] 23 | } 24 | } 25 | } 26 | }, 27 | "mappings": { 28 | "properties": { 29 | "display_name": { 30 | "type": "text", 31 | "analyzer": "ngram_analyzer", 32 | "search_analyzer": "standard" 33 | }, 34 | "username": { 35 | "type": "text", 36 | "analyzer": "ngram_analyzer", 37 | "search_analyzer": "standard" 38 | }, 39 | "image": { 40 | "type": "text" 41 | }, 42 | "followers": { 43 | "type": "long" 44 | }, 45 | "room_time": { 46 | "type": "long" 47 | }, 48 | "id": { 49 | "type": "long" 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /conf/services/accounts.toml: -------------------------------------------------------------------------------- 1 | [twitter] 2 | key = "nAzgMi6loUf3cl0hIkkXhZSth" 3 | secret = "sFQEQ2cjJZSJgepUMmNyeTxiGggFXA1EKfSYAXpbARTu3CXBQY" 4 | 5 | [db] 6 | host = "127.0.0.1" 7 | port = 5432 8 | user = "voicely" 9 | password = "voicely" 10 | database = "voicely" 11 | ssl = "disable" 12 | -------------------------------------------------------------------------------- /conf/services/indexer.toml: -------------------------------------------------------------------------------- 1 | [redis] 2 | host = "localhost" 3 | port = 6379 4 | database = 0 5 | 6 | [db] 7 | host = "127.0.0.1" 8 | port = 5432 9 | user = "voicely" 10 | password = "voicely" 11 | database = "voicely" 12 | ssl = "disable" 13 | -------------------------------------------------------------------------------- /conf/services/metadata.toml: -------------------------------------------------------------------------------- 1 | [db] 2 | host = "127.0.0.1" 3 | port = 5432 4 | user = "voicely" 5 | password = "voicely" 6 | database = "voicely" 7 | ssl = "disable" 8 | 9 | [grpc] 10 | host = "127.0.0.1" 11 | port = "50052" 12 | 13 | [listen] 14 | port = "8081" 15 | -------------------------------------------------------------------------------- /conf/services/notifications.toml: -------------------------------------------------------------------------------- 1 | [notifications] 2 | environment = "dev" 3 | 4 | [redis] 5 | host = "localhost" 6 | port = 6379 7 | database = 0 8 | 9 | [db] 10 | host = "127.0.0.1" 11 | port = 5432 12 | user = "voicely" 13 | password = "voicely" 14 | database = "voicely" 15 | ssl = "disable" 16 | 17 | [apns] 18 | path = "/conf/authkey.p8" 19 | key = "9U8K3MKG2K" 20 | team = "Z9LC5GZ33U" 21 | bundle = "app.social.soapbox" 22 | 23 | [rooms] 24 | host = "127.0.0.1" 25 | port = "50052" 26 | 27 | [grpc] 28 | host = "127.0.0.1" 29 | port = "50053" 30 | -------------------------------------------------------------------------------- /conf/services/recommendations.toml: -------------------------------------------------------------------------------- 1 | [twitter] 2 | key = "nAzgMi6loUf3cl0hIkkXhZSth" 3 | secret = "sFQEQ2cjJZSJgepUMmNyeTxiGggFXA1EKfSYAXpbARTu3CXBQY" 4 | 5 | [redis] 6 | host = "localhost" 7 | port = 6379 8 | database = 0 9 | 10 | [db] 11 | host = "127.0.0.1" 12 | port = 5432 13 | user = "voicely" 14 | password = "voicely" 15 | database = "voicely" 16 | ssl = "disable" 17 | -------------------------------------------------------------------------------- /conf/services/rooms.toml: -------------------------------------------------------------------------------- 1 | [redis] 2 | host = "localhost" 3 | port = 6379 4 | database = 0 5 | 6 | [db] 7 | host = "127.0.0.1" 8 | port = 5432 9 | user = "voicely" 10 | password = "voicely" 11 | database = "voicely" 12 | ssl = "disable" 13 | 14 | [grpc] 15 | host = "127.0.0.1" 16 | port = 50052 17 | 18 | [api] 19 | port = 8082 20 | 21 | [sfu] 22 | withstats = false 23 | 24 | [sfu.webrtc.iceserver] 25 | urls = [ 26 | "stun:stun.l.google.com:19302", 27 | "stun:stun1.l.google.com:19302", 28 | "stun:stun2.l.google.com:19302", 29 | "stun:stun3.l.google.com:19302", 30 | "stun:stun4.l.google.com:19302", 31 | ] 32 | 33 | [sfu.router] 34 | withstats = false 35 | audiolevelthreshold = 40 36 | audiolevelinterval=500 37 | audiolevelfilter = 20 38 | -------------------------------------------------------------------------------- /conf/services/soapbox.toml: -------------------------------------------------------------------------------- 1 | [twitter] 2 | key = "" 3 | secret = "" 4 | 5 | [sendgrid] 6 | key = "" 7 | 8 | [cdn] 9 | images = "/cdn/images" 10 | stories = "/cdn/stories" 11 | 12 | [apple] 13 | path = "/conf/sign-in-key.p8" 14 | key = "" 15 | team = "" 16 | bundle = "app.social.soapbox" 17 | 18 | [redis] 19 | host = "localhost" 20 | port = 6379 21 | database = 0 22 | 23 | [db] 24 | host = "127.0.0.1" 25 | port = 5432 26 | user = "voicely" 27 | password = "voicely" 28 | database = "voicely" 29 | ssl = "disable" 30 | 31 | [listen] 32 | port = 8080 33 | 34 | [grpc] 35 | host = "127.0.0.1" 36 | port = "50052" 37 | 38 | [login] 39 | email = false 40 | 41 | #Birds 42 | [[mini]] 43 | key = "03ba569d-1577-43f0-8acb-602f0c2ca720" 44 | id = 12 45 | 46 | #Trivia 47 | [[mini]] 48 | key = "198ba444-55b7-47c6-8cb5-5cc912f83ea4" 49 | id = 10 50 | 51 | #Draw with Friends 52 | [[mini]] 53 | key = "349c0163-8049-4453-a067-aca72bb51254" 54 | id = 14 55 | -------------------------------------------------------------------------------- /conf/services/stories.toml: -------------------------------------------------------------------------------- 1 | [db] 2 | host = "127.0.0.1" 3 | port = 5432 4 | user = "voicely" 5 | password = "voicely" 6 | database = "voicely" 7 | ssl = "disable" 8 | 9 | [data] 10 | path = "/cdn/stories" -------------------------------------------------------------------------------- /conf/services/tracking.toml: -------------------------------------------------------------------------------- 1 | [trackers] 2 | roomtimelog = true 3 | mixpanel = false 4 | lastactive = true 5 | 6 | [redis] 7 | host = "localhost" 8 | port = 6379 9 | database = 0 10 | 11 | [mixpanel] 12 | token = "d124ce8f1516eb7baa7980f4de68ded5" 13 | url = "https://api-eu.mixpanel.com" 14 | 15 | [db] 16 | host = "127.0.0.1" 17 | port = 5432 18 | user = "voicely" 19 | password = "voicely" 20 | database = "voicely" 21 | ssl = "disable" 22 | -------------------------------------------------------------------------------- /db/database.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE voicely 2 | ENCODING 'UTF8' 3 | LC_COLLATE = 'en_US.UTF-8' 4 | LC_CTYPE = 'en_US.UTF-8'; 5 | 6 | CREATE ROLE voicely WITH PASSWORD 'voicely' SUPERUSER LOGIN; 7 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/soapboxsocial/soapbox 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/DATA-DOG/go-sqlmock v1.5.0 7 | github.com/Timothylock/go-signin-with-apple v0.0.0-20210131195746-828dfdd59ab1 8 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect 9 | github.com/alicebob/miniredis v2.5.0+incompatible 10 | github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d 11 | github.com/dghubble/oauth1 v0.7.0 12 | github.com/dukex/mixpanel v0.0.0-20180925151559-f8d5594f958e 13 | github.com/elastic/go-elasticsearch/v7 v7.12.0 14 | github.com/felixge/httpsnoop v1.0.2 // indirect 15 | github.com/gammazero/workerpool v1.1.2 // indirect 16 | github.com/go-redis/redis/v8 v8.8.3 17 | github.com/golang/mock v1.5.0 18 | github.com/gomodule/redigo v1.8.4 // indirect 19 | github.com/google/go-querystring v1.1.0 // indirect 20 | github.com/google/uuid v1.2.0 21 | github.com/gorilla/handlers v1.5.1 22 | github.com/gorilla/mux v1.8.0 23 | github.com/gorilla/websocket v1.4.2 24 | github.com/lib/pq v1.10.2 25 | github.com/lucsky/cuid v1.2.0 // indirect 26 | github.com/magiconair/properties v1.8.5 // indirect 27 | github.com/mitchellh/mapstructure v1.4.1 // indirect 28 | github.com/pelletier/go-toml v1.9.1 // indirect 29 | github.com/pion/ion-log v1.2.0 // indirect 30 | github.com/pion/ion-sfu v1.10.3 31 | github.com/pion/webrtc/v3 v3.0.29 32 | github.com/pkg/errors v0.9.1 33 | github.com/prometheus/client_golang v1.10.0 // indirect 34 | github.com/prometheus/common v0.24.0 // indirect 35 | github.com/rs/zerolog v1.22.0 // indirect 36 | github.com/segmentio/ksuid v1.0.3 37 | github.com/sendgrid/rest v2.6.4+incompatible // indirect 38 | github.com/sendgrid/sendgrid-go v3.10.0+incompatible 39 | github.com/sideshow/apns2 v0.20.0 40 | github.com/spf13/afero v1.6.0 // indirect 41 | github.com/spf13/cast v1.3.1 // indirect 42 | github.com/spf13/cobra v1.1.3 43 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 44 | github.com/spf13/viper v1.7.1 45 | github.com/tideland/golib v4.24.2+incompatible // indirect 46 | github.com/tideland/gorest v2.15.5+incompatible // indirect 47 | github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da // indirect 48 | go.opentelemetry.io/otel v0.20.0 // indirect 49 | golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect 50 | golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect 51 | golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect 52 | golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect 53 | google.golang.org/genproto v0.0.0-20210517163617-5e0236093d7a // indirect 54 | google.golang.org/grpc v1.37.1 55 | google.golang.org/protobuf v1.26.0 56 | gopkg.in/ini.v1 v1.62.0 // indirect 57 | ) 58 | 59 | replace github.com/dghubble/go-twitter => github.com/soapboxsocial/go-twitter v0.0.0-20210524185127-b3a4d352fece 60 | 61 | replace github.com/pion/ion-sfu => github.com/soapboxsocial/ion-sfu v1.8.2-0.20210511094523-fa2bbed8eb0d 62 | -------------------------------------------------------------------------------- /mocks/apns_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: pkg/notifications/apns.go 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | notifications "github.com/soapboxsocial/soapbox/pkg/notifications" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockAPNS is a mock of APNS interface 14 | type MockAPNS struct { 15 | ctrl *gomock.Controller 16 | recorder *MockAPNSMockRecorder 17 | } 18 | 19 | // MockAPNSMockRecorder is the mock recorder for MockAPNS 20 | type MockAPNSMockRecorder struct { 21 | mock *MockAPNS 22 | } 23 | 24 | // NewMockAPNS creates a new mock instance 25 | func NewMockAPNS(ctrl *gomock.Controller) *MockAPNS { 26 | mock := &MockAPNS{ctrl: ctrl} 27 | mock.recorder = &MockAPNSMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockAPNS) EXPECT() *MockAPNSMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Send mocks base method 37 | func (m *MockAPNS) Send(target string, notification notifications.PushNotification) error { 38 | m.ctrl.T.Helper() 39 | ret := m.ctrl.Call(m, "Send", target, notification) 40 | ret0, _ := ret[0].(error) 41 | return ret0 42 | } 43 | 44 | // Send indicates an expected call of Send 45 | func (mr *MockAPNSMockRecorder) Send(target, notification interface{}) *gomock.Call { 46 | mr.mock.ctrl.T.Helper() 47 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockAPNS)(nil).Send), target, notification) 48 | } 49 | -------------------------------------------------------------------------------- /mocks/signinwithapple_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: pkg/apple/signinwithapple.go 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | apple "github.com/soapboxsocial/soapbox/pkg/apple" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockSignInWithApple is a mock of SignInWithApple interface 14 | type MockSignInWithApple struct { 15 | ctrl *gomock.Controller 16 | recorder *MockSignInWithAppleMockRecorder 17 | } 18 | 19 | // MockSignInWithAppleMockRecorder is the mock recorder for MockSignInWithApple 20 | type MockSignInWithAppleMockRecorder struct { 21 | mock *MockSignInWithApple 22 | } 23 | 24 | // NewMockSignInWithApple creates a new mock instance 25 | func NewMockSignInWithApple(ctrl *gomock.Controller) *MockSignInWithApple { 26 | mock := &MockSignInWithApple{ctrl: ctrl} 27 | mock.recorder = &MockSignInWithAppleMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockSignInWithApple) EXPECT() *MockSignInWithAppleMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Validate mocks base method 37 | func (m *MockSignInWithApple) Validate(jwt string) (*apple.UserInfo, error) { 38 | m.ctrl.T.Helper() 39 | ret := m.ctrl.Call(m, "Validate", jwt) 40 | ret0, _ := ret[0].(*apple.UserInfo) 41 | ret1, _ := ret[1].(error) 42 | return ret0, ret1 43 | } 44 | 45 | // Validate indicates an expected call of Validate 46 | func (mr *MockSignInWithAppleMockRecorder) Validate(jwt interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockSignInWithApple)(nil).Validate), jwt) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/account/backend.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import "database/sql" 4 | 5 | type Backend struct { 6 | db *sql.DB 7 | } 8 | 9 | func NewBackend(db *sql.DB) *Backend { 10 | return &Backend{ 11 | db: db, 12 | } 13 | } 14 | 15 | func (b *Backend) DeleteAccount(id int) error { 16 | stmt, err := b.db.Prepare("DELETE FROM users WHERE id = $1") 17 | if err != nil { 18 | return err 19 | } 20 | 21 | _, err = stmt.Exec(id) 22 | return err 23 | } 24 | -------------------------------------------------------------------------------- /pkg/account/endpoint.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 10 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 11 | "github.com/soapboxsocial/soapbox/pkg/sessions" 12 | ) 13 | 14 | type Endpoint struct { 15 | backend *Backend 16 | queue *pubsub.Queue 17 | sessions *sessions.SessionManager 18 | } 19 | 20 | func NewEndpoint(backend *Backend, queue *pubsub.Queue, sessions *sessions.SessionManager) *Endpoint { 21 | return &Endpoint{ 22 | backend: backend, 23 | queue: queue, 24 | sessions: sessions, 25 | } 26 | } 27 | 28 | func (e *Endpoint) Router() *mux.Router { 29 | r := mux.NewRouter() 30 | 31 | r.HandleFunc("/", e.delete).Methods("DELETE") 32 | 33 | return r 34 | } 35 | 36 | func (e *Endpoint) delete(w http.ResponseWriter, r *http.Request) { 37 | id, ok := httputil.GetUserIDFromContext(r.Context()) 38 | if !ok { 39 | httputil.JsonError(w, http.StatusUnauthorized, httputil.ErrorCodeInvalidRequestBody, "invalid id") 40 | return 41 | } 42 | 43 | err := e.backend.DeleteAccount(id) 44 | if err != nil { 45 | log.Printf("backend.DeleteAccount err: %s", err) 46 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeNotFound, "failed to delete") 47 | return 48 | } 49 | 50 | log.Printf("deleted user %d", id) 51 | 52 | err = e.queue.Publish(pubsub.UserTopic, pubsub.NewDeleteUserEvent(id)) 53 | if err != nil { 54 | log.Printf("failed to write delete event: %v", err) 55 | } 56 | 57 | err = e.sessions.CloseSession(r.Header.Get("Authorization")) 58 | if err != nil { 59 | log.Printf("failed to close session: %v", err) 60 | } 61 | 62 | httputil.JsonSuccess(w) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/account/endpoint_test.go: -------------------------------------------------------------------------------- 1 | package account_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/DATA-DOG/go-sqlmock" 13 | "github.com/alicebob/miniredis" 14 | "github.com/go-redis/redis/v8" 15 | "github.com/golang/mock/gomock" 16 | 17 | "github.com/soapboxsocial/soapbox/pkg/account" 18 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 19 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 20 | "github.com/soapboxsocial/soapbox/pkg/sessions" 21 | ) 22 | 23 | func TestMain(m *testing.M) { 24 | log.SetOutput(ioutil.Discard) 25 | os.Exit(m.Run()) 26 | } 27 | 28 | func TestAccountEndpoint_Delete(t *testing.T) { 29 | db, smock, err := sqlmock.New() 30 | if err != nil { 31 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 32 | } 33 | defer db.Close() 34 | 35 | mr, err := miniredis.Run() 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | rdb := redis.NewClient(&redis.Options{ 41 | Addr: mr.Addr(), 42 | }) 43 | 44 | ctrl := gomock.NewController(t) 45 | defer ctrl.Finish() 46 | 47 | sm := sessions.NewSessionManager(rdb) 48 | 49 | endpoint := account.NewEndpoint( 50 | account.NewBackend(db), 51 | pubsub.NewQueue(rdb), 52 | sm, 53 | ) 54 | 55 | rr := httptest.NewRecorder() 56 | handler := endpoint.Router() 57 | 58 | session := "1234" 59 | userID := 1 60 | 61 | err = sm.NewSession(session, userID, 0) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | r, err := http.NewRequest("DELETE", "/", strings.NewReader("")) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | req := r.WithContext(httputil.WithUserID(r.Context(), userID)) 72 | req.Header.Set("Authorization", session) 73 | 74 | smock.ExpectPrepare("^DELETE (.+)"). 75 | ExpectExec(). 76 | WithArgs(userID). 77 | WillReturnResult(sqlmock.NewResult(1, 1)) 78 | 79 | handler.ServeHTTP(rr, req) 80 | 81 | if status := rr.Code; status != http.StatusOK { 82 | print(rr.Body.String()) 83 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pkg/activeusers/backend.go: -------------------------------------------------------------------------------- 1 | package activeusers 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | type Backend struct { 9 | db *sql.DB 10 | } 11 | 12 | func NewBackend(db *sql.DB) *Backend { 13 | return &Backend{ 14 | db: db, 15 | } 16 | } 17 | 18 | func (b *Backend) SetLastActiveTime(user int, time time.Time) error { 19 | stmt, err := b.db.Prepare("SELECT update_user_active_times($1, $2);") 20 | if err != nil { 21 | return err 22 | } 23 | 24 | _, err = stmt.Exec(user, time) 25 | return err 26 | } 27 | 28 | func (b *Backend) GetActiveUsersForFollower(user int) ([]ActiveUser, error) { 29 | query := `SELECT users.id, users.display_name, users.username, users.image, active.room FROM users 30 | INNER JOIN ( 31 | SELECT user_id, MAX(room) AS room, MAX(last_active) as last_active 32 | FROM ( 33 | SELECT user_id, room, NOW() as last_active FROM current_rooms 34 | UNION 35 | SELECT user_id, NULL as room, last_active FROM user_active_times WHERE last_active > (NOW() - INTERVAL '15 MINUTE') 36 | ) AS foo GROUP BY user_id) active ON users.id = active.user_id 37 | WHERE active.user_id IN ( 38 | SELECT user_id AS user from followers WHERE follower = $1 39 | INTERSECT 40 | SELECT follower as user FROM followers WHERE user_id = $1 41 | ) ORDER BY room, active.last_active DESC;` 42 | 43 | stmt, err := b.db.Prepare(query) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | rows, err := stmt.Query(user) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | result := make([]ActiveUser, 0) 54 | 55 | for rows.Next() { 56 | user := ActiveUser{} 57 | var room sql.NullString 58 | 59 | err := rows.Scan(&user.ID, &user.DisplayName, &user.Username, &user.Image, &room) 60 | if err != nil { 61 | continue 62 | } 63 | 64 | if room.Valid { 65 | user.Room = &room.String 66 | } 67 | 68 | result = append(result, user) 69 | } 70 | 71 | return result, nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/activeusers/types.go: -------------------------------------------------------------------------------- 1 | package activeusers 2 | 3 | import ( 4 | "github.com/soapboxsocial/soapbox/pkg/users/types" 5 | ) 6 | 7 | type ActiveUser struct { 8 | types.User 9 | 10 | Room *string `json:"room,omitempty"` 11 | } 12 | -------------------------------------------------------------------------------- /pkg/analytics/backend.go: -------------------------------------------------------------------------------- 1 | package analytics 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | type Backend struct { 8 | db *sql.DB 9 | } 10 | 11 | func NewBackend(db *sql.DB) *Backend { 12 | return &Backend{db: db} 13 | } 14 | 15 | func (b *Backend) AddSentNotification(user int, notification Notification) error { 16 | stmt, err := b.db.Prepare("INSERT INTO notification_analytics (id, target, origin, category, sent, room) VALUES($1, $2, $3, $4, NOW(), $5);") 17 | if err != nil { 18 | return err 19 | } 20 | 21 | _, err = stmt.Exec(notification.ID, user, notification.Origin, notification.Category, notification.Room) 22 | return err 23 | } 24 | 25 | func (b *Backend) MarkNotificationRead(user int, uuid string) error { 26 | stmt, err := b.db.Prepare("UPDATE notification_analytics SET opened = NOW() WHERE target = $1 AND id = $2;") 27 | if err != nil { 28 | return err 29 | } 30 | 31 | _, err = stmt.Exec(user, uuid) 32 | return err 33 | } 34 | -------------------------------------------------------------------------------- /pkg/analytics/endpoint.go: -------------------------------------------------------------------------------- 1 | package analytics 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 10 | ) 11 | 12 | type Endpoint struct { 13 | backend *Backend 14 | } 15 | 16 | func NewEndpoint(backend *Backend) *Endpoint { 17 | return &Endpoint{ 18 | backend: backend, 19 | } 20 | } 21 | 22 | func (e *Endpoint) Router() *mux.Router { 23 | r := mux.NewRouter() 24 | 25 | r.HandleFunc("/notifications/{id}/opened", e.openedNotification).Methods("POST") 26 | 27 | return r 28 | } 29 | 30 | func (e *Endpoint) openedNotification(w http.ResponseWriter, r *http.Request) { 31 | userID, ok := httputil.GetUserIDFromContext(r.Context()) 32 | if !ok { 33 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "invalid id") 34 | return 35 | } 36 | 37 | params := mux.Vars(r) 38 | 39 | id := params["id"] 40 | if id == "" { 41 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "invalid") 42 | return 43 | } 44 | 45 | go func() { 46 | err := e.backend.MarkNotificationRead(userID, id) 47 | if err != nil { 48 | log.Printf("backend.MarkNotificationRead err: %s", err) 49 | } 50 | }() 51 | 52 | httputil.JsonSuccess(w) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/analytics/endpoint_test.go: -------------------------------------------------------------------------------- 1 | package analytics_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/DATA-DOG/go-sqlmock" 14 | 15 | "github.com/soapboxsocial/soapbox/pkg/analytics" 16 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 17 | ) 18 | 19 | func TestMain(m *testing.M) { 20 | log.SetOutput(ioutil.Discard) 21 | os.Exit(m.Run()) 22 | } 23 | 24 | func TestEndpoint_OpenedNotification(t *testing.T) { 25 | db, mock, err := sqlmock.New() 26 | if err != nil { 27 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 28 | } 29 | defer db.Close() 30 | 31 | endpoint := analytics.NewEndpoint( 32 | analytics.NewBackend(db), 33 | ) 34 | 35 | rr := httptest.NewRecorder() 36 | handler := endpoint.Router() 37 | 38 | session := "1234" 39 | userID := 1 40 | notification := "12345678" 41 | 42 | r, err := http.NewRequest("POST", fmt.Sprintf("/notifications/%s/opened", notification), strings.NewReader("")) 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | req := r.WithContext(httputil.WithUserID(r.Context(), userID)) 48 | req.Header.Set("Authorization", session) 49 | 50 | mock.ExpectPrepare("^UPDATE (.+)"). 51 | ExpectExec(). 52 | WithArgs(userID, notification). 53 | WillReturnResult(sqlmock.NewResult(1, 1)) 54 | 55 | handler.ServeHTTP(rr, req) 56 | 57 | if status := rr.Code; status != http.StatusOK { 58 | print(rr.Body.String()) 59 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pkg/analytics/types.go: -------------------------------------------------------------------------------- 1 | package analytics 2 | 3 | type Notification struct { 4 | ID string 5 | Origin *int 6 | Category string 7 | Room *string 8 | } 9 | -------------------------------------------------------------------------------- /pkg/apple/apns.go: -------------------------------------------------------------------------------- 1 | package apple 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/sideshow/apns2" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/notifications" 11 | ) 12 | 13 | type APNS struct { 14 | topic string 15 | 16 | client *apns2.Client 17 | 18 | // maxConcurrentPushes limits the amount of notification pushes 19 | // this is required for apple, not sure if it should be here tho. 20 | maxConcurrentPushes chan struct{} 21 | } 22 | 23 | func NewAPNS(topic string, client *apns2.Client) *APNS { 24 | return &APNS{ 25 | topic: topic, 26 | client: client, 27 | maxConcurrentPushes: make(chan struct{}, 100), 28 | } 29 | } 30 | 31 | func (a *APNS) Send(target string, notification notifications.PushNotification) error { 32 | data, err := json.Marshal(map[string]interface{}{"aps": notification}) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | payload := &apns2.Notification{ 38 | DeviceToken: target, 39 | Topic: a.topic, 40 | Payload: data, 41 | CollapseID: notification.CollapseID, 42 | } 43 | 44 | a.maxConcurrentPushes <- struct{}{} 45 | resp, err := a.client.Push(payload) 46 | <-a.maxConcurrentPushes 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if resp.StatusCode >= http.StatusInternalServerError { 52 | return notifications.ErrRetryRequired 53 | } 54 | 55 | if resp.Reason == apns2.ReasonUnregistered || resp.Reason == apns2.ReasonBadDeviceToken { 56 | return notifications.ErrDeviceUnregistered 57 | } 58 | 59 | if resp.StatusCode != http.StatusOK { 60 | return fmt.Errorf("failed to send code: %d reason: %s", resp.StatusCode, resp.Reason) 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/apple/signinwithapple.go: -------------------------------------------------------------------------------- 1 | package apple 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/Timothylock/go-signin-with-apple/apple" 9 | ) 10 | 11 | // UserInfo contains the apple ID user info 12 | type UserInfo struct { 13 | ID string 14 | Email string 15 | } 16 | 17 | // SignInWithApple defines an interface for validating apple sign ins 18 | type SignInWithApple interface { 19 | Validate(jwt string) (*UserInfo, error) 20 | } 21 | 22 | type SignInWithAppleAppValidation struct { 23 | client *apple.Client 24 | 25 | id string 26 | secret string 27 | } 28 | 29 | func NewSignInWithAppleAppValidation(client *apple.Client, teamID, clientID, keyID, key string) (*SignInWithAppleAppValidation, error) { 30 | secret, err := apple.GenerateClientSecret(key, teamID, clientID, keyID) 31 | if err != nil { 32 | return nil, fmt.Errorf("apple.GenerateClientSecret err: %v", err) 33 | } 34 | 35 | return &SignInWithAppleAppValidation{ 36 | client: client, 37 | id: clientID, 38 | secret: secret, 39 | }, nil 40 | } 41 | 42 | // Validate validates a JWT token and returns UserInfo 43 | func (s *SignInWithAppleAppValidation) Validate(jwt string) (*UserInfo, error) { 44 | req := apple.AppValidationTokenRequest{ 45 | ClientID: s.id, 46 | ClientSecret: s.secret, 47 | Code: jwt, 48 | } 49 | 50 | resp := apple.ValidationResponse{} 51 | 52 | err := s.client.VerifyAppToken(context.Background(), req, &resp) 53 | if err != nil { 54 | return nil, fmt.Errorf("s.client.VerifyAppToken err: %v", err) 55 | } 56 | 57 | if resp.Error != "" { 58 | return nil, fmt.Errorf("apple response err: %v", resp.Error) 59 | } 60 | 61 | userID, err := apple.GetUniqueID(resp.IDToken) 62 | if err != nil { 63 | return nil, fmt.Errorf("apple.GetUniqueID err: %v", err) 64 | } 65 | 66 | claim, err := apple.GetClaims(resp.IDToken) 67 | if err != nil { 68 | return nil, fmt.Errorf("apple.GetClaims err: %v", err) 69 | } 70 | 71 | email, ok := claim.GetString("email") 72 | if !ok { 73 | return nil, fmt.Errorf("claim.GetString err: %v", err) 74 | } 75 | 76 | return &UserInfo{Email: strings.ToLower(email), ID: userID}, nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/blocks/backend.go: -------------------------------------------------------------------------------- 1 | package blocks 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | type Backend struct { 9 | db *sql.DB 10 | } 11 | 12 | func NewBackend(db *sql.DB) *Backend { 13 | return &Backend{db: db} 14 | } 15 | 16 | func (b *Backend) BlockUser(user, block int) error { 17 | ctx := context.Background() 18 | tx, err := b.db.BeginTx(ctx, nil) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | _, err = tx.ExecContext( 24 | ctx, 25 | "INSERT INTO blocks (user_id, blocked) VALUES ($1, $2);", 26 | user, block, 27 | ) 28 | 29 | if err != nil { 30 | _ = tx.Rollback() 31 | return err 32 | } 33 | 34 | _, err = tx.ExecContext( 35 | ctx, 36 | "DELETE FROM followers WHERE (follower = $1 AND user_id = $2) OR (follower = $2 AND user_id = $1);", 37 | user, block, 38 | ) 39 | 40 | if err != nil { 41 | return err 42 | } 43 | 44 | err = tx.Commit() 45 | if err != nil { 46 | _ = tx.Rollback() 47 | return err 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func (b *Backend) UnblockUser(user, block int) error { 54 | stmt, err := b.db.Prepare("DELETE FROM blocks WHERE user_id = $1 AND blocked = $2;") 55 | if err != nil { 56 | return err 57 | } 58 | 59 | _, err = stmt.Exec(user, block) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (b *Backend) GetUsersWhoBlocked(user int) ([]int, error) { 68 | stmt, err := b.db.Prepare("SELECT user_id FROM blocks WHERE blocked = $1;") 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | rows, err := stmt.Query(user) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | result := make([]int, 0) 79 | 80 | for rows.Next() { 81 | var blocker int 82 | err := rows.Scan(&blocker) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | result = append(result, blocker) 88 | } 89 | 90 | return result, nil 91 | } 92 | 93 | func (b *Backend) GetUsersBlockedBy(user int) ([]int, error) { 94 | stmt, err := b.db.Prepare("SELECT blocked FROM blocks WHERE user_id = $1;") 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | rows, err := stmt.Query(user) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | result := make([]int, 0) 105 | 106 | for rows.Next() { 107 | var blocker int 108 | err := rows.Scan(&blocker) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | result = append(result, blocker) 114 | } 115 | 116 | return result, nil 117 | } 118 | -------------------------------------------------------------------------------- /pkg/blocks/endpoint.go: -------------------------------------------------------------------------------- 1 | package blocks 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 10 | ) 11 | 12 | type Endpoint struct { 13 | backend *Backend 14 | } 15 | 16 | func NewEndpoint(backend *Backend) *Endpoint { 17 | return &Endpoint{ 18 | backend: backend, 19 | } 20 | } 21 | 22 | func (e *Endpoint) Router() *mux.Router { 23 | r := mux.NewRouter() 24 | 25 | r.HandleFunc("/", e.unblock).Methods("DELETE") 26 | r.HandleFunc("/create", e.block).Methods("POST") 27 | 28 | return r 29 | } 30 | 31 | func (e *Endpoint) unblock(w http.ResponseWriter, r *http.Request) { 32 | err := r.ParseForm() 33 | if err != nil { 34 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "") 35 | return 36 | } 37 | 38 | id, err := strconv.Atoi(r.Form.Get("id")) 39 | if err != nil { 40 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "invalid id") 41 | return 42 | } 43 | 44 | userID, ok := httputil.GetUserIDFromContext(r.Context()) 45 | if !ok { 46 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "invalid id") 47 | return 48 | } 49 | 50 | err = e.backend.UnblockUser(userID, id) 51 | if err != nil { 52 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "failed to unblock") 53 | return 54 | } 55 | } 56 | 57 | func (e *Endpoint) block(w http.ResponseWriter, r *http.Request) { 58 | err := r.ParseForm() 59 | if err != nil { 60 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "") 61 | return 62 | } 63 | 64 | id, err := strconv.Atoi(r.Form.Get("id")) 65 | if err != nil { 66 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "invalid id") 67 | return 68 | } 69 | 70 | userID, ok := httputil.GetUserIDFromContext(r.Context()) 71 | if !ok { 72 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "invalid id") 73 | return 74 | } 75 | 76 | err = e.backend.BlockUser(userID, id) 77 | if err != nil { 78 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "failed to block") 79 | return 80 | } 81 | 82 | httputil.JsonSuccess(w) 83 | } 84 | -------------------------------------------------------------------------------- /pkg/conf/conf.go: -------------------------------------------------------------------------------- 1 | // Package conf contains utility functions for loading and parsing configuration files. 2 | package conf 3 | 4 | import ( 5 | "os" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | // AppleConf describes a default configuration for dealing with apple keys. 11 | type AppleConf struct { 12 | Path string `mapstructure:"path"` 13 | KeyID string `mapstructure:"key"` 14 | TeamID string `mapstructure:"team"` 15 | Bundle string `mapstructure:"bundle"` 16 | } 17 | 18 | // PostgresConf describes a default configuration for the postgres database. 19 | type PostgresConf struct { 20 | Host string `mapstructure:"host"` 21 | Port int `mapstructure:"port"` 22 | User string `mapstructure:"user"` 23 | Password string `mapstructure:"password"` 24 | Database string `mapstructure:"database"` 25 | SSL string `mapstructure:"ssl"` 26 | } 27 | 28 | // PostgresConf describes a default configuration for the redis. 29 | type RedisConf struct { 30 | Host string `mapstructure:"host"` 31 | Port int `mapstructure:"port"` 32 | Password string `mapstructure:"password"` 33 | Database int `mapstructure:"database"` 34 | DisableTLS bool `mapstructure:"tls-disabled"` 35 | } 36 | 37 | // AddrConf describes a default configuration for host addresses. 38 | type AddrConf struct { 39 | Host string `mapstructure:"host"` 40 | Port int `mapstructure:"port"` 41 | } 42 | 43 | // Load opens and parses a configuration file. 44 | func Load(file string, conf interface{}) error { 45 | _, err := os.Stat(file) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | viper.SetConfigFile(file) 51 | viper.SetConfigType("toml") 52 | 53 | err = viper.ReadInConfig() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | err = viper.GetViper().Unmarshal(conf) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/conf/conf_test.go: -------------------------------------------------------------------------------- 1 | package conf_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/conf" 8 | ) 9 | 10 | func TestLoad(t *testing.T) { 11 | var conftests = []struct { 12 | in string 13 | err bool 14 | conf *conf.RedisConf 15 | }{ 16 | { 17 | "./testdata/redis.toml", 18 | false, 19 | &conf.RedisConf{ 20 | Database: 12, 21 | Port: 1234, 22 | Password: "test", 23 | Host: "test", 24 | }, 25 | }, 26 | { 27 | "./testdata/invalid.toml", 28 | true, 29 | nil, 30 | }, 31 | { 32 | "./testdata/wow.toml", 33 | true, 34 | nil, 35 | }, 36 | } 37 | 38 | for _, tt := range conftests { 39 | t.Run(tt.in, func(t *testing.T) { 40 | c := &conf.RedisConf{} 41 | err := conf.Load(tt.in, c) 42 | 43 | if err != nil { 44 | if tt.err { 45 | return 46 | } else { 47 | t.Fatalf("unexpected err %s", err) 48 | return 49 | } 50 | } 51 | 52 | if !reflect.DeepEqual(c, tt.conf) { 53 | t.Fatalf("config %v does not match %v", c, tt.conf) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/conf/testdata/invalid.toml: -------------------------------------------------------------------------------- 1 | {"foo": "bar"} -------------------------------------------------------------------------------- /pkg/conf/testdata/redis.toml: -------------------------------------------------------------------------------- 1 | host = "test" 2 | port = 1234 3 | password = "test" 4 | database = 12 -------------------------------------------------------------------------------- /pkg/devices/backend.go: -------------------------------------------------------------------------------- 1 | package devices 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | type Backend struct { 10 | db *sql.DB 11 | } 12 | 13 | func NewBackend(db *sql.DB) *Backend { 14 | return &Backend{ 15 | db: db, 16 | } 17 | } 18 | 19 | func (db *Backend) AddDeviceForUser(id int, token string) error { 20 | stmt, err := db.db.Prepare("INSERT INTO devices (token, user_id) VALUES ($1, $2);") 21 | if err != nil { 22 | return err 23 | } 24 | 25 | _, err = stmt.Exec(token, id) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func (db *Backend) GetDevicesForUser(id int) ([]string, error) { 34 | stmt, err := db.db.Prepare("SELECT token FROM devices WHERE user_id = $1;") 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | rows, err := stmt.Query(id) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | result := make([]string, 0) 45 | 46 | for rows.Next() { 47 | var device string 48 | err := rows.Scan(&device) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | result = append(result, device) 54 | } 55 | 56 | return result, nil 57 | } 58 | 59 | func (db *Backend) GetDevicesForUsers(ids []int) ([]string, error) { 60 | query := fmt.Sprintf( 61 | "SELECT token FROM devices WHERE user_id IN (%s);", 62 | join(ids, ","), 63 | ) 64 | 65 | stmt, err := db.db.Prepare(query) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | rows, err := stmt.Query() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | result := make([]string, 0) 76 | 77 | for rows.Next() { 78 | var device string 79 | err := rows.Scan(&device) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | result = append(result, device) 85 | } 86 | 87 | return result, nil 88 | } 89 | 90 | func (db *Backend) RemoveDevice(token string) error { 91 | stmt, err := db.db.Prepare("DELETE FROM devices WHERE token = $1;") 92 | if err != nil { 93 | return err 94 | } 95 | 96 | _, err = stmt.Exec(token) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func join(elems []int, sep string) string { 105 | switch len(elems) { 106 | case 0: 107 | return "" 108 | case 1: 109 | return strconv.Itoa(elems[0]) 110 | } 111 | 112 | res := strconv.Itoa(elems[0]) 113 | for _, s := range elems[1:] { 114 | res += sep 115 | res += strconv.Itoa(s) 116 | } 117 | 118 | return res 119 | } 120 | -------------------------------------------------------------------------------- /pkg/devices/endpoint.go: -------------------------------------------------------------------------------- 1 | package devices 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | 8 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 9 | ) 10 | 11 | type Endpoint struct { 12 | db *Backend 13 | } 14 | 15 | func NewEndpoint(db *Backend) *Endpoint { 16 | return &Endpoint{ 17 | db: db, 18 | } 19 | } 20 | 21 | func (d *Endpoint) Router() *mux.Router { 22 | r := mux.NewRouter() 23 | 24 | r.HandleFunc("/add", d.add).Methods("POST") 25 | 26 | return r 27 | } 28 | 29 | func (d *Endpoint) add(w http.ResponseWriter, r *http.Request) { 30 | err := r.ParseForm() 31 | if err != nil { 32 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "") 33 | return 34 | } 35 | 36 | token := r.Form.Get("token") 37 | if token == "" { 38 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "invalid token") 39 | return 40 | } 41 | 42 | userID, ok := httputil.GetUserIDFromContext(r.Context()) 43 | if !ok { 44 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "invalid id") 45 | return 46 | } 47 | 48 | err = d.db.AddDeviceForUser(userID, token) 49 | if err != nil && err.Error() != "pq: duplicate key value violates unique constraint \"devices_pkey\"" { 50 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeFailedToStoreDevice, "failed") 51 | return 52 | } 53 | 54 | httputil.JsonSuccess(w) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/followers/backend.go: -------------------------------------------------------------------------------- 1 | package followers 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/soapboxsocial/soapbox/pkg/users/types" 7 | ) 8 | 9 | type FollowersBackend struct { 10 | db *sql.DB 11 | } 12 | 13 | func NewFollowersBackend(db *sql.DB) *FollowersBackend { 14 | return &FollowersBackend{ 15 | db: db, 16 | } 17 | } 18 | 19 | func (fb *FollowersBackend) FollowUser(follower, user int) error { 20 | stmt, err := fb.db.Prepare("INSERT INTO followers (follower, user_id) VALUES ($1, $2);") 21 | if err != nil { 22 | return err 23 | } 24 | 25 | _, err = stmt.Exec(follower, user) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func (fb *FollowersBackend) UnfollowUser(follower, user int) error { 34 | stmt, err := fb.db.Prepare("DELETE FROM followers WHERE follower = $1 AND user_id = $2;") 35 | if err != nil { 36 | return err 37 | } 38 | 39 | _, err = stmt.Exec(follower, user) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func (fb *FollowersBackend) GetAllUsersFollowing(id, limit, offset int) ([]*types.User, error) { 48 | stmt, err := fb.db.Prepare("SELECT users.id, users.display_name, users.username, users.image FROM users INNER JOIN followers ON (users.id = followers.follower) WHERE followers.user_id = $1 ORDER BY users.id LIMIT $2 OFFSET $3;") 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return fb.executeUserQuery(stmt, id, limit, offset) 54 | } 55 | 56 | func (fb *FollowersBackend) GetAllUsersFollowedBy(id, limit, offset int) ([]*types.User, error) { 57 | stmt, err := fb.db.Prepare("SELECT users.id, users.display_name, users.username, users.image FROM users INNER JOIN followers ON (users.id = followers.user_id) WHERE followers.follower = $1 ORDER BY users.id LIMIT $2 OFFSET $3;") 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | return fb.executeUserQuery(stmt, id, limit, offset) 63 | } 64 | 65 | func (fb *FollowersBackend) GetAllFollowerIDsFor(id int) ([]int, error) { 66 | stmt, err := fb.db.Prepare("SELECT follower FROM followers WHERE user_id = $1;") 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | rows, err := stmt.Query(id) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | result := make([]int, 0) 77 | 78 | for rows.Next() { 79 | var id int 80 | 81 | err := rows.Scan(&id) 82 | if err != nil { 83 | return nil, err // @todo 84 | } 85 | 86 | result = append(result, id) 87 | } 88 | 89 | return result, nil 90 | } 91 | 92 | func (fb *FollowersBackend) GetFriends(id int) ([]*types.User, error) { 93 | stmt, err := fb.db.Prepare("SELECT users.id, users.display_name, users.username, users.image FROM users WHERE id in (SELECT user_id AS user from followers WHERE follower = $1 INTERSECT SELECT follower as user FROM followers WHERE user_id = $1);") 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return fb.executeUserQuery(stmt, id) 99 | } 100 | 101 | func (fb *FollowersBackend) executeUserQuery(stmt *sql.Stmt, args ...interface{}) ([]*types.User, error) { 102 | rows, err := stmt.Query(args...) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | result := make([]*types.User, 0) 108 | 109 | for rows.Next() { 110 | user := &types.User{} 111 | 112 | err := rows.Scan(&user.ID, &user.DisplayName, &user.Username, &user.Image) 113 | if err != nil { 114 | return nil, err // @todo 115 | } 116 | 117 | result = append(result, user) 118 | } 119 | 120 | return result, nil 121 | } 122 | -------------------------------------------------------------------------------- /pkg/http/context.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import "context" 4 | 5 | type key string 6 | 7 | const userID key = "id" 8 | 9 | // GetUserIDFromContext returns a user ID from a context 10 | func GetUserIDFromContext(ctx context.Context) (int, bool) { 11 | val := ctx.Value(userID) 12 | id, ok := val.(int) 13 | return id, ok 14 | } 15 | 16 | // WithUserID stores a user ID in the context 17 | func WithUserID(ctx context.Context, id int) context.Context { 18 | return context.WithValue(ctx, userID, id) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/http/context_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/http" 8 | ) 9 | 10 | func TestWithUserID(t *testing.T) { 11 | ctx := context.Background() 12 | id := 12 13 | 14 | with := http.WithUserID(ctx, id) 15 | 16 | val, ok := http.GetUserIDFromContext(with) 17 | if !ok { 18 | t.Fatal("no user ID stored") 19 | } 20 | 21 | if val != id { 22 | t.Fatalf("%d does not match %d", val, id) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/http/cors.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/handlers" 7 | ) 8 | 9 | func AllowedHeaders() handlers.CORSOption { 10 | return handlers.AllowedHeaders([]string{ 11 | "Content-Type", 12 | "X-Requested-With", 13 | "Accept", 14 | "Accept-Language", 15 | "Accept-Encoding", 16 | "Content-Language", 17 | "Origin", 18 | }) 19 | } 20 | 21 | func AllowedOrigins() handlers.CORSOption { 22 | return handlers.AllowedOrigins([]string{"*"}) 23 | } 24 | 25 | func AllowedMethods() handlers.CORSOption { 26 | return handlers.AllowedMethods([]string{ 27 | "GET", 28 | "HEAD", 29 | "POST", 30 | "PUT", 31 | "OPTIONS", 32 | "DELETE", 33 | }) 34 | } 35 | 36 | func CORS(h http.Handler) http.Handler { 37 | return handlers.CORS(AllowedOrigins(), AllowedHeaders(), AllowedMethods())(h) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/http/http.go: -------------------------------------------------------------------------------- 1 | // Package http contains utility functions for request and response handling. 2 | package http 3 | 4 | import ( 5 | "encoding/json" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | ) 11 | 12 | type ErrorCode int 13 | 14 | const ( 15 | ErrorCodeInvalidRequestBody ErrorCode = iota 16 | ErrorCodeMissingParameter 17 | ErrorCodeFailedToRegister 18 | ErrorCodeInvalidEmail 19 | ErrorCodeInvalidUsername 20 | ErrorCodeUsernameAlreadyExists 21 | ErrorCodeFailedToLogin 22 | ErrorCodeIncorrectPin 23 | ErrorCodeUserNotFound 24 | ErrorCodeFailedToGetUser 25 | ErrorCodeFailedToGetFollowers 26 | ErrorCodeUnauthorized 27 | ErrorCodeFailedToStoreDevice 28 | ErrorCodeNotFound 29 | ErrorCodeNotAllowed 30 | ErrorCodeEmailRegistrationDisabled 31 | ) 32 | 33 | // NotFoundHandler handles 404 responses 34 | func NotFoundHandler(w http.ResponseWriter, r *http.Request) { 35 | JsonError(w, http.StatusNotFound, ErrorCodeNotFound, "not found") 36 | } 37 | 38 | // NotAllowed handles 405 responses 39 | func NotAllowedHandler(w http.ResponseWriter, r *http.Request) { 40 | JsonError(w, http.StatusMethodNotAllowed, ErrorCodeNotAllowed, "not allowed") 41 | } 42 | 43 | // JsonError writes an Error to the ResponseWriter with the provided information. 44 | func JsonError(w http.ResponseWriter, responseCode int, code ErrorCode, msg string) { 45 | type ErrorResponse struct { 46 | Code ErrorCode `json:"code"` 47 | Message string `json:"message"` 48 | } 49 | 50 | w.WriteHeader(responseCode) 51 | 52 | err := JsonEncode(w, ErrorResponse{Code: code, Message: msg}) 53 | if err != nil { 54 | log.Printf("failed to encode response: %s", err.Error()) 55 | } 56 | } 57 | 58 | // JsonSuccess writes a success message to the writer. 59 | func JsonSuccess(w http.ResponseWriter) { 60 | type SuccessResponse struct { 61 | Success bool `json:"success"` 62 | } 63 | 64 | w.WriteHeader(200) 65 | err := JsonEncode(w, SuccessResponse{Success: true}) 66 | if err != nil { 67 | log.Printf("failed to encode response: %s", err.Error()) 68 | } 69 | } 70 | 71 | // JsonEncode marshals an interface and writes it to the response. 72 | func JsonEncode(w http.ResponseWriter, v interface{}) error { 73 | w.Header().Set("Content-Type", "application/json") 74 | return json.NewEncoder(w).Encode(v) 75 | } 76 | 77 | // GetInt returns an integer value from a URL query. 78 | func GetInt(v url.Values, key string, defaultValue int) int { 79 | str := v.Get(key) 80 | if str == "" { 81 | return defaultValue 82 | } 83 | 84 | val, err := strconv.Atoi(str) 85 | if err != nil { 86 | return defaultValue 87 | } 88 | 89 | return val 90 | } 91 | -------------------------------------------------------------------------------- /pkg/http/http_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/http" 8 | ) 9 | 10 | func TestGetInt(t *testing.T) { 11 | var tests = []struct { 12 | value string 13 | expected int 14 | defaultValue int 15 | }{ 16 | { 17 | "poop", 18 | 10, 19 | 10, 20 | }, 21 | { 22 | "1", 23 | 1, 24 | 10, 25 | }, 26 | { 27 | "", 28 | 10, 29 | 10, 30 | }, 31 | } 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.value, func(t *testing.T) { 35 | 36 | values := url.Values{} 37 | values.Set("key", tt.value) 38 | 39 | result := http.GetInt(values, "key", tt.defaultValue) 40 | if result != tt.expected { 41 | t.Fatalf("expected %d does not match actual %d", tt.expected, result) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/http/middlewares/authentication.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 7 | "github.com/soapboxsocial/soapbox/pkg/sessions" 8 | ) 9 | 10 | type AuthenticationMiddleware struct { 11 | sm *sessions.SessionManager 12 | } 13 | 14 | func NewAuthenticationMiddleware(sm *sessions.SessionManager) *AuthenticationMiddleware { 15 | return &AuthenticationMiddleware{ 16 | sm: sm, 17 | } 18 | } 19 | 20 | func (h AuthenticationMiddleware) Middleware(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 22 | token := req.Header.Get("Authorization") 23 | if token == "" { 24 | httputil.JsonError(w, http.StatusUnauthorized, httputil.ErrorCodeUnauthorized, "unauthorized") 25 | return 26 | } 27 | 28 | id, err := h.sm.GetUserIDForSession(token) 29 | if err != nil || id == 0 { 30 | httputil.JsonError(w, http.StatusUnauthorized, httputil.ErrorCodeUnauthorized, "unauthorized") 31 | return 32 | } 33 | 34 | r := req.WithContext(httputil.WithUserID(req.Context(), id)) 35 | 36 | next.ServeHTTP(w, r) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/http/middlewares/authentication_test.go: -------------------------------------------------------------------------------- 1 | package middlewares_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/alicebob/miniredis" 9 | "github.com/go-redis/redis/v8" 10 | 11 | "github.com/soapboxsocial/soapbox/pkg/http/middlewares" 12 | "github.com/soapboxsocial/soapbox/pkg/sessions" 13 | ) 14 | 15 | func TestAuthenticationHandler_WithoutAuth(t *testing.T) { 16 | mr, err := miniredis.Run() 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | rdb := redis.NewClient(&redis.Options{ 22 | Addr: mr.Addr(), 23 | }) 24 | 25 | sm := sessions.NewSessionManager(rdb) 26 | mw := middlewares.NewAuthenticationMiddleware(sm) 27 | 28 | r, err := http.NewRequest("POST", "/add", nil) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | rr := httptest.NewRecorder() 34 | handler := mw.Middleware(nil) 35 | 36 | handler.ServeHTTP(rr, r) 37 | 38 | if status := rr.Code; status != http.StatusUnauthorized { 39 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusUnauthorized) 40 | } 41 | } 42 | 43 | func TestAuthenticationHandler_WithoutActiveSession(t *testing.T) { 44 | mr, err := miniredis.Run() 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | rdb := redis.NewClient(&redis.Options{ 50 | Addr: mr.Addr(), 51 | }) 52 | 53 | sm := sessions.NewSessionManager(rdb) 54 | mw := middlewares.NewAuthenticationMiddleware(sm) 55 | 56 | r, err := http.NewRequest("POST", "/add", nil) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | r.Header.Set("Authorization", "123") 62 | 63 | rr := httptest.NewRecorder() 64 | handler := mw.Middleware(nil) 65 | 66 | handler.ServeHTTP(rr, r) 67 | 68 | if status := rr.Code; status != http.StatusUnauthorized { 69 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusUnauthorized) 70 | } 71 | } 72 | 73 | func TestAuthenticationHandler(t *testing.T) { 74 | mr, err := miniredis.Run() 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | 79 | rdb := redis.NewClient(&redis.Options{ 80 | Addr: mr.Addr(), 81 | }) 82 | 83 | sm := sessions.NewSessionManager(rdb) 84 | mw := middlewares.NewAuthenticationMiddleware(sm) 85 | 86 | r, err := http.NewRequest("POST", "/add", nil) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | 91 | sess := "123" 92 | _ = sm.NewSession(sess, 1, 0) 93 | 94 | r.Header.Set("Authorization", sess) 95 | 96 | rr := httptest.NewRecorder() 97 | handler := mw.Middleware(http.NotFoundHandler()) 98 | 99 | handler.ServeHTTP(rr, r) 100 | 101 | if status := rr.Code; status != http.StatusNotFound { 102 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusUnauthorized) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/images/backend.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type Backend struct { 10 | path string 11 | } 12 | 13 | func NewImagesBackend(path string) *Backend { 14 | return &Backend{ 15 | path: path, 16 | } 17 | } 18 | 19 | func (ib *Backend) Store(bytes []byte) (string, error) { 20 | return ib.store(ib.path, bytes) 21 | } 22 | 23 | func (ib *Backend) Remove(name string) error { 24 | return os.Remove(ib.path + "/" + name) 25 | } 26 | 27 | func (ib *Backend) store(path string, bytes []byte) (string, error) { 28 | file, err := ioutil.TempFile(path, "*.png") 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | defer file.Close() 34 | 35 | _, err = file.Write(bytes) 36 | if err != nil { 37 | return "", nil 38 | } 39 | 40 | return filepath.Base(file.Name()), nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/images/utils.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image/jpeg" 7 | "image/png" 8 | "io/ioutil" 9 | "mime/multipart" 10 | "net/http" 11 | 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | func MultipartFileToPng(file multipart.File) ([]byte, error) { 16 | imgBytes, err := ioutil.ReadAll(file) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | pngBytes, err := ToPNG(imgBytes) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return pngBytes, nil 27 | } 28 | 29 | func ToPNG(imageBytes []byte) ([]byte, error) { 30 | contentType := http.DetectContentType(imageBytes) 31 | 32 | switch contentType { 33 | case "image/png": 34 | return imageBytes, nil 35 | case "image/jpeg": 36 | img, err := jpeg.Decode(bytes.NewReader(imageBytes)) 37 | if err != nil { 38 | return nil, errors.Wrap(err, "unable to decode jpeg") 39 | } 40 | 41 | buf := new(bytes.Buffer) 42 | if err := png.Encode(buf, img); err != nil { 43 | return nil, errors.Wrap(err, "unable to encode png") 44 | } 45 | 46 | return buf.Bytes(), nil 47 | } 48 | 49 | return nil, fmt.Errorf("unable to convert %#v to png", contentType) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/linkedaccounts/backend.go: -------------------------------------------------------------------------------- 1 | package linkedaccounts 2 | 3 | import "database/sql" 4 | 5 | type LinkedAccount struct { 6 | ID int 7 | Provider string 8 | ProfileID int64 9 | Token string 10 | Secret string 11 | Username string 12 | } 13 | 14 | type Backend struct { 15 | db *sql.DB 16 | } 17 | 18 | func NewLinkedAccountsBackend(db *sql.DB) *Backend { 19 | return &Backend{ 20 | db: db, 21 | } 22 | } 23 | 24 | func (pb *Backend) LinkTwitterProfile(user, profile int, token, secret, username string) error { 25 | stmt, err := pb.db.Prepare("INSERT INTO linked_accounts (user_id, provider, profile_id, token, secret, username) VALUES ($1, $2, $3, $4, $5, $6);") 26 | if err != nil { 27 | return err 28 | } 29 | 30 | _, err = stmt.Exec(user, "twitter", profile, token, secret, username) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (pb *Backend) UnlinkTwitterProfile(user int) error { 39 | stmt, err := pb.db.Prepare("DELETE FROM linked_accounts WHERE user_id = $1 AND provider = $2;") 40 | if err != nil { 41 | return err 42 | } 43 | 44 | _, err = stmt.Exec(user, "twitter") 45 | if err != nil { 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func (pb *Backend) UpdateTwitterUsernameFor(user int, username string) error { 53 | stmt, err := pb.db.Prepare("UPDATE linked_accounts SET username = $1 WHERE user_id = $2 AND provider = $3") 54 | if err != nil { 55 | return err 56 | } 57 | 58 | _, err = stmt.Exec(username, user, "twitter") 59 | return err 60 | } 61 | 62 | func (pb *Backend) GetTwitterProfileFor(user int) (*LinkedAccount, error) { 63 | stmt, err := pb.db.Prepare("SELECT profile_id, token, secret, username FROM linked_accounts WHERE user_id = $1 AND provider = $2") 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | account := &LinkedAccount{ID: user, Provider: "twitter"} 69 | 70 | row := stmt.QueryRow(user, "twitter") 71 | 72 | err = row.Scan(&account.ProfileID, &account.Token, &account.Secret, &account.Username) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | return account, nil 78 | } 79 | 80 | func (pb *Backend) GetAllTwitterProfilesForUsersNotRecommendedToAndNotFollowedBy(user int) ([]LinkedAccount, error) { 81 | query := ` 82 | SELECT user_id, profile_id, token, secret, username FROM linked_accounts 83 | WHERE user_id NOT IN (SELECT user_id FROM followers WHERE follower = $1) 84 | AND user_id NOT IN (SELECT recommendation FROM follow_recommendations WHERE user_id = $1) AND user_id != $1` 85 | 86 | stmt, err := pb.db.Prepare(query) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | rows, err := stmt.Query(user) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | result := make([]LinkedAccount, 0) 97 | for rows.Next() { 98 | account := LinkedAccount{Provider: "twitter"} 99 | 100 | err := rows.Scan(&account.ID, &account.ProfileID, &account.Token, &account.Secret, &account.Username) 101 | if err != nil { 102 | continue 103 | } 104 | 105 | result = append(result, account) 106 | } 107 | 108 | return result, nil 109 | } 110 | -------------------------------------------------------------------------------- /pkg/login/internal/utils.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "io" 7 | "regexp" 8 | ) 9 | 10 | var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 11 | 12 | func ValidateEmail(email string) bool { 13 | return len(email) < 254 && emailRegex.MatchString(email) 14 | } 15 | 16 | var usernameRegex = regexp.MustCompile("^([a-z0-9_]+)*$") 17 | 18 | func ValidateUsername(username string) bool { 19 | return len(username) < 100 && len(username) > 2 && usernameRegex.MatchString(username) 20 | } 21 | 22 | func GenerateToken() (string, error) { 23 | b := make([]byte, 16) 24 | _, err := rand.Read(b) 25 | if err != nil { 26 | return "", err 27 | } 28 | 29 | return fmt.Sprintf("%x", b), nil 30 | } 31 | 32 | func GeneratePin() (string, error) { 33 | max := 6 34 | b := make([]byte, max) 35 | n, err := io.ReadAtLeast(rand.Reader, b, max) 36 | if n != max { 37 | return "", err 38 | } 39 | 40 | for i := 0; i < len(b); i++ { 41 | b[i] = table[int(b[i])%len(table)] 42 | } 43 | 44 | return string(b), nil 45 | } 46 | 47 | var table = []byte{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'} 48 | -------------------------------------------------------------------------------- /pkg/login/state_manager.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | ) 10 | 11 | const pinExpiration = 15 * time.Minute 12 | 13 | // State represents the user login state 14 | type State struct { 15 | Email string 16 | AppleUserID string 17 | Pin string 18 | } 19 | 20 | // StateManager is responsible for handling the login state of a user 21 | type StateManager struct { 22 | rdb *redis.Client 23 | } 24 | 25 | // NewStateManager creates a new state manager 26 | func NewStateManager(rdb *redis.Client) *StateManager { 27 | return &StateManager{rdb: rdb} 28 | } 29 | 30 | // GetState returns the login state for a given token 31 | func (sm *StateManager) GetState(token string) (*State, error) { 32 | res, err := sm.rdb.Get(sm.rdb.Context(), key(token)).Result() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | state := &State{} 38 | err = json.Unmarshal([]byte(res), state) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return state, nil 44 | } 45 | 46 | // SetPinState sets the login pin state 47 | func (sm *StateManager) SetPinState(token, email, pin string) error { 48 | state := &State{ 49 | Pin: pin, 50 | Email: email, 51 | } 52 | 53 | data, err := json.Marshal(state) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | _, err = sm.rdb.Set(sm.rdb.Context(), key(token), data, pinExpiration).Result() 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | 66 | // SetRegistrationState sets the registration state 67 | func (sm *StateManager) SetRegistrationState(token, email string) error { 68 | state := &State{ 69 | Email: email, 70 | } 71 | 72 | data, err := json.Marshal(state) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | _, err = sm.rdb.Set(sm.rdb.Context(), key(token), data, 0).Result() 78 | if err != nil { 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // SetAppleRegistrationState starts the registration state for apple 86 | func (sm *StateManager) SetAppleRegistrationState(token, email, userID string) error { 87 | state := &State{ 88 | Email: email, 89 | AppleUserID: userID, 90 | } 91 | 92 | data, err := json.Marshal(state) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | _, err = sm.rdb.Set(sm.rdb.Context(), key(token), data, 0).Result() 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | 105 | // RemoveState removes the state 106 | func (sm *StateManager) RemoveState(token string) { 107 | sm.rdb.Del(sm.rdb.Context(), key(token)) 108 | } 109 | 110 | func key(token string) string { 111 | return fmt.Sprintf("login_state_%s", token) 112 | } 113 | -------------------------------------------------------------------------------- /pkg/mail/service.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sendgrid/sendgrid-go" 7 | "github.com/sendgrid/sendgrid-go/helpers/mail" 8 | ) 9 | 10 | type Service struct { 11 | client *sendgrid.Client 12 | } 13 | 14 | func NewMailService(client *sendgrid.Client) *Service { 15 | return &Service{client: client} 16 | } 17 | 18 | func (s *Service) SendPinEmail(recipient, pin string) error { 19 | m := mail.NewV3Mail() 20 | m.SetFrom(mail.NewEmail("Soapbox", "no-reply@mail.soapbox.social")) 21 | m.SetTemplateID("d-94ee80b7ff33499894de719c02f095cf") 22 | 23 | p := mail.NewPersonalization() 24 | p.AddTos(mail.NewEmail("", recipient)) 25 | p.SetDynamicTemplateData("pin", pin) 26 | 27 | m.AddPersonalizations(p) 28 | 29 | resp, err := s.client.Send(m) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if resp.StatusCode >= 400 { 35 | return fmt.Errorf("failed to send email %v", resp.Body) 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/metadata/endpoint.go: -------------------------------------------------------------------------------- 1 | package metadata 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | 10 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 11 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 12 | "github.com/soapboxsocial/soapbox/pkg/users" 13 | ) 14 | 15 | type Endpoint struct { 16 | usersBackend *users.Backend 17 | 18 | roomService pb.RoomServiceClient 19 | } 20 | 21 | func NewEndpoint(usersBackend *users.Backend, roomService pb.RoomServiceClient) *Endpoint { 22 | return &Endpoint{ 23 | usersBackend: usersBackend, 24 | roomService: roomService, 25 | } 26 | } 27 | 28 | func (e *Endpoint) Router() *mux.Router { 29 | r := mux.NewRouter() 30 | 31 | r.HandleFunc("/users/{username}", e.user).Methods("GET") 32 | r.HandleFunc("/rooms/{id}", e.room).Methods("GET") 33 | 34 | return r 35 | } 36 | 37 | func (e *Endpoint) user(w http.ResponseWriter, r *http.Request) { 38 | params := mux.Vars(r) 39 | 40 | username := params["username"] 41 | user, err := e.usersBackend.GetUserByUsername(username) 42 | if err != nil { 43 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 44 | return 45 | } 46 | 47 | err = httputil.JsonEncode(w, user) 48 | if err != nil { 49 | log.Printf("failed to encode: %v", err) 50 | } 51 | } 52 | 53 | func (e *Endpoint) room(w http.ResponseWriter, r *http.Request) { 54 | params := mux.Vars(r) 55 | 56 | id := params["id"] 57 | if id == "" { 58 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 59 | return 60 | } 61 | 62 | response, err := e.roomService.GetRoom(context.Background(), &pb.GetRoomRequest{Id: id}) 63 | if err != nil { 64 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 65 | return 66 | } 67 | 68 | if response.State == nil { 69 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 70 | return 71 | } 72 | 73 | if response.State.Visibility == pb.Visibility_VISIBILITY_PRIVATE { 74 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 75 | return 76 | } 77 | 78 | err = httputil.JsonEncode(w, response.State) 79 | if err != nil { 80 | log.Printf("failed to encode: %v", err) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pkg/minis/backend.go: -------------------------------------------------------------------------------- 1 | package minis 2 | 3 | import "database/sql" 4 | 5 | type Backend struct { 6 | db *sql.DB 7 | } 8 | 9 | func NewBackend(db *sql.DB) *Backend { 10 | return &Backend{db: db} 11 | } 12 | 13 | func (b *Backend) ListMinis() ([]Mini, error) { 14 | query := `SELECT id, name, slug, image, size, description FROM minis ORDER BY weight ASC;` 15 | 16 | stmt, err := b.db.Prepare(query) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | rows, err := stmt.Query() 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | result := make([]Mini, 0) 27 | 28 | for rows.Next() { 29 | mini := Mini{} 30 | 31 | err := rows.Scan(&mini.ID, &mini.Name, &mini.Slug, &mini.Image, &mini.Size, &mini.Description) 32 | if err != nil { 33 | continue 34 | } 35 | 36 | result = append(result, mini) 37 | } 38 | 39 | return result, nil 40 | } 41 | 42 | func (b *Backend) GetMiniWithSlug(slug string) (*Mini, error) { 43 | stmt, err := b.db.Prepare("SELECT id, name, image, size, description FROM minis WHERE slug = $1;") 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | mini := &Mini{} 49 | err = stmt.QueryRow(slug).Scan(&mini.ID, &mini.Name, &mini.Image, &mini.Size, &mini.Description) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | mini.Slug = slug 55 | 56 | return mini, nil 57 | } 58 | 59 | func (b *Backend) GetMiniWithID(id int) (*Mini, error) { 60 | stmt, err := b.db.Prepare("SELECT name, image, slug, size, description FROM minis WHERE id = $1;") 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | mini := &Mini{} 66 | err = stmt.QueryRow(id).Scan(&mini.Name, &mini.Image, &mini.Slug, &mini.Size, &mini.Description) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | mini.ID = id 72 | 73 | return mini, nil 74 | } 75 | 76 | func (b *Backend) SaveScores(mini int, room string, scores Scores) error { 77 | tx, err := b.db.Begin() 78 | if err != nil { 79 | return err 80 | } 81 | 82 | stmt, err := tx.Prepare("INSERT INTO mini_scores(mini_id, room, user_id, score) VALUES ($1, $2, $3, $4)") 83 | if err != nil { 84 | _ = tx.Rollback() 85 | return err 86 | } 87 | 88 | for user, score := range scores { 89 | _, err = stmt.Exec(mini, room, user, score) 90 | if err != nil { 91 | _ = tx.Rollback() 92 | return err 93 | } 94 | } 95 | 96 | err = tx.Commit() 97 | if err != nil { 98 | _ = tx.Rollback() 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/minis/backend_test.go: -------------------------------------------------------------------------------- 1 | package minis_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/DATA-DOG/go-sqlmock" 7 | 8 | "github.com/soapboxsocial/soapbox/pkg/minis" 9 | ) 10 | 11 | func TestBackend_GetMiniWithID(t *testing.T) { 12 | db, mock, err := sqlmock.New() 13 | if err != nil { 14 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 15 | } 16 | defer db.Close() 17 | 18 | backend := minis.NewBackend(db) 19 | 20 | id := 1 21 | mock.ExpectPrepare("SELECT"). 22 | ExpectQuery(). 23 | WithArgs(id). 24 | WillReturnRows(mock.NewRows([]string{"name", "slug", "image", "size", "description"}).AddRow("name", "slug", "image", 0, "")) 25 | 26 | result, err := backend.GetMiniWithID(id) 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | if result.ID != id { 32 | t.Fatal("id not matching") 33 | } 34 | } 35 | 36 | func TestBackend_GetMiniWithSlug(t *testing.T) { 37 | db, mock, err := sqlmock.New() 38 | if err != nil { 39 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 40 | } 41 | defer db.Close() 42 | 43 | backend := minis.NewBackend(db) 44 | 45 | slug := "/1" 46 | mock.ExpectPrepare("SELECT"). 47 | ExpectQuery(). 48 | WithArgs(slug). 49 | WillReturnRows(mock.NewRows([]string{"name", "slug", "image", "size", "description"}).AddRow(1, "name", "image", 0, "")) 50 | 51 | result, err := backend.GetMiniWithSlug(slug) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | if result.Slug != slug { 57 | t.Fatal("slug not matching") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/minis/endpoint.go: -------------------------------------------------------------------------------- 1 | package minis 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | 10 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 11 | "github.com/soapboxsocial/soapbox/pkg/http/middlewares" 12 | ) 13 | 14 | type Endpoint struct { 15 | backend *Backend 16 | auth *middlewares.AuthenticationMiddleware 17 | keys AuthKeys 18 | } 19 | 20 | func NewEndpoint(backend *Backend, auth *middlewares.AuthenticationMiddleware, keys AuthKeys) *Endpoint { 21 | return &Endpoint{ 22 | backend: backend, 23 | auth: auth, 24 | keys: keys, 25 | } 26 | } 27 | 28 | func (e *Endpoint) Router() *mux.Router { 29 | r := mux.NewRouter() 30 | 31 | r.HandleFunc("/scores", e.saveScores).Methods("POST") 32 | 33 | r.Path("/").Methods("GET").Handler(e.auth.Middleware(http.HandlerFunc(e.listMinis))) 34 | 35 | return r 36 | } 37 | 38 | func (e *Endpoint) listMinis(w http.ResponseWriter, _ *http.Request) { 39 | minis, err := e.backend.ListMinis() 40 | if err != nil { 41 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 42 | return 43 | } 44 | 45 | err = httputil.JsonEncode(w, minis) 46 | if err != nil { 47 | log.Printf("failed to encode: %v", err) 48 | } 49 | } 50 | 51 | func (e *Endpoint) saveScores(w http.ResponseWriter, r *http.Request) { 52 | query := r.URL.Query() 53 | token := query.Get("token") 54 | id, ok := e.keys[token] 55 | if !ok { 56 | httputil.JsonError(w, http.StatusUnauthorized, httputil.ErrorCodeUnauthorized, "unauthorized") 57 | return 58 | } 59 | 60 | room := query.Get("room") 61 | if room == "" { 62 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "bad request") 63 | return 64 | } 65 | 66 | var scores Scores 67 | err := json.NewDecoder(r.Body).Decode(&scores) 68 | if err != nil { 69 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "bad request") 70 | return 71 | } 72 | 73 | err = e.backend.SaveScores(id, room, scores) 74 | if err != nil { 75 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "failed") 76 | return 77 | } 78 | 79 | httputil.JsonSuccess(w) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/minis/types.go: -------------------------------------------------------------------------------- 1 | package minis 2 | 3 | // Scores maps user id to score 4 | type Scores map[int]int 5 | 6 | // AuthKeys maps an access token to a game ID. 7 | type AuthKeys map[string]int 8 | 9 | type Mini struct { 10 | ID int `json:"id"` 11 | Name string `json:"name"` 12 | Image string `json:"image"` 13 | Slug string `json:"slug"` 14 | Size int `json:"size"` 15 | Description string `json:"description"` 16 | } 17 | -------------------------------------------------------------------------------- /pkg/notifications/apns.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | type APNS interface { 4 | Send(target string, notification PushNotification) error 5 | } 6 | -------------------------------------------------------------------------------- /pkg/notifications/errors.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrDeviceUnregistered is returned when an apns token is unregistered. 7 | ErrDeviceUnregistered = errors.New("apns device unregistered") 8 | 9 | // ErrRetryRequired is returned when a notification was not send due to a server and a retry is required. 10 | ErrRetryRequired = errors.New("failed to send retry required") 11 | ) 12 | -------------------------------------------------------------------------------- /pkg/notifications/grpc/service.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/notifications" 8 | "github.com/soapboxsocial/soapbox/pkg/notifications/pb" 9 | "github.com/soapboxsocial/soapbox/pkg/notifications/worker" 10 | ) 11 | 12 | type Service struct { 13 | pb.UnimplementedNotificationServiceServer 14 | 15 | dispatch *worker.Dispatcher 16 | settings *notifications.Settings 17 | } 18 | 19 | func NewService(dispatch *worker.Dispatcher, settings *notifications.Settings) *Service { 20 | return &Service{ 21 | dispatch: dispatch, 22 | settings: settings, 23 | } 24 | } 25 | 26 | func (s *Service) SendNotification(_ context.Context, request *pb.SendNotificationRequest) (*pb.SendNotificationResponse, error) { 27 | notification := request.Notification 28 | if notification == nil { 29 | return nil, errors.New("empty notification") 30 | } 31 | 32 | push := notification.ToPushNotification() 33 | ids := request.Targets 34 | 35 | targets, err := s.settings.GetSettingsForUsers(ids) 36 | if err != nil { 37 | return nil, errors.New("failed to get targets") 38 | } 39 | 40 | s.dispatch.Dispatch(0, targets, push) 41 | 42 | return &pb.SendNotificationResponse{Success: true}, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/errors.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "errors" 4 | 5 | var ( 6 | errRoomPrivate = errors.New("room is private") 7 | errNoRoomMembers = errors.New("room is empty") 8 | errFailedToSort = errors.New("failed to sort") 9 | errEmptyResponse = errors.New("empty response") 10 | errMemberNoLongerPresent = errors.New("member no longer present") 11 | ErrNoCreator = errors.New("no creator") 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/followernotificationhandler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/soapboxsocial/soapbox/pkg/notifications" 5 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 6 | "github.com/soapboxsocial/soapbox/pkg/users" 7 | ) 8 | 9 | type FollowerNotificationHandler struct { 10 | targets *notifications.Settings 11 | users *users.Backend 12 | } 13 | 14 | func NewFollowerNotificationHandler(targets *notifications.Settings, u *users.Backend) *FollowerNotificationHandler { 15 | return &FollowerNotificationHandler{ 16 | targets: targets, 17 | users: u, 18 | } 19 | } 20 | 21 | func (f FollowerNotificationHandler) Type() pubsub.EventType { 22 | return pubsub.EventTypeNewFollower 23 | } 24 | 25 | func (f FollowerNotificationHandler) Origin(event *pubsub.Event) (int, error) { 26 | follower, err := event.GetInt("follower") 27 | if err != nil { 28 | return 0, err 29 | } 30 | 31 | return follower, nil 32 | } 33 | 34 | func (f FollowerNotificationHandler) Targets(event *pubsub.Event) ([]notifications.Target, error) { 35 | targetID, err := event.GetInt("id") 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | target, err := f.targets.GetSettingsFor(targetID) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return []notifications.Target{*target}, nil 46 | } 47 | 48 | func (f FollowerNotificationHandler) Build(event *pubsub.Event) (*notifications.PushNotification, error) { 49 | creator, err := event.GetInt("follower") 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | displayName, err := f.getDisplayName(creator) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | return ¬ifications.PushNotification{ 60 | Category: notifications.NEW_FOLLOWER, 61 | Alert: notifications.Alert{ 62 | Key: "new_follower_notification", 63 | Arguments: []string{displayName}, 64 | }, 65 | Arguments: map[string]interface{}{"id": creator}, 66 | }, nil 67 | } 68 | 69 | func (f FollowerNotificationHandler) getDisplayName(id int) (string, error) { 70 | user, err := f.users.FindByID(id) 71 | if err != nil { 72 | return "", err 73 | } 74 | 75 | return user.DisplayName, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/followernotificationhandler_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/DATA-DOG/go-sqlmock" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/notifications" 10 | "github.com/soapboxsocial/soapbox/pkg/notifications/handlers" 11 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 12 | "github.com/soapboxsocial/soapbox/pkg/users" 13 | ) 14 | 15 | func TestFollowerNotificationHandler_Targets(t *testing.T) { 16 | raw := pubsub.NewFollowerEvent(12, 1) 17 | event, err := getRawEvent(&raw) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | db, mock, err := sqlmock.New() 23 | if err != nil { 24 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 25 | } 26 | defer db.Close() 27 | 28 | handler := handlers.NewFollowerNotificationHandler( 29 | notifications.NewSettings(db), 30 | nil, 31 | ) 32 | 33 | mock. 34 | ExpectPrepare("SELECT"). 35 | ExpectQuery(). 36 | WillReturnRows(mock.NewRows([]string{"user_id", "room_frequency", "follows", "welcome_rooms"}).FromCSVString("1,2,false,false")) 37 | 38 | target, err := handler.Targets(event) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | expected := []notifications.Target{ 44 | {ID: 1, RoomFrequency: 2, Follows: false, WelcomeRooms: false}, 45 | } 46 | 47 | if !reflect.DeepEqual(target, expected) { 48 | t.Fatalf("expected %v actual %v", expected, target) 49 | } 50 | } 51 | 52 | func TestFollowerNotificationHandler_Build(t *testing.T) { 53 | db, mock, err := sqlmock.New() 54 | if err != nil { 55 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 56 | } 57 | defer db.Close() 58 | 59 | handler := handlers.NewFollowerNotificationHandler(notifications.NewSettings(nil), users.NewBackend(db)) 60 | 61 | displayName := "foo" 62 | user := 12 63 | 64 | raw := pubsub.NewFollowerEvent(user, 13) 65 | 66 | event, err := getRawEvent(&raw) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | mock. 72 | ExpectPrepare("SELECT"). 73 | ExpectQuery(). 74 | WillReturnRows(mock.NewRows([]string{"id", "display_name", "username", "image", "bio", "email"}).FromCSVString("1,foo,t,t,t,t")) 75 | 76 | n, err := handler.Build(event) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | notification := ¬ifications.PushNotification{ 82 | Category: notifications.NEW_FOLLOWER, 83 | Alert: notifications.Alert{ 84 | Key: "new_follower_notification", 85 | Arguments: []string{displayName}, 86 | }, 87 | Arguments: map[string]interface{}{"id": user}, 88 | } 89 | 90 | if !reflect.DeepEqual(n, notification) { 91 | t.Fatalf("expected %v actual %v", notification, n) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/followrecommendationsnotificationhandler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/soapboxsocial/soapbox/pkg/notifications" 9 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 10 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows" 11 | ) 12 | 13 | type FollowRecommendationsNotificationHandler struct { 14 | targets *notifications.Settings 15 | backend *follows.Backend 16 | } 17 | 18 | func NewFollowRecommendationsNotificationHandler(targets *notifications.Settings, backend *follows.Backend) *FollowRecommendationsNotificationHandler { 19 | return &FollowRecommendationsNotificationHandler{ 20 | targets: targets, 21 | backend: backend, 22 | } 23 | } 24 | 25 | func (f FollowRecommendationsNotificationHandler) Type() pubsub.EventType { 26 | return pubsub.EventTypeFollowRecommendations 27 | } 28 | 29 | func (f FollowRecommendationsNotificationHandler) Origin(*pubsub.Event) (int, error) { 30 | return 0, errors.New("no origin for event") 31 | } 32 | 33 | func (f FollowRecommendationsNotificationHandler) Targets(event *pubsub.Event) ([]notifications.Target, error) { 34 | targetID, err := event.GetInt("id") 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | target, err := f.targets.GetSettingsFor(targetID) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return []notifications.Target{*target}, nil 45 | } 46 | 47 | func (f FollowRecommendationsNotificationHandler) Build(event *pubsub.Event) (*notifications.PushNotification, error) { 48 | targetID, err := event.GetInt("id") 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | recommendations, err := f.backend.RecommendationsFor(targetID) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | count := len(recommendations) 59 | if count == 0 { 60 | return nil, errors.New("no recommendations") 61 | } 62 | 63 | translation := "" 64 | body := "" 65 | args := make([]string, 0) 66 | 67 | switch count { 68 | case 1: 69 | translation += "1" 70 | body = fmt.Sprintf("%s who you may know is on Soapbox, why not follow them?", recommendations[0].DisplayName) 71 | args = append(args, recommendations[0].DisplayName) 72 | case 2: 73 | translation += "2" 74 | body = fmt.Sprintf( 75 | "%s and %s who you may know are on Soapbox, why not follow them?", 76 | recommendations[0].DisplayName, recommendations[1].DisplayName, 77 | ) 78 | args = append(args, recommendations[0].DisplayName, recommendations[1].DisplayName) 79 | case 3: 80 | translation += "3" 81 | body = fmt.Sprintf( 82 | "%s, %s and %s who you may know are on Soapbox, why not follow them?", 83 | recommendations[0].DisplayName, recommendations[1].DisplayName, recommendations[2].DisplayName, 84 | ) 85 | 86 | args = append(args, recommendations[0].DisplayName, recommendations[1].DisplayName, recommendations[2].DisplayName) 87 | default: 88 | translation += "3_and_more" 89 | body = fmt.Sprintf( 90 | "%s, %s, %s and %d others who you may know are on Soapbox, why not follow them?", 91 | recommendations[0].DisplayName, recommendations[1].DisplayName, recommendations[2].DisplayName, count-3, 92 | ) 93 | 94 | args = append(args, recommendations[0].DisplayName, recommendations[1].DisplayName, recommendations[2].DisplayName, strconv.Itoa(count-3)) 95 | } 96 | 97 | translation += "_follow_recommendations_notification" 98 | 99 | return ¬ifications.PushNotification{ 100 | Category: notifications.FOLLOW_RECOMMENDATIONS, 101 | Alert: notifications.Alert{ 102 | Body: body, 103 | Key: translation, 104 | Arguments: args, 105 | }, 106 | }, nil 107 | } 108 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/roomcreationnotificationhandler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/notifications" 8 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 9 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 10 | "github.com/soapboxsocial/soapbox/pkg/users" 11 | ) 12 | 13 | const testAccountID = 19 14 | 15 | type RoomCreationNotificationHandler struct { 16 | targets *notifications.Settings 17 | users *users.Backend 18 | metadata pb.RoomServiceClient 19 | } 20 | 21 | func NewRoomCreationNotificationHandler(targets *notifications.Settings, u *users.Backend, metadata pb.RoomServiceClient) *RoomCreationNotificationHandler { 22 | return &RoomCreationNotificationHandler{ 23 | targets: targets, 24 | users: u, 25 | metadata: metadata, 26 | } 27 | } 28 | 29 | func (r RoomCreationNotificationHandler) Type() pubsub.EventType { 30 | return pubsub.EventTypeNewRoom 31 | } 32 | 33 | func (r RoomCreationNotificationHandler) Origin(event *pubsub.Event) (int, error) { 34 | creator, err := event.GetInt("creator") 35 | if err != nil { 36 | return 0, err 37 | } 38 | 39 | return creator, nil 40 | } 41 | 42 | func (r RoomCreationNotificationHandler) Targets(event *pubsub.Event) ([]notifications.Target, error) { 43 | if pubsub.RoomVisibility(event.Params["visibility"].(string)) == pubsub.Private { 44 | return []notifications.Target{}, nil 45 | } 46 | 47 | creator, err := event.GetInt("creator") 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | targets, err := r.targets.GetSettingsFollowingUser(creator) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return targets, nil 58 | } 59 | 60 | func (r RoomCreationNotificationHandler) Build(event *pubsub.Event) (*notifications.PushNotification, error) { 61 | if pubsub.RoomVisibility(event.Params["visibility"].(string)) == pubsub.Private { 62 | return nil, errors.New("room is private") 63 | } 64 | 65 | creator, err := event.GetInt("creator") 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | if creator == testAccountID { 71 | return nil, errors.New("test account started room") 72 | } 73 | 74 | room := event.Params["id"].(string) 75 | response, err := r.metadata.GetRoom(context.Background(), &pb.GetRoomRequest{Id: room}) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | if response == nil || response.State == nil { 81 | return nil, errEmptyResponse 82 | } 83 | 84 | if response.State.Visibility == pb.Visibility_VISIBILITY_PRIVATE { 85 | return nil, errors.New("room is private") 86 | } 87 | 88 | displayName, err := r.getDisplayName(creator) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if response.State.Name != "" { 94 | return notifications.NewRoomNotificationWithName(room, displayName, response.State.Name), nil 95 | } 96 | 97 | return notifications.NewRoomNotification(room, displayName, creator), nil 98 | } 99 | 100 | func (r RoomCreationNotificationHandler) getDisplayName(id int) (string, error) { 101 | user, err := r.users.FindByID(id) 102 | if err != nil { 103 | return "", err 104 | } 105 | 106 | return user.DisplayName, nil 107 | } 108 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/roomcreationnotificationhandler_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/DATA-DOG/go-sqlmock" 8 | "github.com/golang/mock/gomock" 9 | 10 | "github.com/soapboxsocial/soapbox/mocks" 11 | "github.com/soapboxsocial/soapbox/pkg/notifications" 12 | "github.com/soapboxsocial/soapbox/pkg/notifications/handlers" 13 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 14 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 15 | "github.com/soapboxsocial/soapbox/pkg/users" 16 | ) 17 | 18 | func TestRoomCreationNotificationHandler_Targets(t *testing.T) { 19 | raw := pubsub.NewRoomCreationEvent("id", 12, pubsub.Public) 20 | event, err := getRawEvent(&raw) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | db, mock, err := sqlmock.New() 26 | if err != nil { 27 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 28 | } 29 | defer db.Close() 30 | 31 | ctrl := gomock.NewController(t) 32 | defer ctrl.Finish() 33 | 34 | m := mocks.NewMockRoomServiceClient(ctrl) 35 | 36 | handler := handlers.NewRoomCreationNotificationHandler( 37 | notifications.NewSettings(db), 38 | nil, 39 | m, 40 | ) 41 | 42 | mock. 43 | ExpectPrepare("SELECT"). 44 | ExpectQuery(). 45 | WillReturnRows(mock.NewRows([]string{"user_id", "room_frequency", "follows", "welcome_rooms"}).FromCSVString("1,2,false,false")) 46 | 47 | target, err := handler.Targets(event) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | expected := []notifications.Target{ 53 | {ID: 1, RoomFrequency: 2, Follows: false, WelcomeRooms: false}, 54 | } 55 | 56 | if !reflect.DeepEqual(target, expected) { 57 | t.Fatalf("expected %v actual %v", expected, target) 58 | } 59 | } 60 | 61 | func TestRoomCreationNotificationHandler_Build(t *testing.T) { 62 | db, mock, err := sqlmock.New() 63 | if err != nil { 64 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 65 | } 66 | defer db.Close() 67 | 68 | ctrl := gomock.NewController(t) 69 | defer ctrl.Finish() 70 | 71 | m := mocks.NewMockRoomServiceClient(ctrl) 72 | 73 | handler := handlers.NewRoomCreationNotificationHandler(notifications.NewSettings(nil), users.NewBackend(db), m) 74 | 75 | displayName := "foo" 76 | user := 12 77 | room := "123" 78 | 79 | raw := pubsub.NewRoomCreationEvent(room, user, pubsub.Public) 80 | 81 | event, err := getRawEvent(&raw) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | m.EXPECT().GetRoom(gomock.Any(), gomock.Any(), gomock.Any()).Return(&pb.GetRoomResponse{State: &pb.RoomState{Id: "123"}}, nil) 87 | 88 | mock. 89 | ExpectPrepare("SELECT"). 90 | ExpectQuery(). 91 | WillReturnRows(mock.NewRows([]string{"id", "display_name", "username", "image", "bio", "email"}).FromCSVString("1,foo,t,t,t,t")) 92 | 93 | n, err := handler.Build(event) 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | 98 | notification := ¬ifications.PushNotification{ 99 | Category: notifications.NEW_ROOM, 100 | Alert: notifications.Alert{ 101 | Key: "new_room_notification", 102 | Arguments: []string{displayName}, 103 | }, 104 | Arguments: map[string]interface{}{"id": room, "creator": user}, 105 | CollapseID: room, 106 | } 107 | 108 | if !reflect.DeepEqual(n, notification) { 109 | t.Fatalf("expected %v actual %v", notification, n) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/roominvitenotificationhandler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/soapboxsocial/soapbox/pkg/notifications" 5 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 6 | "github.com/soapboxsocial/soapbox/pkg/users" 7 | ) 8 | 9 | type RoomInviteNotificationHandler struct { 10 | targets *notifications.Settings 11 | users *users.Backend 12 | } 13 | 14 | func NewRoomInviteNotificationHandler(targets *notifications.Settings, u *users.Backend) *RoomInviteNotificationHandler { 15 | return &RoomInviteNotificationHandler{ 16 | targets: targets, 17 | users: u, 18 | } 19 | } 20 | 21 | func (r RoomInviteNotificationHandler) Type() pubsub.EventType { 22 | return pubsub.EventTypeRoomInvite 23 | } 24 | 25 | func (r RoomInviteNotificationHandler) Origin(event *pubsub.Event) (int, error) { 26 | creator, err := event.GetInt("from") 27 | if err != nil { 28 | return 0, err 29 | } 30 | 31 | return creator, nil 32 | } 33 | 34 | func (r RoomInviteNotificationHandler) Targets(event *pubsub.Event) ([]notifications.Target, error) { 35 | targetID, err := event.GetInt("id") 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | target, err := r.targets.GetSettingsFor(targetID) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return []notifications.Target{*target}, nil 46 | } 47 | 48 | func (r RoomInviteNotificationHandler) Build(event *pubsub.Event) (*notifications.PushNotification, error) { 49 | creator, err := event.GetInt("from") 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | name := event.Params["name"].(string) 55 | room := event.Params["room"].(string) 56 | 57 | displayName, err := r.getDisplayName(creator) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | if name == "" { 63 | return notifications.NewRoomInviteNotification(room, displayName), nil 64 | } 65 | 66 | return notifications.NewRoomInviteNotificationWithName(room, displayName, name), nil 67 | } 68 | 69 | func (r RoomInviteNotificationHandler) getDisplayName(id int) (string, error) { 70 | user, err := r.users.FindByID(id) 71 | if err != nil { 72 | return "", err 73 | } 74 | 75 | return user.DisplayName, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/roominvitenotificationhandler_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "reflect" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/DATA-DOG/go-sqlmock" 9 | "github.com/golang/mock/gomock" 10 | 11 | "github.com/soapboxsocial/soapbox/pkg/notifications" 12 | "github.com/soapboxsocial/soapbox/pkg/notifications/handlers" 13 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 14 | "github.com/soapboxsocial/soapbox/pkg/users" 15 | ) 16 | 17 | func TestRoomInviteNotificationHandler_Targets(t *testing.T) { 18 | raw := pubsub.NewRoomInviteEvent("id", "room", 13, 12) 19 | event, err := getRawEvent(&raw) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | db, mock, err := sqlmock.New() 25 | if err != nil { 26 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 27 | } 28 | defer db.Close() 29 | 30 | handler := handlers.NewRoomInviteNotificationHandler( 31 | notifications.NewSettings(db), 32 | nil, 33 | ) 34 | 35 | mock. 36 | ExpectPrepare("SELECT"). 37 | ExpectQuery(). 38 | WillReturnRows(mock.NewRows([]string{"user_id", "room_frequency", "follows", "welcome_rooms"}).FromCSVString("1,2,false,false")) 39 | 40 | target, err := handler.Targets(event) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | expected := []notifications.Target{ 46 | {ID: 1, RoomFrequency: 2, Follows: false, WelcomeRooms: false}, 47 | } 48 | 49 | if !reflect.DeepEqual(target, expected) { 50 | t.Fatalf("expected %v actual %v", expected, target) 51 | } 52 | } 53 | 54 | func TestRoomInviteNotificationHandler_Build(t *testing.T) { 55 | var tests = []struct { 56 | event pubsub.Event 57 | notification *notifications.PushNotification 58 | }{ 59 | { 60 | event: pubsub.NewRoomInviteEvent("", "xyz", 1, 2), 61 | notification: ¬ifications.PushNotification{ 62 | Category: notifications.ROOM_INVITE, 63 | Alert: notifications.Alert{ 64 | Key: "room_invite_notification", 65 | Arguments: []string{"user"}, 66 | }, 67 | Arguments: map[string]interface{}{"id": "xyz"}, 68 | CollapseID: "xyz", 69 | }, 70 | }, 71 | { 72 | event: pubsub.NewRoomInviteEvent("foo", "xyz", 1, 2), 73 | notification: ¬ifications.PushNotification{ 74 | Category: notifications.ROOM_INVITE, 75 | Alert: notifications.Alert{ 76 | Key: "room_invite_with_name_notification", 77 | Arguments: []string{"user", "foo"}, 78 | }, 79 | Arguments: map[string]interface{}{"id": "xyz"}, 80 | CollapseID: "xyz", 81 | }, 82 | }, 83 | } 84 | 85 | for i, tt := range tests { 86 | t.Run(strconv.Itoa(i), func(t *testing.T) { 87 | db, mock, err := sqlmock.New() 88 | if err != nil { 89 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 90 | } 91 | defer db.Close() 92 | 93 | ctrl := gomock.NewController(t) 94 | defer ctrl.Finish() 95 | 96 | handler := handlers.NewRoomInviteNotificationHandler( 97 | notifications.NewSettings(db), 98 | users.NewBackend(db), 99 | ) 100 | 101 | mock. 102 | ExpectPrepare("SELECT"). 103 | ExpectQuery(). 104 | WillReturnRows(mock.NewRows([]string{"id", "display_name", "username", "image", "bio", "email"}).FromCSVString("1,user,t,t,t,t")) 105 | 106 | event, err := getRawEvent(&tt.event) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | n, err := handler.Build(event) 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | if !reflect.DeepEqual(n, tt.notification) { 117 | t.Fatalf("expected %v actual %v", tt.notification, n) 118 | } 119 | }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/types.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/soapboxsocial/soapbox/pkg/notifications" 5 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 6 | ) 7 | 8 | // Handler handles a specific type of notification 9 | type Handler interface { 10 | 11 | // Type returns the event handled to build a notification 12 | Type() pubsub.EventType 13 | 14 | // Origin returns the notification origin 15 | Origin(event *pubsub.Event) (int, error) 16 | 17 | // Targets returns the notification receivers 18 | Targets(event *pubsub.Event) ([]notifications.Target, error) 19 | 20 | // Build builds the notification 21 | Build(event *pubsub.Event) (*notifications.PushNotification, error) 22 | } 23 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/util_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 7 | ) 8 | 9 | func getRawEvent(event *pubsub.Event) (*pubsub.Event, error) { 10 | data, err := json.Marshal(event) 11 | if err != nil { 12 | return nil, err 13 | } 14 | 15 | evt := &pubsub.Event{} 16 | err = json.Unmarshal(data, evt) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return evt, nil 22 | } 23 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/welcomeroomnotificationhandler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/soapboxsocial/soapbox/pkg/notifications" 7 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 8 | "github.com/soapboxsocial/soapbox/pkg/users" 9 | ) 10 | 11 | var staticTargets = []notifications.Target{ 12 | {ID: 1}, 13 | {ID: 75}, 14 | {ID: 962}, 15 | } 16 | 17 | type WelcomeRoomNotificationHandler struct { 18 | users *users.Backend 19 | settings *notifications.Settings 20 | } 21 | 22 | func NewWelcomeRoomNotificationHandler(u *users.Backend, settings *notifications.Settings) *WelcomeRoomNotificationHandler { 23 | return &WelcomeRoomNotificationHandler{ 24 | users: u, 25 | settings: settings, 26 | } 27 | } 28 | 29 | func (w WelcomeRoomNotificationHandler) Type() pubsub.EventType { 30 | return pubsub.EventTypeWelcomeRoom 31 | } 32 | 33 | func (w WelcomeRoomNotificationHandler) Origin(*pubsub.Event) (int, error) { 34 | return 0, ErrNoCreator 35 | } 36 | 37 | func (w WelcomeRoomNotificationHandler) Targets(*pubsub.Event) ([]notifications.Target, error) { 38 | targets, err := w.settings.GetSettingsForRecentlyActiveUsers() 39 | if err != nil { 40 | log.Printf("settings.GetSettingsForRecentlyActiveUsers err: %s", err) 41 | } 42 | 43 | if len(targets) > 7 { 44 | targets = targets[:6] 45 | } 46 | 47 | return append(targets, staticTargets...), nil 48 | } 49 | 50 | func (w WelcomeRoomNotificationHandler) Build(event *pubsub.Event) (*notifications.PushNotification, error) { 51 | creator, err := event.GetInt("id") 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | room := event.Params["room"].(string) 57 | 58 | displayName, err := w.getDisplayName(creator) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | return ¬ifications.PushNotification{ 64 | Category: notifications.WELCOME_ROOM, 65 | Alert: notifications.Alert{ 66 | Key: "welcome_room_notification", 67 | Arguments: []string{displayName}, 68 | }, 69 | Arguments: map[string]interface{}{"id": room, "from": creator}, 70 | CollapseID: room, 71 | }, nil 72 | } 73 | 74 | func (w WelcomeRoomNotificationHandler) getDisplayName(id int) (string, error) { 75 | user, err := w.users.FindByID(id) 76 | if err != nil { 77 | return "", err 78 | } 79 | 80 | return user.DisplayName, nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/notifications/handlers/welcomeroomnotificationhandler_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/DATA-DOG/go-sqlmock" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/notifications" 10 | "github.com/soapboxsocial/soapbox/pkg/notifications/handlers" 11 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 12 | "github.com/soapboxsocial/soapbox/pkg/users" 13 | ) 14 | 15 | func TestWelcomeRoomNotificationHandler_Targets(t *testing.T) { 16 | raw := pubsub.NewWelcomeRoomEvent(12, "1234") 17 | event, err := getRawEvent(&raw) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | db, mock, err := sqlmock.New() 23 | if err != nil { 24 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 25 | } 26 | defer db.Close() 27 | 28 | handler := handlers.NewWelcomeRoomNotificationHandler( 29 | nil, 30 | notifications.NewSettings(db), 31 | ) 32 | 33 | mock. 34 | ExpectPrepare("SELECT"). 35 | ExpectQuery(). 36 | WillReturnRows(mock.NewRows([]string{"user_id", "room_frequency", "follows", "welcome_rooms"}).FromCSVString("12,2,false,false")) 37 | 38 | target, err := handler.Targets(event) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | expected := []notifications.Target{ 44 | {ID: 12, RoomFrequency: 2, Follows: false, WelcomeRooms: false}, 45 | {ID: 1, RoomFrequency: 0, Follows: false, WelcomeRooms: false}, 46 | {ID: 75, RoomFrequency: 0, Follows: false, WelcomeRooms: false}, 47 | {ID: 962, RoomFrequency: 0, Follows: false, WelcomeRooms: false}, 48 | } 49 | 50 | if !reflect.DeepEqual(target, expected) { 51 | t.Fatalf("expected %v actual %v", expected, target) 52 | } 53 | } 54 | 55 | func TestWelcomeRoomNotificationHandler_Build(t *testing.T) { 56 | db, mock, err := sqlmock.New() 57 | if err != nil { 58 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 59 | } 60 | defer db.Close() 61 | 62 | handler := handlers.NewWelcomeRoomNotificationHandler(users.NewBackend(db), nil) 63 | 64 | displayName := "foo" 65 | user := 12 66 | room := "123" 67 | 68 | raw := pubsub.NewWelcomeRoomEvent(12, room) 69 | 70 | event, err := getRawEvent(&raw) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | mock. 76 | ExpectPrepare("SELECT"). 77 | ExpectQuery(). 78 | WillReturnRows(mock.NewRows([]string{"id", "display_name", "username", "image", "bio", "email"}).FromCSVString("1,foo,t,t,t,t")) 79 | 80 | n, err := handler.Build(event) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | 85 | notification := ¬ifications.PushNotification{ 86 | Category: notifications.WELCOME_ROOM, 87 | Alert: notifications.Alert{ 88 | Key: "welcome_room_notification", 89 | Arguments: []string{displayName}, 90 | }, 91 | Arguments: map[string]interface{}{"id": room, "from": user}, 92 | CollapseID: room, 93 | } 94 | 95 | if !reflect.DeepEqual(n, notification) { 96 | t.Fatalf("expected %v actual %v", notification, n) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/notifications/pb/utils.go: -------------------------------------------------------------------------------- 1 | package pb 2 | 3 | import "github.com/soapboxsocial/soapbox/pkg/notifications" 4 | 5 | func (x *Notification) ToPushNotification() *notifications.PushNotification { 6 | res := ¬ifications.PushNotification{ 7 | Category: notifications.NotificationCategory(x.Category), 8 | CollapseID: x.CollapseId, 9 | Arguments: make(map[string]interface{}), 10 | Alert: notifications.Alert{ 11 | Body: x.Alert.Body, 12 | Key: x.Alert.LocalizationKey, 13 | Arguments: x.Alert.LocalizationArguments, 14 | }, 15 | } 16 | 17 | for key, arg := range x.Arguments { 18 | switch arg.Value.(type) { 19 | case *Notification_Argument_Int: 20 | res.Arguments[key] = arg.GetInt() 21 | case *Notification_Argument_Str: 22 | res.Arguments[key] = arg.GetStr() 23 | } 24 | } 25 | 26 | return res 27 | } 28 | -------------------------------------------------------------------------------- /pkg/notifications/settings.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | type Settings struct { 10 | db *sql.DB 11 | } 12 | 13 | func NewSettings(db *sql.DB) *Settings { 14 | return &Settings{db: db} 15 | } 16 | 17 | func (s *Settings) GetSettingsFor(user int) (*Target, error) { 18 | stmt, err := s.db.Prepare("SELECT user_id, room_frequency, follows, welcome_rooms FROM notification_settings WHERE user_id = $1;") 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | row := stmt.QueryRow(user) 24 | 25 | target := &Target{} 26 | err = row.Scan(&target.ID, &target.RoomFrequency, &target.Follows, &target.WelcomeRooms) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return target, nil 32 | } 33 | 34 | func (s *Settings) GetSettingsFollowingUser(user int) ([]Target, error) { 35 | return s.getSettings( 36 | "SELECT notification_settings.user_id, notification_settings.room_frequency, notification_settings.follows, notification_settings.welcome_rooms FROM notification_settings INNER JOIN followers ON (notification_settings.user_id = followers.follower) WHERE followers.user_id = $1", 37 | user, 38 | ) 39 | } 40 | 41 | // @TODO THIS NEEDS FIXING 42 | func (s *Settings) GetSettingsForRecentlyActiveUsers() ([]Target, error) { 43 | return s.getSettings( 44 | `SELECT notification_settings.user_id, notification_settings.room_frequency, notification_settings.follows, notification_settings.welcome_rooms FROM notification_settings 45 | INNER JOIN ( 46 | SELECT user_id 47 | FROM ( 48 | SELECT user_id FROM current_rooms 49 | UNION 50 | SELECT user_id FROM user_active_times WHERE last_active > (NOW() - INTERVAL '15 MINUTE') 51 | ) foo GROUP BY user_id) active 52 | ON notification_settings.user_id = active.user_id 53 | INNER JOIN user_room_time ON user_room_time.user_id = active.user_id WHERE seconds >= 36000 AND visibility = 'public' AND active.user_id NOT IN (1, 75, 962);`, 54 | ) 55 | } 56 | 57 | func (s *Settings) GetSettingsForUsers(users []int64) ([]Target, error) { 58 | query := fmt.Sprintf( 59 | "SELECT notification_settings.user_id, notification_settings.room_frequency, notification_settings.follows, notification_settings.welcome_rooms FROM notification_settings WHERE user_id IN (%s)", 60 | join(users, ","), 61 | ) 62 | 63 | return s.getSettings(query) 64 | } 65 | 66 | func (s *Settings) UpdateSettingsFor(user int, frequency Frequency, follows, welcomeRooms bool) error { 67 | stmt, err := s.db.Prepare("UPDATE notification_settings SET room_frequency = $1, follows = $2, welcome_rooms = $3 WHERE user_id = $4;") 68 | if err != nil { 69 | return err 70 | } 71 | 72 | _, err = stmt.Exec(frequency, follows, welcomeRooms, user) 73 | return err 74 | } 75 | 76 | func join(elems []int64, sep string) string { 77 | switch len(elems) { 78 | case 0: 79 | return "" 80 | case 1: 81 | return strconv.FormatInt(elems[0], 10) 82 | } 83 | 84 | res := strconv.FormatInt(elems[0], 10) 85 | for _, s := range elems[1:] { 86 | res += sep 87 | res += strconv.FormatInt(s, 10) 88 | } 89 | 90 | return res 91 | } 92 | 93 | func (s *Settings) getSettings(query string, args ...interface{}) ([]Target, error) { 94 | stmt, err := s.db.Prepare(query) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | rows, err := stmt.Query(args...) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | targets := make([]Target, 0) 105 | for rows.Next() { 106 | target := Target{} 107 | err = rows.Scan(&target.ID, &target.RoomFrequency, &target.Follows, &target.WelcomeRooms) 108 | if err != nil { 109 | continue 110 | } 111 | 112 | targets = append(targets, target) 113 | } 114 | 115 | return targets, nil 116 | } 117 | -------------------------------------------------------------------------------- /pkg/notifications/storage.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/go-redis/redis/v8" 9 | ) 10 | 11 | const placeholder = "val" 12 | 13 | type Storage struct { 14 | rdb *redis.Client 15 | } 16 | 17 | func NewStorage(rdb *redis.Client) *Storage { 18 | return &Storage{ 19 | rdb: rdb, 20 | } 21 | } 22 | 23 | func (s *Storage) Store(user int, notification *Notification) error { 24 | data, err := json.Marshal(notification) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | key := notificationListKey(user) 30 | err = s.rdb.LPush(s.rdb.Context(), key, string(data)).Err() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | s.setHasNewNotifications(user) 36 | 37 | return s.rdb.LTrim(s.rdb.Context(), key, 0, 39).Err() 38 | } 39 | 40 | func (s *Storage) GetNotifications(user int) ([]*Notification, error) { 41 | data, err := s.rdb.LRange(s.rdb.Context(), notificationListKey(user), 0, -1).Result() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | notifications := make([]*Notification, 0) 47 | for _, item := range data { 48 | n := &Notification{} 49 | err := json.Unmarshal([]byte(item), n) 50 | if err != nil { 51 | log.Printf("failed to unmarshal notification err: %v\n", err) 52 | continue 53 | } 54 | 55 | notifications = append(notifications, n) 56 | } 57 | 58 | return notifications, nil 59 | } 60 | 61 | func (s *Storage) MarkNotificationsViewed(user int) { 62 | s.rdb.Del(s.rdb.Context(), hasNewNotificationsKey(user)) 63 | } 64 | 65 | func (s *Storage) HasNewNotifications(user int) bool { 66 | res, err := s.rdb.Get(s.rdb.Context(), hasNewNotificationsKey(user)).Result() 67 | if err != nil { 68 | return false 69 | } 70 | 71 | return res == placeholder 72 | } 73 | 74 | func (s *Storage) setHasNewNotifications(user int) { 75 | s.rdb.Set(s.rdb.Context(), hasNewNotificationsKey(user), placeholder, 0) 76 | } 77 | 78 | func hasNewNotificationsKey(user int) string { 79 | return fmt.Sprintf("has_new_notifications_%d", user) 80 | } 81 | 82 | func notificationListKey(user int) string { 83 | return fmt.Sprintf("notifications_%d", user) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/notifications/types.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import "github.com/soapboxsocial/soapbox/pkg/analytics" 4 | 5 | type NotificationCategory string 6 | 7 | const ( 8 | NEW_ROOM NotificationCategory = "NEW_ROOM" 9 | NEW_FOLLOWER NotificationCategory = "NEW_FOLLOWER" 10 | ROOM_INVITE NotificationCategory = "ROOM_INVITE" 11 | ROOM_JOINED NotificationCategory = "ROOM_JOINED" 12 | WELCOME_ROOM NotificationCategory = "WELCOME_ROOM" 13 | REENGAGEMENT NotificationCategory = "REENGAGEMENT" 14 | TEST NotificationCategory = "TEST" 15 | INFO NotificationCategory = "INFO" 16 | FOLLOW_RECOMMENDATIONS NotificationCategory = "FOLLOW_RECOMMENDATIONS" 17 | ) 18 | 19 | type Frequency int 20 | 21 | const ( 22 | FrequencyOff = iota 23 | Infrequent 24 | Normal 25 | Frequent 26 | ) 27 | 28 | // Target represents the notification target and their settings. 29 | type Target struct { 30 | ID int `json:"-"` 31 | RoomFrequency Frequency `json:"room_frequency"` 32 | Follows bool `json:"follows"` 33 | WelcomeRooms bool `json:"welcome_rooms"` 34 | } 35 | 36 | type Alert struct { 37 | Body string `json:"body,omitempty"` 38 | Key string `json:"loc-key"` 39 | Arguments []string `json:"loc-args"` 40 | } 41 | 42 | // PushNotification is JSON encoded and sent to the APNS service. 43 | type PushNotification struct { 44 | Category NotificationCategory `json:"category"` 45 | Alert Alert `json:"alert"` 46 | Arguments map[string]interface{} `json:"arguments"` 47 | UUID string `json:"uuid"` 48 | CollapseID string `json:"-"` 49 | } 50 | 51 | // Notification is stored in redis for the notification endpoint. 52 | type Notification struct { 53 | Timestamp int64 `json:"timestamp"` 54 | From int `json:"from"` 55 | Category NotificationCategory `json:"category"` 56 | Arguments map[string]interface{} `json:"arguments"` 57 | } 58 | 59 | func NewRoomNotification(id, creator string, creatorID int) *PushNotification { 60 | return &PushNotification{ 61 | Category: NEW_ROOM, 62 | Alert: Alert{ 63 | Key: "new_room_notification", 64 | Arguments: []string{creator}, 65 | }, 66 | Arguments: map[string]interface{}{"id": id, "creator": creatorID}, 67 | CollapseID: id, 68 | } 69 | } 70 | 71 | func NewRoomNotificationWithName(id, creator, name string) *PushNotification { 72 | return &PushNotification{ 73 | Category: NEW_ROOM, 74 | Alert: Alert{ 75 | Key: "new_room_with_name_notification", 76 | Arguments: []string{creator, name}, 77 | }, 78 | Arguments: map[string]interface{}{"id": id}, 79 | CollapseID: id, 80 | } 81 | } 82 | 83 | func NewRoomInviteNotification(id, from string) *PushNotification { 84 | return &PushNotification{ 85 | Category: ROOM_INVITE, 86 | Alert: Alert{ 87 | Key: "room_invite_notification", 88 | Arguments: []string{from}, 89 | }, 90 | Arguments: map[string]interface{}{"id": id}, 91 | CollapseID: id, 92 | } 93 | } 94 | 95 | func NewRoomInviteNotificationWithName(id, from, room string) *PushNotification { 96 | return &PushNotification{ 97 | Category: ROOM_INVITE, 98 | Alert: Alert{ 99 | Key: "room_invite_with_name_notification", 100 | Arguments: []string{from, room}, 101 | }, 102 | Arguments: map[string]interface{}{"id": id}, 103 | CollapseID: id, 104 | } 105 | } 106 | 107 | func (n PushNotification) AnalyticsNotification() analytics.Notification { 108 | return analytics.Notification{ 109 | ID: n.UUID, 110 | Category: string(n.Category), 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /pkg/notifications/worker/dispatcher.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import "github.com/soapboxsocial/soapbox/pkg/notifications" 4 | 5 | type Dispatcher struct { 6 | jobs chan Job 7 | pool chan chan Job 8 | 9 | maxWorkers int 10 | 11 | config *Config 12 | } 13 | 14 | func NewDispatcher(maxWorkers int, config *Config) *Dispatcher { 15 | return &Dispatcher{ 16 | jobs: make(chan Job), 17 | pool: make(chan chan Job), 18 | maxWorkers: maxWorkers, 19 | config: config, 20 | } 21 | } 22 | 23 | func (d *Dispatcher) Run() { 24 | // starting n number of workers 25 | for i := 0; i < d.maxWorkers; i++ { 26 | worker := NewWorker(d.pool, d.config) 27 | worker.Start() 28 | } 29 | 30 | go d.dispatch() 31 | } 32 | 33 | func (d *Dispatcher) dispatch() { 34 | for { 35 | select { 36 | case job := <-d.jobs: 37 | // a job request has been received 38 | go func(job Job) { 39 | // try to obtain a worker job channel that is available. 40 | // this will block until a worker is idle 41 | jobChannel := <-d.pool 42 | 43 | // dispatch the job to the worker job channel 44 | jobChannel <- job 45 | }(job) 46 | } 47 | } 48 | } 49 | 50 | func (d *Dispatcher) Dispatch(origin int, targets []notifications.Target, notification *notifications.PushNotification) { 51 | go func() { 52 | d.jobs <- Job{Origin: origin, Targets: targets, Notification: notification} 53 | }() 54 | } 55 | -------------------------------------------------------------------------------- /pkg/notifications/worker/types.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import "github.com/soapboxsocial/soapbox/pkg/notifications" 4 | 5 | type Job struct { 6 | Origin int 7 | Targets []notifications.Target 8 | Notification *notifications.PushNotification 9 | } 10 | -------------------------------------------------------------------------------- /pkg/pubsub/queue.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | 7 | "github.com/go-redis/redis/v8" 8 | ) 9 | 10 | type Topic string 11 | 12 | const ( 13 | RoomTopic Topic = "room" 14 | UserTopic Topic = "user" 15 | StoryTopic Topic = "story" 16 | ) 17 | 18 | type Queue struct { 19 | buffer chan *Event 20 | 21 | rdb *redis.Client 22 | } 23 | 24 | // NewQueue creates a new redis pubsub Queue. 25 | func NewQueue(rdb *redis.Client) *Queue { 26 | return &Queue{ 27 | buffer: make(chan *Event, 100), 28 | rdb: rdb, 29 | } 30 | } 31 | 32 | // Publish an Event on a specific topic. 33 | func (q *Queue) Publish(topic Topic, event Event) error { 34 | data, err := json.Marshal(event) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | q.rdb.Publish(q.rdb.Context(), string(topic), data) 40 | return nil 41 | } 42 | 43 | // Subscribe to a list of topics. 44 | func (q *Queue) Subscribe(topics ...Topic) <-chan *Event { 45 | t := make([]string, 0) 46 | for _, topic := range topics { 47 | t = append(t, string(topic)) 48 | } 49 | 50 | pubsub := q.rdb.Subscribe(q.rdb.Context(), t...) 51 | go q.read(pubsub) 52 | 53 | return q.buffer 54 | } 55 | 56 | func (q *Queue) read(pubsub *redis.PubSub) { 57 | c := pubsub.Channel() 58 | for msg := range c { 59 | event := &Event{} 60 | err := json.Unmarshal([]byte(msg.Payload), event) 61 | if err != nil { 62 | log.Printf("failed to decode event err: %v event: %s", err, msg.Payload) 63 | continue 64 | } 65 | 66 | q.buffer <- event 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/recommendations/follows/backend.go: -------------------------------------------------------------------------------- 1 | package follows 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/users/types" 8 | ) 9 | 10 | type Backend struct { 11 | db *sql.DB 12 | } 13 | 14 | func NewBackend(db *sql.DB) *Backend { 15 | return &Backend{db: db} 16 | } 17 | 18 | func (b *Backend) RecommendationsFor(user int) ([]types.User, error) { 19 | stmt, err := b.db.Prepare("SELECT id, display_name, username, image FROM users WHERE id IN (SELECT recommendation FROM follow_recommendations WHERE user_id = $1);") 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | rows, err := stmt.Query(user) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | result := make([]types.User, 0) 30 | 31 | for rows.Next() { 32 | user := types.User{} 33 | 34 | err := rows.Scan(&user.ID, &user.DisplayName, &user.Username, &user.Image) 35 | if err != nil { 36 | return nil, err // @todo 37 | } 38 | 39 | result = append(result, user) 40 | } 41 | 42 | return result, nil 43 | } 44 | 45 | func (b *Backend) AddRecommendationsFor(user int, recommendations []int) error { 46 | tx, err := b.db.Begin() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | stmt, err := tx.Prepare("INSERT INTO follow_recommendations (user_id, recommendation) VALUES ($1, $2)") 52 | if err != nil { 53 | _ = tx.Rollback() 54 | return err 55 | } 56 | 57 | for _, id := range recommendations { 58 | _, err = stmt.Exec(user, id) 59 | if err != nil { 60 | _ = tx.Rollback() 61 | return err 62 | } 63 | } 64 | 65 | err = tx.Commit() 66 | if err != nil { 67 | _ = tx.Rollback() 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func (b *Backend) LastUpdatedFor(user int) (*time.Time, error) { 75 | stmt, err := b.db.Prepare("SELECT last_recommended FROM last_follow_recommended WHERE user_id = $1;") 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | timestamp := &time.Time{} 81 | err = stmt.QueryRow(user).Scan(timestamp) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return timestamp, nil 87 | } 88 | 89 | func (b *Backend) SetLastUpdatedFor(user int) error { 90 | stmt, err := b.db.Prepare("INSERT INTO last_follow_recommended (user_id, last_recommended) VALUES ($1, $2);") 91 | if err != nil { 92 | return err 93 | } 94 | 95 | _, err = stmt.Exec(user, time.Now()) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/recommendations/follows/providers/twitter.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/http" 8 | "sync" 9 | 10 | "github.com/dghubble/go-twitter/twitter" 11 | "github.com/dghubble/oauth1" 12 | 13 | "github.com/soapboxsocial/soapbox/pkg/linkedaccounts" 14 | ) 15 | 16 | type Twitter struct { 17 | oauth *oauth1.Config 18 | 19 | transport *http.Client 20 | 21 | backend *linkedaccounts.Backend 22 | } 23 | 24 | func NewTwitter(oauth *oauth1.Config, backend *linkedaccounts.Backend, transport *http.Client) *Twitter { 25 | return &Twitter{ 26 | oauth: oauth, 27 | backend: backend, 28 | transport: transport, 29 | } 30 | } 31 | 32 | func (t *Twitter) FindUsersToFollowFor(user int) ([]int, error) { 33 | client, err := t.getClientForUser(user) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | accounts, err := t.backend.GetAllTwitterProfilesForUsersNotRecommendedToAndNotFollowedBy(user) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | parts := chunkAccounts(accounts, 100) 44 | 45 | friendships := make([]twitter.FriendshipResponse, 0) 46 | 47 | var wg sync.WaitGroup 48 | for _, part := range parts { 49 | wg.Add(1) 50 | 51 | go func(accounts []linkedaccounts.LinkedAccount) { 52 | defer wg.Done() 53 | 54 | resp, err := request(client, accounts) 55 | if err != nil { 56 | log.Printf("request err: %s\n", err) 57 | return 58 | } 59 | 60 | friendships = append(friendships, resp...) 61 | }(part) 62 | } 63 | 64 | wg.Wait() 65 | 66 | ids := make([]int, 0) 67 | for _, account := range accounts { 68 | if isFollowedOnTwitter(account, friendships) { 69 | ids = append(ids, account.ID) 70 | } 71 | } 72 | 73 | return ids, nil 74 | } 75 | 76 | func (t *Twitter) getClientForUser(id int) (*twitter.Client, error) { 77 | account, err := t.backend.GetTwitterProfileFor(id) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | access := oauth1.NewToken(account.Token, account.Secret) 83 | 84 | ctx := oauth1.NoContext 85 | if t.transport != nil { 86 | ctx = context.WithValue(ctx, oauth1.HTTPClient, t.transport) 87 | } 88 | 89 | httpClient := t.oauth.Client(ctx, access) 90 | return twitter.NewClient(httpClient), nil 91 | } 92 | 93 | func isFollowedOnTwitter(account linkedaccounts.LinkedAccount, friendships []twitter.FriendshipResponse) bool { 94 | for _, friendship := range friendships { 95 | if friendship.ID != account.ProfileID { 96 | continue 97 | } 98 | 99 | for _, conn := range friendship.Connections { 100 | if conn == "following" { 101 | return true 102 | } 103 | } 104 | } 105 | 106 | return false 107 | } 108 | 109 | func request(client *twitter.Client, accounts []linkedaccounts.LinkedAccount) ([]twitter.FriendshipResponse, error) { 110 | ids := make([]int64, 0) 111 | for _, account := range accounts { 112 | ids = append(ids, account.ProfileID) 113 | } 114 | 115 | res, _, err := client.Friendships.Lookup(&twitter.FriendshipLookupParams{UserID: ids}) // @Todo 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | if res == nil { 121 | return nil, errors.New("no response") 122 | } 123 | 124 | return *res, nil 125 | } 126 | 127 | func chunkAccounts(accounts []linkedaccounts.LinkedAccount, chunkSize int) [][]linkedaccounts.LinkedAccount { 128 | var divided [][]linkedaccounts.LinkedAccount 129 | 130 | for i := 0; i < len(accounts); i += chunkSize { 131 | end := i + chunkSize 132 | 133 | if end > len(accounts) { 134 | end = len(accounts) 135 | } 136 | 137 | divided = append(divided, accounts[i:end]) 138 | } 139 | 140 | return divided 141 | } 142 | -------------------------------------------------------------------------------- /pkg/recommendations/follows/providers/types.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | // FollowRecommendationsProvider is a generic interface for returning a set of recommended users to follow 4 | // for any specific user based on the algorithm. 5 | type FollowRecommendationsProvider interface { 6 | FindUsersToFollowFor(user int) ([]int, error) 7 | } 8 | -------------------------------------------------------------------------------- /pkg/recommendations/follows/worker/dispatcher.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import "sync" 4 | 5 | type Dispatcher struct { 6 | jobs chan *Job 7 | pool chan chan *Job 8 | quit chan bool 9 | 10 | maxWorkers int 11 | 12 | config *Config 13 | wg *sync.WaitGroup 14 | } 15 | 16 | func NewDispatcher(maxWorkers int, config *Config) *Dispatcher { 17 | return &Dispatcher{ 18 | jobs: make(chan *Job), 19 | pool: make(chan chan *Job), 20 | quit: make(chan bool), 21 | maxWorkers: maxWorkers, 22 | config: config, 23 | wg: &sync.WaitGroup{}, 24 | } 25 | } 26 | 27 | func (d *Dispatcher) Run() { 28 | // starting n number of workers 29 | for i := 0; i < d.maxWorkers; i++ { 30 | worker := NewWorker(d.pool, d.config) 31 | worker.Start() 32 | } 33 | 34 | go d.dispatch() 35 | } 36 | 37 | func (d *Dispatcher) Stop() { 38 | go func() { 39 | d.quit <- true 40 | }() 41 | } 42 | 43 | func (d *Dispatcher) dispatch() { 44 | for { 45 | select { 46 | case job := <-d.jobs: 47 | // a job request has been received 48 | go func(job *Job) { 49 | // try to obtain a worker job channel that is available. 50 | // this will block until a worker is idle 51 | jobChannel := <-d.pool 52 | 53 | // dispatch the job to the worker job channel 54 | jobChannel <- job 55 | }(job) 56 | case <-d.quit: 57 | // We have been asked to stop. 58 | return 59 | } 60 | } 61 | } 62 | 63 | func (d *Dispatcher) Wait() { 64 | d.wg.Wait() 65 | } 66 | 67 | func (d *Dispatcher) Dispatch(user int) { 68 | d.wg.Add(1) 69 | 70 | go func() { 71 | d.jobs <- &Job{UserID: user, WaitGroup: d.wg} 72 | }() 73 | } 74 | -------------------------------------------------------------------------------- /pkg/recommendations/follows/worker/dispatcher_test.go: -------------------------------------------------------------------------------- 1 | package worker_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/DATA-DOG/go-sqlmock" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows" 11 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows/worker" 12 | ) 13 | 14 | func TestDispatcher(t *testing.T) { 15 | db, mock, err := sqlmock.New() 16 | if err != nil { 17 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 18 | } 19 | defer db.Close() 20 | 21 | dispatcher := worker.NewDispatcher(1, &worker.Config{Recommendations: follows.NewBackend(db)}) 22 | 23 | dispatcher.Run() 24 | 25 | for i := 0; i < 30; i++ { 26 | expect := mock.ExpectPrepare("^SELECT (.+)").WillReturnError(errors.New("poop")) 27 | if i == 29 { 28 | expect.WillDelayFor(1 * time.Second) 29 | } 30 | 31 | dispatcher.Dispatch(i) 32 | } 33 | 34 | dispatcher.Wait() 35 | dispatcher.Stop() 36 | 37 | err = mock.ExpectationsWereMet() 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pkg/recommendations/follows/worker/types.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import "sync" 4 | 5 | type Job struct { 6 | UserID int 7 | WaitGroup *sync.WaitGroup 8 | } 9 | -------------------------------------------------------------------------------- /pkg/recommendations/follows/worker/worker.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 8 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows" 9 | "github.com/soapboxsocial/soapbox/pkg/recommendations/follows/providers" 10 | ) 11 | 12 | type Config struct { 13 | Twitter *providers.Twitter 14 | Recommendations *follows.Backend 15 | Queue *pubsub.Queue 16 | } 17 | 18 | type Worker struct { 19 | jobs chan *Job 20 | workers chan<- chan *Job 21 | quit chan bool 22 | 23 | config *Config 24 | } 25 | 26 | func NewWorker(pool chan<- chan *Job, config *Config) *Worker { 27 | w := &Worker{ 28 | workers: pool, 29 | jobs: make(chan *Job), 30 | quit: make(chan bool), 31 | config: config, 32 | } 33 | 34 | return w 35 | } 36 | 37 | func (w *Worker) Start() { 38 | go func() { 39 | for { 40 | w.workers <- w.jobs 41 | 42 | select { 43 | case job := <-w.jobs: 44 | // Receive a work request. 45 | w.handle(job) 46 | case <-w.quit: 47 | // We have been asked to stop. 48 | return 49 | } 50 | } 51 | }() 52 | } 53 | 54 | func (w *Worker) Stop() { 55 | go func() { 56 | w.quit <- true 57 | }() 58 | } 59 | 60 | func (w *Worker) handle(job *Job) { 61 | defer job.WaitGroup.Done() 62 | 63 | id := job.UserID 64 | 65 | last, err := w.config.Recommendations.LastUpdatedFor(id) 66 | if err != nil { 67 | log.Printf("backend.LastUpdatedFor err: %s", err) 68 | return 69 | } 70 | 71 | if time.Since(*last) < 14*(24*time.Hour) { 72 | log.Println("week not passed") 73 | return 74 | } 75 | 76 | users, err := w.config.Twitter.FindUsersToFollowFor(id) 77 | if err != nil { 78 | log.Printf("twitter.FindUsersToFollowFor err: %s", err) 79 | return 80 | } 81 | 82 | if len(users) == 0 { 83 | return 84 | } 85 | 86 | err = w.config.Recommendations.AddRecommendationsFor(id, users) 87 | if err != nil { 88 | log.Printf("backend.AddRecommendationsFor err: %s", err) 89 | return 90 | } 91 | 92 | err = w.config.Queue.Publish(pubsub.UserTopic, pubsub.NewFollowRecommendationsEvent(id)) 93 | if err != nil { 94 | log.Printf("w.queue.Publish err: %s", err) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /pkg/redis/redis.go: -------------------------------------------------------------------------------- 1 | // Package redis contains helper functions for working with redis. 2 | package redis 3 | 4 | import ( 5 | "crypto/tls" 6 | "fmt" 7 | 8 | "github.com/go-redis/redis/v8" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/conf" 11 | ) 12 | 13 | // NewRedis returns a new redis instance created using the config 14 | func NewRedis(config conf.RedisConf) *redis.Client { 15 | 16 | opts := &redis.Options{ 17 | Addr: fmt.Sprintf("%s:%d", config.Host, config.Port), 18 | Password: config.Password, 19 | DB: config.Database, 20 | } 21 | 22 | if !config.DisableTLS { 23 | opts.TLSConfig = &tls.Config{} 24 | } 25 | 26 | return redis.NewClient(opts) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/redis/redis_test.go: -------------------------------------------------------------------------------- 1 | package redis_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/alicebob/miniredis" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/conf" 10 | "github.com/soapboxsocial/soapbox/pkg/redis" 11 | ) 12 | 13 | func TestNewRedis(t *testing.T) { 14 | mr, err := miniredis.Run() 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | port, err := strconv.Atoi(mr.Port()) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | rdb := redis.NewRedis(conf.RedisConf{ 25 | Port: port, 26 | Host: mr.Host(), 27 | DisableTLS: true, 28 | }) 29 | 30 | val, err := rdb.Ping(rdb.Context()).Result() 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | if val != "PONG" { 36 | t.Fatalf("unexpected val %s", val) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/redis/timeoutstore.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-redis/redis/v8" 7 | ) 8 | 9 | const timeout = "timeout" 10 | 11 | type TimeoutStore struct { 12 | rdb *redis.Client 13 | } 14 | 15 | func NewTimeoutStore(rdb *redis.Client) *TimeoutStore { 16 | return &TimeoutStore{rdb: rdb} 17 | } 18 | 19 | func (t *TimeoutStore) SetTimeout(key string, expiration time.Duration) error { 20 | return t.rdb.Set(t.rdb.Context(), key, timeout, expiration).Err() 21 | } 22 | 23 | func (t *TimeoutStore) IsOnTimeout(key string) bool { 24 | val, err := t.rdb.Get(t.rdb.Context(), key).Result() 25 | if err != nil { 26 | return false 27 | } 28 | 29 | return val == timeout 30 | } 31 | -------------------------------------------------------------------------------- /pkg/redis/timeoutstore_test.go: -------------------------------------------------------------------------------- 1 | package redis_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/alicebob/miniredis" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/conf" 11 | "github.com/soapboxsocial/soapbox/pkg/redis" 12 | ) 13 | 14 | func TestTimeoutStore(t *testing.T) { 15 | mr, err := miniredis.Run() 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | port, err := strconv.Atoi(mr.Port()) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | rdb := redis.NewRedis(conf.RedisConf{ 26 | Port: port, 27 | Host: mr.Host(), 28 | DisableTLS: true, 29 | }) 30 | 31 | ts := redis.NewTimeoutStore(rdb) 32 | 33 | key := "foo" 34 | 35 | if ts.IsOnTimeout(key) { 36 | t.Fatal("should not be on timeout") 37 | } 38 | 39 | err = ts.SetTimeout(key, 5*time.Minute) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | if !ts.IsOnTimeout(key) { 45 | t.Fatal("key is on timeout") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/rooms/auth.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/soapboxsocial/soapbox/pkg/blocks" 7 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 8 | ) 9 | 10 | type Auth struct { 11 | rooms *Repository 12 | blocked *blocks.Backend 13 | } 14 | 15 | func NewAuth(rooms *Repository, blocked *blocks.Backend) *Auth { 16 | return &Auth{ 17 | rooms: rooms, 18 | blocked: blocked, 19 | } 20 | } 21 | 22 | // CanJoin returns whether a user can join a specific room. 23 | func (a *Auth) CanJoin(room string, user int) bool { 24 | r, err := a.rooms.Get(room) 25 | if err != nil { 26 | return false 27 | } 28 | 29 | if !a.canJoin(r, user) { 30 | return false 31 | } 32 | 33 | return !a.containsBlockers(r, user) 34 | } 35 | 36 | // FilterWhoCanJoin checks for a set of users who can join a room. 37 | func (a *Auth) FilterWhoCanJoin(room string, users []int64) []int64 { 38 | r, err := a.rooms.Get(room) 39 | if err != nil { 40 | return []int64{} 41 | } 42 | 43 | res := make([]int64, 0) 44 | 45 | for _, user := range users { 46 | if !a.canJoin(r, int(user)) { 47 | continue 48 | } 49 | 50 | if a.containsBlockers(r, int(user)) { 51 | continue 52 | } 53 | 54 | res = append(res, user) 55 | } 56 | 57 | return res 58 | } 59 | 60 | func (a *Auth) canJoin(room *Room, user int) bool { 61 | if room.IsKicked(user) { 62 | return false 63 | } 64 | 65 | if room.Visibility() == pb.Visibility_VISIBILITY_PRIVATE { 66 | return room.IsInvited(user) 67 | } 68 | 69 | return true 70 | } 71 | 72 | func (a *Auth) containsBlockers(room *Room, user int) bool { 73 | blockingUsers, err := a.blocked.GetUsersWhoBlocked(user) 74 | if err != nil { 75 | fmt.Printf("failed to get blocked users who blocked: %+v", err) 76 | } 77 | 78 | return room.ContainsUsers(blockingUsers) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/rooms/buffereddatachannel.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/pion/webrtc/v3" 7 | ) 8 | 9 | type BufferedDataChannel struct { 10 | channel *webrtc.DataChannel 11 | msgQueue chan []byte 12 | } 13 | 14 | func NewBufferedDataChannel() *BufferedDataChannel { 15 | return &BufferedDataChannel{ 16 | msgQueue: make(chan []byte, 500), 17 | } 18 | } 19 | 20 | func (b *BufferedDataChannel) Start(channel *webrtc.DataChannel) { 21 | b.channel = channel 22 | 23 | b.channel.OnOpen(func() { 24 | go b.handle() 25 | }) 26 | 27 | b.channel.OnClose(func() { 28 | close(b.msgQueue) 29 | }) 30 | } 31 | 32 | func (b *BufferedDataChannel) Write(data []byte) (err error) { 33 | defer func() { 34 | if r := recover(); r != nil { 35 | err = io.EOF 36 | } 37 | }() 38 | 39 | b.msgQueue <- data 40 | 41 | return nil 42 | } 43 | 44 | func (b *BufferedDataChannel) handle() { 45 | for msg := range b.msgQueue { 46 | err := b.channel.Send(msg) 47 | if err != nil && (err.Error() == "Stream closed" || err == io.EOF) { 48 | close(b.msgQueue) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/rooms/currentroombackend.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | type CurrentRoomBackend struct { 8 | db *sql.DB 9 | } 10 | 11 | func NewCurrentRoomBackend(db *sql.DB) *CurrentRoomBackend { 12 | return &CurrentRoomBackend{ 13 | db: db, 14 | } 15 | } 16 | 17 | func (b *CurrentRoomBackend) GetCurrentRoomForUser(id int) (string, error) { 18 | stmt, err := b.db.Prepare("SELECT room FROM current_rooms WHERE user_id = $1;") 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | row := stmt.QueryRow(id) 24 | 25 | var room string 26 | err = row.Scan(&room) 27 | if err != nil { 28 | return "", err 29 | } 30 | 31 | return room, nil 32 | } 33 | 34 | func (b *CurrentRoomBackend) SetCurrentRoomForUser(user int, room string) error { 35 | stmt, err := b.db.Prepare("SELECT update_current_rooms($1, $2);") 36 | if err != nil { 37 | return err 38 | } 39 | 40 | _, err = stmt.Exec(user, room) 41 | return err 42 | } 43 | 44 | func (b *CurrentRoomBackend) RemoveCurrentRoomForUser(user int) error { 45 | stmt, err := b.db.Prepare("DELETE FROM current_rooms WHERE user_id = $1") 46 | if err != nil { 47 | return err 48 | } 49 | 50 | _, err = stmt.Exec(user) 51 | return err 52 | } 53 | -------------------------------------------------------------------------------- /pkg/rooms/currentroombackend_test.go: -------------------------------------------------------------------------------- 1 | package rooms_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/DATA-DOG/go-sqlmock" 7 | 8 | "github.com/soapboxsocial/soapbox/pkg/rooms" 9 | ) 10 | 11 | func TestCurrentRoomBackend_GetCurrentRoomForUser(t *testing.T) { 12 | db, mock, err := sqlmock.New() 13 | if err != nil { 14 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 15 | } 16 | defer db.Close() 17 | 18 | backend := rooms.NewCurrentRoomBackend(db) 19 | 20 | user := 10 21 | room := "foo" 22 | 23 | mock. 24 | ExpectPrepare("SELECT room"). 25 | ExpectQuery(). 26 | WillReturnRows(sqlmock.NewRows([]string{"room"}).AddRow(room)) 27 | 28 | val, err := backend.GetCurrentRoomForUser(user) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | if val != room { 34 | t.Fatalf("expected: %s actual: %s", room, val) 35 | } 36 | } 37 | 38 | func TestCurrentRoomBackend_SetCurrentRoomForUser(t *testing.T) { 39 | db, mock, err := sqlmock.New() 40 | if err != nil { 41 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 42 | } 43 | defer db.Close() 44 | 45 | backend := rooms.NewCurrentRoomBackend(db) 46 | 47 | user := 10 48 | room := "foo" 49 | 50 | mock. 51 | ExpectPrepare("SELECT"). 52 | ExpectExec(). 53 | WithArgs(user, room). 54 | WillReturnResult(sqlmock.NewResult(1, 1)) 55 | 56 | err = backend.SetCurrentRoomForUser(user, room) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | } 61 | 62 | func TestCurrentRoomBackend_RemoveCurrentRoomForUser(t *testing.T) { 63 | db, mock, err := sqlmock.New() 64 | if err != nil { 65 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 66 | } 67 | defer db.Close() 68 | 69 | backend := rooms.NewCurrentRoomBackend(db) 70 | 71 | user := 10 72 | 73 | mock. 74 | ExpectPrepare("DELETE FROM current_rooms"). 75 | ExpectExec(). 76 | WithArgs(user). 77 | WillReturnResult(sqlmock.NewResult(1, 1)) 78 | 79 | err = backend.RemoveCurrentRoomForUser(user) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pkg/rooms/endpoint.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 10 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 11 | ) 12 | 13 | type RoomState struct { 14 | ID string `json:"id"` 15 | Name string `json:"name"` 16 | Visibility string `json:"visibility"` 17 | Members []RoomMember `json:"members"` 18 | } 19 | 20 | type RoomMember struct { 21 | ID int `json:"id"` 22 | DisplayName string `json:"display_name"` 23 | Image string `json:"image"` 24 | } 25 | 26 | type Endpoint struct { 27 | repository *Repository 28 | server *Server 29 | auth *Auth 30 | } 31 | 32 | func NewEndpoint(repository *Repository, server *Server, auth *Auth) *Endpoint { 33 | return &Endpoint{ 34 | repository: repository, 35 | server: server, 36 | auth: auth, 37 | } 38 | } 39 | 40 | func (e *Endpoint) Router() *mux.Router { 41 | r := mux.NewRouter() 42 | 43 | r.HandleFunc("/v1/rooms", e.rooms).Methods("GET") 44 | r.HandleFunc("/v1/rooms/{id}", e.room).Methods("GET") 45 | r.HandleFunc("/v1/signal", e.server.Signal).Methods("GET") 46 | 47 | return r 48 | } 49 | 50 | func (e *Endpoint) rooms(w http.ResponseWriter, r *http.Request) { 51 | rooms := make([]RoomState, 0) 52 | 53 | userID, ok := httputil.GetUserIDFromContext(r.Context()) 54 | if !ok { 55 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "invalid id") 56 | return 57 | } 58 | 59 | e.repository.Map(func(room *Room) { 60 | if room.ConnectionState() == closed { 61 | return 62 | } 63 | 64 | if !e.auth.CanJoin(room.id, userID) { 65 | return 66 | } 67 | 68 | rooms = append(rooms, roomToRoomState(room)) 69 | }) 70 | 71 | err := httputil.JsonEncode(w, rooms) 72 | if err != nil { 73 | log.Printf("rooms error: %v\n", err) 74 | } 75 | } 76 | 77 | func (e *Endpoint) room(w http.ResponseWriter, r *http.Request) { 78 | params := mux.Vars(r) 79 | 80 | userID, ok := httputil.GetUserIDFromContext(r.Context()) 81 | if !ok { 82 | httputil.JsonError(w, http.StatusInternalServerError, httputil.ErrorCodeInvalidRequestBody, "invalid id") 83 | return 84 | } 85 | 86 | room, err := e.repository.Get(params["id"]) 87 | if err != nil { 88 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 89 | return 90 | } 91 | 92 | if !e.auth.CanJoin(room.id, userID) { 93 | httputil.JsonError(w, http.StatusNotFound, httputil.ErrorCodeNotFound, "not found") 94 | return 95 | } 96 | 97 | err = httputil.JsonEncode(w, roomToRoomState(room)) 98 | if err != nil { 99 | log.Printf("room error: %v\n", err) 100 | } 101 | } 102 | 103 | // roomToRoomState turns a room into a RoomState object. 104 | func roomToRoomState(room *Room) RoomState { 105 | members := make([]RoomMember, 0) 106 | room.MapMembers(func(member *Member) { 107 | members = append(members, RoomMember{ 108 | ID: member.id, 109 | DisplayName: member.name, 110 | Image: member.image, 111 | }) 112 | }) 113 | 114 | visibility := "public" 115 | if room.visibility == pb.Visibility_VISIBILITY_PRIVATE { 116 | visibility = "private" 117 | } 118 | 119 | return RoomState{ 120 | ID: room.id, 121 | Name: room.name, 122 | Visibility: visibility, 123 | Members: members, 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/rooms/grpc/service.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/rooms" 8 | "github.com/soapboxsocial/soapbox/pkg/rooms/internal" 9 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 10 | ) 11 | 12 | type Service struct { 13 | pb.UnsafeRoomServiceServer 14 | 15 | repository *rooms.Repository 16 | ws *rooms.WelcomeStore 17 | auth *rooms.Auth 18 | } 19 | 20 | func NewService(repository *rooms.Repository, ws *rooms.WelcomeStore, auth *rooms.Auth) *Service { 21 | return &Service{ 22 | repository: repository, 23 | ws: ws, 24 | auth: auth, 25 | } 26 | } 27 | 28 | func (s *Service) GetRoom(_ context.Context, request *pb.GetRoomRequest) (*pb.GetRoomResponse, error) { 29 | r, err := s.repository.Get(request.Id) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &pb.GetRoomResponse{State: r.ToProto()}, nil 35 | } 36 | 37 | func (s *Service) ListRooms(context.Context, *pb.ListRoomsRequest) (*pb.ListRoomsResponse, error) { 38 | result := make([]*pb.RoomState, 0) 39 | 40 | s.repository.Map(func(room *rooms.Room) { 41 | result = append(result, room.ToProto()) 42 | }) 43 | 44 | return &pb.ListRoomsResponse{Rooms: result}, nil 45 | } 46 | 47 | func (s *Service) CloseRoom(_ context.Context, request *pb.CloseRoomRequest) (*pb.CloseRoomResponse, error) { 48 | room, err := s.repository.Get(request.Id) 49 | if err != nil { 50 | return nil, err // @TODO PROBABLY FALSE RESPONSE 51 | } 52 | 53 | s.repository.Remove(request.Id) 54 | 55 | room.MapMembers(func(member *rooms.Member) { 56 | _ = member.Close() 57 | }) 58 | 59 | return &pb.CloseRoomResponse{Success: true}, nil 60 | } 61 | 62 | func (s *Service) RegisterWelcomeRoom(_ context.Context, request *pb.RegisterWelcomeRoomRequest) (*pb.RegisterWelcomeRoomResponse, error) { 63 | id := internal.GenerateRoomID() 64 | 65 | if request == nil || request.UserId == 0 { 66 | return nil, errors.New("no message") 67 | } 68 | 69 | err := s.ws.StoreWelcomeRoomID(id, request.UserId) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | return &pb.RegisterWelcomeRoomResponse{Id: id}, nil 75 | } 76 | 77 | func (s *Service) FilterUsersThatCanJoin(_ context.Context, request *pb.FilterUsersThatCanJoinRequest) (*pb.FilterUsersThatCanJoinResponse, error) { 78 | if request == nil || request.Room == "" { 79 | return nil, errors.New("no message") 80 | } 81 | 82 | if request.Ids == nil || len(request.Ids) == 0 { 83 | return &pb.FilterUsersThatCanJoinResponse{Ids: []int64{}}, nil 84 | } 85 | 86 | users := s.auth.FilterWhoCanJoin(request.Room, request.Ids) 87 | return &pb.FilterUsersThatCanJoinResponse{Ids: users}, nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/rooms/grpc/service_test.go: -------------------------------------------------------------------------------- 1 | package grpc_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/alicebob/miniredis" 8 | "github.com/go-redis/redis/v8" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/rooms" 11 | "github.com/soapboxsocial/soapbox/pkg/rooms/grpc" 12 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 13 | ) 14 | 15 | func TestService_RegisterWelcomeRoom(t *testing.T) { 16 | mr, err := miniredis.Run() 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | rdb := redis.NewClient(&redis.Options{ 22 | Addr: mr.Addr(), 23 | }) 24 | 25 | repository := rooms.NewRepository() 26 | ws := rooms.NewWelcomeStore(rdb) 27 | 28 | service := grpc.NewService(repository, ws, nil) 29 | 30 | userID := int64(1) 31 | resp, err := service.RegisterWelcomeRoom(context.Background(), &pb.RegisterWelcomeRoomRequest{UserId: userID}) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | id, err := ws.GetUserIDForWelcomeRoom(resp.Id) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | if int64(id) != userID { 42 | t.Errorf("%d does not equal %d", id, userID) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/rooms/internal/utils.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/segmentio/ksuid" 7 | ) 8 | 9 | // TrimRoomNameToLimit ensures the room name does not exceed 30 characters. 10 | func TrimRoomNameToLimit(input string) string { 11 | name := strings.TrimSpace(input) 12 | if len([]rune(name)) > 30 { 13 | return string([]rune(name)[:30]) 14 | } 15 | 16 | return name 17 | } 18 | 19 | // GenerateRoomID generates a random alpha-numeric room ID. 20 | func GenerateRoomID() string { 21 | return strings.ToLower(ksuid.New().String()) 22 | } 23 | -------------------------------------------------------------------------------- /pkg/rooms/internal/utils_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/soapboxsocial/soapbox/pkg/rooms/internal" 7 | ) 8 | 9 | func TestTrimRoomNameToLimit(t *testing.T) { 10 | var tests = []struct { 11 | in string 12 | out string 13 | }{ 14 | { 15 | "Test ", 16 | "Test", 17 | }, 18 | { 19 | " Test ", 20 | "Test", 21 | }, 22 | { 23 | "1037619662938620156055447100655", 24 | "103761966293862015605544710065", 25 | }, 26 | } 27 | 28 | for _, tt := range tests { 29 | t.Run(tt.in, func(t *testing.T) { 30 | 31 | result := internal.TrimRoomNameToLimit(tt.in) 32 | if tt.out != result { 33 | t.Fatalf("expected: %s did not match actual: %s", tt.out, result) 34 | } 35 | 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/rooms/repository.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | type Repository struct { 9 | mux sync.RWMutex 10 | 11 | rooms map[string]*Room 12 | } 13 | 14 | func NewRepository() *Repository { 15 | return &Repository{ 16 | mux: sync.RWMutex{}, 17 | rooms: make(map[string]*Room), 18 | } 19 | } 20 | 21 | func (r *Repository) Set(room *Room) { 22 | r.mux.Lock() 23 | defer r.mux.Unlock() 24 | 25 | r.rooms[room.id] = room 26 | } 27 | 28 | func (r *Repository) Get(id string) (*Room, error) { 29 | r.mux.RLock() 30 | defer r.mux.RUnlock() 31 | 32 | room, ok := r.rooms[id] 33 | if !ok { 34 | return nil, errors.New("room not found") 35 | } 36 | 37 | return room, nil 38 | } 39 | 40 | func (r *Repository) Remove(id string) { 41 | r.mux.Lock() 42 | defer r.mux.Unlock() 43 | 44 | delete(r.rooms, id) 45 | } 46 | 47 | func (r *Repository) Map(f func(room *Room)) { 48 | r.mux.RLock() 49 | defer r.mux.RUnlock() 50 | 51 | for _, r := range r.rooms { 52 | f(r) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/rooms/signal/transport.go: -------------------------------------------------------------------------------- 1 | package signal 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gorilla/websocket" 7 | "google.golang.org/protobuf/proto" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/rooms/pb" 10 | ) 11 | 12 | // Transport is used for signalling communication 13 | type Transport interface { 14 | // ReadMsg returns the message sent by a client 15 | ReadMsg() (*pb.SignalRequest, error) 16 | 17 | // Write sends a message to the client 18 | Write(msg *pb.SignalReply) error 19 | 20 | // Close closes the signalling transport 21 | Close() error 22 | } 23 | 24 | type WebSocketTransport struct { 25 | mux sync.Mutex 26 | 27 | conn *websocket.Conn 28 | } 29 | 30 | func NewWebSocketTransport(conn *websocket.Conn) *WebSocketTransport { 31 | return &WebSocketTransport{ 32 | conn: conn, 33 | } 34 | } 35 | 36 | func (w *WebSocketTransport) ReadMsg() (*pb.SignalRequest, error) { 37 | _, data, err := w.conn.ReadMessage() 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | msg := &pb.SignalRequest{} 43 | err = proto.Unmarshal(data, msg) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return msg, nil 49 | } 50 | 51 | func (w *WebSocketTransport) Write(msg *pb.SignalReply) error { 52 | data, err := proto.Marshal(msg) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | w.mux.Lock() 58 | defer w.mux.Unlock() 59 | 60 | return w.conn.WriteMessage(websocket.BinaryMessage, data) 61 | } 62 | 63 | func (w *WebSocketTransport) WriteError(in string, err pb.SignalReply_Error) error { 64 | return w.Write(&pb.SignalReply{ 65 | Id: in, 66 | Payload: &pb.SignalReply_Error_{ 67 | Error: err, 68 | }, 69 | }) 70 | } 71 | 72 | func (w *WebSocketTransport) Close() error { 73 | return w.conn.Close() 74 | } 75 | -------------------------------------------------------------------------------- /pkg/rooms/welcomestore.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | ) 10 | 11 | const timeout = 30 * time.Minute 12 | 13 | type WelcomeStore struct { 14 | rdb *redis.Client 15 | } 16 | 17 | func NewWelcomeStore(rdb *redis.Client) *WelcomeStore { 18 | return &WelcomeStore{rdb: rdb} 19 | } 20 | 21 | func (w *WelcomeStore) StoreWelcomeRoomID(room string, user int64) error { 22 | _, err := w.rdb.Set(w.rdb.Context(), key(room), strconv.Itoa(int(user)), timeout).Result() 23 | return err 24 | } 25 | 26 | func (w *WelcomeStore) GetUserIDForWelcomeRoom(room string) (int, error) { 27 | str, err := w.rdb.Get(w.rdb.Context(), key(room)).Result() 28 | if err != nil { 29 | return 0, err 30 | } 31 | 32 | return strconv.Atoi(str) 33 | } 34 | 35 | func (w *WelcomeStore) DeleteWelcomeRoom(room string) error { 36 | _, err := w.rdb.Del(w.rdb.Context(), key(room)).Result() 37 | return err 38 | } 39 | 40 | func key(room string) string { 41 | return fmt.Sprintf("welcome_room_%s", room) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/rooms/welcomestore_test.go: -------------------------------------------------------------------------------- 1 | package rooms_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/alicebob/miniredis" 7 | "github.com/go-redis/redis/v8" 8 | 9 | "github.com/soapboxsocial/soapbox/pkg/rooms" 10 | ) 11 | 12 | func TestWelcomeStore(t *testing.T) { 13 | mr, err := miniredis.Run() 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | rdb := redis.NewClient(&redis.Options{ 19 | Addr: mr.Addr(), 20 | }) 21 | 22 | ws := rooms.NewWelcomeStore(rdb) 23 | 24 | room := "roomID-123" 25 | user := int64(1) 26 | 27 | err = ws.StoreWelcomeRoomID(room, user) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | resp, err := ws.GetUserIDForWelcomeRoom(room) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | if int64(resp) != user { 38 | t.Errorf("%d did not match %d", resp, user) 39 | } 40 | 41 | err = ws.DeleteWelcomeRoom(room) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | _, err = ws.GetUserIDForWelcomeRoom(room) 47 | if err == nil { 48 | t.Error("unexpected return value") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/search/endpoint.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/elastic/go-elasticsearch/v7" 15 | "github.com/elastic/go-elasticsearch/v7/esapi" 16 | "github.com/gorilla/mux" 17 | 18 | httputil "github.com/soapboxsocial/soapbox/pkg/http" 19 | "github.com/soapboxsocial/soapbox/pkg/search/internal" 20 | "github.com/soapboxsocial/soapbox/pkg/users/types" 21 | ) 22 | 23 | // @TODO maybe do a type? 24 | const ( 25 | usersIndex = "users" 26 | ) 27 | 28 | type Response struct { 29 | Users []*types.User `json:"users,omitempty"` 30 | } 31 | 32 | type Endpoint struct { 33 | client *elasticsearch.Client 34 | } 35 | 36 | func NewEndpoint(client *elasticsearch.Client) *Endpoint { 37 | return &Endpoint{client: client} 38 | } 39 | 40 | func (e *Endpoint) Router() *mux.Router { 41 | r := mux.NewRouter() 42 | 43 | r.Path("/").Methods("GET").HandlerFunc(e.Search) 44 | 45 | return r 46 | } 47 | 48 | func (e *Endpoint) Search(w http.ResponseWriter, r *http.Request) { 49 | indexes, err := indexTypes(r.URL.Query()) 50 | if err != nil { 51 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "") 52 | return 53 | } 54 | 55 | query := r.URL.Query().Get("query") 56 | if query == "" { 57 | httputil.JsonError(w, http.StatusBadRequest, httputil.ErrorCodeInvalidRequestBody, "") 58 | return 59 | } 60 | 61 | limit := httputil.GetInt(r.URL.Query(), "limit", 10) 62 | offset := httputil.GetInt(r.URL.Query(), "offset", 0) 63 | 64 | response := Response{} 65 | 66 | var wg sync.WaitGroup 67 | for _, index := range indexes { 68 | if index == "users" { 69 | wg.Add(1) 70 | 71 | go func() { 72 | list, err := e.searchUsers(query, limit, offset) 73 | if err != nil { 74 | log.Printf("failed to search users: %s\n", err.Error()) 75 | wg.Done() 76 | return 77 | } 78 | 79 | response.Users = list 80 | wg.Done() 81 | }() 82 | } 83 | } 84 | 85 | wg.Wait() 86 | 87 | err = httputil.JsonEncode(w, response) 88 | if err != nil { 89 | log.Printf("failed to write search response: %s\n", err.Error()) 90 | } 91 | } 92 | 93 | func indexTypes(query url.Values) ([]string, error) { 94 | indexes := query.Get("type") 95 | if indexes == "" { 96 | return nil, errors.New("no indexes") 97 | } 98 | 99 | vals := strings.Split(indexes, ",") 100 | for _, val := range vals { 101 | if val != usersIndex { 102 | return nil, fmt.Errorf("invalid index %s", vals) 103 | } 104 | } 105 | 106 | return vals, nil 107 | } 108 | 109 | func (e *Endpoint) searchUsers(query string, limit, offset int) ([]*types.User, error) { 110 | res, err := e.search("users", query, limit, offset) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | data := make([]*types.User, 0) 116 | for _, hit := range res.Hits.Hits { 117 | user := &types.User{} 118 | err := json.Unmarshal(hit.Source, user) 119 | if err != nil { 120 | continue 121 | } 122 | 123 | data = append(data, user) 124 | } 125 | 126 | return data, nil 127 | } 128 | 129 | func (e *Endpoint) search(index, query string, limit, offset int) (*internal.Result, error) { 130 | config := []func(*esapi.SearchRequest){ 131 | e.client.Search.WithContext(context.Background()), 132 | e.client.Search.WithIndex(index), 133 | e.client.Search.WithQuery(query), 134 | e.client.Search.WithSize(limit), 135 | e.client.Search.WithFrom(offset), 136 | e.client.Search.WithTrackTotalHits(true), 137 | } 138 | 139 | if index == "users" { 140 | if query == "*" { 141 | config = append(config, e.client.Search.WithSort("room_time:desc", "followers:desc")) 142 | } else { 143 | config = append(config, e.client.Search.WithSort("_score:desc")) 144 | } 145 | } 146 | 147 | res, err := e.client.Search(config...) 148 | if err != nil { 149 | return nil, err 150 | } 151 | 152 | defer res.Body.Close() 153 | 154 | result := &internal.Result{} 155 | err = json.NewDecoder(res.Body).Decode(result) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | return result, nil 161 | } 162 | -------------------------------------------------------------------------------- /pkg/search/internal/types.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "encoding/json" 4 | 5 | type Hits struct { 6 | Total map[string]interface{} `json:"total"` 7 | MaxScore float64 `json:"max_score"` 8 | Hits []struct { 9 | Index string `json:"_index"` 10 | Type string `json:"_type"` 11 | ID string `json:"_id"` 12 | Score float64 `json:"_score"` 13 | Source json.RawMessage `json:"_source"` 14 | } `json:"hits"` 15 | } 16 | 17 | type Result struct { 18 | Took int `json:"took"` 19 | TimedOut bool `json:"timed_out"` 20 | Shards map[string]int `json:"_shards"` 21 | Hits Hits `json:"hits"` 22 | } 23 | -------------------------------------------------------------------------------- /pkg/sessions/manager.go: -------------------------------------------------------------------------------- 1 | package sessions 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/go-redis/redis/v8" 8 | ) 9 | 10 | type SessionManager struct { 11 | db *redis.Client 12 | } 13 | 14 | func NewSessionManager(db *redis.Client) *SessionManager { 15 | return &SessionManager{db: db} 16 | } 17 | 18 | func (sm *SessionManager) NewSession(id string, user int, expiration time.Duration) error { 19 | return sm.db.Set(sm.db.Context(), generateSessionKey(id), user, expiration).Err() 20 | } 21 | 22 | func (sm *SessionManager) GetUserIDForSession(id string) (int, error) { 23 | str, err := sm.db.Get(sm.db.Context(), generateSessionKey(id)).Result() 24 | if err != nil { 25 | return 0, err 26 | } 27 | 28 | return strconv.Atoi(str) 29 | } 30 | 31 | func (sm *SessionManager) CloseSession(id string) error { 32 | _, err := sm.db.Del(sm.db.Context(), generateSessionKey(id)).Result() 33 | return err 34 | } 35 | 36 | func generateSessionKey(id string) string { 37 | return "session_" + id 38 | } 39 | -------------------------------------------------------------------------------- /pkg/sql/sql.go: -------------------------------------------------------------------------------- 1 | // Package sql provides helper functions for sql database code. 2 | package sql 3 | 4 | import ( 5 | "database/sql" 6 | "fmt" 7 | 8 | _ "github.com/lib/pq" 9 | 10 | "github.com/soapboxsocial/soapbox/pkg/conf" 11 | ) 12 | 13 | // Open opens a Postgres database from the passed config. 14 | func Open(config conf.PostgresConf) (*sql.DB, error) { 15 | return sql.Open( 16 | "postgres", 17 | fmt.Sprintf( 18 | "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", 19 | config.Host, config.Port, config.User, config.Password, config.Database, config.SSL, 20 | ), 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /pkg/stories/filebackend.go: -------------------------------------------------------------------------------- 1 | package stories 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type FileBackend struct { 10 | path string 11 | } 12 | 13 | func NewFileBackend(path string) *FileBackend { 14 | return &FileBackend{path: path} 15 | } 16 | 17 | // Store places a story in the designated directory 18 | func (fb *FileBackend) Store(bytes []byte) (string, error) { 19 | file, err := ioutil.TempFile(fb.path, "*.aac") 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | defer file.Close() 25 | 26 | _, err = file.Write(bytes) 27 | if err != nil { 28 | return "", nil 29 | } 30 | 31 | return filepath.Base(file.Name()), nil 32 | } 33 | 34 | // Remove permanently deletes a story from the file system 35 | func (fb *FileBackend) Remove(name string) error { 36 | return os.Remove(fb.path + "/" + name) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/stories/types.go: -------------------------------------------------------------------------------- 1 | package stories 2 | 3 | import "github.com/soapboxsocial/soapbox/pkg/users/types" 4 | 5 | // Reaction represents the reactions users submitted to the story. 6 | type Reaction struct { 7 | Emoji string `json:"emoji"` 8 | Count int `json:"count"` 9 | } 10 | 11 | // Story represents a user story. 12 | type Story struct { 13 | ID string `json:"id"` 14 | ExpiresAt int64 `json:"expires_at"` 15 | DeviceTimestamp int64 `json:"device_timestamp"` 16 | Reactions []Reaction `json:"reactions"` 17 | } 18 | 19 | // StoryFeed represents all of a users stories. 20 | type StoryFeed struct { 21 | User types.User `json:"user"` 22 | Stories []Story `json:"stories"` 23 | } 24 | -------------------------------------------------------------------------------- /pkg/stories/utils.go: -------------------------------------------------------------------------------- 1 | package stories 2 | 3 | import "strings" 4 | 5 | // IDFromName trims the file extension from the file and returns only the ID. 6 | func IDFromName(name string) string { 7 | return strings.TrimSuffix(name, ".aac") 8 | } 9 | -------------------------------------------------------------------------------- /pkg/tracking/backends/userroomlogbackend.go: -------------------------------------------------------------------------------- 1 | package backends 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | type UserRoomLogBackend struct { 9 | db *sql.DB 10 | } 11 | 12 | func NewUserRoomLogBackend(db *sql.DB) *UserRoomLogBackend { 13 | return &UserRoomLogBackend{db: db} 14 | } 15 | 16 | func (b *UserRoomLogBackend) Store(user int, room, visibility string, joined, left time.Time) error { 17 | stmt, err := b.db.Prepare("INSERT INTO user_room_logs (user_id, room, join_time, left_time, visibility) VALUES ($1, $2, $3, $4, $5);") 18 | if err != nil { 19 | return err 20 | } 21 | 22 | _, err = stmt.Exec(user, room, joined, left, visibility) 23 | return err 24 | } 25 | -------------------------------------------------------------------------------- /pkg/tracking/event.go: -------------------------------------------------------------------------------- 1 | package tracking 2 | 3 | // Event represents an event for tracking 4 | type Event struct { 5 | ID string 6 | Name string 7 | Properties map[string]interface{} 8 | } 9 | -------------------------------------------------------------------------------- /pkg/tracking/trackers/mixpaneltracker_test.go: -------------------------------------------------------------------------------- 1 | package trackers_test 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/dukex/mixpanel" 10 | 11 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 12 | "github.com/soapboxsocial/soapbox/pkg/tracking/trackers" 13 | ) 14 | 15 | func TestMixpanelTracker_Track(t *testing.T) { 16 | client := mixpanel.NewMock() 17 | tracker := trackers.NewMixpanelTracker(client) 18 | 19 | id := 123 20 | event, err := getRawEvent(pubsub.NewUserEvent(id, "foo")) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | err = tracker.Track(event) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | people := client.People[strconv.Itoa(id)] 31 | 32 | if len(people.Events) != 1 { 33 | t.Fatal("event not logged") 34 | } 35 | 36 | if !reflect.DeepEqual(people.Events[0].Properties, map[string]interface{}{"user_id": 123.0, "username": "foo"}) { 37 | t.Fatal("did not store properties.") 38 | } 39 | } 40 | 41 | func TestMixpanelTracker_CanTrack(t *testing.T) { 42 | tests := []pubsub.EventType{ 43 | pubsub.EventTypeNewRoom, 44 | pubsub.EventTypeRoomJoin, 45 | pubsub.EventTypeNewFollower, 46 | pubsub.EventTypeRoomLeft, 47 | pubsub.EventTypeNewUser, 48 | pubsub.EventTypeNewStory, 49 | pubsub.EventTypeStoryReaction, 50 | pubsub.EventTypeUserHeartbeat, 51 | pubsub.EventTypeRoomLinkShare, 52 | pubsub.EventTypeRoomOpenMini, 53 | pubsub.EventTypeDeleteUser, 54 | } 55 | 56 | client := mixpanel.NewMock() 57 | tracker := trackers.NewMixpanelTracker(client) 58 | 59 | for _, tt := range tests { 60 | t.Run(strconv.Itoa(int(tt)), func(t *testing.T) { 61 | 62 | if !tracker.CanTrack(&pubsub.Event{Type: tt}) { 63 | t.Fatalf("cannot track: %d", tt) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func getRawEvent(event pubsub.Event) (*pubsub.Event, error) { 70 | data, err := json.Marshal(event) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | evt := &pubsub.Event{} 76 | err = json.Unmarshal(data, evt) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return evt, nil 82 | } 83 | -------------------------------------------------------------------------------- /pkg/tracking/trackers/recentlyactivetracker.go: -------------------------------------------------------------------------------- 1 | package trackers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/soapboxsocial/soapbox/pkg/activeusers" 9 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 10 | "github.com/soapboxsocial/soapbox/pkg/redis" 11 | ) 12 | 13 | type RecentlyActiveTracker struct { 14 | backend *activeusers.Backend 15 | timeout *redis.TimeoutStore 16 | } 17 | 18 | func NewRecentlyActiveTracker(backend *activeusers.Backend, timeout *redis.TimeoutStore) *RecentlyActiveTracker { 19 | return &RecentlyActiveTracker{ 20 | backend: backend, 21 | timeout: timeout, 22 | } 23 | } 24 | 25 | func (r *RecentlyActiveTracker) CanTrack(event *pubsub.Event) bool { 26 | return event.Type == pubsub.EventTypeUserHeartbeat || 27 | event.Type == pubsub.EventTypeRoomLeft 28 | } 29 | 30 | func (r *RecentlyActiveTracker) Track(event *pubsub.Event) error { 31 | id, err := event.GetInt("id") 32 | if err != nil { 33 | return err 34 | } 35 | 36 | timeoutkey := fmt.Sprintf("recently_active_timout_%d", id) 37 | if r.timeout.IsOnTimeout(timeoutkey) { 38 | return nil 39 | } 40 | 41 | err = r.backend.SetLastActiveTime(id, time.Now()) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | err = r.timeout.SetTimeout(timeoutkey, 3*time.Minute) 47 | if err != nil { 48 | log.Printf("failed to set recently active timeout err: %s", err) 49 | } 50 | 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/tracking/trackers/recentlyactivetracker_test.go: -------------------------------------------------------------------------------- 1 | package trackers_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/DATA-DOG/go-sqlmock" 8 | "github.com/alicebob/miniredis" 9 | "github.com/go-redis/redis/v8" 10 | 11 | redisutil "github.com/soapboxsocial/soapbox/pkg/redis" 12 | 13 | "github.com/soapboxsocial/soapbox/pkg/activeusers" 14 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 15 | "github.com/soapboxsocial/soapbox/pkg/tracking/trackers" 16 | ) 17 | 18 | func TestRecentlyActiveTracker_CanTrack(t *testing.T) { 19 | tests := []pubsub.EventType{ 20 | pubsub.EventTypeUserHeartbeat, 21 | pubsub.EventTypeRoomLeft, 22 | } 23 | 24 | db, _, err := sqlmock.New() 25 | if err != nil { 26 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 27 | } 28 | defer db.Close() 29 | 30 | mr, err := miniredis.Run() 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | rdb := redis.NewClient(&redis.Options{ 36 | Addr: mr.Addr(), 37 | }) 38 | 39 | tracker := trackers.NewRecentlyActiveTracker(activeusers.NewBackend(db), redisutil.NewTimeoutStore(rdb)) 40 | 41 | for _, tt := range tests { 42 | t.Run(strconv.Itoa(int(tt)), func(t *testing.T) { 43 | 44 | if !tracker.CanTrack(&pubsub.Event{Type: tt}) { 45 | t.Fatalf("cannot track: %d", tt) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func TestRecentlyActiveTracker_Track(t *testing.T) { 52 | db, mock, err := sqlmock.New() 53 | if err != nil { 54 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 55 | } 56 | defer db.Close() 57 | 58 | mr, err := miniredis.Run() 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | rdb := redis.NewClient(&redis.Options{ 64 | Addr: mr.Addr(), 65 | }) 66 | 67 | tracker := trackers.NewRecentlyActiveTracker(activeusers.NewBackend(db), redisutil.NewTimeoutStore(rdb)) 68 | 69 | id := 10 70 | event, err := getRawEvent(pubsub.NewUserHeartbeatEvent(id)) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | mock. 76 | ExpectPrepare("SELECT update_user_active_times"). 77 | ExpectExec(). 78 | WillReturnResult(sqlmock.NewResult(1, 1)) 79 | 80 | err = tracker.Track(event) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /pkg/tracking/trackers/tracker.go: -------------------------------------------------------------------------------- 1 | package trackers 2 | 3 | import "github.com/soapboxsocial/soapbox/pkg/pubsub" 4 | 5 | // Tracker is a interface for tracking Events 6 | type Tracker interface { 7 | 8 | // CanTrack returns whether tracker tracks a specific event. 9 | CanTrack(event *pubsub.Event) bool 10 | 11 | // Track tracks an event, returns an error if failed. 12 | Track(event *pubsub.Event) error 13 | } 14 | -------------------------------------------------------------------------------- /pkg/tracking/trackers/userroomlogtracker.go: -------------------------------------------------------------------------------- 1 | package trackers 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 8 | "github.com/soapboxsocial/soapbox/pkg/tracking/backends" 9 | ) 10 | 11 | // UserRoomLogTracker tracks a users join / leave room events. 12 | type UserRoomLogTracker struct { 13 | backend *backends.UserRoomLogBackend 14 | queue *pubsub.Queue 15 | } 16 | 17 | func NewUserRoomLogTracker(backend *backends.UserRoomLogBackend, queue *pubsub.Queue) *UserRoomLogTracker { 18 | return &UserRoomLogTracker{ 19 | backend: backend, 20 | queue: queue, 21 | } 22 | } 23 | 24 | func (r UserRoomLogTracker) CanTrack(event *pubsub.Event) bool { 25 | return event.Type == pubsub.EventTypeRoomLeft 26 | } 27 | 28 | func (r UserRoomLogTracker) Track(event *pubsub.Event) error { 29 | if event.Type != pubsub.EventTypeRoomLeft { 30 | return fmt.Errorf("invalid type for tracker: %d", event.Type) 31 | } 32 | 33 | user, err := event.GetInt("creator") 34 | if err != nil { 35 | return err 36 | } 37 | 38 | joined, err := getTime(event, "joined") 39 | if err != nil { 40 | return err 41 | } 42 | 43 | err = r.backend.Store(user, event.Params["id"].(string), event.Params["visibility"].(string), joined, time.Now()) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | _ = r.queue.Publish(pubsub.UserTopic, pubsub.NewUserUpdateEvent(user)) 49 | 50 | return nil 51 | } 52 | 53 | func getTime(event *pubsub.Event, field string) (time.Time, error) { 54 | value, err := event.GetInt(field) 55 | if err != nil { 56 | return time.Time{}, err 57 | } 58 | 59 | return time.Unix(int64(value), 0), nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/tracking/trackers/userroomlogtracker_test.go: -------------------------------------------------------------------------------- 1 | package trackers_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/DATA-DOG/go-sqlmock" 8 | "github.com/alicebob/miniredis" 9 | "github.com/go-redis/redis/v8" 10 | 11 | "github.com/soapboxsocial/soapbox/pkg/pubsub" 12 | "github.com/soapboxsocial/soapbox/pkg/tracking/backends" 13 | "github.com/soapboxsocial/soapbox/pkg/tracking/trackers" 14 | ) 15 | 16 | func TestUserRoomLogTracker_Track(t *testing.T) { 17 | db, mock, err := sqlmock.New() 18 | if err != nil { 19 | t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) 20 | } 21 | defer db.Close() 22 | 23 | mr, err := miniredis.Run() 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | rdb := redis.NewClient(&redis.Options{ 29 | Addr: mr.Addr(), 30 | }) 31 | 32 | tracker := trackers.NewUserRoomLogTracker( 33 | backends.NewUserRoomLogBackend(db), 34 | pubsub.NewQueue(rdb), 35 | ) 36 | 37 | event, err := getRawEvent(pubsub.NewRoomLeftEvent("123", 10, pubsub.Public, time.Now())) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | mock. 43 | ExpectPrepare("INSERT INTO"). 44 | ExpectExec(). 45 | WillReturnResult(sqlmock.NewResult(1, 1)) 46 | 47 | err = tracker.Track(event) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/users/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type User struct { 4 | ID int `json:"id"` 5 | DisplayName string `json:"display_name"` 6 | Username string `json:"username"` 7 | Image string `json:"image"` 8 | Bio string `json:"bio"` 9 | Email *string `json:"email,omitempty"` 10 | } 11 | -------------------------------------------------------------------------------- /vagrant/Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | provisioned = File.exists?(File.join(__dir__, 'provisioned')) 5 | 6 | Vagrant.configure(2) do |config| 7 | 8 | config.vm.box = 'bento/centos-7.7' 9 | config.vm.box_check_update = false 10 | config.vm.hostname = 'jukebox' 11 | config.vbguest.auto_update = false 12 | 13 | if provisioned 14 | config.vm.synced_folder '../', '/var/www', create: true, owner: 'nginx', group: 'nginx', mount_options: ['dmode=775', 'fmode=775'] 15 | else 16 | config.vm.synced_folder '../', '/var/www', create: true 17 | end 18 | 19 | config.vm.network :private_network, ip: '192.168.33.16' 20 | config.vm.network "forwarded_port", guest: 8080, host: 8080 21 | config.vm.network "forwarded_port", guest: 8081, host: 8081 22 | config.vm.network "forwarded_port", guest: 8082, host: 8082 23 | config.vm.network "forwarded_port", guest: 9200, host: 9200 24 | config.vm.network "forwarded_port", guest: 50051, host: 50051 25 | 26 | config.vm.provision :shell, path: 'bin/provision.sh', privileged: true 27 | config.vm.provision :shell, path: 'bin/boot.sh', privileged: true, run: :always 28 | 29 | config.vm.provider :virtualbox do |vb| 30 | vb.customize ['modifyvm', :id, '--natdnshostresolver1', 'on'] 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /vagrant/bin/boot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PATH=$PATH:/usr/local/bin 4 | 5 | setsebool httpd_can_network_connect on -P 6 | 7 | sudo service nginx start 8 | sudo service postgresql-9.6 start 9 | sudo service redis start 10 | sudo service elasticsearch start 11 | 12 | sudo systemctl start supervisord 13 | sudo systemctl enable supervisord 14 | -------------------------------------------------------------------------------- /vagrant/bin/provision.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sudo echo nameserver 8.8.8.8 >> /etc/resolv.conf 4 | sudo yum install -y epel-release 5 | 6 | sudo yum clean all 7 | 8 | sudo yum remove git* 9 | sudo yum -y install https://packages.endpoint.com/rhel/7/os/x86_64/endpoint-repo-1.7-1.x86_64.rpm 10 | sudo yum -y install git 11 | 12 | wget http://rpms.famillecollet.com/enterprise/remi-release-7.rpm 13 | sudo rpm -Uvh remi-release-7*.rpm 14 | 15 | sudo yum clean all 16 | 17 | sudo yum install -y nginx 18 | 19 | sudo yum install -y golang 20 | 21 | sudo yum install -y supervisor 22 | 23 | sudo yum install -y redis 24 | 25 | rm -rf /etc/supervisord.conf 26 | sudo ln -s /vagrant/conf/supervisord.conf /etc/supervisord.conf 27 | sudo mkdir -p /etc/supervisor/conf.d/ 28 | sudo ln -s /vagrant/conf/supervisord/soapbox.conf /etc/supervisor/conf.d/soapbox.conf 29 | sudo ln -s /vagrant/conf/supervisord/notifications.conf /etc/supervisor/conf.d/notifications.conf 30 | sudo ln -s /vagrant/conf/supervisord/indexer.conf /etc/supervisor/conf.d/indexer.conf 31 | sudo ln -s /vagrant/conf/supervisord/rooms.conf /etc/supervisor/conf.d/rooms.conf 32 | sudo ln -s /vagrant/conf/supervisord/metadata.conf /etc/supervisor/conf.d/metadata.conf 33 | 34 | echo 'export GOPATH="/home/vagrant/go"' >> ~/.bashrc 35 | echo 'export PATH="$PATH:${GOPATH//://bin:}/bin"' >> ~/.bashrc 36 | mkdir -p $GOPATH/{bin,pkg,src} 37 | 38 | source ~/.bashrc 39 | 40 | sudo rpm -Uvh https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm 41 | sudo yum install -y postgresql96-server postgresql96 42 | sudo /usr/pgsql-9.6/bin/postgresql96-setup initdb 43 | 44 | sudo systemctl start postgresql-9.6 45 | sudo systemctl enable postgresql-9.6 46 | 47 | sudo su - postgres -c "psql -a -w -f /var/www/db/database.sql" 48 | sudo su - postgres -c "psql -t voicely -a -w -f /var/www/db/tables.sql" 49 | 50 | rm /var/lib/pgsql/9.6/data/pg_hba.conf 51 | ln -s /vagrant/conf/pg_hba.conf /var/lib/pgsql/9.6/data/pg_hba.conf 52 | 53 | wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.8.1-x86_64.rpm 54 | wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.8.1-x86_64.rpm.sha512 55 | sudo rpm --install elasticsearch-7.8.1-x86_64.rpm 56 | 57 | sudo rm -rf /etc/nginx/nginx.conf 58 | sudo ln -s /vagrant/conf/nginx.conf /etc/nginx/nginx.conf 59 | 60 | mkdir -p $GOPATH/src/github.com/soapboxsocial/ 61 | sudo ln -s /var/www/ $GOPATH/src/github.com/soapboxsocial/soapbox 62 | 63 | mkdir -p /conf/services 64 | sudo cp -p /var/www/conf/services/* /conf/services 65 | sudo chown nginx:nginx -R /conf/services 66 | 67 | sudo ln -s $GOPATH/src/github.com/soapboxsocial/soapbox/conf/services/ /conf/services 68 | 69 | sudo chown nginx:nginx -R /cdn/images 70 | sudo chmod -R 0777 /cdn/images 71 | 72 | sudo mkdir -p /cdn/stories/ 73 | sudo chown nginx:nginx -R /cdn/stories 74 | sudo chmod -R 0777 /cdn/stories 75 | 76 | cd $GOPATH/src/github.com/soapboxsocial/soapbox && sudo go build -o /usr/local/bin/soapbox main.go 77 | cd $GOPATH/src/github.com/soapboxsocial/soapbox/cmd/indexer && sudo go build -o /usr/local/bin/indexer main.go 78 | cd $GOPATH/src/github.com/soapboxsocial/soapbox/cmd/rooms && sudo go build -o /usr/local/bin/rooms main.go 79 | cd $GOPATH/src/github.com/soapboxsocial/soapbox/cmd/stories && sudo go build -o /usr/local/bin/stories main.go 80 | 81 | crontab /vagrant/conf/crontab 82 | 83 | touch /vagrant/provisioned 84 | 85 | echo "Provisioning done! Run 'vagrant reload'" 86 | -------------------------------------------------------------------------------- /vagrant/conf/crontab: -------------------------------------------------------------------------------- 1 | 0 * * * * /usr/local/bin/stories -c /conf/services/stories.toml >> /var/log/stories.log 2>&1 2 | 0 12 * * * /usr/local/bin/indexer writer -c /conf/services/indexer.toml >> /var/log/indexer.log 2>&1 3 | 0 16 * * * /usr/local/bin/recommendations follows -c /conf/services/recommendations.toml >> /var/log/recommendations.log 2>&1 4 | 0 13 * * * /usr/local/bin/accounts twitter -c /conf/services/accounts.toml >> /var/log/accounts.log 2>&1 5 | -------------------------------------------------------------------------------- /vagrant/conf/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | error_log /var/log/nginx/error.log; 4 | pid /run/nginx.pid; 5 | 6 | # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | http { 13 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 14 | '$status $body_bytes_sent "$http_referer" ' 15 | '"$http_user_agent" "$http_x_forwarded_for"'; 16 | 17 | access_log /var/log/nginx/access.log main; 18 | 19 | sendfile on; 20 | tcp_nopush on; 21 | tcp_nodelay on; 22 | keepalive_timeout 65; 23 | types_hash_max_size 2048; 24 | 25 | default_type application/octet-stream; 26 | 27 | server { 28 | listen 80 default_server; 29 | listen [::]:80 default_server; 30 | server_name _; 31 | 32 | sendfile off; 33 | 34 | location /cdn { 35 | alias /cdn; 36 | try_files $uri =404; 37 | } 38 | 39 | location / { 40 | proxy_pass http://localhost:8080; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /vagrant/conf/supervisord.conf: -------------------------------------------------------------------------------- 1 | ; Sample supervisor config file. 2 | 3 | [unix_http_server] 4 | file=/var/run/supervisor/supervisor.sock ; (the path to the socket file) 5 | ;chmod=0700 ; sockef file mode (default 0700) 6 | ;chown=nobody:nogroup ; socket file uid:gid owner 7 | ;username=user ; (default is no username (open server)) 8 | ;password=123 ; (default is no password (open server)) 9 | 10 | ;[inet_http_server] ; inet (TCP) server disabled by default 11 | ;port=127.0.0.1:9001 ; (ip_address:port specifier, *:port for all iface) 12 | ;username=user ; (default is no username (open server)) 13 | ;password=123 ; (default is no password (open server)) 14 | 15 | [supervisord] 16 | logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log) 17 | logfile_maxbytes=50MB ; (max main logfile bytes b4 rotation;default 50MB) 18 | logfile_backups=10 ; (num of main logfile rotation backups;default 10) 19 | loglevel=info ; (log level;default info; others: debug,warn,trace) 20 | pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid) 21 | nodaemon=false ; (start in foreground if true;default false) 22 | minfds=1024 ; (min. avail startup file descriptors;default 1024) 23 | minprocs=200 ; (min. avail process descriptors;default 200) 24 | ;umask=022 ; (process file creation umask;default 022) 25 | ;user=chrism ; (default is current user, required if root) 26 | ;identifier=supervisor ; (supervisord identifier, default is 'supervisor') 27 | ;directory=/tmp ; (default is not to cd during start) 28 | ;nocleanup=true ; (don't clean up tempfiles at start;default false) 29 | ;childlogdir=/tmp ; ('AUTO' child log dir, default $TEMP) 30 | ;environment=KEY=value ; (key value pairs to add to environment) 31 | ;strip_ansi=false ; (strip ansi escape codes in logs; def. false) 32 | 33 | ; the below section must remain in the config file for RPC 34 | ; (supervisorctl/web interface) to work, additional interfaces may be 35 | ; added by defining them in separate rpcinterface: sections 36 | [rpcinterface:supervisor] 37 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 38 | 39 | [supervisorctl] 40 | serverurl=unix:///var/run/supervisor/supervisor.sock ; use a unix:// URL for a unix socket 41 | 42 | [include] 43 | files = /etc/supervisor/conf.d/*.conf 44 | -------------------------------------------------------------------------------- /vagrant/conf/supervisord/indexer.conf: -------------------------------------------------------------------------------- 1 | [program:indexer] 2 | directory=/usr/local/bin 3 | command=/usr/local/bin/indexer worker -c /conf/services/indexer.toml 4 | stderr_logfile=/var/log/indexer.log 5 | stdout_logfile=/var/log/indexer.log 6 | autorestart=true 7 | -------------------------------------------------------------------------------- /vagrant/conf/supervisord/metadata.conf: -------------------------------------------------------------------------------- 1 | [program:metadata] 2 | directory=/usr/local/bin 3 | command=/usr/local/bin/metadata -c /conf/services/metadata.toml 4 | stderr_logfile=/var/log/metadata.log 5 | stdout_logfile=/var/log/metadata.log 6 | autorestart=true 7 | -------------------------------------------------------------------------------- /vagrant/conf/supervisord/notifications.conf: -------------------------------------------------------------------------------- 1 | [program:notifications] 2 | directory=/usr/local/bin 3 | command=/usr/local/bin/notifications worker -c /conf/services/notifications.toml 4 | stderr_logfile=/var/log/notifications.log 5 | stdout_logfile=/var/log/notifications.log 6 | autorestart=true 7 | -------------------------------------------------------------------------------- /vagrant/conf/supervisord/rooms.conf: -------------------------------------------------------------------------------- 1 | [program:rooms] 2 | directory=/usr/local/bin 3 | command=/usr/local/bin/rooms server -c /conf/services/rooms.toml 4 | stderr_logfile=/var/log/rooms.log 5 | stdout_logfile=/var/log/rooms.log 6 | autorestart=true 7 | -------------------------------------------------------------------------------- /vagrant/conf/supervisord/soapbox.conf: -------------------------------------------------------------------------------- 1 | [program:soapbox] 2 | directory=/usr/local/bin 3 | command=/usr/local/bin/soapbox -c /conf/services/soapbox.toml 4 | stderr_logfile=/var/log/soapbox.log 5 | stdout_logfile=/var/log/soapbox.log 6 | user=nginx 7 | autorestart=true 8 | -------------------------------------------------------------------------------- /vagrant/conf/supervisord/tracking.conf: -------------------------------------------------------------------------------- 1 | [program:tracking] 2 | directory=/usr/local/bin 3 | command=/usr/local/bin/tracking -c /conf/services/tracking.toml 4 | stderr_logfile=/var/log/tracking.log 5 | stdout_logfile=/var/log/tracking.log 6 | autorestart=true 7 | --------------------------------------------------------------------------------