├── .env.sample ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .mergify.yml ├── .vscode └── launch.json ├── CaddyFile ├── Dockerfile ├── LICENSE ├── NOTICE ├── README.md ├── alpaca.go ├── app.yaml ├── apperr └── apperr.go ├── cmd ├── create_db.go ├── create_schema.go ├── create_superadmin.go ├── generate_secret.go ├── migrate.go ├── migrate_down.go ├── migrate_init.go ├── migrate_reset.go ├── migrate_set_version.go ├── migrate_up.go ├── migrate_version.go ├── root.go └── sync_assets.go ├── config ├── config.go ├── files │ ├── config.dev.yaml │ ├── config.prod.yaml │ ├── config.staging.yaml │ ├── config.test-e2e.yaml │ ├── config.test-int.yaml │ └── config.test.yaml ├── jwt.go ├── magic.go ├── mail.go ├── postgres.go ├── site.go └── twilio.go ├── countries.csv ├── docker-compose.yml ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── e2e ├── e2e.go ├── e2e_i_test.go ├── login_i_test.go ├── mobile_i_test.go └── signupemail_i_test.go ├── entry └── main.go ├── generate-ssl.sh ├── go.mod ├── go.sum ├── initdb.sh ├── k8-cluster.yaml ├── magic ├── magic.go └── magic_interface.go ├── mail ├── mail.go └── mail_interface.go ├── manager ├── createdb.go ├── createdbuser.go ├── createschema.go └── manager.go ├── middleware ├── jwt.go ├── jwt_test.go ├── middleware.go └── middleware_test.go ├── migration └── migration.go ├── mobile ├── mobile.go └── mobile_interface.go ├── mock ├── auth.go ├── magic.go ├── mail.go ├── middleware.go ├── mobile.go ├── mock.go ├── mockdb │ ├── account.go │ └── user.go ├── rbac.go └── secret.go ├── mockgopg ├── build_insert.go ├── build_query.go ├── formatter.go ├── mock.go ├── orm.go └── row_result.go ├── model ├── asset.go ├── auth.go ├── bank_account.go ├── coins_transactions.go ├── model.go ├── model_test.go ├── plaid.go ├── reward.go ├── role.go ├── user.go ├── user_reward.go ├── user_test.go └── verification.go ├── public ├── 2140998d-7f62-46f2-a9b2-e44350bd4807.svg ├── 39a26dc1-927a-4590-b103-b8068a013e7f.svg ├── 4f5baf1e-0e9b-4d85-b88a-d874dc4a3c42.svg ├── 57c36644-876b-437c-b913-3cdb58b18fd3.svg ├── 662a919f-1455-497c-90e7-f76248e6d3a6.svg ├── 69b15845-7c63-4586-b274-1cfdfe9df3d8.svg ├── 83e52ac1-bb18-4e9f-b68d-dda5a8af3ec0.svg ├── 8ccae427-5dd0-45b3-b5fe-7ba5e422c766.svg ├── AAPL.svg ├── AMZN.svg ├── FB.svg ├── GE.svg ├── GOOG.svg ├── GOOGL.svg ├── MA.svg ├── NFLX.svg ├── SNAP.svg ├── SPOT.svg ├── TME.svg ├── TSLA.svg ├── V.svg ├── assets │ └── img │ │ ├── failed_approval.png │ │ ├── failed_approval.svg │ │ ├── forgot_password.png │ │ ├── forgot_password.svg │ │ ├── header_logo.png │ │ ├── header_logo.svg │ │ ├── header_logo2.png │ │ ├── social_icons_fb.png │ │ ├── social_icons_instagram.png │ │ ├── social_icons_twitter.png │ │ ├── submitted_application.png │ │ ├── submitted_application.svg │ │ ├── verify_email.png │ │ ├── verify_email.svg │ │ ├── welcome_page.png │ │ └── welcome_page.svg ├── b0b6dd9d-8b9b-48a9-ba46-b9d54906e415.svg ├── bb2a26c0-4c77-4801-8afc-82e8142ac7b8.svg ├── f30d734c-2806-4d0d-b145-f9fade61432b.svg ├── f801f835-bfe6-4a9d-a6b1-ccbb84bfd75f.svg └── fc6a5dcd-4a70-4b8d-b64f-d83a6dae9ba4.svg ├── repository ├── account.go ├── account │ └── account.go ├── account_i_test.go ├── account_test.go ├── asset.go ├── assets │ └── assets.go ├── auth │ └── auth.go ├── plaid │ └── plaid.go ├── platform │ ├── query │ │ └── query.go │ └── structs │ │ └── structs.go ├── rbac.go ├── rbac_i_test.go ├── role.go ├── transfer │ └── transfer.go ├── user.go ├── user │ └── user.go ├── user_i_test.go └── user_test.go ├── request ├── account.go ├── auth.go ├── bank_account.go ├── request.go ├── signup.go └── user.go ├── route ├── custom_route.go └── route.go ├── secret ├── cryptorandom.go ├── secret.go └── secret_interface.go ├── server └── server.go ├── service ├── account.go ├── account_test.go ├── assets.go ├── auth.go ├── auth_test.go ├── plaid.go ├── transfers.go ├── user.go └── user_test.go ├── templates ├── terms_conditions.html └── terms_conditions_old.html ├── test.sh ├── testhelper └── testhelper.go └── uscities.csv /.env.sample: -------------------------------------------------------------------------------- 1 | # change to localhost when running on local machine and not docker 2 | export POSTGRES_HOST=database # for local development 3 | # export POSTGRES_HOST=postgres # for docker/kubernetes 4 | export POSTGRES_PORT=5432 5 | export POSTGRES_USER=test_user 6 | export POSTGRES_PASSWORD=test_password 7 | export POSTGRES_DB=test_db 8 | 9 | # DATABASE_URL will be used in preference if it exists 10 | # export DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB 11 | export DATABASE_URL=$DATABASE_URL 12 | 13 | # these are needed to create the database 14 | # and create the postgres user/password initially 15 | # if they are not set in env, these are the default values 16 | export POSTGRES_SUPERUSER=postgres 17 | # change this to empty string when running on local machine and not docker 18 | export POSTGRES_SUPERUSER_PASSWORD=password 19 | export POSTGRES_SUPERUSER_DB=postgres 20 | 21 | # for transactional emails 22 | export SENDGRID_API_KEY= 23 | export DEFAULT_NAME= 24 | export DEFAULT_EMAIL= 25 | 26 | # Change this to a FQDN as needed 27 | export EXTERNAL_URL="https://localhost:8080" 28 | 29 | export TWILIO_ACCOUNT="your Account SID from twil.io/console" 30 | export TWILIO_TOKEN="your Token from twil.io/console" 31 | 32 | export TWILIO_VERIFY_NAME="calvinx" 33 | export TWILIO_VERIFY="servicetoken" 34 | 35 | export MAGIC_API_KEY="" 36 | export MAGIC_API_SECRET="" 37 | 38 | export PLAID_CLIENT_ID= 39 | export PLAID_SECRET= 40 | export PLAID_ENV= 41 | # app expects comma separated strings 42 | export PLAID_PRODUCTS= 43 | # app expects comma separated strings 44 | export PLAID_COUNTRY_CODES= 45 | 46 | # BROKER TOKEN must be in the format "Basic 0 { 20 | _, err := strconv.Atoi(args[0]) 21 | if err != nil { 22 | passthrough = append(passthrough, args[0]) 23 | } 24 | } 25 | 26 | err := migration.Run(passthrough...) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | }, 31 | } 32 | 33 | func init() { 34 | migrateCmd.AddCommand(setVersionCmd) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/migrate_up.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | 7 | "github.com/alpacahq/ribbit-backend/migration" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // upCmd represents the up command 13 | var upCmd = &cobra.Command{ 14 | Use: "up [target]", 15 | Short: "runs all available migrations or up to the target if provided", 16 | Long: `runs all available migrations or up to the target if provided`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | var passthrough = []string{"up"} 19 | if len(args) > 0 { 20 | _, err := strconv.Atoi(args[0]) 21 | if err != nil { 22 | passthrough = append(passthrough, args[0]) 23 | } 24 | } 25 | 26 | err := migration.Run(passthrough...) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | }, 31 | } 32 | 33 | func init() { 34 | migrateCmd.AddCommand(upCmd) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/migrate_version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/alpacahq/ribbit-backend/migration" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // versionCmd represents the version command 13 | var versionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "version prints current db version", 16 | Long: `version prints current db version`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | fmt.Println("version called") 19 | err := migration.Run("version") 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | }, 24 | } 25 | 26 | func init() { 27 | migrateCmd.AddCommand(versionCmd) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/alpacahq/ribbit-backend/route" 9 | "github.com/alpacahq/ribbit-backend/server" 10 | 11 | "github.com/spf13/cobra" 12 | 13 | homedir "github.com/mitchellh/go-homedir" 14 | "github.com/spf13/viper" 15 | ) 16 | 17 | // routes will be attached to s 18 | var s server.Server 19 | 20 | var cfgFile string 21 | 22 | // rootCmd represents the base command when called without any subcommands 23 | var rootCmd = &cobra.Command{ 24 | Use: "alpaca", 25 | Short: "Broker API middleware", 26 | Long: `Broker MVP that uses golang gin as webserver, and go-pg library for connecting with a PostgreSQL database.`, 27 | // Uncomment the following line if your bare application 28 | // has an action associated with it: 29 | Run: func(cmd *cobra.Command, args []string) { 30 | var env string 31 | var ok bool 32 | if env, ok = os.LookupEnv("ALPACA_ENV"); !ok { 33 | env = "dev" 34 | fmt.Printf("Run server in %s mode\n", env) 35 | } 36 | err := s.Run(env) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | }, 41 | } 42 | 43 | // Execute adds all child commands to the root command and sets flags appropriately. 44 | // This is called by main.main(). It only needs to happen once to the rootCmd. 45 | func Execute(customRouteServices []route.ServicesI) { 46 | s.RouteServices = customRouteServices 47 | if err := rootCmd.Execute(); err != nil { 48 | fmt.Println(err, "sdjsbhfjbhsfb") 49 | os.Exit(1) 50 | } 51 | } 52 | 53 | func init() { 54 | cobra.OnInitialize(initConfig) 55 | 56 | // Here you will define your flags and configuration settings. 57 | // Cobra supports persistent flags, which, if defined here, 58 | // will be global for your application. 59 | 60 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.alpaca.yaml)") 61 | 62 | // Cobra also supports local flags, which will only run 63 | // when this action is called directly. 64 | // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 65 | } 66 | 67 | // initConfig reads in config file and ENV variables if set. 68 | func initConfig() { 69 | if cfgFile != "" { 70 | // Use config file from the flag. 71 | viper.SetConfigFile(cfgFile) 72 | } else { 73 | // Find home directory. 74 | home, err := homedir.Dir() 75 | if err != nil { 76 | fmt.Println(err) 77 | os.Exit(1) 78 | } 79 | 80 | // Search config in home directory with name ".alpaca" (without extension). 81 | viper.AddConfigPath(home) 82 | viper.SetConfigName(".alpaca") 83 | } 84 | 85 | viper.AutomaticEnv() // read in environment variables that match 86 | 87 | // If a config file is found, read it in. 88 | if err := viper.ReadInConfig(); err == nil { 89 | fmt.Println("Using config file:", viper.ConfigFileUsed()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cmd/sync_assets.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/alpacahq/ribbit-backend/config" 11 | "github.com/alpacahq/ribbit-backend/model" 12 | "github.com/alpacahq/ribbit-backend/repository" 13 | "github.com/alpacahq/ribbit-backend/secret" 14 | "github.com/spf13/cobra" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // syncAssetsCmd represents the syncAssets command 19 | var syncAssetsCmd = &cobra.Command{ 20 | Use: "sync_assets", 21 | Short: "sync_assets sync all the assets from broker", 22 | Long: `sync_assets sync all the assets from broker`, 23 | Run: func(cmd *cobra.Command, args []string) { 24 | fmt.Println("syncAssets called") 25 | db := config.GetConnection() 26 | log, _ := zap.NewDevelopment() 27 | defer log.Sync() 28 | assetRepo := repository.NewAssetRepo(db, log, secret.New()) 29 | 30 | client := &http.Client{} 31 | 32 | req, err := http.NewRequest("GET", os.Getenv("BROKER_API_BASE")+"/v1/assets", nil) 33 | if err != nil { 34 | fmt.Print(err.Error()) 35 | } 36 | 37 | req.Header.Add("Authorization", os.Getenv("BROKER_TOKEN")) 38 | response, err := client.Do(req) 39 | 40 | if err != nil { 41 | fmt.Print(err.Error()) 42 | } 43 | 44 | responseData, err := ioutil.ReadAll(response.Body) 45 | if err != nil { 46 | log.Fatal(err.Error()) 47 | } 48 | // fmt.Printf("%v", string(responseData)) 49 | 50 | var responseObject []interface{} 51 | json.Unmarshal(responseData, &responseObject) 52 | 53 | for _, asset := range responseObject { 54 | asset, _ := asset.(map[string]interface{}) 55 | 56 | newAsset := new(model.Asset) 57 | newAsset.ID = asset["id"].(string) 58 | newAsset.Class = asset["class"].(string) 59 | newAsset.Exchange = asset["exchange"].(string) 60 | newAsset.Symbol = asset["symbol"].(string) 61 | newAsset.Name = asset["name"].(string) 62 | newAsset.Status = asset["status"].(string) 63 | newAsset.Tradable = asset["tradable"].(bool) 64 | newAsset.Marginable = asset["marginable"].(bool) 65 | newAsset.Shortable = asset["shortable"].(bool) 66 | newAsset.EasyToBorrow = asset["easy_to_borrow"].(bool) 67 | newAsset.Fractionable = asset["fractionable"].(bool) 68 | 69 | if _, err := assetRepo.CreateOrUpdate(newAsset); err != nil { 70 | log.Fatal(err.Error()) 71 | } else { 72 | // fmt.Println(asset) 73 | } 74 | } 75 | 76 | // m := manager.NewManager(accountRepo, roleRepo, db) 77 | // models := manager.GetModels() 78 | // m.CreateSchema(models...) 79 | // m.CreateRoles() 80 | }, 81 | } 82 | 83 | func init() { 84 | rootCmd.AddCommand(syncAssetsCmd) 85 | } 86 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | "runtime" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // Load returns Configuration struct 13 | func Load(env string) *Configuration { 14 | _, filePath, _, _ := runtime.Caller(0) 15 | configName := "config." + env + ".yaml" 16 | configPath := filePath[:len(filePath)-9] + "files" + string(filepath.Separator) 17 | 18 | viper.SetConfigName(configName) 19 | viper.AddConfigPath(configPath) 20 | viper.SetConfigType("yaml") 21 | 22 | err := viper.ReadInConfig() 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | var config Configuration 28 | viper.Unmarshal(&config) 29 | setGinMode(config.Server.Mode) 30 | 31 | return &config 32 | } 33 | 34 | // Configuration holds data necessery for configuring application 35 | type Configuration struct { 36 | Server *Server `yaml:"server"` 37 | } 38 | 39 | // Server holds data necessary for server configuration 40 | type Server struct { 41 | Mode string `yaml:"mode"` 42 | } 43 | 44 | func setGinMode(mode string) { 45 | switch mode { 46 | case "release": 47 | gin.SetMode(gin.ReleaseMode) 48 | break 49 | case "test": 50 | gin.SetMode(gin.TestMode) 51 | break 52 | default: 53 | gin.SetMode(gin.DebugMode) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /config/files/config.dev.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "debug" -------------------------------------------------------------------------------- /config/files/config.prod.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "release" -------------------------------------------------------------------------------- /config/files/config.staging.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "release" -------------------------------------------------------------------------------- /config/files/config.test-e2e.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "test" -------------------------------------------------------------------------------- /config/files/config.test-int.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: test -------------------------------------------------------------------------------- /config/files/config.test.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "test" -------------------------------------------------------------------------------- /config/jwt.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/alpacahq/ribbit-backend/secret" 13 | 14 | "github.com/joho/godotenv" 15 | "github.com/mcuadros/go-defaults" 16 | "github.com/spf13/viper" 17 | ) 18 | 19 | // LoadJWT returns our JWT with env variables and relevant defaults 20 | func LoadJWT(env string) *JWT { 21 | jwt := new(JWT) 22 | defaults.SetDefaults(jwt) 23 | 24 | _, b, _, _ := runtime.Caller(0) 25 | d := path.Join(path.Dir(b)) 26 | projectRoot := filepath.Dir(d) 27 | suffix := "" 28 | if env != "" { 29 | suffix = suffix + "." + env 30 | } 31 | dotenvPath := path.Join(projectRoot, ".env"+suffix) 32 | _ = godotenv.Load(dotenvPath) 33 | 34 | viper.AutomaticEnv() 35 | 36 | jwt.Secret = viper.GetString("JWT_SECRET") 37 | if jwt.Secret == "" { 38 | if strings.HasPrefix(env, "test") { 39 | // generate jwt secret and write into file 40 | s, err := secret.GenerateRandomString(256) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | jwtString := fmt.Sprintf("JWT_SECRET=%s\n", s) 45 | err = ioutil.WriteFile(dotenvPath, []byte(jwtString), 0644) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | } else { 50 | log.Fatalf("Failed to set your environment variable JWT_SECRET. \n" + 51 | "Please do so via \n" + 52 | "go run . generate_secret\n" + 53 | "export JWT_SECRET=[the generated secret]") 54 | } 55 | } 56 | 57 | return jwt 58 | } 59 | 60 | // JWT holds data necessary for JWT configuration 61 | type JWT struct { 62 | Realm string `default:"jwtrealm"` 63 | Secret string `default:""` 64 | Duration int `default:"15"` 65 | RefreshDuration int `default:"10"` 66 | MaxRefresh int `default:"10"` 67 | SigningAlgorithm string `default:"HS256"` 68 | } 69 | -------------------------------------------------------------------------------- /config/magic.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/caarlos0/env/v6" 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | // MagicConfig persists the config for our Magic services 14 | type MagicConfig struct { 15 | Key string `env:"MAGIC_API_KEY"` 16 | Secret string `env:"MAGIC_API_SECRET"` 17 | } 18 | 19 | // GetMagicConfig returns a MagicConfig pointer with the correct Magic.link Config values 20 | func GetMagicConfig() *MagicConfig { 21 | c := MagicConfig{} 22 | 23 | _, b, _, _ := runtime.Caller(0) 24 | d := path.Join(path.Dir(b)) 25 | projectRoot := filepath.Dir(d) 26 | dotenvPath := path.Join(projectRoot, ".env") 27 | _ = godotenv.Load(dotenvPath) 28 | 29 | if err := env.Parse(&c); err != nil { 30 | fmt.Printf("%+v\n", err) 31 | } 32 | return &c 33 | } 34 | -------------------------------------------------------------------------------- /config/mail.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/caarlos0/env/v6" 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | // MailConfig persists the config for our PostgreSQL database connection 14 | type MailConfig struct { 15 | Name string `env:"DEFAULT_NAME"` 16 | Email string `env:"DEFAULT_EMAIL"` 17 | } 18 | 19 | // GetMailConfig returns a MailConfig pointer with the correct Mail Config values 20 | func GetMailConfig() *MailConfig { 21 | c := MailConfig{} 22 | 23 | _, b, _, _ := runtime.Caller(0) 24 | d := path.Join(path.Dir(b)) 25 | projectRoot := filepath.Dir(d) 26 | dotenvPath := path.Join(projectRoot, ".env") 27 | _ = godotenv.Load(dotenvPath) 28 | 29 | if err := env.Parse(&c); err != nil { 30 | fmt.Printf("%+v\n", err) 31 | } 32 | return &c 33 | } 34 | -------------------------------------------------------------------------------- /config/postgres.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/caarlos0/env/v6" 13 | "github.com/go-pg/pg/v9" 14 | "github.com/joho/godotenv" 15 | ) 16 | 17 | // PostgresConfig persists the config for our PostgreSQL database connection 18 | type PostgresConfig struct { 19 | URL string `env:"DATABASE_URL"` // DATABASE_URL will be used in preference if it exists 20 | Host string `env:"POSTGRES_HOST" envDefault:"localhost"` 21 | Port string `env:"POSTGRES_PORT" envDefault:"5432"` 22 | User string `env:"POSTGRES_USER"` 23 | Password string `env:"POSTGRES_PASSWORD"` 24 | Database string `env:"POSTGRES_DB"` 25 | } 26 | 27 | // PostgresSuperUser persists the config for our PostgreSQL superuser 28 | type PostgresSuperUser struct { 29 | Host string `env:"POSTGRES_HOST" envDefault:"localhost"` 30 | Port string `env:"POSTGRES_PORT" envDefault:"5432"` 31 | User string `env:"POSTGRES_SUPERUSER" envDefault:"postgres"` 32 | Password string `env:"POSTGRES_SUPERUSER_PASSWORD" envDefault:""` 33 | Database string `env:"POSTGRES_SUPERUSER_DB" envDefault:"postgres"` 34 | } 35 | 36 | // GetConnection returns our pg database connection 37 | // usage: 38 | // db := config.GetConnection() 39 | // defer db.Close() 40 | func GetConnection() *pg.DB { 41 | c := GetPostgresConfig() 42 | // if DATABASE_URL is valid, we will use its constituent values in preference 43 | validConfig, err := validPostgresURL(c.URL) 44 | if err == nil { 45 | c = validConfig 46 | } 47 | db := pg.Connect(&pg.Options{ 48 | Addr: c.Host + ":" + c.Port, 49 | User: c.User, 50 | Password: c.Password, 51 | Database: c.Database, 52 | PoolSize: 150, 53 | }) 54 | return db 55 | } 56 | 57 | // GetPostgresConfig returns a PostgresConfig pointer with the correct Postgres Config values 58 | func GetPostgresConfig() *PostgresConfig { 59 | c := PostgresConfig{} 60 | 61 | _, b, _, _ := runtime.Caller(0) 62 | d := path.Join(path.Dir(b)) 63 | projectRoot := filepath.Dir(d) 64 | dotenvPath := path.Join(projectRoot, ".env") 65 | _ = godotenv.Load(dotenvPath) 66 | 67 | if err := env.Parse(&c); err != nil { 68 | fmt.Printf("%+v\n", err) 69 | } 70 | return &c 71 | } 72 | 73 | // GetPostgresSuperUserConnection gets the corresponding db connection for our superuser 74 | func GetPostgresSuperUserConnection() *pg.DB { 75 | c := getPostgresSuperUser() 76 | db := pg.Connect(&pg.Options{ 77 | Addr: c.Host + ":" + c.Port, 78 | User: c.User, 79 | Password: c.Password, 80 | Database: c.Database, 81 | PoolSize: 150, 82 | }) 83 | return db 84 | } 85 | 86 | func getPostgresSuperUser() *PostgresSuperUser { 87 | c := PostgresSuperUser{} 88 | if err := env.Parse(&c); err != nil { 89 | fmt.Printf("%+v\n", err) 90 | } 91 | return &c 92 | } 93 | 94 | func validPostgresURL(URL string) (*PostgresConfig, error) { 95 | if URL == "" || strings.TrimSpace(URL) == "" { 96 | return nil, errors.New("database url is blank") 97 | } 98 | 99 | validURL, err := url.Parse(URL) 100 | if err != nil { 101 | return nil, err 102 | } 103 | c := &PostgresConfig{} 104 | c.URL = URL 105 | c.Host = validURL.Host 106 | c.Database = validURL.Path 107 | c.Port = validURL.Port() 108 | c.User = validURL.User.Username() 109 | c.Password, _ = validURL.User.Password() 110 | return c, nil 111 | } 112 | -------------------------------------------------------------------------------- /config/site.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/caarlos0/env/v6" 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | // SiteConfig persists global configs needed for our application 14 | type SiteConfig struct { 15 | ExternalURL string `env:"EXTERNAL_URL" envDefault:"http://localhost:8080"` 16 | } 17 | 18 | // GetSiteConfig returns a SiteConfig pointer with the correct Site Config values 19 | func GetSiteConfig() *SiteConfig { 20 | c := SiteConfig{} 21 | 22 | _, b, _, _ := runtime.Caller(0) 23 | d := path.Join(path.Dir(b)) 24 | projectRoot := filepath.Dir(d) 25 | dotenvPath := path.Join(projectRoot, ".env") 26 | _ = godotenv.Load(dotenvPath) 27 | 28 | if err := env.Parse(&c); err != nil { 29 | fmt.Printf("%+v\n", err) 30 | } 31 | return &c 32 | } 33 | -------------------------------------------------------------------------------- /config/twilio.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/caarlos0/env/v6" 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | // TwilioConfig persists the config for our Twilio services 14 | type TwilioConfig struct { 15 | Account string `env:"TWILIO_ACCOUNT"` 16 | Token string `env:"TWILIO_TOKEN"` 17 | VerifyName string `env:"TWILIO_VERIFY_NAME"` 18 | Verify string `env:"TWILIO_VERIFY"` 19 | } 20 | 21 | // GetTwilioConfig returns a TwilioConfig pointer with the correct Mail Config values 22 | func GetTwilioConfig() *TwilioConfig { 23 | c := TwilioConfig{} 24 | 25 | _, b, _, _ := runtime.Caller(0) 26 | d := path.Join(path.Dir(b)) 27 | projectRoot := filepath.Dir(d) 28 | dotenvPath := path.Join(projectRoot, ".env") 29 | _ = godotenv.Load(dotenvPath) 30 | 31 | if err := env.Parse(&c); err != nil { 32 | fmt.Printf("%+v\n", err) 33 | } 34 | return &c 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | caddy: 4 | image: "caddy:latest" 5 | volumes: 6 | - ./ribbit.com.pem:/root/certs/ribbit.com.pem 7 | - ./ribbit-public.com.pem:/root/certs/ribbit-public.com.pem 8 | - ./Caddyfile:/etc/caddy/Caddyfile # to mount custom Caddyfile 9 | ports: 10 | - "443:443" 11 | depends_on: 12 | - ribbit 13 | 14 | ribbit: 15 | depends_on: 16 | - database 17 | build: . 18 | entrypoint: ["sh", "-c", "./initdb.sh"] 19 | 20 | database: 21 | image: "postgres:14.0" 22 | ports: 23 | - "5432:5432" 24 | # volumes: 25 | # add local volume mount if needed 26 | # - ./data:/var/lib/postgresql/data/pgdata 27 | environment: 28 | POSTGRES_PASSWORD: password 29 | -------------------------------------------------------------------------------- /e2e/e2e.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/manager" 5 | "github.com/alpacahq/ribbit-backend/model" 6 | ) 7 | 8 | // SetupDatabase creates the schema, populates it with data and returns with superadmin user 9 | func SetupDatabase(m *manager.Manager) (*model.User, error) { 10 | models := manager.GetModels() 11 | m.CreateSchema(models...) 12 | m.CreateRoles() 13 | return m.CreateSuperAdmin("superuser@example.org", "testpassword") 14 | } 15 | -------------------------------------------------------------------------------- /e2e/e2e_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/alpacahq/ribbit-backend/config" 12 | "github.com/alpacahq/ribbit-backend/e2e" 13 | "github.com/alpacahq/ribbit-backend/manager" 14 | mw "github.com/alpacahq/ribbit-backend/middleware" 15 | "github.com/alpacahq/ribbit-backend/mock" 16 | "github.com/alpacahq/ribbit-backend/model" 17 | "github.com/alpacahq/ribbit-backend/repository" 18 | "github.com/alpacahq/ribbit-backend/route" 19 | "github.com/alpacahq/ribbit-backend/secret" 20 | "github.com/alpacahq/ribbit-backend/testhelper" 21 | 22 | embeddedpostgres "github.com/fergusstrange/embedded-postgres" 23 | "github.com/gin-contrib/cors" 24 | "github.com/gin-gonic/gin" 25 | "github.com/go-pg/pg/v9" 26 | "github.com/stretchr/testify/assert" 27 | "github.com/stretchr/testify/suite" 28 | "go.uber.org/zap" 29 | ) 30 | 31 | var ( 32 | superUser *model.User 33 | isCI bool 34 | port uint32 = 5432 // uses 5432 in CI, and 9877 when running integration tests locally, against embedded postgresql 35 | ) 36 | 37 | // end-to-end test constants 38 | const ( 39 | username string = "db_test_user" 40 | password string = "db_test_password" 41 | database string = "db_test_database" 42 | host string = "localhost" 43 | tmpDirname string = "tmp2" 44 | ) 45 | 46 | type E2ETestSuite struct { 47 | suite.Suite 48 | db *pg.DB 49 | postgres *embeddedpostgres.EmbeddedPostgres 50 | m *manager.Manager 51 | r *gin.Engine 52 | v *model.Verification 53 | authToken model.AuthToken 54 | } 55 | 56 | // SetupSuite runs before all tests in this test suite 57 | func (suite *E2ETestSuite) SetupSuite() { 58 | _, b, _, _ := runtime.Caller(0) 59 | d := path.Join(path.Dir(b)) 60 | projectRoot := filepath.Dir(d) 61 | tmpDir := path.Join(projectRoot, tmpDirname) 62 | os.RemoveAll(tmpDir) // ensure that we start afresh 63 | 64 | _, isCI = os.LookupEnv("CIRCLECI") 65 | if !isCI { // not in CI environment, so setup our embedded postgresql for integration test 66 | port = testhelper.AllocatePort(host, 9877) 67 | testConfig := embeddedpostgres.DefaultConfig(). 68 | Username(username). 69 | Password(password). 70 | Database(database). 71 | Version(embeddedpostgres.V12). 72 | RuntimePath(tmpDir). 73 | Port(port) 74 | suite.postgres = embeddedpostgres.NewDatabase(testConfig) 75 | err := suite.postgres.Start() 76 | if err != nil { 77 | fmt.Println(err) 78 | } 79 | } 80 | 81 | suite.db = pg.Connect(&pg.Options{ 82 | Addr: host + ":" + fmt.Sprint(port), 83 | User: username, 84 | Password: password, 85 | Database: database, 86 | }) 87 | 88 | log, _ := zap.NewDevelopment() 89 | defer log.Sync() 90 | accountRepo := repository.NewAccountRepo(suite.db, log, secret.New()) 91 | roleRepo := repository.NewRoleRepo(suite.db, log) 92 | suite.m = manager.NewManager(accountRepo, roleRepo, suite.db) 93 | 94 | superUser, _ = e2e.SetupDatabase(suite.m) 95 | 96 | gin.SetMode(gin.TestMode) 97 | r := gin.Default() 98 | 99 | // middleware 100 | mw.Add(r, cors.Default()) 101 | 102 | // load configuration 103 | _ = config.Load("test") 104 | j := config.LoadJWT("test") 105 | jwt := mw.NewJWT(j) 106 | 107 | // mock mail 108 | m := &mock.Mail{ 109 | SendVerificationEmailFn: suite.sendVerification, 110 | } 111 | // mock mobile 112 | mobile := &mock.Mobile{ 113 | GenerateSMSTokenFn: func(string, string) error { 114 | return nil 115 | }, 116 | CheckCodeFn: func(string, string, string) error { 117 | return nil 118 | }, 119 | } 120 | 121 | // setup routes 122 | rs := route.NewServices(suite.db, log, jwt, m, mobile, r) 123 | rs.SetupV1Routes() 124 | 125 | // we can now test our routes in an end-to-end fashion by making http calls 126 | suite.r = r 127 | } 128 | 129 | // TearDownSuite runs after all tests in this test suite 130 | func (suite *E2ETestSuite) TearDownSuite() { 131 | if !isCI { // not in CI environment, so stop our embedded postgresql db 132 | suite.postgres.Stop() 133 | } 134 | } 135 | 136 | func (suite *E2ETestSuite) TestGetModels() { 137 | models := manager.GetModels() 138 | sql := `SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';` 139 | var count int 140 | res, err := suite.db.Query(pg.Scan(&count), sql, nil) 141 | 142 | assert.NotNil(suite.T(), res) 143 | assert.Nil(suite.T(), err) 144 | assert.Equal(suite.T(), len(models), count) 145 | 146 | sql = `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';` 147 | var names pg.Strings 148 | res, err = suite.db.Query(&names, sql, nil) 149 | 150 | assert.NotNil(suite.T(), res) 151 | assert.Nil(suite.T(), err) 152 | assert.Equal(suite.T(), len(models), len(names)) 153 | } 154 | 155 | func (suite *E2ETestSuite) TestSuperUser() { 156 | assert.NotNil(suite.T(), superUser) 157 | } 158 | 159 | func TestE2ETestSuiteIntegration(t *testing.T) { 160 | if testing.Short() { 161 | t.Skip("skipping integration test") 162 | return 163 | } 164 | suite.Run(t, new(E2ETestSuite)) 165 | } 166 | 167 | // our mock verification token is saved into suite.token for subsequent use 168 | func (suite *E2ETestSuite) sendVerification(email string, v *model.Verification) error { 169 | suite.v = v 170 | return nil 171 | } 172 | -------------------------------------------------------------------------------- /e2e/login_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "time" 11 | 12 | "github.com/alpacahq/ribbit-backend/model" 13 | "github.com/alpacahq/ribbit-backend/request" 14 | 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func (suite *E2ETestSuite) TestLogin() { 19 | t := suite.T() 20 | 21 | ts := httptest.NewServer(suite.r) 22 | defer ts.Close() 23 | 24 | url := ts.URL + "/login" 25 | 26 | req := &request.Credentials{ 27 | Email: "superuser@example.org", 28 | Password: "testpassword", 29 | } 30 | b, err := json.Marshal(req) 31 | assert.Nil(t, err) 32 | 33 | resp, err := http.Post(url, "application/json", bytes.NewBuffer(b)) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | defer resp.Body.Close() 38 | 39 | body, err := ioutil.ReadAll(resp.Body) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | 44 | var authToken model.AuthToken 45 | err = json.Unmarshal(body, &authToken) 46 | assert.Nil(t, err) 47 | assert.NotNil(t, authToken) 48 | suite.authToken = authToken 49 | } 50 | 51 | func (suite *E2ETestSuite) TestRefreshToken() { 52 | t := suite.T() 53 | assert.NotNil(t, suite.authToken) 54 | 55 | ts := httptest.NewServer(suite.r) 56 | defer ts.Close() 57 | 58 | // delay by 1 second so that our re-generated JWT will have a 1 second difference 59 | time.Sleep(1 * time.Second) 60 | 61 | url := ts.URL + "/refresh/" + suite.authToken.RefreshToken 62 | resp, err := http.Get(url) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | defer resp.Body.Close() 67 | 68 | assert.Equal(t, http.StatusOK, resp.StatusCode) 69 | assert.Nil(t, err) 70 | 71 | body, err := ioutil.ReadAll(resp.Body) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | 76 | var refreshToken model.RefreshToken 77 | err = json.Unmarshal(body, &refreshToken) 78 | assert.Nil(t, err) 79 | assert.NotNil(t, refreshToken) 80 | 81 | // because of a 1 second delay, our re-generated JWT will definitely be different 82 | assert.NotEqual(t, suite.authToken.Token, refreshToken.Token) 83 | assert.NotEqual(t, suite.authToken.Expires, refreshToken.Expires) 84 | } 85 | -------------------------------------------------------------------------------- /e2e/mobile_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | 11 | "github.com/alpacahq/ribbit-backend/request" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func (suite *E2ETestSuite) TestSignupMobile() { 17 | t := suite.T() 18 | ts := httptest.NewServer(suite.r) 19 | defer ts.Close() 20 | 21 | urlSignupMobile := ts.URL + "/mobile" 22 | 23 | req := &request.MobileSignup{ 24 | CountryCode: "+65", 25 | Mobile: "91919191", 26 | } 27 | b, err := json.Marshal(req) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | resp, err := http.Post(urlSignupMobile, "application/json", bytes.NewBuffer(b)) 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | defer resp.Body.Close() 36 | 37 | assert.Equal(t, http.StatusCreated, resp.StatusCode) 38 | assert.Nil(t, err) 39 | 40 | // the sms code will be separately sms-ed to user's mobile phone, trigger above 41 | // we now test against the /mobile/verify 42 | 43 | url := ts.URL + "/mobile/verify" 44 | req2 := &request.MobileVerify{ 45 | CountryCode: "+65", 46 | Mobile: "91919191", 47 | Code: "123456", 48 | Signup: true, 49 | } 50 | b, err = json.Marshal(req2) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | resp, err = http.Post(url, "application/json", bytes.NewBuffer(b)) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | defer resp.Body.Close() 59 | 60 | fmt.Println("Verify Code") 61 | assert.Equal(t, http.StatusOK, resp.StatusCode) 62 | assert.Nil(t, err) 63 | } 64 | -------------------------------------------------------------------------------- /e2e/signupemail_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | 12 | "github.com/alpacahq/ribbit-backend/request" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func (suite *E2ETestSuite) TestSignupEmail() { 18 | 19 | t := suite.T() 20 | 21 | ts := httptest.NewServer(suite.r) 22 | defer ts.Close() 23 | 24 | urlSignup := ts.URL + "/signup" 25 | 26 | req := &request.EmailSignup{ 27 | Email: "user@example.org", 28 | Password: "userpassword1", 29 | } 30 | b, err := json.Marshal(req) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | resp, err := http.Post(urlSignup, "application/json", bytes.NewBuffer(b)) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | defer resp.Body.Close() 40 | 41 | assert.Equal(t, http.StatusCreated, resp.StatusCode) 42 | 43 | body, err := ioutil.ReadAll(resp.Body) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | fmt.Println(string(body)) 48 | 49 | assert.Nil(t, err) 50 | } 51 | 52 | func (suite *E2ETestSuite) TestVerification() { 53 | t := suite.T() 54 | v := suite.v 55 | // verify that we can retrieve our test verification token 56 | assert.NotNil(t, v) 57 | 58 | ts := httptest.NewServer(suite.r) 59 | defer ts.Close() 60 | 61 | url := ts.URL + "/verification/" + v.Token 62 | fmt.Println("This is our verification url", url) 63 | 64 | resp, err := http.Get(url) 65 | if err != nil { 66 | log.Fatal(err) 67 | } 68 | defer resp.Body.Close() 69 | assert.Equal(t, http.StatusOK, resp.StatusCode) 70 | 71 | body, err := ioutil.ReadAll(resp.Body) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | fmt.Println(string(body)) 76 | assert.Nil(t, err) 77 | 78 | // The second time we call our verification url, it should return not found 79 | resp, err = http.Get(url) 80 | assert.Equal(t, http.StatusNotFound, resp.StatusCode) 81 | assert.Nil(t, err) 82 | } 83 | -------------------------------------------------------------------------------- /entry/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | alpaca "github.com/alpacahq/ribbit-backend" 7 | ) 8 | 9 | func main() { 10 | alpaca.New(). 11 | WithRoutes(&MyServices{}). 12 | Run() 13 | } 14 | 15 | // MyServices implements github.com/alpacahq/ribbit-backend/route.ServicesI 16 | type MyServices struct{} 17 | 18 | // SetupRoutes is our implementation of custom routes 19 | func (s *MyServices) SetupRoutes() { 20 | fmt.Println("set up our custom routes!") 21 | } 22 | -------------------------------------------------------------------------------- /generate-ssl.sh: -------------------------------------------------------------------------------- 1 | mkcert -key-file ribbit.com.pem -cert-file ribbit-public.com.pem ribbit.com 2 | 3 | # for client request encryption 4 | openssl genrsa -out private_key.pem 1024 5 | openssl rsa -in private_key.pem -outform PEM -pubout -out public_key.pem 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alpacahq/ribbit-backend 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 7 | github.com/bradfitz/slice v0.0.0-20180809154707-2b758aa73013 8 | github.com/caarlos0/env/v6 v6.5.0 9 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 10 | github.com/fergusstrange/embedded-postgres v1.4.0 11 | github.com/gertd/go-pluralize v0.1.7 12 | github.com/gin-contrib/cors v1.3.1 13 | github.com/gin-gonic/gin v1.7.0 14 | github.com/go-pg/migrations/v7 v7.1.11 15 | github.com/go-pg/pg/v9 v9.2.0 16 | github.com/joho/godotenv v1.3.0 17 | github.com/lithammer/shortuuid/v3 v3.0.6 18 | github.com/magiclabs/magic-admin-go v0.1.0 19 | github.com/mcuadros/go-defaults v1.2.0 20 | github.com/mitchellh/go-homedir v1.1.0 21 | github.com/plaid/plaid-go v0.0.0-20210216195344-700b8cfc627d 22 | github.com/rs/xid v1.2.1 23 | github.com/satori/go.uuid v1.2.0 24 | github.com/sendgrid/rest v2.4.1+incompatible // indirect 25 | github.com/sendgrid/sendgrid-go v3.8.0+incompatible 26 | github.com/spf13/cobra v1.1.3 27 | github.com/spf13/viper v1.7.1 28 | github.com/stretchr/testify v1.7.0 29 | github.com/swaggo/gin-swagger v1.3.0 30 | github.com/swaggo/swag v1.7.0 31 | go.uber.org/zap v1.16.0 32 | go4.org v0.0.0-20201209231011-d4a079459e60 // indirect 33 | golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a 34 | gopkg.in/go-playground/validator.v8 v8.18.2 35 | ) 36 | -------------------------------------------------------------------------------- /initdb.sh: -------------------------------------------------------------------------------- 1 | source .env 2 | export JWT_SECRET=$(openssl rand -base64 256) 3 | echo $JWT_SECRET 4 | 5 | go run ./entry create_db 6 | go run ./entry create_schema 7 | go run ./entry create_superadmin -e test_super_admin@gmail.com -p password 8 | 9 | go run ./entry/main.go 10 | -------------------------------------------------------------------------------- /k8-cluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: postgresql-configmap 5 | data: 6 | database_url: postgresql-service 7 | --- 8 | apiVersion: v1 9 | data: 10 | .dockerconfigjson: eyJhdXRocyI6eyJodHRwczovL3JlZ2lzdHJ5LmdpdGxhYi5jb20iOnsidXNlcm5hbWUiOiJyZW1vdGUtYWRtaW4iLCJwYXNzd29yZCI6IjhaYjU3X1duck5SRFF5eVlGV0s0IiwiZW1haWwiOiJub3QtbmVlZGVkQGV4YW1wbGUuY29tIiwiYXV0aCI6ImNtVnRiM1JsTFdGa2JXbHVPamhhWWpVM1gxZHVjazVTUkZGNWVWbEdWMHMwIn19fQ== 11 | kind: Secret 12 | metadata: 13 | creationTimestamp: "2021-08-13T14:23:14Z" 14 | name: regcerd 15 | namespace: default 16 | resourceVersion: "24010" 17 | uid: 6876b2ff-36cf-4b06-b8f3-49e0d7b51133 18 | type: kubernetes.io/dockerconfigjson 19 | --- 20 | apiVersion: v1 21 | kind: Secret 22 | metadata: 23 | name: postgresql-secret 24 | type: Opaque # Default key/value secret type 25 | data: 26 | postgres-root-username: dXNlcm5hbWU= # echo -n 'username' | base64 27 | postgres-root-password: cGFzc3dvcmQ= # echo -n 'password' | base64 28 | --- 29 | apiVersion: apps/v1 30 | kind: StatefulSet 31 | metadata: 32 | name: postgresql 33 | spec: 34 | serviceName: postgresql-service 35 | selector: 36 | matchLabels: 37 | app: postgresql 38 | replicas: 2 39 | template: 40 | metadata: 41 | labels: 42 | app: postgresql 43 | spec: 44 | containers: 45 | - name: postgresql 46 | image: postgres:latest 47 | volumeMounts: 48 | - name: postgresql-disk 49 | mountPath: /data 50 | env: 51 | - name: POSTGRES_USER 52 | valueFrom: 53 | secretKeyRef: 54 | name: postgresql-secret 55 | key: postgres-root-username 56 | - name: POSTGRES_PASSWORD 57 | valueFrom: 58 | secretKeyRef: 59 | name: postgresql-secret 60 | key: postgres-root-password 61 | - name: PGDATA 62 | value: /data/pgdata 63 | # Volume Claim 64 | volumeClaimTemplates: 65 | - metadata: 66 | name: postgresql-disk 67 | spec: 68 | accessModes: ["ReadWriteOnce"] 69 | resources: 70 | requests: 71 | storage: 25Gi 72 | --- 73 | apiVersion: v1 74 | kind: Service 75 | metadata: 76 | name: postgresql-lb 77 | spec: 78 | selector: 79 | app: postgresql 80 | type: LoadBalancer 81 | ports: 82 | - port: 5432 83 | targetPort: 5432 84 | --- 85 | apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 86 | kind: Deployment 87 | metadata: 88 | name: api-backend 89 | labels: 90 | app: api-backend 91 | spec: 92 | selector: 93 | matchLabels: 94 | app: api-backend 95 | replicas: 1 # tells deployment to run x pods matching the template 96 | template: 97 | metadata: 98 | labels: 99 | app: api-backend 100 | spec: # For pod 101 | containers: 102 | - name: api-backend 103 | image: registry.github.com/alpacahq/ribbit-backend/env-printer:latest 104 | ports: 105 | - containerPort: 8080 106 | env: 107 | - name: PGADMIN_DEFAULT_EMAIL 108 | valueFrom: 109 | secretKeyRef: 110 | name: postgresql-secret 111 | key: postgres-root-username 112 | - name: PGADMIN_DEFAULT_PASSWORD 113 | valueFrom: 114 | secretKeyRef: 115 | name: postgresql-secret 116 | key: postgres-root-password 117 | - name: PGADMIN_CONFIG_DEFAULT_SERVER 118 | valueFrom: 119 | configMapKeyRef: 120 | name: postgresql-configmap 121 | key: database_url 122 | - name: PGADMIN_LISTEN_PORT 123 | value: "8081" 124 | imagePullSecrets: 125 | - name: regcerd 126 | --- 127 | apiVersion: v1 128 | kind: Service 129 | metadata: 130 | name: api-backend-service 131 | spec: 132 | selector: 133 | app: api-backend 134 | type: LoadBalancer # for External service 135 | ports: 136 | - protocol: TCP 137 | port: 8080 138 | targetPort: 8080 139 | nodePort: 30001 # External port (can be in between 30000-32767) 140 | -------------------------------------------------------------------------------- /magic/magic.go: -------------------------------------------------------------------------------- 1 | package magic 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/alpacahq/ribbit-backend/apperr" 9 | "github.com/alpacahq/ribbit-backend/config" 10 | "github.com/magiclabs/magic-admin-go" 11 | "github.com/magiclabs/magic-admin-go/client" 12 | "github.com/magiclabs/magic-admin-go/token" 13 | ) 14 | 15 | // NewMagic creates a new magic service implementation 16 | func NewMagic(config *config.MagicConfig) *Magic { 17 | return &Magic{config} 18 | } 19 | 20 | // Magic provides a magic service implementation 21 | type Magic struct { 22 | config *config.MagicConfig 23 | } 24 | 25 | // IsValidToken validates a token with magic link 26 | func (m *Magic) IsValidToken(tkn string) (*token.Token, error) { 27 | authBearer := "Bearer" 28 | fmt.Printf("%s", authBearer) 29 | if tkn == "" { 30 | return nil, apperr.New(http.StatusUnauthorized, "Bearer token is required") 31 | } 32 | 33 | if !strings.HasPrefix(tkn, authBearer) { 34 | return nil, apperr.New(http.StatusUnauthorized, "Bearer token is required") 35 | } 36 | 37 | did := tkn[len(authBearer)+1:] 38 | if did == "" { 39 | return nil, apperr.New(http.StatusUnauthorized, "DID token is required") 40 | } 41 | 42 | tk, err := token.NewToken(did) 43 | if err != nil { 44 | 45 | return nil, apperr.New(http.StatusUnauthorized, "Malformed DID token error: "+err.Error()) 46 | } 47 | 48 | if err := tk.Validate(); err != nil { 49 | return nil, apperr.New(http.StatusUnauthorized, "DID token failed validation: "+err.Error()) 50 | } 51 | 52 | return tk, nil 53 | } 54 | 55 | // GetIssuer retrieves the issuer from token 56 | // func (m *Magic) GetIssuer(c *gin.Context) error 57 | func (m *Magic) GetIssuer(tk *token.Token) (*magic.UserInfo, error) { 58 | client := client.New(m.config.Secret, magic.NewDefaultClient()) 59 | userInfo, err := client.User.GetMetadataByIssuer(tk.GetIssuer()) 60 | if err != nil { 61 | return nil, apperr.New(http.StatusBadRequest, "Bad request") 62 | } 63 | 64 | return userInfo, nil 65 | } 66 | -------------------------------------------------------------------------------- /magic/magic_interface.go: -------------------------------------------------------------------------------- 1 | package magic 2 | 3 | import ( 4 | mag "github.com/magiclabs/magic-admin-go" 5 | "github.com/magiclabs/magic-admin-go/token" 6 | ) 7 | 8 | // Service is the interface to our magic service 9 | type Service interface { 10 | IsValidToken(string) (*token.Token, error) 11 | GetIssuer(*token.Token) (*mag.UserInfo, error) 12 | } 13 | -------------------------------------------------------------------------------- /mail/mail_interface.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import "github.com/alpacahq/ribbit-backend/model" 4 | 5 | // Service is the interface to access our Mail 6 | type Service interface { 7 | Send(subject string, toName string, toEmail string, content string, HTMLContent string) error 8 | SendWithDefaults(subject, toEmail, content string, HTMLContent string) error 9 | SendVerificationEmail(toEmail string, v *model.Verification) error 10 | SendForgotVerificationEmail(toEmail string, v *model.Verification) error 11 | } 12 | -------------------------------------------------------------------------------- /manager/createdb.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/alpacahq/ribbit-backend/config" 7 | 8 | "github.com/go-pg/pg/v9" 9 | ) 10 | 11 | // CreateDatabaseIfNotExist creates our postgresql database from postgres config 12 | func CreateDatabaseIfNotExist(db *pg.DB, p *config.PostgresConfig) { 13 | statement := fmt.Sprintf(`SELECT 1 AS result FROM pg_database WHERE datname = '%s';`, p.Database) 14 | res, _ := db.Exec(statement) 15 | if res.RowsReturned() == 0 { 16 | fmt.Println("creating database") 17 | statement = fmt.Sprintf(`CREATE DATABASE %s WITH OWNER %s;`, p.Database, p.User) 18 | _, err := db.Exec(statement) 19 | if err != nil { 20 | fmt.Println(err) 21 | } else { 22 | fmt.Printf(`Created database %s`, p.Database) 23 | } 24 | } else { 25 | fmt.Printf("Database named %s already exists\n", p.Database) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /manager/createdbuser.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/alpacahq/ribbit-backend/config" 7 | 8 | "github.com/go-pg/pg/v9" 9 | ) 10 | 11 | // CreateDatabaseUserIfNotExist creates a database user 12 | func CreateDatabaseUserIfNotExist(db *pg.DB, p *config.PostgresConfig) { 13 | statement := fmt.Sprintf(`SELECT * FROM pg_roles WHERE rolname = '%s';`, p.User) 14 | res, _ := db.Exec(statement) 15 | if res.RowsReturned() == 0 { 16 | statement = fmt.Sprintf(`CREATE USER %s WITH PASSWORD '%s';`, p.User, p.Password) 17 | _, err := db.Exec(statement) 18 | if err != nil { 19 | fmt.Println(err) 20 | } else { 21 | fmt.Printf(`Created user %s`, p.User) 22 | } 23 | } else { 24 | fmt.Printf("Database user %s already exists\n", p.User) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /manager/createschema.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/go-pg/pg/v9" 7 | "github.com/go-pg/pg/v9/orm" 8 | ) 9 | 10 | // CreateSchema creates the tables for given models 11 | func CreateSchema(db *pg.DB, models ...interface{}) { 12 | for _, model := range models { 13 | opt := &orm.CreateTableOptions{ 14 | IfNotExists: true, 15 | FKConstraints: true, 16 | } 17 | err := db.CreateTable(model, opt) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/alpacahq/ribbit-backend/model" 10 | "github.com/alpacahq/ribbit-backend/repository" 11 | "github.com/alpacahq/ribbit-backend/secret" 12 | 13 | "github.com/gertd/go-pluralize" 14 | "github.com/go-pg/pg/v9" 15 | "github.com/go-pg/pg/v9/orm" 16 | ) 17 | 18 | // NewManager returns a new manager 19 | func NewManager(accountRepo *repository.AccountRepo, roleRepo *repository.RoleRepo, db *pg.DB) *Manager { 20 | return &Manager{accountRepo, roleRepo, db} 21 | } 22 | 23 | // Manager holds a group of methods for writing tests 24 | type Manager struct { 25 | accountRepo *repository.AccountRepo 26 | roleRepo *repository.RoleRepo 27 | db *pg.DB 28 | } 29 | 30 | // CreateSchema creates tables declared as models (struct) 31 | func (m *Manager) CreateSchema(models ...interface{}) { 32 | for _, model := range models { 33 | opt := &orm.CreateTableOptions{ 34 | IfNotExists: true, 35 | FKConstraints: true, 36 | } 37 | err := m.db.CreateTable(model, opt) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | p := pluralize.NewClient() 42 | modelName := GetType(model) 43 | tableName := p.Plural(strings.ToLower(modelName)) 44 | fmt.Printf("Created model %s as table %s\n", modelName, tableName) 45 | } 46 | } 47 | 48 | // CreateRoles is a thin wrapper for roleRepo.CreateRoles(), which populates our roles table 49 | func (m *Manager) CreateRoles() { 50 | err := m.roleRepo.CreateRoles() 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | } 55 | 56 | // CreateSuperAdmin is used to create a user object with superadmin role 57 | func (m *Manager) CreateSuperAdmin(email, password string) (*model.User, error) { 58 | u := &model.User{ 59 | Email: email, 60 | Password: secret.New().HashPassword(password), 61 | Active: true, 62 | Verified: true, 63 | RoleID: int(model.SuperAdminRole), 64 | } 65 | return m.accountRepo.Create(u) 66 | } 67 | 68 | // GetType is a useful utility function to help us inspect the name of a model (struct) which is expressed as an interface{} 69 | func GetType(myvar interface{}) string { 70 | valueOf := reflect.ValueOf(myvar) 71 | if valueOf.Type().Kind() == reflect.Ptr { 72 | return reflect.Indirect(valueOf).Type().Name() 73 | } 74 | return valueOf.Type().Name() 75 | } 76 | 77 | // GetModels retrieve models 78 | func GetModels() []interface{} { 79 | return model.Models 80 | } 81 | -------------------------------------------------------------------------------- /middleware/jwt.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | 8 | "github.com/alpacahq/ribbit-backend/apperr" 9 | "github.com/alpacahq/ribbit-backend/config" 10 | "github.com/alpacahq/ribbit-backend/model" 11 | 12 | jwt "github.com/dgrijalva/jwt-go" 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | // NewJWT generates new JWT variable necessery for auth middleware 17 | func NewJWT(c *config.JWT) *JWT { 18 | return &JWT{ 19 | Realm: c.Realm, 20 | Key: []byte(c.Secret), 21 | Duration: time.Duration(c.Duration) * time.Minute, 22 | Algo: c.SigningAlgorithm, 23 | } 24 | } 25 | 26 | // JWT provides a Json-Web-Token authentication implementation 27 | type JWT struct { 28 | // Realm name to display to the user. 29 | Realm string 30 | 31 | // Secret key used for signing. 32 | Key []byte 33 | 34 | // Duration for which the jwt token is valid. 35 | Duration time.Duration 36 | 37 | // JWT signing algorithm 38 | Algo string 39 | } 40 | 41 | // MWFunc makes JWT implement the Middleware interface. 42 | func (j *JWT) MWFunc() gin.HandlerFunc { 43 | 44 | return func(c *gin.Context) { 45 | token, err := j.ParseToken(c) 46 | if err != nil || !token.Valid { 47 | c.Header("WWW-Authenticate", "JWT realm="+j.Realm) 48 | c.AbortWithStatus(http.StatusUnauthorized) 49 | return 50 | } 51 | 52 | claims := token.Claims.(jwt.MapClaims) 53 | 54 | id := int(claims["id"].(float64)) 55 | username := claims["u"].(string) 56 | email := claims["e"].(string) 57 | role := int8(claims["r"].(float64)) 58 | 59 | c.Set("id", id) 60 | c.Set("username", username) 61 | c.Set("email", email) 62 | c.Set("role", role) 63 | 64 | // Generate new token 65 | newToken := jwt.New(jwt.GetSigningMethod(j.Algo)) 66 | newClaims := newToken.Claims.(jwt.MapClaims) 67 | 68 | expire := time.Now().Add(j.Duration) 69 | newClaims["id"] = id 70 | newClaims["u"] = username 71 | newClaims["e"] = email 72 | newClaims["r"] = role 73 | newClaims["exp"] = expire.Unix() 74 | 75 | newTokenString, err := newToken.SignedString(j.Key) 76 | if err == nil { 77 | c.Writer.Header().Set("New-Token", newTokenString) 78 | } 79 | 80 | c.Next() 81 | } 82 | } 83 | 84 | // ParseToken parses token from Authorization header 85 | func (j *JWT) ParseToken(c *gin.Context) (*jwt.Token, error) { 86 | 87 | token := c.Request.Header.Get("Authorization") 88 | if token == "" { 89 | return nil, apperr.New(http.StatusUnauthorized, "Unauthorized") 90 | } 91 | parts := strings.SplitN(token, " ", 2) 92 | if !(len(parts) == 2 && parts[0] == "Bearer") { 93 | return nil, apperr.New(http.StatusUnauthorized, "Unauthorized") 94 | } 95 | 96 | return jwt.Parse(parts[1], func(token *jwt.Token) (interface{}, error) { 97 | if jwt.GetSigningMethod(j.Algo) != token.Method { 98 | return nil, apperr.Generic 99 | } 100 | return j.Key, nil 101 | }) 102 | 103 | } 104 | 105 | // GenerateToken generates new JWT token and populates it with user data 106 | func (j *JWT) GenerateToken(u *model.User) (string, string, error) { 107 | token := jwt.New(jwt.GetSigningMethod(j.Algo)) 108 | claims := token.Claims.(jwt.MapClaims) 109 | 110 | expire := time.Now().Add(j.Duration) 111 | claims["id"] = u.ID 112 | claims["u"] = u.Username 113 | claims["e"] = u.Email 114 | claims["r"] = u.Role.AccessLevel 115 | claims["exp"] = expire.Unix() 116 | 117 | tokenString, err := token.SignedString(j.Key) 118 | return tokenString, expire.Format(time.RFC3339), err 119 | } 120 | -------------------------------------------------------------------------------- /middleware/jwt_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/alpacahq/ribbit-backend/config" 10 | mw "github.com/alpacahq/ribbit-backend/middleware" 11 | "github.com/alpacahq/ribbit-backend/mock" 12 | "github.com/alpacahq/ribbit-backend/model" 13 | 14 | "github.com/gin-gonic/gin" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | func hwHandler(c *gin.Context) { 19 | c.JSON(200, gin.H{ 20 | "text": "Hello World.", 21 | }) 22 | } 23 | 24 | func ginHandler(mw ...gin.HandlerFunc) *gin.Engine { 25 | gin.SetMode(gin.TestMode) 26 | r := gin.New() 27 | for _, v := range mw { 28 | r.Use(v) 29 | } 30 | r.GET("/hello", hwHandler) 31 | return r 32 | } 33 | 34 | func TestMWFunc(t *testing.T) { 35 | cases := []struct { 36 | name string 37 | wantStatus int 38 | header string 39 | }{ 40 | { 41 | name: "Empty header", 42 | wantStatus: http.StatusUnauthorized, 43 | }, 44 | { 45 | name: "Header not containing Bearer", 46 | header: "notBearer", 47 | wantStatus: http.StatusUnauthorized, 48 | }, 49 | { 50 | name: "Invalid header", 51 | header: mock.HeaderInvalid(), 52 | wantStatus: http.StatusUnauthorized, 53 | }, 54 | { 55 | name: "Success", 56 | header: mock.HeaderValid(), 57 | wantStatus: http.StatusOK, 58 | }, 59 | } 60 | jwtCfg := &config.JWT{Realm: "testRealm", Secret: "jwtsecret", Duration: 60, SigningAlgorithm: "HS256"} 61 | jwtMW := mw.NewJWT(jwtCfg) 62 | ts := httptest.NewServer(ginHandler(jwtMW.MWFunc())) 63 | defer ts.Close() 64 | path := ts.URL + "/hello" 65 | client := &http.Client{} 66 | 67 | for _, tt := range cases { 68 | t.Run(tt.name, func(t *testing.T) { 69 | req, _ := http.NewRequest("GET", path, nil) 70 | req.Header.Set("Authorization", tt.header) 71 | res, err := client.Do(req) 72 | if err != nil { 73 | t.Fatal("Cannot create http request") 74 | } 75 | assert.Equal(t, tt.wantStatus, res.StatusCode) 76 | }) 77 | } 78 | } 79 | 80 | func TestGenerateToken(t *testing.T) { 81 | cases := []struct { 82 | name string 83 | wantToken string 84 | req *model.User 85 | }{ 86 | { 87 | name: "Success", 88 | req: &model.User{ 89 | Base: model.Base{}, 90 | ID: 1, 91 | Username: "johndoe", 92 | Email: "johndoe@mail.com", 93 | Role: &model.Role{ 94 | AccessLevel: model.SuperAdminRole, 95 | }, 96 | }, 97 | wantToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", 98 | }, 99 | } 100 | jwtCfg := &config.JWT{Realm: "testRealm", Secret: "jwtsecret", Duration: 60, SigningAlgorithm: "HS256"} 101 | 102 | for _, tt := range cases { 103 | t.Run(tt.name, func(t *testing.T) { 104 | jwt := mw.NewJWT(jwtCfg) 105 | str, _, err := jwt.GenerateToken(tt.req) 106 | assert.Nil(t, err) 107 | assert.Equal(t, tt.wantToken, strings.Split(str, ".")[0]) 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | // Add adds middlewares to gin engine 6 | func Add(r *gin.Engine, h ...gin.HandlerFunc) { 7 | for _, v := range h { 8 | r.Use(v) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "testing" 5 | 6 | mw "github.com/alpacahq/ribbit-backend/middleware" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func TestAdd(t *testing.T) { 12 | gin.SetMode(gin.TestMode) 13 | r := gin.New() 14 | mw.Add(r, gin.Logger()) 15 | } 16 | -------------------------------------------------------------------------------- /migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/alpacahq/ribbit-backend/config" 10 | "github.com/alpacahq/ribbit-backend/model" 11 | 12 | migrations "github.com/go-pg/migrations/v7" 13 | "github.com/go-pg/pg/v9" 14 | "github.com/go-pg/pg/v9/orm" 15 | ) 16 | 17 | const usageText = `This program runs command on the db. Supported commands are: 18 | - init - creates version info table in the database 19 | - up - runs all available migrations. 20 | - up [target] - runs available migrations up to the target one. 21 | - down - reverts last migration. 22 | - reset - reverts all migrations. 23 | - version - prints current db version. 24 | - set_version [version] - sets db version without running migrations. 25 | - create_schema [version] - creates initial set of tables from models (structs). 26 | - sync_assets - sync all the assets from broker. 27 | Usage: 28 | go run *.go [args] 29 | ` 30 | 31 | // Run executes migration subcommands 32 | func Run(args ...string) error { 33 | fmt.Println("Running migration") 34 | 35 | p := config.GetPostgresConfig() 36 | 37 | // connection to db as postgres superuser 38 | dbSuper := config.GetPostgresSuperUserConnection() 39 | defer dbSuper.Close() 40 | 41 | // connection to db as POSTGRES_USER 42 | db := config.GetConnection() 43 | defer db.Close() 44 | 45 | createUserIfNotExist(dbSuper, p) 46 | 47 | createDatabaseIfNotExist(dbSuper, p) 48 | 49 | if flag.Arg(0) == "create_schema" { 50 | createSchema(db, &model.Role{}, &model.User{}, &model.Verification{}) 51 | os.Exit(2) 52 | } 53 | 54 | oldVersion, newVersion, err := migrations.Run(db, args...) 55 | if err != nil { 56 | exitf(err.Error()) 57 | } 58 | if newVersion != oldVersion { 59 | fmt.Printf("migrated from version %d to %d\n", oldVersion, newVersion) 60 | } else { 61 | fmt.Printf("version is %d\n", oldVersion) 62 | } 63 | return nil 64 | } 65 | 66 | func usage() { 67 | fmt.Print(usageText) 68 | flag.PrintDefaults() 69 | os.Exit(2) 70 | } 71 | 72 | func errorf(s string, args ...interface{}) { 73 | fmt.Fprintf(os.Stderr, s+"\n", args...) 74 | } 75 | 76 | func exitf(s string, args ...interface{}) { 77 | errorf(s, args...) 78 | os.Exit(1) 79 | } 80 | 81 | func createUserIfNotExist(db *pg.DB, p *config.PostgresConfig) { 82 | statement := fmt.Sprintf(`SELECT * FROM pg_roles WHERE rolname = '%s';`, p.User) 83 | res, _ := db.Exec(statement) 84 | if res.RowsReturned() == 0 { 85 | statement = fmt.Sprintf(`CREATE USER %s WITH PASSWORD '%s';`, p.User, p.Password) 86 | _, err := db.Exec(statement) 87 | if err != nil { 88 | fmt.Println(err) 89 | } else { 90 | fmt.Printf(`Created user %s`, p.User) 91 | } 92 | } 93 | } 94 | 95 | func createDatabaseIfNotExist(db *pg.DB, p *config.PostgresConfig) { 96 | statement := fmt.Sprintf(`SELECT 1 AS result FROM pg_database WHERE datname = '%s';`, p.Database) 97 | res, _ := db.Exec(statement) 98 | if res.RowsReturned() == 0 { 99 | fmt.Println("creating database") 100 | statement = fmt.Sprintf(`CREATE DATABASE %s WITH OWNER %s;`, p.Database, p.User) 101 | _, err := db.Exec(statement) 102 | if err != nil { 103 | fmt.Println(err) 104 | } else { 105 | fmt.Printf(`Created database %s`, p.Database) 106 | } 107 | } 108 | } 109 | 110 | func createSchema(db *pg.DB, models ...interface{}) { 111 | for _, model := range models { 112 | opt := &orm.CreateTableOptions{ 113 | IfNotExists: true, 114 | FKConstraints: true, 115 | } 116 | err := db.CreateTable(model, opt) 117 | if err != nil { 118 | log.Fatal(err) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /mobile/mobile.go: -------------------------------------------------------------------------------- 1 | package mobile 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/alpacahq/ribbit-backend/config" 13 | ) 14 | 15 | // NewMobile creates a new mobile service implementation 16 | func NewMobile(config *config.TwilioConfig) *Mobile { 17 | return &Mobile{config} 18 | } 19 | 20 | // Mobile provides a mobile service implementation 21 | type Mobile struct { 22 | config *config.TwilioConfig 23 | } 24 | 25 | // GenerateSMSToken sends an sms token to the mobile numer 26 | func (m *Mobile) GenerateSMSToken(countryCode, mobile string) error { 27 | apiURL := m.getTwilioVerifyURL() 28 | data := url.Values{} 29 | data.Set("To", countryCode+mobile) 30 | data.Set("Channel", "sms") 31 | resp, err := m.send(apiURL, data) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | bodyBytes, err := ioutil.ReadAll(resp.Body) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | bodyString := string(bodyBytes) 41 | fmt.Println(bodyString) 42 | return err 43 | } 44 | 45 | // CheckCode verifies if the user-provided code is approved 46 | func (m *Mobile) CheckCode(countryCode, mobile, code string) error { 47 | apiURL := m.getTwilioVerifyURL() 48 | data := url.Values{} 49 | data.Set("To", countryCode+mobile) 50 | data.Set("Code", code) 51 | resp, err := m.send(apiURL, data) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | // take a look at our response 57 | fmt.Println(resp.StatusCode) 58 | fmt.Println(resp.Body) 59 | bodyBytes, err := ioutil.ReadAll(resp.Body) 60 | if err != nil { 61 | log.Fatal(err) 62 | } 63 | bodyString := string(bodyBytes) 64 | fmt.Println(bodyString) 65 | return nil 66 | } 67 | 68 | func (m *Mobile) getTwilioVerifyURL() string { 69 | return "https://verify.twilio.com/v2/Services/" + m.config.Verify + "/Verifications" 70 | } 71 | 72 | func (m *Mobile) send(apiURL string, data url.Values) (*http.Response, error) { 73 | u, _ := url.ParseRequestURI(apiURL) 74 | urlStr := u.String() 75 | // http client 76 | client := &http.Client{} 77 | r, _ := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode())) // URL-encoded payload 78 | r.SetBasicAuth(m.config.Account, m.config.Token) 79 | r.Header.Add("Content-Type", "application/x-www-form-urlencoded") 80 | r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) 81 | 82 | return client.Do(r) 83 | } 84 | -------------------------------------------------------------------------------- /mobile/mobile_interface.go: -------------------------------------------------------------------------------- 1 | package mobile 2 | 3 | // Service is the interface to our mobile service 4 | type Service interface { 5 | GenerateSMSToken(countryCode, mobile string) error 6 | CheckCode(countryCode, mobile, code string) error 7 | } 8 | -------------------------------------------------------------------------------- /mock/auth.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/model" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // Auth mock 10 | type Auth struct { 11 | UserFn func(*gin.Context) *model.AuthUser 12 | } 13 | 14 | // User mock 15 | func (a *Auth) User(c *gin.Context) *model.AuthUser { 16 | return a.UserFn(c) 17 | } 18 | -------------------------------------------------------------------------------- /mock/magic.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | mag "github.com/magiclabs/magic-admin-go" 5 | "github.com/magiclabs/magic-admin-go/token" 6 | ) 7 | 8 | type Magic struct { 9 | IsValidtokenFn func(string) (*token.Token, error) 10 | GetIssuerFn func(*token.Token) (*mag.UserInfo, error) 11 | } 12 | 13 | func (m *Magic) IsValidToken(token string) (*token.Token, error) { 14 | return m.IsValidtokenFn(token) 15 | } 16 | 17 | func (m *Magic) GetIssuer(tok *token.Token) (*mag.UserInfo, error) { 18 | return m.GetIssuerFn(tok) 19 | } 20 | -------------------------------------------------------------------------------- /mock/mail.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/alpacahq/ribbit-backend/model" 4 | 5 | // Mail mock 6 | type Mail struct { 7 | ExternalURL string 8 | SendFn func(string, string, string, string, string) error 9 | SendWithDefaultsFn func(string, string, string, string) error 10 | SendVerificationEmailFn func(string, *model.Verification) error 11 | SendForgotVerificationEmailFn func(string, *model.Verification) error 12 | } 13 | 14 | // Send mock 15 | func (m *Mail) Send(subject, toName, toEmail, content, html string) error { 16 | return m.SendFn(subject, toName, toEmail, content, html) 17 | } 18 | 19 | // SendWithDefaults mock 20 | func (m *Mail) SendWithDefaults(subject, toEmail, content, html string) error { 21 | return m.SendWithDefaultsFn(subject, toEmail, content, html) 22 | } 23 | 24 | // SendVerificationEmail mock 25 | func (m *Mail) SendVerificationEmail(toEmail string, v *model.Verification) error { 26 | return m.SendVerificationEmailFn(toEmail, v) 27 | } 28 | 29 | func (m *Mail) SendForgotVerificationEmail(toEmail string, v *model.Verification) error { 30 | return m.SendForgotVerificationEmailFn(toEmail, v) 31 | } 32 | -------------------------------------------------------------------------------- /mock/middleware.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/alpacahq/ribbit-backend/model" 4 | 5 | // JWT mock 6 | type JWT struct { 7 | GenerateTokenFn func(*model.User) (string, string, error) 8 | } 9 | 10 | // GenerateToken mock 11 | func (j *JWT) GenerateToken(u *model.User) (string, string, error) { 12 | return j.GenerateTokenFn(u) 13 | } 14 | -------------------------------------------------------------------------------- /mock/mobile.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | // Mobile mock 4 | type Mobile struct { 5 | GenerateSMSTokenFn func(string, string) error 6 | CheckCodeFn func(string, string, string) error 7 | } 8 | 9 | // GenerateSMSToken mock 10 | func (m *Mobile) GenerateSMSToken(countryCode, mobile string) error { 11 | return m.GenerateSMSTokenFn(countryCode, mobile) 12 | } 13 | 14 | // CheckCode mock 15 | func (m *Mobile) CheckCode(countryCode, mobile, code string) error { 16 | return m.CheckCodeFn(countryCode, mobile, code) 17 | } 18 | -------------------------------------------------------------------------------- /mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "net/http/httptest" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // TestTime is used for testing time fields 11 | func TestTime(year int) time.Time { 12 | return time.Date(year, time.May, 19, 1, 2, 3, 4, time.UTC) 13 | } 14 | 15 | // TestTimePtr is used for testing pointer time fields 16 | func TestTimePtr(year int) *time.Time { 17 | t := time.Date(year, time.May, 19, 1, 2, 3, 4, time.UTC) 18 | return &t 19 | } 20 | 21 | // Str2Ptr converts string to pointer 22 | func Str2Ptr(s string) *string { 23 | return &s 24 | } 25 | 26 | // GinCtxWithKeys returns new gin context with keys 27 | func GinCtxWithKeys(keys []string, values ...interface{}) *gin.Context { 28 | w := httptest.NewRecorder() 29 | gin.SetMode(gin.TestMode) 30 | c, _ := gin.CreateTestContext(w) 31 | for i, k := range keys { 32 | c.Set(k, values[i]) 33 | } 34 | return c 35 | } 36 | 37 | // HeaderValid is used for jwt testing 38 | func HeaderValid() string { 39 | return "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.8Fa8mhshx3tiQVzS5FoUXte5lHHC4cvaa_tzvcel38I" 40 | } 41 | 42 | // HeaderInvalid is used for jwt testing 43 | func HeaderInvalid() string { 44 | return "Bearer eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.7uPfVeZBkkyhICZSEINZfPo7ZsaY0NNeg0ebEGHuAvNjFvoKNn8dWYTKaZrqE1X4" 45 | } 46 | -------------------------------------------------------------------------------- /mock/mockdb/account.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/model" 5 | ) 6 | 7 | // Account database mock 8 | type Account struct { 9 | ActivateFn func(*model.User) error 10 | CreateFn func(*model.User) (*model.User, error) 11 | CreateAndVerifyFn func(*model.User) (*model.Verification, error) 12 | CreateWithMobileFn func(*model.User) error 13 | CreateForgotTokenFn func(*model.User) (*model.Verification, error) 14 | CreateNewOTPFn func(*model.User) (*model.Verification, error) 15 | CreateWithMagicFn func(*model.User) (int, error) 16 | ChangePasswordFn func(*model.User) error 17 | ResetPasswordFn func(*model.User) error 18 | UpdateAvatarFn func(*model.User) error 19 | FindVerificationTokenFn func(string) (*model.Verification, error) 20 | FindVerificationTokenByUserFn func(*model.User) (*model.Verification, error) 21 | DeleteVerificationTokenFn func(*model.Verification) error 22 | } 23 | 24 | func (a *Account) Activate(usr *model.User) error { 25 | return a.ActivateFn(usr) 26 | } 27 | 28 | // Create mock 29 | func (a *Account) Create(usr *model.User) (*model.User, error) { 30 | return a.CreateFn(usr) 31 | } 32 | 33 | // CreateAndVerify mock 34 | func (a *Account) CreateAndVerify(usr *model.User) (*model.Verification, error) { 35 | return a.CreateAndVerifyFn(usr) 36 | } 37 | 38 | // CreateWithMobile mock 39 | func (a *Account) CreateWithMobile(usr *model.User) error { 40 | return a.CreateWithMobileFn(usr) 41 | } 42 | 43 | func (a *Account) CreateForgotToken(usr *model.User) (*model.Verification, error) { 44 | return a.CreateForgotTokenFn(usr) 45 | } 46 | 47 | func (a *Account) CreateNewOTP(usr *model.User) (*model.Verification, error) { 48 | return a.CreateNewOTPFn(usr) 49 | } 50 | 51 | func (a *Account) CreateWithMagic(usr *model.User) (int, error) { 52 | return a.CreateWithMagicFn(usr) 53 | } 54 | 55 | // ChangePassword mock 56 | func (a *Account) ChangePassword(usr *model.User) error { 57 | return a.ChangePasswordFn(usr) 58 | } 59 | 60 | func (a *Account) UpdateAvatar(usr *model.User) error { 61 | return a.UpdateAvatarFn(usr) 62 | } 63 | 64 | func (a *Account) ResetPassword(usr *model.User) error { 65 | return a.ResetPasswordFn(usr) 66 | } 67 | 68 | // FindVerificationToken mock 69 | func (a *Account) FindVerificationToken(token string) (*model.Verification, error) { 70 | return a.FindVerificationTokenFn(token) 71 | } 72 | 73 | func (a *Account) FindVerificationTokenByUser(usr *model.User) (*model.Verification, error) { 74 | return a.FindVerificationTokenByUserFn(usr) 75 | } 76 | 77 | // DeleteVerificationToken mock 78 | func (a *Account) DeleteVerificationToken(v *model.Verification) error { 79 | return a.DeleteVerificationTokenFn(v) 80 | } 81 | -------------------------------------------------------------------------------- /mock/mockdb/user.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/model" 5 | ) 6 | 7 | // User database mock 8 | type User struct { 9 | ViewFn func(int) (*model.User, error) 10 | FindByReferralCodeFn func(string) (*model.ReferralCodeVerifyResponse, error) 11 | FindByUsernameFn func(string) (*model.User, error) 12 | FindByEmailFn func(string) (*model.User, error) 13 | FindByMobileFn func(string, string) (*model.User, error) 14 | FindByTokenFn func(string) (*model.User, error) 15 | UpdateLoginFn func(*model.User) error 16 | ListFn func(*model.ListQuery, *model.Pagination) ([]model.User, error) 17 | DeleteFn func(*model.User) error 18 | UpdateFn func(*model.User) (*model.User, error) 19 | } 20 | 21 | // View mock 22 | func (u *User) View(id int) (*model.User, error) { 23 | return u.ViewFn(id) 24 | } 25 | 26 | // FindByReferralCode mock 27 | func (u *User) FindByReferralCode(username string) (*model.ReferralCodeVerifyResponse, error) { 28 | return u.FindByReferralCodeFn(username) 29 | } 30 | 31 | // FindByUsername mock 32 | func (u *User) FindByUsername(username string) (*model.User, error) { 33 | return u.FindByUsernameFn(username) 34 | } 35 | 36 | // FindByEmail mock 37 | func (u *User) FindByEmail(email string) (*model.User, error) { 38 | return u.FindByEmailFn(email) 39 | } 40 | 41 | // FindByMobile mock 42 | func (u *User) FindByMobile(countryCode, mobile string) (*model.User, error) { 43 | return u.FindByMobileFn(countryCode, mobile) 44 | } 45 | 46 | // FindByToken mock 47 | func (u *User) FindByToken(token string) (*model.User, error) { 48 | return u.FindByTokenFn(token) 49 | } 50 | 51 | // UpdateLogin mock 52 | func (u *User) UpdateLogin(usr *model.User) error { 53 | return u.UpdateLoginFn(usr) 54 | } 55 | 56 | // List mock 57 | func (u *User) List(lq *model.ListQuery, p *model.Pagination) ([]model.User, error) { 58 | return u.ListFn(lq, p) 59 | } 60 | 61 | // Delete mock 62 | func (u *User) Delete(usr *model.User) error { 63 | return u.DeleteFn(usr) 64 | } 65 | 66 | // Update mock 67 | func (u *User) Update(usr *model.User) (*model.User, error) { 68 | return u.UpdateFn(usr) 69 | } 70 | -------------------------------------------------------------------------------- /mock/rbac.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/model" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // RBAC Mock 10 | type RBAC struct { 11 | EnforceRoleFn func(*gin.Context, model.AccessRole) bool 12 | EnforceUserFn func(*gin.Context, int) bool 13 | AccountCreateFn func(*gin.Context, int) bool 14 | IsLowerRoleFn func(*gin.Context, model.AccessRole) bool 15 | } 16 | 17 | // EnforceRole mock 18 | func (a *RBAC) EnforceRole(c *gin.Context, role model.AccessRole) bool { 19 | return a.EnforceRoleFn(c, role) 20 | } 21 | 22 | // EnforceUser mock 23 | func (a *RBAC) EnforceUser(c *gin.Context, id int) bool { 24 | return a.EnforceUserFn(c, id) 25 | } 26 | 27 | // AccountCreate mock 28 | func (a *RBAC) AccountCreate(c *gin.Context, roleID int) bool { 29 | return a.AccountCreateFn(c, roleID) 30 | } 31 | 32 | // IsLowerRole mock 33 | func (a *RBAC) IsLowerRole(c *gin.Context, role model.AccessRole) bool { 34 | return a.IsLowerRoleFn(c, role) 35 | } 36 | -------------------------------------------------------------------------------- /mock/secret.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | // Password mock 4 | type Password struct { 5 | HashPasswordFn func(string) string 6 | HashMatchesPasswordFn func(hash, password string) bool 7 | HashRandomPasswordFn func() (string, error) 8 | } 9 | 10 | // HashPassword mock 11 | func (p *Password) HashPassword(password string) string { 12 | return p.HashPasswordFn(password) 13 | } 14 | 15 | // HashMatchesPassword mock 16 | func (p *Password) HashMatchesPassword(hash, password string) bool { 17 | return p.HashMatchesPasswordFn(hash, password) 18 | } 19 | 20 | // HashRandomPassword mock 21 | func (p *Password) HashRandomPassword() (string, error) { 22 | return p.HashRandomPasswordFn() 23 | } 24 | -------------------------------------------------------------------------------- /mockgopg/build_insert.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | type buildInsert struct { 4 | insert string 5 | err error 6 | } 7 | -------------------------------------------------------------------------------- /mockgopg/build_query.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | type buildQuery struct { 4 | funcName string 5 | query string 6 | params []interface{} 7 | result *OrmResult 8 | err error 9 | } 10 | -------------------------------------------------------------------------------- /mockgopg/formatter.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import "github.com/go-pg/pg/v9/orm" 4 | 5 | // Formatter implements orm.Formatter 6 | type Formatter struct { 7 | } 8 | 9 | // FormatQuery formats our query and params to byte 10 | func (f *Formatter) FormatQuery(b []byte, query string, params ...interface{}) []byte { 11 | formatter := new(orm.Formatter) 12 | got := formatter.FormatQuery(b, query, params...) 13 | return got 14 | } 15 | -------------------------------------------------------------------------------- /mockgopg/mock.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | 7 | "github.com/alpacahq/ribbit-backend/manager" 8 | ) 9 | 10 | // SQLMock handles query mocks 11 | type SQLMock struct { 12 | lock *sync.RWMutex 13 | currentQuery string // tracking queries 14 | currentParams []interface{} 15 | queries map[string]buildQuery 16 | currentInsert string // tracking inserts 17 | inserts map[string]buildInsert 18 | } 19 | 20 | // ExpectInsert is a builder method that accepts a model as interface and returns an SQLMock pointer 21 | func (sqlMock *SQLMock) ExpectInsert(models ...interface{}) *SQLMock { 22 | sqlMock.lock.Lock() 23 | defer sqlMock.lock.Unlock() 24 | 25 | var inserts []string 26 | for _, v := range models { 27 | inserts = append(inserts, strings.ToLower(manager.GetType(v))) 28 | } 29 | currentInsert := strings.Join(inserts, ",") 30 | 31 | sqlMock.currentInsert = currentInsert 32 | return sqlMock 33 | } 34 | 35 | // ExpectExec is a builder method that accepts a query in string and returns an SQLMock pointer 36 | func (sqlMock *SQLMock) ExpectExec(query string) *SQLMock { 37 | sqlMock.lock.Lock() 38 | defer sqlMock.lock.Unlock() 39 | 40 | sqlMock.currentQuery = strings.TrimSpace(query) 41 | return sqlMock 42 | } 43 | 44 | // ExpectQuery accepts a query in string and returns an SQLMock pointer 45 | func (sqlMock *SQLMock) ExpectQuery(query string) *SQLMock { 46 | sqlMock.lock.Lock() 47 | defer sqlMock.lock.Unlock() 48 | 49 | sqlMock.currentQuery = strings.TrimSpace(query) 50 | return sqlMock 51 | } 52 | 53 | // ExpectQueryOne accepts a query in string and returns an SQLMock pointer 54 | func (sqlMock *SQLMock) ExpectQueryOne(query string) *SQLMock { 55 | sqlMock.lock.Lock() 56 | defer sqlMock.lock.Unlock() 57 | 58 | sqlMock.currentQuery = strings.TrimSpace(query) 59 | return sqlMock 60 | } 61 | 62 | // WithArgs is a builder method that accepts a query in string and returns an SQLMock pointer 63 | func (sqlMock *SQLMock) WithArgs(params ...interface{}) *SQLMock { 64 | sqlMock.lock.Lock() 65 | defer sqlMock.lock.Unlock() 66 | 67 | sqlMock.currentParams = make([]interface{}, 0) 68 | for _, p := range params { 69 | sqlMock.currentParams = append(sqlMock.currentParams, p) 70 | } 71 | 72 | return sqlMock 73 | } 74 | 75 | // Returns accepts expected result and error, and completes the build of our sqlMock object 76 | func (sqlMock *SQLMock) Returns(result *OrmResult, err error) { 77 | sqlMock.lock.Lock() 78 | defer sqlMock.lock.Unlock() 79 | 80 | q := buildQuery{ 81 | query: sqlMock.currentQuery, 82 | params: sqlMock.currentParams, 83 | result: result, 84 | err: err, 85 | } 86 | sqlMock.queries[sqlMock.currentQuery] = q 87 | sqlMock.currentQuery = "" 88 | sqlMock.currentParams = nil 89 | 90 | i := buildInsert{ 91 | insert: sqlMock.currentInsert, 92 | err: err, 93 | } 94 | sqlMock.inserts[sqlMock.currentInsert] = i 95 | sqlMock.currentInsert = "" 96 | } 97 | 98 | // FlushAll resets our sqlMock object 99 | func (sqlMock *SQLMock) FlushAll() { 100 | sqlMock.lock.Lock() 101 | defer sqlMock.lock.Unlock() 102 | 103 | sqlMock.currentQuery = "" 104 | sqlMock.currentParams = nil 105 | sqlMock.queries = make(map[string]buildQuery) 106 | 107 | sqlMock.currentInsert = "" 108 | sqlMock.inserts = make(map[string]buildInsert) 109 | } 110 | -------------------------------------------------------------------------------- /mockgopg/orm.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "regexp" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/alpacahq/ribbit-backend/manager" 13 | 14 | "github.com/go-pg/pg/v9/orm" 15 | ) 16 | 17 | type goPgDB struct { 18 | sqlMock *SQLMock 19 | } 20 | 21 | // NewGoPGDBTest returns method that already implements orm.DB and mock instance to mocking arguments and results. 22 | func NewGoPGDBTest() (conn orm.DB, mock *SQLMock, err error) { 23 | sqlMock := &SQLMock{ 24 | lock: new(sync.RWMutex), 25 | currentQuery: "", 26 | currentParams: nil, 27 | queries: make(map[string]buildQuery), 28 | currentInsert: "", 29 | inserts: make(map[string]buildInsert), 30 | } 31 | 32 | goPG := &goPgDB{ 33 | sqlMock: sqlMock, 34 | } 35 | 36 | return goPG, sqlMock, nil 37 | } 38 | 39 | // not yet implemented 40 | func (p *goPgDB) Model(model ...interface{}) *orm.Query { 41 | return nil 42 | } 43 | 44 | func (p *goPgDB) ModelContext(c context.Context, model ...interface{}) *orm.Query { 45 | return nil 46 | } 47 | 48 | func (p *goPgDB) Select(model interface{}) error { 49 | return nil 50 | } 51 | 52 | func (p *goPgDB) Insert(model ...interface{}) error { 53 | // return nil 54 | return p.doInsert(context.Background(), model...) 55 | } 56 | 57 | func (p *goPgDB) Update(model interface{}) error { 58 | return nil 59 | } 60 | 61 | func (p *goPgDB) Delete(model interface{}) error { 62 | return nil 63 | } 64 | 65 | func (p *goPgDB) ForceDelete(model interface{}) error { 66 | return nil 67 | } 68 | 69 | func (p *goPgDB) Exec(query interface{}, params ...interface{}) (orm.Result, error) { 70 | sqlQuery := fmt.Sprintf("%v", query) 71 | return p.doQuery(context.Background(), nil, sqlQuery, params...) 72 | } 73 | 74 | func (p *goPgDB) ExecContext(c context.Context, query interface{}, params ...interface{}) (orm.Result, error) { 75 | sqlQuery := fmt.Sprintf("%v", query) 76 | return p.doQuery(c, nil, sqlQuery, params...) 77 | } 78 | 79 | func (p *goPgDB) ExecOne(query interface{}, params ...interface{}) (orm.Result, error) { 80 | return nil, nil 81 | } 82 | 83 | func (p *goPgDB) ExecOneContext(c context.Context, query interface{}, params ...interface{}) (orm.Result, error) { 84 | return nil, nil 85 | } 86 | 87 | func (p *goPgDB) Query(model, query interface{}, params ...interface{}) (orm.Result, error) { 88 | sqlQuery := fmt.Sprintf("%v", query) 89 | return p.doQuery(context.Background(), model, sqlQuery, params...) 90 | } 91 | 92 | func (p *goPgDB) QueryContext(c context.Context, model, query interface{}, params ...interface{}) (orm.Result, error) { 93 | sqlQuery := fmt.Sprintf("%v", query) 94 | return p.doQuery(c, model, sqlQuery, params...) 95 | } 96 | 97 | func (p *goPgDB) QueryOne(model, query interface{}, params ...interface{}) (orm.Result, error) { 98 | sqlQuery := fmt.Sprintf("%v", query) 99 | return p.doQuery(context.Background(), model, sqlQuery, params...) 100 | } 101 | 102 | func (p *goPgDB) QueryOneContext(c context.Context, model, query interface{}, params ...interface{}) (orm.Result, error) { 103 | return nil, nil 104 | } 105 | 106 | func (p *goPgDB) CopyFrom(r io.Reader, query interface{}, params ...interface{}) (orm.Result, error) { 107 | return nil, nil 108 | } 109 | 110 | func (p *goPgDB) CopyTo(w io.Writer, query interface{}, params ...interface{}) (orm.Result, error) { 111 | return nil, nil 112 | } 113 | 114 | func (p *goPgDB) Context() context.Context { 115 | return context.Background() 116 | } 117 | 118 | func (p *goPgDB) Formatter() orm.QueryFormatter { 119 | f := new(Formatter) 120 | return f 121 | } 122 | 123 | func (p *goPgDB) doInsert(ctx context.Context, models ...interface{}) error { 124 | // update p.insertMock 125 | for k, v := range p.sqlMock.inserts { 126 | 127 | // not handling value at the moment 128 | 129 | onTheListInsertStr := k 130 | 131 | var inserts []string 132 | for _, v := range models { 133 | inserts = append(inserts, strings.ToLower(manager.GetType(v))) 134 | } 135 | wantedInsertStr := strings.Join(inserts, ",") 136 | 137 | if onTheListInsertStr == wantedInsertStr { 138 | return v.err 139 | } 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (p *goPgDB) doQuery(ctx context.Context, dst interface{}, query string, params ...interface{}) (orm.Result, error) { 146 | // replace duplicate space 147 | space := regexp.MustCompile(`\s+`) 148 | 149 | for k, v := range p.sqlMock.queries { 150 | onTheList := p.Formatter().FormatQuery(nil, k, v.params...) 151 | onTheListQueryStr := strings.TrimSpace(space.ReplaceAllString(string(onTheList), " ")) 152 | 153 | wantedQuery := p.Formatter().FormatQuery(nil, query, params...) 154 | wantedQueryStr := strings.TrimSpace(space.ReplaceAllString(string(wantedQuery), " ")) 155 | 156 | if onTheListQueryStr == wantedQueryStr { 157 | var ( 158 | data []byte 159 | err error 160 | ) 161 | 162 | if dst == nil { 163 | return v.result, v.err 164 | } 165 | 166 | data, err = json.Marshal(v.result.model) 167 | if err != nil { 168 | return v.result, err 169 | } 170 | 171 | err = json.Unmarshal(data, dst) 172 | if err != nil { 173 | return v.result, err 174 | } 175 | 176 | return v.result, v.err 177 | } 178 | } 179 | 180 | return nil, fmt.Errorf("no mock expectation result") 181 | } 182 | -------------------------------------------------------------------------------- /mockgopg/row_result.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import "github.com/go-pg/pg/v9/orm" 4 | 5 | // OrmResult struct to implements orm.Result 6 | type OrmResult struct { 7 | rowsAffected int 8 | rowsReturned int 9 | model interface{} 10 | } 11 | 12 | // Model implements an orm.Model 13 | func (o *OrmResult) Model() orm.Model { 14 | if o.model == nil { 15 | return nil 16 | } 17 | 18 | model, err := orm.NewModel(o.model) 19 | if err != nil { 20 | return nil 21 | } 22 | 23 | return model 24 | } 25 | 26 | // RowsAffected returns the number of rows affected in the data table 27 | func (o *OrmResult) RowsAffected() int { 28 | return o.rowsAffected 29 | } 30 | 31 | // RowsReturned returns the number of rows 32 | func (o *OrmResult) RowsReturned() int { 33 | return o.rowsReturned 34 | } 35 | 36 | // NewResult implements orm.Result in go-pg package 37 | func NewResult(rowAffected, rowReturned int, model interface{}) *OrmResult { 38 | return &OrmResult{ 39 | rowsAffected: rowAffected, 40 | rowsReturned: rowReturned, 41 | model: model, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /model/asset.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Asset{}) 5 | } 6 | 7 | type Asset struct { 8 | Base 9 | ID string `json:"id"` 10 | Class string `json:"class"` 11 | Exchange string `json:"exchange"` 12 | Symbol string `json:"symbol"` 13 | Name string `json:"name"` 14 | Status string `json:"status"` 15 | Tradable bool `json:"tradable"` 16 | Marginable bool `json:"marginable"` 17 | Shortable bool `json:"shortable"` 18 | EasyToBorrow bool `json:"easy_to_borrow"` 19 | Fractionable bool `json:"fractionable"` 20 | IsWatchlisted bool `json:"is_watchlisted"` 21 | } 22 | 23 | type AssetsRepo interface { 24 | CreateOrUpdate(*Asset) (*Asset, error) 25 | UpdateAsset(*Asset) error 26 | Search(string) ([]Asset, error) 27 | } 28 | -------------------------------------------------------------------------------- /model/auth.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | // AuthToken holds authentication token details with refresh token 8 | type AuthToken struct { 9 | Token string `json:"token"` 10 | Expires string `json:"expires"` 11 | RefreshToken string `json:"refresh_token"` 12 | } 13 | 14 | // LoginResponseWithToken holds authentication token details with refresh token 15 | type LoginResponseWithToken struct { 16 | Token string `json:"token"` 17 | Expires string `json:"expires"` 18 | RefreshToken string `json:"refresh_token"` 19 | User User `json:"user"` 20 | } 21 | 22 | // RefreshToken holds authentication token details 23 | type RefreshToken struct { 24 | Token string `json:"token"` 25 | Expires string `json:"expires"` 26 | } 27 | 28 | // AuthService represents authentication service interface 29 | type AuthService interface { 30 | User(*gin.Context) *AuthUser 31 | } 32 | 33 | // RBACService represents role-based access control service interface 34 | type RBACService interface { 35 | EnforceRole(*gin.Context, AccessRole) bool 36 | EnforceUser(*gin.Context, int) bool 37 | AccountCreate(*gin.Context, int) bool 38 | IsLowerRole(*gin.Context, AccessRole) bool 39 | } 40 | -------------------------------------------------------------------------------- /model/bank_account.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&BankAccount{}) 5 | } 6 | 7 | // Verification stores randomly generated tokens that can be redeemed 8 | type BankAccount struct { 9 | Base 10 | ID int `json:"id"` 11 | UserID int `json:"user_id"` 12 | AccessToken string `json:"access_token"` 13 | AccountID string `json:"account_id"` 14 | BankName string `json:"bank_name"` 15 | AccountName string `json:"account_name"` 16 | Status bool `json:"status"` 17 | } 18 | -------------------------------------------------------------------------------- /model/coins_transactions.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&CoinStatement{}) 5 | } 6 | 7 | // Verification stores randomly generated tokens that can be redeemed 8 | type CoinStatement struct { 9 | Base 10 | ID int `json:"id"` 11 | UserID int `json:"user_id"` 12 | Coins int `json:"coins"` 13 | Type string `json:"type"` 14 | Reason string `json:"reason"` 15 | Status bool `json:"status"` 16 | } 17 | -------------------------------------------------------------------------------- /model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Models hold registered models in-memory 9 | var Models []interface{} 10 | 11 | // Base contains common fields for all tables 12 | type Base struct { 13 | CreatedAt time.Time `json:"created_at"` 14 | UpdatedAt time.Time `json:"updated_at"` 15 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 16 | } 17 | 18 | // Pagination holds pagination's data 19 | type Pagination struct { 20 | Limit int 21 | Offset int 22 | } 23 | 24 | // ListQuery holds company/location data used for list db queries 25 | type ListQuery struct { 26 | Query string 27 | ID int 28 | } 29 | 30 | // BeforeInsert hooks into insert operations, setting createdAt and updatedAt to current time 31 | func (b *Base) BeforeInsert(ctx context.Context) (context.Context, error) { 32 | now := time.Now() 33 | if b.CreatedAt.IsZero() { 34 | b.CreatedAt = now 35 | } 36 | if b.UpdatedAt.IsZero() { 37 | b.UpdatedAt = now 38 | } 39 | return ctx, nil 40 | } 41 | 42 | // BeforeUpdate hooks into update operations, setting updatedAt to current time 43 | func (b *Base) BeforeUpdate(ctx context.Context) (context.Context, error) { 44 | b.UpdatedAt = time.Now() 45 | return ctx, nil 46 | } 47 | 48 | // Delete sets deleted_at time to current_time 49 | func (b *Base) Delete() { 50 | t := time.Now() 51 | b.DeletedAt = &t 52 | } 53 | 54 | // Register is used for registering models 55 | func Register(m interface{}) { 56 | Models = append(Models, m) 57 | } 58 | -------------------------------------------------------------------------------- /model/model_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/alpacahq/ribbit-backend/mock" 7 | "github.com/alpacahq/ribbit-backend/model" 8 | ) 9 | 10 | func TestBeforeInsert(t *testing.T) { 11 | base := &model.Base{} 12 | base.BeforeInsert(nil) 13 | if base.CreatedAt.IsZero() { 14 | t.Errorf("CreatedAt was not changed") 15 | } 16 | if base.UpdatedAt.IsZero() { 17 | t.Errorf("UpdatedAt was not changed") 18 | } 19 | } 20 | 21 | func TestBeforeUpdate(t *testing.T) { 22 | base := &model.Base{ 23 | CreatedAt: mock.TestTime(2000), 24 | } 25 | base.BeforeUpdate(nil) 26 | if base.UpdatedAt == mock.TestTime(2001) { 27 | t.Errorf("UpdatedAt was not changed") 28 | } 29 | 30 | } 31 | 32 | func TestDelete(t *testing.T) { 33 | baseModel := &model.Base{ 34 | CreatedAt: mock.TestTime(2000), 35 | UpdatedAt: mock.TestTime(2001), 36 | } 37 | baseModel.Delete() 38 | if baseModel.DeletedAt.IsZero() { 39 | t.Errorf("DeletedAt not changed") 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /model/plaid.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type PlaidAuthToken struct { 4 | LinkToken string `json:"link_token"` 5 | } 6 | 7 | type AccessToken struct { 8 | ID int `json:"id"` 9 | PublicToken string `json:"public_token"` 10 | ItemID string `json:"item_id"` 11 | } 12 | -------------------------------------------------------------------------------- /model/reward.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Reward{}) 5 | } 6 | 7 | type Reward struct { 8 | Base 9 | ID int `json:"id"` 10 | PerAccountLimit int `json:"per_account_limit"` 11 | ReferralKycReward float64 `json:"referral_kyc_reward"` 12 | ReferralSignupReward float64 `json:"referral_signup_reward"` 13 | ReferreKycReward float64 `json:"referre_Kyc_reward"` 14 | } 15 | -------------------------------------------------------------------------------- /model/role.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Role{}) 5 | } 6 | 7 | // AccessRole represents access role type 8 | type AccessRole int8 9 | 10 | const ( 11 | // SuperAdminRole has all permissions 12 | SuperAdminRole AccessRole = iota + 1 13 | 14 | // AdminRole has admin specific permissions 15 | AdminRole 16 | 17 | // UserRole is a standard user 18 | UserRole 19 | ) 20 | 21 | // Role model 22 | type Role struct { 23 | ID int `json:"id"` 24 | AccessLevel AccessRole `json:"access_level"` 25 | Name string `json:"name"` 26 | } 27 | 28 | // RoleRepo represents the database interface 29 | type RoleRepo interface { 30 | CreateRoles() error 31 | } 32 | -------------------------------------------------------------------------------- /model/user_reward.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&UserReward{}) 5 | } 6 | 7 | type UserReward struct { 8 | Base 9 | ID int `json:"id"` 10 | UserID int `json:"user_id"` 11 | JournalID string `json:"journal_id"` 12 | ReferredBy int `json:"referred_by"` 13 | RewardValue float32 `json:"reward_value"` 14 | RewardType string `json:"reward_type"` 15 | RewardTransferStatus bool `json:"reward_transfer_status"` 16 | ErrorResponse string `json:"error_response"` 17 | } 18 | -------------------------------------------------------------------------------- /model/user_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/alpacahq/ribbit-backend/model" 7 | ) 8 | 9 | func TestUpdateLastLogin(t *testing.T) { 10 | user := &model.User{ 11 | FirstName: "TestGuy", 12 | } 13 | user.UpdateLastLogin() 14 | if user.LastLogin.IsZero() { 15 | t.Errorf("Last login time was not changed") 16 | } 17 | } 18 | 19 | func TestUpdateUpdatedAt(t *testing.T) { 20 | user := &model.User{ 21 | FirstName: "TestGal", 22 | } 23 | user.Update() 24 | if user.UpdatedAt.IsZero() { 25 | t.Errorf("updated_at is not changed") 26 | } 27 | } 28 | 29 | func TestUpdateDeletedAt(t *testing.T) { 30 | user := &model.User{ 31 | FirstName: "TestGod", 32 | } 33 | user.Delete() 34 | if user.DeletedAt.IsZero() { 35 | t.Errorf("deleted_at is not changed") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /model/verification.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Verification{}) 5 | } 6 | 7 | // Verification stores randomly generated tokens that can be redeemed 8 | type Verification struct { 9 | Base 10 | ID int `json:"id"` 11 | Token string `json:"token"` 12 | UserID int `json:"user_id"` 13 | } 14 | -------------------------------------------------------------------------------- /public/2140998d-7f62-46f2-a9b2-e44350bd4807.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/4f5baf1e-0e9b-4d85-b88a-d874dc4a3c42.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/57c36644-876b-437c-b913-3cdb58b18fd3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/662a919f-1455-497c-90e7-f76248e6d3a6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/69b15845-7c63-4586-b274-1cfdfe9df3d8.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/83e52ac1-bb18-4e9f-b68d-dda5a8af3ec0.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/8ccae427-5dd0-45b3-b5fe-7ba5e422c766.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/AAPL.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/AMZN.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/FB.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/GE.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/GOOG.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/GOOGL.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/MA.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/NFLX.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/SNAP.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/TME.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/TSLA.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/V.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/img/failed_approval.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/failed_approval.png -------------------------------------------------------------------------------- /public/assets/img/failed_approval.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/assets/img/forgot_password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/forgot_password.png -------------------------------------------------------------------------------- /public/assets/img/forgot_password.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/assets/img/header_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/header_logo.png -------------------------------------------------------------------------------- /public/assets/img/header_logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/header_logo2.png -------------------------------------------------------------------------------- /public/assets/img/social_icons_fb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/social_icons_fb.png -------------------------------------------------------------------------------- /public/assets/img/social_icons_instagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/social_icons_instagram.png -------------------------------------------------------------------------------- /public/assets/img/social_icons_twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/social_icons_twitter.png -------------------------------------------------------------------------------- /public/assets/img/submitted_application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/submitted_application.png -------------------------------------------------------------------------------- /public/assets/img/submitted_application.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/assets/img/verify_email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/verify_email.png -------------------------------------------------------------------------------- /public/assets/img/verify_email.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/assets/img/welcome_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alpacahq/ribbit-backend/8cb3723bd921eed7b8c76a202da455578181d9f9/public/assets/img/welcome_page.png -------------------------------------------------------------------------------- /public/assets/img/welcome_page.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/b0b6dd9d-8b9b-48a9-ba46-b9d54906e415.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/bb2a26c0-4c77-4801-8afc-82e8142ac7b8.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/f30d734c-2806-4d0d-b145-f9fade61432b.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/f801f835-bfe6-4a9d-a6b1-ccbb84bfd75f.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/fc6a5dcd-4a70-4b8d-b64f-d83a6dae9ba4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /repository/account/account.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/alpacahq/ribbit-backend/apperr" 7 | "github.com/alpacahq/ribbit-backend/model" 8 | "github.com/alpacahq/ribbit-backend/repository/platform/structs" 9 | "github.com/alpacahq/ribbit-backend/request" 10 | "github.com/alpacahq/ribbit-backend/secret" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | // Service represents the account application service 16 | type Service struct { 17 | accountRepo model.AccountRepo 18 | userRepo model.UserRepo 19 | rbac model.RBACService 20 | secret secret.Service 21 | } 22 | 23 | // NewAccountService creates a new account application service 24 | func NewAccountService(userRepo model.UserRepo, accountRepo model.AccountRepo, rbac model.RBACService, secret secret.Service) *Service { 25 | return &Service{ 26 | accountRepo: accountRepo, 27 | userRepo: userRepo, 28 | rbac: rbac, 29 | secret: secret, 30 | } 31 | } 32 | 33 | // Create creates a new user account 34 | func (s *Service) Create(c *gin.Context, u *model.User) error { 35 | if !s.rbac.AccountCreate(c, u.RoleID) { 36 | return apperr.New(http.StatusForbidden, "Forbidden") 37 | } 38 | u.Password = s.secret.HashPassword(u.Password) 39 | u, err := s.accountRepo.Create(u) 40 | return err 41 | } 42 | 43 | // ChangePassword changes user's password 44 | func (s *Service) ChangePassword(c *gin.Context, oldPass, newPass string, id int) error { 45 | if !s.rbac.EnforceUser(c, id) { 46 | return apperr.New(http.StatusForbidden, "Forbidden") 47 | } 48 | u, err := s.userRepo.View(id) 49 | if err != nil { 50 | return err 51 | } 52 | if !s.secret.HashMatchesPassword(u.Password, oldPass) { 53 | return apperr.New(http.StatusBadGateway, "old password is not correct") 54 | } 55 | u.Password = s.secret.HashPassword(newPass) 56 | return s.accountRepo.ChangePassword(u) 57 | } 58 | 59 | // UpdateAvatar changes user's avatar 60 | func (s *Service) UpdateAvatar(c *gin.Context, newAvatar string, id int) error { 61 | if !s.rbac.EnforceUser(c, id) { 62 | return apperr.New(http.StatusForbidden, "Forbidden") 63 | } 64 | u, err := s.userRepo.View(id) 65 | if err != nil { 66 | return err 67 | } 68 | u.Avatar = newAvatar 69 | return s.accountRepo.UpdateAvatar(u) 70 | } 71 | 72 | // GetProfile gets user's profile 73 | func (s *Service) GetProfile(c *gin.Context, id int) *model.User { 74 | if !s.rbac.EnforceUser(c, id) { 75 | return nil 76 | } 77 | u, err := s.userRepo.View(id) 78 | if err != nil { 79 | return nil 80 | } 81 | 82 | return u 83 | } 84 | 85 | // UpdateProfile updated user's profile 86 | func (s *Service) UpdateProfile(c *gin.Context, update *request.Update) (*model.User, error) { 87 | if !s.rbac.EnforceUser(c, update.ID) { 88 | return nil, apperr.New(http.StatusForbidden, "Forbidden") 89 | } 90 | u, err := s.userRepo.View(update.ID) 91 | if err != nil { 92 | return nil, err 93 | } 94 | structs.Merge(u, update) 95 | return s.userRepo.Update(u) 96 | } 97 | -------------------------------------------------------------------------------- /repository/asset.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/alpacahq/ribbit-backend/apperr" 8 | "github.com/alpacahq/ribbit-backend/model" 9 | "github.com/alpacahq/ribbit-backend/secret" 10 | 11 | "github.com/go-pg/pg/v9/orm" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // NewAssetRepo returns an AssetRepo instance 16 | func NewAssetRepo(db orm.DB, log *zap.Logger, secret secret.Service) *AssetRepo { 17 | return &AssetRepo{db, log, secret} 18 | } 19 | 20 | // AssetRepo represents the client for the user table 21 | type AssetRepo struct { 22 | db orm.DB 23 | log *zap.Logger 24 | Secret secret.Service 25 | } 26 | 27 | // Create creates a new asset in our database. 28 | func (a *AssetRepo) CreateOrUpdate(ass *model.Asset) (*model.Asset, error) { 29 | _asset := new(model.Asset) 30 | sql := `SELECT id FROM assets WHERE symbol = ?` 31 | res, err := a.db.Query(_asset, sql, ass.Symbol) 32 | if err == apperr.DB { 33 | a.log.Error("AssetRepo Error: ", zap.Error(err)) 34 | return nil, apperr.DB 35 | } 36 | if res.RowsReturned() != 0 { 37 | // update.. 38 | fmt.Println("updating...") 39 | _, err := a.db.Model(ass).Column( 40 | "class", 41 | "exchange", 42 | "name", 43 | "status", 44 | "tradable", 45 | "marginable", 46 | "shortable", 47 | "easy_to_borrow", 48 | "fractionable", 49 | "is_watchlisted", 50 | "updated_at", 51 | ).WherePK().Update() 52 | if err != nil { 53 | a.log.Warn("AssetRepo Error: ", zap.Error(err)) 54 | return nil, err 55 | } 56 | return ass, nil 57 | } else { 58 | // create 59 | fmt.Println("creating...") 60 | if err := a.db.Insert(ass); err != nil { 61 | a.log.Warn("AssetRepo error: ", zap.Error(err)) 62 | return nil, apperr.DB 63 | } 64 | } 65 | return ass, nil 66 | } 67 | 68 | // UpdateAsset changes user's avatar 69 | func (a *AssetRepo) UpdateAsset(u *model.Asset) error { 70 | _, err := a.db.Model(u).Column( 71 | "class", 72 | "exchange", 73 | "name", 74 | "status", 75 | "tradable", 76 | "marginable", 77 | "shortable", 78 | "easy_to_borrow", 79 | "fractionable", 80 | "is_watchlisted", 81 | "updated_at", 82 | ).WherePK().Update() 83 | if err != nil { 84 | a.log.Warn("AssetRepo Error: ", zap.Error(err)) 85 | } 86 | return err 87 | } 88 | 89 | // SearchAssets changes user's avatar 90 | func (a *AssetRepo) Search(query string) ([]model.Asset, error) { 91 | var exactAsset model.Asset 92 | var assets []model.Asset 93 | sql := `SELECT * FROM assets WHERE LOWER(symbol) = LOWER(?) LIMIT 1` 94 | _, err := a.db.QueryOne(&exactAsset, sql, query, query, query) 95 | if err != nil { 96 | a.log.Warn("AssetRepo Error", zap.String("Error:", err.Error())) 97 | } 98 | 99 | sql2 := `SELECT * FROM assets WHERE symbol ILIKE ? || '%' OR name ILIKE ? || '%' ORDER BY symbol ASC LIMIT 50` 100 | _, err2 := a.db.Query(&assets, sql2, query, query, query) 101 | if err2 != nil { 102 | a.log.Warn("AssetRepo Error", zap.String("Error:", err2.Error())) 103 | return assets, apperr.New(http.StatusNotFound, "404 not found") 104 | } 105 | 106 | if err == nil { 107 | fmt.Println(exactAsset) 108 | assets = append([]model.Asset{exactAsset}, findAndDelete(assets, exactAsset)...) 109 | } 110 | 111 | return assets, nil 112 | } 113 | 114 | func findAndDelete(s []model.Asset, item model.Asset) []model.Asset { 115 | index := 0 116 | for _, i := range s { 117 | if i != item { 118 | s[index] = i 119 | index++ 120 | } 121 | } 122 | return s[:index] 123 | } 124 | -------------------------------------------------------------------------------- /repository/assets/assets.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/alpacahq/ribbit-backend/model" 7 | "github.com/go-pg/pg/v9/orm" 8 | "go.uber.org/zap" 9 | 10 | "github.com/joho/godotenv" 11 | ) 12 | 13 | func init() { 14 | err := godotenv.Load() 15 | 16 | if err != nil { 17 | panic(fmt.Errorf("unexpected error while initializing plaid client %w", err)) 18 | } 19 | } 20 | 21 | // NewAuthService creates new auth service 22 | func NewAssetsService(userRepo model.UserRepo, accountRepo model.AccountRepo, assetRepo model.AssetsRepo, jwt JWT, db orm.DB, log *zap.Logger) *Service { 23 | return &Service{userRepo, assetRepo, accountRepo, jwt, db, log} 24 | } 25 | 26 | // Service represents the auth application service 27 | type Service struct { 28 | userRepo model.UserRepo 29 | assetRepo model.AssetsRepo 30 | accountRepo model.AccountRepo 31 | jwt JWT 32 | db orm.DB 33 | log *zap.Logger 34 | } 35 | 36 | // JWT represents jwt interface 37 | type JWT interface { 38 | GenerateToken(*model.User) (string, string, error) 39 | } 40 | 41 | // SearchAssets changes user's avatar 42 | func (a *Service) SearchAssets(query string) ([]model.Asset, error) { 43 | assets, err := a.assetRepo.Search(query) 44 | if err != nil { 45 | return nil, err 46 | } 47 | return assets, nil 48 | } 49 | -------------------------------------------------------------------------------- /repository/platform/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/alpacahq/ribbit-backend/apperr" 7 | "github.com/alpacahq/ribbit-backend/model" 8 | ) 9 | 10 | // List prepares data for list queries 11 | func List(u *model.AuthUser) (*model.ListQuery, error) { 12 | switch true { 13 | case int(u.Role) <= 2: // user is SuperAdmin or Admin 14 | return nil, nil 15 | default: 16 | return nil, apperr.New(http.StatusForbidden, "Forbidden") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /repository/platform/structs/structs.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import "reflect" 4 | 5 | // Merge receives two structs, and merges them excluding fields with tag name: `structs`, value "-" 6 | func Merge(dst, src interface{}) { 7 | s := reflect.ValueOf(src) 8 | d := reflect.ValueOf(dst) 9 | if s.Kind() != reflect.Ptr || d.Kind() != reflect.Ptr { 10 | return 11 | } 12 | for i := 0; i < s.Elem().NumField(); i++ { 13 | v := s.Elem().Field(i) 14 | fieldName := s.Elem().Type().Field(i).Name 15 | skip := s.Elem().Type().Field(i).Tag.Get("structs") 16 | if skip == "-" { 17 | continue 18 | } 19 | if v.Kind() > reflect.Float64 && 20 | v.Kind() != reflect.String && 21 | v.Kind() != reflect.Struct && 22 | v.Kind() != reflect.Ptr && 23 | v.Kind() != reflect.Slice { 24 | continue 25 | } 26 | if v.Kind() == reflect.Ptr { 27 | // Field is pointer check if it's nil or set 28 | if !v.IsNil() { 29 | // Field is set assign it to dest 30 | 31 | if d.Elem().FieldByName(fieldName).Kind() == reflect.Ptr { 32 | d.Elem().FieldByName(fieldName).Set(v) 33 | continue 34 | } 35 | f := d.Elem().FieldByName(fieldName) 36 | if f.IsValid() { 37 | f.Set(v.Elem()) 38 | } 39 | } 40 | continue 41 | } 42 | d.Elem().FieldByName(fieldName).Set(v) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /repository/rbac.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/model" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // NewRBACService creates new RBAC service 10 | func NewRBACService(userRepo model.UserRepo) *RBACService { 11 | return &RBACService{ 12 | userRepo: userRepo, 13 | } 14 | } 15 | 16 | // RBACService is RBAC application service 17 | type RBACService struct { 18 | userRepo model.UserRepo 19 | } 20 | 21 | // EnforceRole authorizes request by AccessRole 22 | func (s *RBACService) EnforceRole(c *gin.Context, r model.AccessRole) bool { 23 | return !(c.MustGet("role").(int8) > int8(r)) 24 | } 25 | 26 | // EnforceUser checks whether the request to change user data is done by the same user 27 | func (s *RBACService) EnforceUser(c *gin.Context, ID int) bool { 28 | // TODO: Implement querying db and checking the requested user's company_id/location_id 29 | // to allow company/location admins to view the user 30 | return (c.GetInt("id") == ID) || s.isAdmin(c) 31 | } 32 | 33 | func (s *RBACService) isAdmin(c *gin.Context) bool { 34 | return !(c.MustGet("role").(int8) > int8(model.AdminRole)) 35 | } 36 | 37 | // AccountCreate performs auth check when creating a new account 38 | // Location admin cannot create accounts, needs to be fixed on EnforceLocation function 39 | func (s *RBACService) AccountCreate(c *gin.Context, roleID int) bool { 40 | roleCheck := s.EnforceRole(c, model.AccessRole(roleID)) 41 | return roleCheck && s.IsLowerRole(c, model.AccessRole(roleID)) 42 | } 43 | 44 | // IsLowerRole checks whether the requesting user has higher role than the user it wants to change 45 | // Used for account creation/deletion 46 | func (s *RBACService) IsLowerRole(c *gin.Context, r model.AccessRole) bool { 47 | return !(c.MustGet("role").(int8) >= int8(r)) 48 | } 49 | -------------------------------------------------------------------------------- /repository/rbac_i_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/alpacahq/ribbit-backend/model" 12 | "github.com/alpacahq/ribbit-backend/repository" 13 | "github.com/alpacahq/ribbit-backend/repository/account" 14 | "github.com/alpacahq/ribbit-backend/secret" 15 | 16 | embeddedpostgres "github.com/fergusstrange/embedded-postgres" 17 | "github.com/gin-gonic/gin" 18 | "github.com/go-pg/pg/v9" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/suite" 21 | "go.uber.org/zap" 22 | ) 23 | 24 | type RBACTestSuite struct { 25 | suite.Suite 26 | db *pg.DB 27 | postgres *embeddedpostgres.EmbeddedPostgres 28 | } 29 | 30 | func (suite *RBACTestSuite) SetupTest() { 31 | _, b, _, _ := runtime.Caller(0) 32 | d := path.Join(path.Dir(b)) 33 | projectRoot := filepath.Dir(d) 34 | tmpDir := path.Join(projectRoot, "tmp") 35 | os.RemoveAll(tmpDir) 36 | testConfig := embeddedpostgres.DefaultConfig(). 37 | Username("db_test_user"). 38 | Password("db_test_password"). 39 | Database("db_test_database"). 40 | Version(embeddedpostgres.V12). 41 | RuntimePath(tmpDir). 42 | Port(9876) 43 | 44 | suite.postgres = embeddedpostgres.NewDatabase(testConfig) 45 | err := suite.postgres.Start() 46 | assert.Equal(suite.T(), err, nil) 47 | 48 | suite.db = pg.Connect(&pg.Options{ 49 | Addr: "localhost:9876", 50 | User: "db_test_user", 51 | Password: "db_test_password", 52 | Database: "db_test_database", 53 | }) 54 | createSchema(suite.db, &model.Role{}, &model.User{}, &model.Verification{}) 55 | } 56 | 57 | func (suite *RBACTestSuite) TearDownTest() { 58 | suite.postgres.Stop() 59 | } 60 | 61 | func TestRBACTestSuiteIntegration(t *testing.T) { 62 | if testing.Short() { 63 | t.Skip("skipping integration test") 64 | return 65 | } 66 | suite.Run(t, new(RBACTestSuite)) 67 | } 68 | 69 | func (suite *RBACTestSuite) TestRBAC() { 70 | // create a context for tests 71 | resp := httptest.NewRecorder() 72 | gin.SetMode(gin.TestMode) 73 | c, _ := gin.CreateTestContext(resp) 74 | c.Set("role", int8(model.SuperAdminRole)) 75 | 76 | // create a user in our test database, which is superadmin 77 | log, _ := zap.NewDevelopment() 78 | userRepo := repository.NewUserRepo(suite.db, log) 79 | accountRepo := repository.NewAccountRepo(suite.db, log, secret.New()) 80 | rbac := repository.NewRBACService(userRepo) 81 | 82 | // ensure that our roles table is populated with default roles 83 | roleRepo := repository.NewRoleRepo(suite.db, log) 84 | err := roleRepo.CreateRoles() 85 | assert.Nil(suite.T(), err) 86 | 87 | accountService := account.NewAccountService(userRepo, accountRepo, rbac, secret.New()) 88 | err = accountService.Create(c, &model.User{ 89 | CountryCode: "+65", 90 | Mobile: "91919191", 91 | Active: true, 92 | RoleID: 3, 93 | }) 94 | 95 | assert.Nil(suite.T(), err) 96 | assert.NotNil(suite.T(), rbac) 97 | 98 | // since the current user is a superadmin, we should be able to change user data 99 | userID := 1 100 | access := rbac.EnforceUser(c, userID) 101 | assert.True(suite.T(), access) 102 | 103 | // since the current user is a superadmin, we should be able to change location data 104 | // access = rbac.EnforceLocation(c, 1) 105 | // assert.True(suite.T(), access) 106 | } 107 | -------------------------------------------------------------------------------- /repository/role.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/model" 5 | 6 | "github.com/go-pg/pg/v9" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // NewRoleRepo returns a Role Repo instance 11 | func NewRoleRepo(db *pg.DB, log *zap.Logger) *RoleRepo { 12 | return &RoleRepo{db, log} 13 | } 14 | 15 | // RoleRepo represents the client for the role table 16 | type RoleRepo struct { 17 | db *pg.DB 18 | log *zap.Logger 19 | } 20 | 21 | // CreateRoles creates role objects in our database 22 | func (r *RoleRepo) CreateRoles() error { 23 | role := new(model.Role) 24 | sql := `INSERT INTO roles (id, access_level, name) VALUES (?, ?, ?) ON CONFLICT DO NOTHING` 25 | r.db.Query(role, sql, 1, model.SuperAdminRole, "superadmin") 26 | r.db.Query(role, sql, 2, model.AdminRole, "admin") 27 | r.db.Query(role, sql, 3, model.UserRole, "user") 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /repository/transfer/transfer.go: -------------------------------------------------------------------------------- 1 | package transfer 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/model" 5 | "github.com/go-pg/pg/v9/orm" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // NewAuthService creates new auth service 10 | func NewTransferService(userRepo model.UserRepo, accountRepo model.AccountRepo, jwt JWT, db orm.DB, log *zap.Logger) *Service { 11 | return &Service{userRepo, accountRepo, jwt, db, log} 12 | } 13 | 14 | // Service represents the auth application service 15 | type Service struct { 16 | userRepo model.UserRepo 17 | accountRepo model.AccountRepo 18 | jwt JWT 19 | db orm.DB 20 | log *zap.Logger 21 | } 22 | 23 | // JWT represents jwt interface 24 | type JWT interface { 25 | GenerateToken(*model.User) (string, string, error) 26 | } 27 | -------------------------------------------------------------------------------- /repository/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/alpacahq/ribbit-backend/apperr" 7 | "github.com/alpacahq/ribbit-backend/model" 8 | "github.com/alpacahq/ribbit-backend/repository/platform/query" 9 | "github.com/alpacahq/ribbit-backend/repository/platform/structs" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | // NewUserService create a new user application service 15 | func NewUserService(userRepo model.UserRepo, auth model.AuthService, rbac model.RBACService) *Service { 16 | return &Service{ 17 | userRepo: userRepo, 18 | auth: auth, 19 | rbac: rbac, 20 | } 21 | } 22 | 23 | // Service represents the user application service 24 | type Service struct { 25 | userRepo model.UserRepo 26 | auth model.AuthService 27 | rbac model.RBACService 28 | } 29 | 30 | // List returns list of users 31 | func (s *Service) List(c *gin.Context, p *model.Pagination) ([]model.User, error) { 32 | u := s.auth.User(c) 33 | q, err := query.List(u) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return s.userRepo.List(q, p) 38 | } 39 | 40 | // View returns single user 41 | func (s *Service) View(c *gin.Context, id int) (*model.User, error) { 42 | if !s.rbac.EnforceUser(c, id) { 43 | return nil, apperr.New(http.StatusForbidden, "Forbidden") 44 | } 45 | return s.userRepo.View(id) 46 | } 47 | 48 | // Update contains user's information used for updating 49 | type Update struct { 50 | ID int 51 | FirstName *string 52 | LastName *string 53 | Mobile *string 54 | Phone *string 55 | Address *string 56 | } 57 | 58 | // Update updates user's contact information 59 | func (s *Service) Update(c *gin.Context, update *Update) (*model.User, error) { 60 | if !s.rbac.EnforceUser(c, update.ID) { 61 | return nil, apperr.New(http.StatusForbidden, "Forbidden") 62 | } 63 | u, err := s.userRepo.View(update.ID) 64 | if err != nil { 65 | return nil, err 66 | } 67 | structs.Merge(u, update) 68 | return s.userRepo.Update(u) 69 | } 70 | 71 | // Delete deletes a user 72 | func (s *Service) Delete(c *gin.Context, id int) error { 73 | u, err := s.userRepo.View(id) 74 | if err != nil { 75 | return err 76 | } 77 | if !s.rbac.IsLowerRole(c, u.Role.AccessLevel) { 78 | return apperr.New(http.StatusForbidden, "Forbidden") 79 | } 80 | u.Delete() 81 | return s.userRepo.Delete(u) 82 | } 83 | -------------------------------------------------------------------------------- /repository/user_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/alpacahq/ribbit-backend/mockgopg" 7 | "github.com/alpacahq/ribbit-backend/model" 8 | "github.com/alpacahq/ribbit-backend/repository" 9 | 10 | "github.com/go-pg/pg/v9/orm" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/suite" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | type UserUnitTestSuite struct { 17 | suite.Suite 18 | mock *mockgopg.SQLMock 19 | u *model.User 20 | userRepo *repository.UserRepo 21 | } 22 | 23 | func (suite *UserUnitTestSuite) SetupTest() { 24 | var err error 25 | var db orm.DB 26 | db, suite.mock, err = mockgopg.NewGoPGDBTest() 27 | if err != nil { 28 | suite.T().Fatalf("an error '%s' was not expected when opening a stub database connection", err) 29 | } 30 | suite.u = &model.User{ 31 | Username: "hello", 32 | Email: "hello@world.org", 33 | CountryCode: "+65", 34 | Mobile: "91919191", 35 | Token: "someusertoken", 36 | } 37 | 38 | log, _ := zap.NewDevelopment() 39 | suite.userRepo = repository.NewUserRepo(db, log) 40 | } 41 | 42 | func (suite *UserUnitTestSuite) TearDownTest() { 43 | suite.mock.FlushAll() 44 | } 45 | 46 | func TestUserUnitTestSuite(t *testing.T) { 47 | suite.Run(t, new(UserUnitTestSuite)) 48 | } 49 | 50 | func (suite *UserUnitTestSuite) TestFindByReferralCodeSuccess() { 51 | u := suite.u 52 | userRepo := suite.userRepo 53 | t := suite.T() 54 | mock := suite.mock 55 | 56 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 57 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 58 | WHERE ("user"."username" = ? and deleted_at is null)` 59 | mock.ExpectQueryOne(sql). 60 | WithArgs(u.Username). 61 | Returns(mockgopg.NewResult(1, 1, u), nil) 62 | 63 | uReturned, err := userRepo.FindByReferralCode("hello") 64 | assert.Equal(t, u.Username, uReturned.Username) 65 | assert.Nil(t, err) 66 | } 67 | 68 | func (suite *UserUnitTestSuite) TestFindByUsernameSuccess() { 69 | u := suite.u 70 | userRepo := suite.userRepo 71 | t := suite.T() 72 | mock := suite.mock 73 | 74 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 75 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 76 | WHERE ("user"."username" = ? and deleted_at is null)` 77 | mock.ExpectQueryOne(sql). 78 | WithArgs(u.Username). 79 | Returns(mockgopg.NewResult(1, 1, u), nil) 80 | 81 | uReturned, err := userRepo.FindByUsername("hello") 82 | assert.Equal(t, u.Username, uReturned.Username) 83 | assert.Nil(t, err) 84 | } 85 | 86 | func (suite *UserUnitTestSuite) TestFindByEmailSuccess() { 87 | u := suite.u 88 | userRepo := suite.userRepo 89 | t := suite.T() 90 | mock := suite.mock 91 | 92 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 93 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 94 | WHERE ("user"."email" = ? and deleted_at is null)` 95 | mock.ExpectQueryOne(sql). 96 | WithArgs(u.Email). 97 | Returns(mockgopg.NewResult(1, 1, u), nil) 98 | 99 | uReturned, err := userRepo.FindByEmail("hello@world.org") 100 | assert.Equal(t, u.Email, uReturned.Email) 101 | assert.Nil(t, err) 102 | } 103 | 104 | func (suite *UserUnitTestSuite) TestFindByMobileSuccess() { 105 | u := suite.u 106 | userRepo := suite.userRepo 107 | t := suite.T() 108 | mock := suite.mock 109 | 110 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 111 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 112 | WHERE ("user"."country_code" = ? and "user"."mobile" = ? and deleted_at is null)` 113 | mock.ExpectQueryOne(sql). 114 | WithArgs(u.CountryCode, u.Mobile). 115 | Returns(mockgopg.NewResult(1, 1, u), nil) 116 | 117 | uReturned, err := userRepo.FindByMobile(u.CountryCode, u.Mobile) 118 | assert.Equal(t, u.Mobile, uReturned.Mobile) 119 | assert.Nil(t, err) 120 | } 121 | 122 | func (suite *UserUnitTestSuite) TestFindByTokenSuccess() { 123 | u := suite.u 124 | userRepo := suite.userRepo 125 | t := suite.T() 126 | mock := suite.mock 127 | 128 | u.Token = "someusertoken" 129 | 130 | var user = new(model.User) 131 | user.Token = "someusertoken" 132 | user.ID = 1 133 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 134 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 135 | WHERE ("user"."token" = ? and deleted_at is null)` 136 | mock.ExpectQueryOne(sql). 137 | WithArgs("someusertoken"). 138 | Returns(mockgopg.NewResult(1, 1, user), nil) 139 | 140 | _, err := userRepo.FindByToken(u.Token) 141 | assert.Equal(t, u.Token, user.Token) 142 | assert.Nil(t, err) 143 | } 144 | -------------------------------------------------------------------------------- /request/auth.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/apperr" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // Credentials stores the username and password provided in the request 10 | type Credentials struct { 11 | Email string `json:"email" binding:"required"` 12 | Password string `json:"password" binding:"required"` 13 | } 14 | 15 | // Login parses out the username and password in gin's request context, into Credentials 16 | func Login(c *gin.Context) (*Credentials, error) { 17 | cred := new(Credentials) 18 | if err := c.ShouldBindJSON(cred); err != nil { 19 | apperr.Response(c, err) 20 | return nil, err 21 | } 22 | return cred, nil 23 | } 24 | 25 | // ForgotPayload stores the email provided in the request 26 | type ForgotPayload struct { 27 | Email string `json:"email" binding:"required"` 28 | } 29 | 30 | // Forgot parses out the email in gin's request context, into ForgotPayload 31 | func Forgot(c *gin.Context) (*ForgotPayload, error) { 32 | fgt := new(ForgotPayload) 33 | if err := c.ShouldBindJSON(fgt); err != nil { 34 | apperr.Response(c, err) 35 | return nil, err 36 | } 37 | return fgt, nil 38 | } 39 | 40 | // RecoverPasswordPayload stores the data provided in the request 41 | type RecoverPasswordPayload struct { 42 | Email string `json:"email" binding:"required"` 43 | OTP string `json:"otp" binding:"required"` 44 | Password string `json:"password" binding:"required"` 45 | ConfirmPassword string `json:"confrim_password" binding:"required"` 46 | } 47 | 48 | // RecoverPassword parses out the data in gin's request context, into RecoverPasswordPayload 49 | func RecoverPassword(c *gin.Context) (*RecoverPasswordPayload, error) { 50 | rpp := new(RecoverPasswordPayload) 51 | if err := c.ShouldBindJSON(rpp); err != nil { 52 | apperr.Response(c, err) 53 | return nil, err 54 | } 55 | return rpp, nil 56 | } 57 | -------------------------------------------------------------------------------- /request/bank_account.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/alpacahq/ribbit-backend/apperr" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // Credentials stores the username and password provided in the request 13 | type SetAccessToken struct { 14 | PublicToken string `json:"public_token" binding:"required"` 15 | AccountID string `json:"account_id" binding:"required"` 16 | } 17 | 18 | // Login parses out the username and password in gin's request context, into Credentials 19 | func SetAccessTokenbody(c *gin.Context) (*SetAccessToken, error) { 20 | data := new(SetAccessToken) 21 | if err := c.ShouldBindJSON(data); err != nil { 22 | apperr.Response(c, err) 23 | return nil, err 24 | } 25 | return data, nil 26 | } 27 | 28 | // Credentials stores the username and password provided in the request 29 | type Charge struct { 30 | Amount string `json:"amount" binding:"required"` 31 | AccountID string `json:"account_id" binding:"required"` 32 | } 33 | 34 | // Login parses out the username and password in gin's request context, into Credentials 35 | func ChargeBody(c *gin.Context) (*Charge, error) { 36 | data := new(Charge) 37 | if err := c.ShouldBindJSON(data); err != nil { 38 | apperr.Response(c, err) 39 | return nil, err 40 | } 41 | return data, nil 42 | } 43 | 44 | func AccountID(c *gin.Context) (int, error) { 45 | id, err := strconv.Atoi(c.Param("account_id")) 46 | if err != nil { 47 | c.AbortWithStatus(http.StatusBadRequest) 48 | return 0, apperr.New(http.StatusBadRequest, "Account ID isn't valid") 49 | } 50 | return id, nil 51 | } 52 | -------------------------------------------------------------------------------- /request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/alpacahq/ribbit-backend/apperr" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | const ( 13 | defaultLimit = 100 14 | maxLimit = 1000 15 | ) 16 | 17 | // Pagination contains pagination request 18 | type Pagination struct { 19 | Limit int `form:"limit"` 20 | Page int `form:"page" binding:"min=0"` 21 | Offset int `json:"-"` 22 | } 23 | 24 | // Paginate validates pagination requests 25 | func Paginate(c *gin.Context) (*Pagination, error) { 26 | p := new(Pagination) 27 | if err := c.ShouldBindQuery(p); err != nil { 28 | apperr.Response(c, err) 29 | return nil, err 30 | } 31 | if p.Limit < 1 { 32 | p.Limit = defaultLimit 33 | } 34 | if p.Limit > 1000 { 35 | p.Limit = maxLimit 36 | } 37 | p.Offset = p.Limit * p.Page 38 | return p, nil 39 | } 40 | 41 | // ID returns id url parameter. 42 | // In case of conversion error to int, request will be aborted with StatusBadRequest. 43 | func ID(c *gin.Context) (int, error) { 44 | id, err := strconv.Atoi(c.Param("id")) 45 | if err != nil { 46 | c.AbortWithStatus(http.StatusBadRequest) 47 | return 0, apperr.New(http.StatusBadRequest, "Bad request") 48 | } 49 | return id, nil 50 | } 51 | -------------------------------------------------------------------------------- /request/signup.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/apperr" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // EmailSignup contains the user signup request 10 | type EmailSignup struct { 11 | Email string `json:"email" binding:"required,min=3,email"` 12 | Password string `json:"password" binding:"required,min=8"` 13 | } 14 | 15 | // AccountSignup validates user signup request 16 | func AccountSignup(c *gin.Context) (*EmailSignup, error) { 17 | var r EmailSignup 18 | if err := c.ShouldBindJSON(&r); err != nil { 19 | apperr.Response(c, err) 20 | return nil, err 21 | } 22 | return &r, nil 23 | } 24 | 25 | // MobileSignup contains the user signup request with a mobile number 26 | type MobileSignup struct { 27 | CountryCode string `json:"country_code" binding:"required,min=2"` 28 | Mobile string `json:"mobile" binding:"required"` 29 | } 30 | 31 | // Mobile validates user signup request via mobile 32 | func Mobile(c *gin.Context) (*MobileSignup, error) { 33 | var r MobileSignup 34 | if err := c.ShouldBindJSON(&r); err != nil { 35 | apperr.Response(c, err) 36 | return nil, err 37 | } 38 | return &r, nil 39 | } 40 | 41 | // MagicSignup contains the user signup request with a mobile number 42 | type MagicSignup struct { 43 | Email string `json:"email" binding:"required,min=3,email"` 44 | } 45 | 46 | // Magic validates user signup request via mobile 47 | func Magic(c *gin.Context) (*MagicSignup, error) { 48 | var r MagicSignup 49 | if err := c.ShouldBindJSON(&r); err != nil { 50 | apperr.Response(c, err) 51 | return nil, err 52 | } 53 | return &r, nil 54 | } 55 | 56 | // MobileVerify contains the user's mobile verification country code, mobile number and verification code 57 | type MobileVerify struct { 58 | CountryCode string `json:"country_code" binding:"required,min=2"` 59 | Mobile string `json:"mobile" binding:"required"` 60 | Code string `json:"code" binding:"required"` 61 | Signup bool `json:"signup" binding:"required"` 62 | } 63 | 64 | // AccountVerifyMobile validates user mobile verification 65 | func AccountVerifyMobile(c *gin.Context) (*MobileVerify, error) { 66 | var r MobileVerify 67 | if err := c.ShouldBindJSON(&r); err != nil { 68 | return nil, err 69 | } 70 | return &r, nil 71 | } 72 | 73 | type ReferralVerify struct { 74 | ReferralCode string `json:"referral_code" binding:"required"` 75 | } 76 | 77 | // ReferralCodeVerify verifies referral code 78 | func ReferralCodeVerify(c *gin.Context) (*ReferralVerify, error) { 79 | var r ReferralVerify 80 | if err := c.ShouldBindJSON(&r); err != nil { 81 | return nil, err 82 | } 83 | return &r, nil 84 | } 85 | -------------------------------------------------------------------------------- /request/user.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/alpacahq/ribbit-backend/apperr" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // UpdateUser contains user update data from json request 10 | type UpdateUser struct { 11 | ID int `json:"-"` 12 | FirstName *string `json:"first_name,omitempty" binding:"omitempty,min=2"` 13 | LastName *string `json:"last_name,omitempty" binding:"omitempty,min=2"` 14 | Mobile *string `json:"mobile,omitempty"` 15 | Phone *string `json:"phone,omitempty"` 16 | Address *string `json:"address,omitempty"` 17 | AccountID *string `json:"account_id,omitempty"` 18 | AccountNumber *string `json:"account_number,omitempty"` 19 | AccountCurrency *string `json:"account_currency,omitempty"` 20 | AccountStatus *string `json:"account_status,omitempty"` 21 | DOB *string `json:"dob,omitempty"` 22 | City *string `json:"city,omitempty"` 23 | State *string `json:"state,omitempty"` 24 | Country *string `json:"country,omitempty"` 25 | TaxIDType *string `json:"tax_id_type,omitempty"` 26 | TaxID *string `json:"tax_id,omitempty"` 27 | FundingSource *string `json:"funding_source,omitempty"` 28 | EmploymentStatus *string `json:"employment_status"` 29 | InvestingExperience *string `json:"investing_experience,omitempty"` 30 | PublicShareholder *string `json:"public_shareholder,omitempty"` 31 | AnotherBrokerage *string `json:"another_brokerage,omitempty"` 32 | DeviceID *string `json:"device_id,omitempty"` 33 | ProfileCompletion *string `json:"profile_completion,omitempty"` 34 | BIO *string `json:"bio,omitempty"` 35 | FacebookURL *string `json:"facebook_url,omitempty"` 36 | TwitterURL *string `json:"twitter_url,omitempty"` 37 | InstagramURL *string `json:"instagram_url,omitempty"` 38 | PublicPortfolio *string `json:"public_portfolio,omitempty"` 39 | EmployerName *string `json:"employer_name,omitempty"` 40 | Occupation *string `json:"occupation,omitempty"` 41 | UnitApt *string `json:"unit_apt,omitempty"` 42 | ZipCode *string `json:"zip_code,omitempty"` 43 | StockSymbol *string `json:"stock_symbol,omitempty"` 44 | BrokerageFirmName *string `json:"brokerage_firm_name,omitempty"` 45 | BrokerageFirmEmployeeName *string `json:"brokerage_firm_employee_name,omitempty"` 46 | BrokerageFirmEmployeeRelationship *string `json:"brokerage_firm_employee_relationship,omitempty"` 47 | ShareholderCompanyName *string `json:"shareholder_company_name,omitempty"` 48 | Avatar *string `json:"avatar,omitempty"` 49 | ReferredBy *string `json:"referred_by,omitempty"` 50 | ReferralCode *string `json:"referral_code,omitempty"` 51 | } 52 | 53 | // UserUpdate validates user update request 54 | func UserUpdate(c *gin.Context) (*UpdateUser, error) { 55 | var u UpdateUser 56 | id, err := ID(c) 57 | if err != nil { 58 | return nil, err 59 | } 60 | if err := c.ShouldBindJSON(&u); err != nil { 61 | apperr.Response(c, err) 62 | return nil, err 63 | } 64 | u.ID = id 65 | return &u, nil 66 | } 67 | -------------------------------------------------------------------------------- /route/custom_route.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | // ServicesI is the interface for our user-defined custom routes and related services 4 | type ServicesI interface { 5 | SetupRoutes() 6 | } 7 | -------------------------------------------------------------------------------- /route/route.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/alpacahq/ribbit-backend/docs" 7 | "github.com/alpacahq/ribbit-backend/magic" 8 | "github.com/alpacahq/ribbit-backend/mail" 9 | mw "github.com/alpacahq/ribbit-backend/middleware" 10 | "github.com/alpacahq/ribbit-backend/mobile" 11 | "github.com/alpacahq/ribbit-backend/repository" 12 | "github.com/alpacahq/ribbit-backend/repository/account" 13 | assets "github.com/alpacahq/ribbit-backend/repository/assets" 14 | "github.com/alpacahq/ribbit-backend/repository/auth" 15 | "github.com/alpacahq/ribbit-backend/repository/plaid" 16 | "github.com/alpacahq/ribbit-backend/repository/transfer" 17 | "github.com/alpacahq/ribbit-backend/repository/user" 18 | "github.com/alpacahq/ribbit-backend/secret" 19 | "github.com/alpacahq/ribbit-backend/service" 20 | 21 | "github.com/gin-gonic/gin" 22 | "github.com/go-pg/pg/v9" 23 | ginSwagger "github.com/swaggo/gin-swagger" // gin-swagger middleware 24 | "github.com/swaggo/gin-swagger/swaggerFiles" // swagger embed files 25 | "go.uber.org/zap" 26 | ) 27 | 28 | // NewServices creates a new router services 29 | func NewServices(DB *pg.DB, Log *zap.Logger, JWT *mw.JWT, Mail mail.Service, Mobile mobile.Service, Magic magic.Service, R *gin.Engine) *Services { 30 | return &Services{DB, Log, JWT, Mail, Mobile, Magic, R} 31 | } 32 | 33 | // Services lets us bind specific services when setting up routes 34 | type Services struct { 35 | DB *pg.DB 36 | Log *zap.Logger 37 | JWT *mw.JWT 38 | Mail mail.Service 39 | Mobile mobile.Service 40 | Magic magic.Service 41 | R *gin.Engine 42 | } 43 | 44 | // SetupV1Routes instances various repos and services and sets up the routers 45 | func (s *Services) SetupV1Routes() { 46 | // database logic 47 | userRepo := repository.NewUserRepo(s.DB, s.Log) 48 | accountRepo := repository.NewAccountRepo(s.DB, s.Log, secret.New()) 49 | assetRepo := repository.NewAssetRepo(s.DB, s.Log, secret.New()) 50 | rbac := repository.NewRBACService(userRepo) 51 | 52 | // s.R.Use(cors.New(cors.Config{ 53 | // AllowAllOrigins: true, 54 | // AllowMethods: []string{"GET", "PUT", "DELETE", "PATCH", "POST", "OPTIONS"}, 55 | // AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "User-Agent", "Referrer", "Host", "Token", "Authorization"}, 56 | // ExposeHeaders: []string{"Content-Length"}, 57 | // AllowCredentials: false, 58 | // // AllowOriginFunc: func(origin string) bool { 59 | // // return origin == "https://github.com" 60 | // // }, 61 | // MaxAge: 12 * time.Hour, 62 | // })) 63 | 64 | // service logic 65 | authService := auth.NewAuthService(userRepo, accountRepo, s.JWT, s.Mail, s.Mobile, s.Magic) 66 | accountService := account.NewAccountService(userRepo, accountRepo, rbac, secret.New()) 67 | userService := user.NewUserService(userRepo, authService, rbac) 68 | plaidService := plaid.NewPlaidService(userRepo, accountRepo, s.JWT, s.DB, s.Log) 69 | transferService := transfer.NewTransferService(userRepo, accountRepo, s.JWT, s.DB, s.Log) 70 | assetsService := assets.NewAssetsService(userRepo, accountRepo, assetRepo, s.JWT, s.DB, s.Log) 71 | 72 | // no prefix, no jwt 73 | service.AuthRouter(authService, s.R) 74 | 75 | // prefixed with /v1 and protected by jwt 76 | v1Router := s.R.Group("/v1") 77 | v1Router.Use(s.JWT.MWFunc()) 78 | service.AccountRouter(accountService, s.DB, v1Router) 79 | service.PlaidRouter(plaidService, accountService, v1Router) 80 | service.TransferRouter(transferService, accountService, v1Router) 81 | service.AssetsRouter(assetsService, accountService, v1Router) 82 | service.UserRouter(userService, v1Router) 83 | 84 | // Routes for static files 85 | s.R.StaticFS("/file", http.Dir("public")) 86 | s.R.StaticFS("/template", http.Dir("templates")) 87 | 88 | //Routes for swagger 89 | swagger := s.R.Group("swagger") 90 | { 91 | docs.SwaggerInfo.Title = "Alpaca MVP" 92 | docs.SwaggerInfo.Description = "Broker MVP that uses golang gin as webserver, and go-pg library for connecting with a PostgreSQL database" 93 | docs.SwaggerInfo.Version = "1.0" 94 | 95 | swagger.GET("/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) 96 | } 97 | 98 | s.R.NoRoute(func(c *gin.Context) { 99 | c.Redirect(http.StatusMovedPermanently, "/swagger/index.html") 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /secret/cryptorandom.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | ) 7 | 8 | // GenerateRandomBytes returns securely generated random bytes. 9 | // It will return an error if the system's secure random 10 | // number generator fails to function correctly, in which 11 | // case the caller should not continue. 12 | func GenerateRandomBytes(n int) ([]byte, error) { 13 | b := make([]byte, n) 14 | _, err := rand.Read(b) 15 | // Note that err == nil only if we read len(b) bytes. 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return b, nil 21 | } 22 | 23 | // GenerateRandomString returns a securely generated random string. 24 | // It will return an error if the system's secure random 25 | // number generator fails to function correctly, in which 26 | // case the caller should not continue. 27 | // Example: this will give us a 32 byte output 28 | // token, err = GenerateRandomString(32) 29 | func GenerateRandomString(n int) (string, error) { 30 | const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" 31 | bytes, err := GenerateRandomBytes(n) 32 | if err != nil { 33 | return "", err 34 | } 35 | for i, b := range bytes { 36 | bytes[i] = letters[b%byte(len(letters))] 37 | } 38 | return string(bytes), nil 39 | } 40 | 41 | // GenerateRandomStringURLSafe returns a URL-safe, base64 encoded 42 | // securely generated random string. 43 | // It will return an error if the system's secure random 44 | // number generator fails to function correctly, in which 45 | // case the caller should not continue. 46 | // Example: this will give us a 44 byte, base64 encoded output 47 | // token, err := GenerateRandomStringURLSafe(32) 48 | func GenerateRandomStringURLSafe(n int) (string, error) { 49 | b, err := GenerateRandomBytes(n) 50 | return base64.URLEncoding.EncodeToString(b), err 51 | } 52 | -------------------------------------------------------------------------------- /secret/secret.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | ) 6 | 7 | // New returns a password object 8 | func New() *Password { 9 | return &Password{} 10 | } 11 | 12 | // Password is our secret service implementation 13 | type Password struct{} 14 | 15 | // HashPassword hashes the password using bcrypt 16 | func (p *Password) HashPassword(password string) string { 17 | hashedPW, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 18 | return string(hashedPW) 19 | } 20 | 21 | // HashMatchesPassword matches hash with password. Returns true if hash and password match. 22 | func (p *Password) HashMatchesPassword(hash, password string) bool { 23 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil 24 | } 25 | 26 | // HashRandomPassword creates a random password for passwordless mobile signup 27 | func (p *Password) HashRandomPassword() (string, error) { 28 | randomPassword, err := GenerateRandomString(16) 29 | if err != nil { 30 | return "", err 31 | } 32 | r := p.HashPassword(randomPassword) 33 | return r, nil 34 | } 35 | -------------------------------------------------------------------------------- /secret/secret_interface.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | // Service is the interface to our secret service 4 | type Service interface { 5 | HashPassword(password string) string 6 | HashMatchesPassword(hash, password string) bool 7 | HashRandomPassword() (string, error) 8 | } 9 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/alpacahq/ribbit-backend/config" 7 | "github.com/alpacahq/ribbit-backend/mail" 8 | mw "github.com/alpacahq/ribbit-backend/middleware" 9 | "github.com/alpacahq/ribbit-backend/mobile" 10 | "github.com/alpacahq/ribbit-backend/route" 11 | 12 | "github.com/gin-gonic/gin" 13 | 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // Server holds all the routes and their services 18 | type Server struct { 19 | RouteServices []route.ServicesI 20 | } 21 | 22 | func CORSMiddleware() gin.HandlerFunc { 23 | return func(c *gin.Context) { 24 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") 25 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") 26 | c.Writer.Header().Set("Access-Control-Allow-Headers", "Access-Control-Allow-Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") 27 | c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH") 28 | 29 | if c.Request.Method == "OPTIONS" { 30 | c.AbortWithStatus(204) 31 | return 32 | } 33 | 34 | c.Next() 35 | } 36 | } 37 | 38 | // Run runs our API server 39 | func (server *Server) Run(env string) error { 40 | 41 | // load configuration 42 | j := config.LoadJWT(env) 43 | 44 | r := gin.Default() 45 | r.LoadHTMLGlob("templates/*") 46 | 47 | // middleware 48 | mw.Add(r, CORSMiddleware()) 49 | jwt := mw.NewJWT(j) 50 | m := mail.NewMail(config.GetMailConfig(), config.GetSiteConfig()) 51 | mobile := mobile.NewMobile(config.GetTwilioConfig()) 52 | db := config.GetConnection() 53 | log, _ := zap.NewDevelopment() 54 | defer log.Sync() 55 | 56 | // setup default routes 57 | rsDefault := &route.Services{ 58 | DB: db, 59 | Log: log, 60 | JWT: jwt, 61 | Mail: m, 62 | Mobile: mobile, 63 | R: r} 64 | rsDefault.SetupV1Routes() 65 | 66 | // setup all custom/user-defined route services 67 | for _, rs := range server.RouteServices { 68 | rs.SetupRoutes() 69 | } 70 | 71 | port, ok := os.LookupEnv("PORT") 72 | if !ok { 73 | port = "8080" 74 | } 75 | 76 | // run with port from config 77 | return r.Run(":" + port) 78 | } 79 | -------------------------------------------------------------------------------- /service/plaid.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/alpacahq/ribbit-backend/apperr" 11 | "github.com/alpacahq/ribbit-backend/repository/account" 12 | "github.com/alpacahq/ribbit-backend/repository/plaid" 13 | "github.com/alpacahq/ribbit-backend/request" 14 | 15 | "github.com/gin-gonic/gin" 16 | ) 17 | 18 | func PlaidRouter(svc *plaid.Service, acc *account.Service, r *gin.RouterGroup) { 19 | a := Plaid{svc, acc} 20 | 21 | ar := r.Group("/plaid") 22 | ar.GET("/create_link_token", a.createLinkToken) 23 | ar.POST("/set_access_token", a.setAccessToken) 24 | ar.GET("/recipient_banks", a.accountsList) 25 | ar.DELETE("/recipient_banks/:bank_id", a.detachAccount) 26 | } 27 | 28 | // Auth represents auth http service 29 | type Plaid struct { 30 | svc *plaid.Service 31 | acc *account.Service 32 | } 33 | 34 | func (a *Plaid) createLinkToken(c *gin.Context) { 35 | id, _ := c.Get("id") 36 | user := a.acc.GetProfile(c, id.(int)) 37 | 38 | name := user.FirstName + " " + user.LastName 39 | linkToken, err := a.svc.CreateLinkToken(c, user.AccountID, name) 40 | if err != nil { 41 | apperr.Response(c, err) 42 | return 43 | } 44 | 45 | c.JSON(http.StatusOK, linkToken) 46 | } 47 | 48 | func (a *Plaid) setAccessToken(c *gin.Context) { 49 | data, err := request.SetAccessTokenbody(c) 50 | if err != nil { 51 | apperr.Response(c, err) 52 | return 53 | } 54 | 55 | id, _ := c.Get("id") 56 | user := a.acc.GetProfile(c, id.(int)) 57 | 58 | if user.AccountID == "" { 59 | apperr.Response(c, apperr.New(http.StatusBadRequest, "Account not found.")) 60 | return 61 | } 62 | 63 | response, err := a.svc.SetAccessToken(c, id.(int), user.AccountID, data) 64 | if err != nil { 65 | apperr.Response(c, apperr.New(http.StatusBadRequest, err.Error())) 66 | return 67 | } 68 | c.JSON(http.StatusOK, response) 69 | } 70 | 71 | func (a *Plaid) accountsList(c *gin.Context) { 72 | id, _ := c.Get("id") 73 | user := a.acc.GetProfile(c, id.(int)) 74 | accountID := user.AccountID 75 | 76 | if accountID == "" { 77 | apperr.Response(c, apperr.New(http.StatusBadRequest, "Account not found.")) 78 | return 79 | } 80 | 81 | client := &http.Client{} 82 | acountStatus, _ := json.Marshal(map[string]string{ 83 | "statuses": "QUEUED,APPROVED,PENDING", 84 | }) 85 | accountStatuses := bytes.NewBuffer(acountStatus) 86 | 87 | getAchAccountsList := os.Getenv("BROKER_API_BASE") + "/v1/accounts/" + accountID + "/ach_relationships" 88 | 89 | req, _ := http.NewRequest("GET", getAchAccountsList, accountStatuses) 90 | req.Header.Add("Authorization", os.Getenv("BROKER_TOKEN")) 91 | 92 | response, _ := client.Do(req) 93 | responseData, err := ioutil.ReadAll(response.Body) 94 | if err != nil { 95 | apperr.Response(c, apperr.New(http.StatusInternalServerError, "Something went wrong. Try again later.")) 96 | return 97 | } 98 | 99 | var responseObject interface{} 100 | json.Unmarshal(responseData, &responseObject) 101 | c.JSON(response.StatusCode, responseObject) 102 | } 103 | 104 | func (a *Plaid) detachAccount(c *gin.Context) { 105 | id, _ := c.Get("id") 106 | user := a.acc.GetProfile(c, id.(int)) 107 | 108 | accountID := user.AccountID 109 | bankID := c.Param("bank_id") 110 | 111 | if accountID == "" { 112 | apperr.Response(c, apperr.New(http.StatusBadRequest, "Account not found.")) 113 | return 114 | } 115 | 116 | deleteAccountAPIURL := os.Getenv("BROKER_API_BASE") + "/v1/accounts/" + accountID + "/ach_relationships/" + bankID 117 | 118 | client := &http.Client{} 119 | req, _ := http.NewRequest("DELETE", deleteAccountAPIURL, nil) 120 | req.Header.Add("Authorization", os.Getenv("BROKER_TOKEN")) 121 | response, _ := client.Do(req) 122 | 123 | responseData, err := ioutil.ReadAll(response.Body) 124 | if err != nil { 125 | apperr.Response(c, apperr.New(http.StatusInternalServerError, "Something went wrong. Try again later.")) 126 | return 127 | } 128 | 129 | var responseObject interface{} 130 | json.Unmarshal(responseData, &responseObject) 131 | c.JSON(response.StatusCode, responseObject) 132 | } 133 | -------------------------------------------------------------------------------- /service/user.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/alpacahq/ribbit-backend/apperr" 7 | "github.com/alpacahq/ribbit-backend/model" 8 | "github.com/alpacahq/ribbit-backend/repository/user" 9 | "github.com/alpacahq/ribbit-backend/request" 10 | 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | // User represents the user http service 15 | type User struct { 16 | svc *user.Service 17 | } 18 | 19 | // UserRouter declares the orutes for users router group 20 | func UserRouter(svc *user.Service, r *gin.RouterGroup) { 21 | u := User{ 22 | svc: svc, 23 | } 24 | ur := r.Group("/users") 25 | ur.GET("", u.list) 26 | ur.GET("/:id", u.view) 27 | ur.PATCH("/:id", u.update) 28 | ur.DELETE("/:id", u.delete) 29 | } 30 | 31 | type listResponse struct { 32 | Users []model.User `json:"users"` 33 | Page int `json:"page"` 34 | } 35 | 36 | func (u *User) list(c *gin.Context) { 37 | p, err := request.Paginate(c) 38 | if err != nil { 39 | return 40 | } 41 | result, err := u.svc.List(c, &model.Pagination{ 42 | Limit: p.Limit, Offset: p.Offset, 43 | }) 44 | if err != nil { 45 | apperr.Response(c, err) 46 | return 47 | } 48 | c.JSON(http.StatusOK, listResponse{ 49 | Users: result, 50 | Page: p.Page, 51 | }) 52 | } 53 | 54 | func (u *User) view(c *gin.Context) { 55 | id, err := request.ID(c) 56 | if err != nil { 57 | return 58 | } 59 | result, err := u.svc.View(c, id) 60 | if err != nil { 61 | apperr.Response(c, err) 62 | return 63 | } 64 | c.JSON(http.StatusOK, result) 65 | } 66 | 67 | func (u *User) update(c *gin.Context) { 68 | updateUser, err := request.UserUpdate(c) 69 | if err != nil { 70 | return 71 | } 72 | userUpdate, err := u.svc.Update(c, &user.Update{ 73 | ID: updateUser.ID, 74 | FirstName: updateUser.FirstName, 75 | LastName: updateUser.LastName, 76 | Mobile: updateUser.Mobile, 77 | Phone: updateUser.Phone, 78 | Address: updateUser.Address, 79 | }) 80 | if err != nil { 81 | apperr.Response(c, err) 82 | return 83 | } 84 | c.JSON(http.StatusOK, userUpdate) 85 | } 86 | 87 | func (u *User) delete(c *gin.Context) { 88 | id, err := request.ID(c) 89 | if err != nil { 90 | return 91 | } 92 | if err := u.svc.Delete(c, id); err != nil { 93 | apperr.Response(c, err) 94 | return 95 | } 96 | c.JSON(http.StatusOK, gin.H{}) 97 | } 98 | -------------------------------------------------------------------------------- /templates/terms_conditions_old.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Page Title 5 | 6 | 7 | 8 | 9 | 10 | 11 | 20 | 21 |

22 | This following sets out the terms and conditions on which you may use the content on business-standard.com website, business-standard.com's mobile browser site, Business Standard instore Applications and other digital publishing services (www.smartinvestor.in, www.bshindi.com and www.bsmotoring,com) owned by Business Standard Private Limited, all the services herein will be referred to as Business Standard Content Services) 23 |

24 |

Registration Access and Use We welcome users to register on our digital platforms. We offer the below mentioned registration services which may be subject to change in the future. All changes will be appended in the terms and conditions page and communicated to existing users by email. 25 |

26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | case "$1" in 4 | -s | --short) 5 | case "$2" in 6 | -c | --coverage) echo "Run only unit tests (with coverage)" 7 | go test -v -coverprofile c.out -short ./... 8 | go tool cover -html=c.out 9 | ;; 10 | *) echo "Run only unit tests" 11 | go test -v -short ./... 12 | ;; 13 | esac 14 | ;; 15 | -i | --integration) echo "Run only integration tests" 16 | go test -v -run Integration ./... 17 | ;; 18 | *) echo "Run all tests (with coverage)" 19 | go test -coverprofile c.out ./... 20 | go tool cover -html=c.out 21 | ;; 22 | esac -------------------------------------------------------------------------------- /testhelper/testhelper.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | // GetFreePort asks the kernel for a free open port that is ready to use. 9 | func GetFreePort(host string, preferredPort uint32) (int, error) { 10 | address := host + ":" + fmt.Sprint(preferredPort) 11 | addr, err := net.ResolveTCPAddr("tcp", address) 12 | if err != nil { 13 | return 0, err 14 | } 15 | 16 | l, err := net.ListenTCP("tcp", addr) 17 | if err != nil { 18 | return 0, err 19 | } 20 | defer l.Close() 21 | return l.Addr().(*net.TCPAddr).Port, nil 22 | } 23 | 24 | // AllocatePort returns a port that is available, given host and a preferred port 25 | // if none of the preferred ports are available, it will keep searching by adding 1 to the port number 26 | func AllocatePort(host string, preferredPort uint32) uint32 { 27 | preferredPortStr := fmt.Sprint(preferredPort) 28 | allocatedPort, err := GetFreePort(host, preferredPort) 29 | for err != nil { 30 | preferredPort = preferredPort + 1 31 | allocatedPort, err = GetFreePort(host, preferredPort) 32 | if err != nil { 33 | fmt.Println("Failed to connect to", preferredPortStr) 34 | } 35 | } 36 | fmt.Println("Allocated port", allocatedPort) 37 | return uint32(allocatedPort) 38 | } 39 | --------------------------------------------------------------------------------