commit 9a1dade58be5b2051062f3beddc5da0412681aa6
parent e7f470fc7658239c17698759c8f62b09ed91ec64
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Fri, 30 Aug 2024 17:53:41 -0700
Large commit with many changes
I'd have liked to have separated these changes into smaller, focused
chunks, but I kind of ended up working backwards and one change
required another, etc.
Anyway the major changes are as follows:
- Email verification
This introduces GET and POST on /users/:user/email/code aswell
as GET /users/:user/email/verified
These provide the client with access to an email verification
system: The client would GET email/code to have an email sent to
the user's email setting with a code. They would then POST
email/code with that code and the email would be verified (and
placed in the new spaceplanner.users.verified_email column in the
database).
The email/verified endpoint just returns the current verified
email, which can be used to tell if email verification needs
to be done, etc.
- Configuration file
This actually made things much cleaner, although the purpose was
to keep sensitive information like private keys from being commited.
- Stripe integration
Each user now has a Stripe Customer associated with it. Verified
emails are given to Stripe for now (since I'm using their Checkout
and Billing Portal services) but I'll see if this can be avoided
in the future.
In addition to Customers, there is an interface to Stripe Prices
and Products. I want to have Prices and Products initiate from
the server and get pushed to Stripe, but for now the opposite is
true.
There are a couple functions to initialize a Stripe Checkout
session and Stripe Billing session with the correct Customers.
- Settings validation in backend
Settings used to be validated in the front-end, there was a reason
(that I forgot), but now I think it would be better to have it
done in the backend.
Diffstat:
20 files changed, 824 insertions(+), 237 deletions(-)
diff --git a/cmd/api/Makefile b/cmd/api/Makefile
@@ -24,11 +24,15 @@ ${name}:
# I would use ${name} as dependancy, but since this must be run as
# root and I offload everything to go(1), this isn't what I want.
run:
+ # This is obviously not great with the private keys in there
+ # In the future I intend to load from outside the chroot.
+ mkdir -p ${srvroot}/etc/spaceplanner.app
+ install -o ${srvuser} -g ${srvuser} -m 0700 api.config ${srvroot}/etc/spaceplanner.app/api.config
install -o ${srvuser} -g ${srvuser} -m 0700 ${prog} ${srvroot}/${srvdir}/${prog}
kfcgi -dv -n 1 -N 1 \
-s ${srvsock} -u ${srvsockuser} \
-U ${srvuser} \
- -p ${srvroot} ${srvdir}/${prog}
+ -p ${srvroot} ${srvdir}/${prog} < api.config
test-run: ${name}
go run . localhost:8888
diff --git a/cmd/api/config.go b/cmd/api/config.go
@@ -0,0 +1,13 @@
+package main
+
+import "jacobedwards.org/spaceplanner.app/internal/backend"
+
+type Config struct {
+ JWT JWTConfig `json:"jwt" binding:"required"`
+ Backend backend.Config `json:"backend" binding:"required"`
+}
+
+type JWTConfig struct {
+ // JWT encryption key
+ Key string `json:"key" binding:"required"`
+}
diff --git a/cmd/api/env.go b/cmd/api/env.go
@@ -1,27 +1,31 @@
package main
import (
- "errors"
- "database/sql"
-
"jacobedwards.org/spaceplanner.app/internal/backend"
+ jwt "github.com/appleboy/gin-jwt/v2"
)
type Env struct {
+ Auth *jwt.GinJWTMiddleware
+ Config Config
backend *backend.Env
}
-func NewEnv(db *sql.DB) (*Env, error) {
- if db == nil {
- return nil, errors.New("No database")
+func NewEnv(c Config) (*Env, error) {
+ var e Env
+
+ e.Config = c
+ p, err := (&e).AuthParams([]byte(e.Config.JWT.Key))
+ if err != nil {
+ return nil, err
}
- backend, err := backend.NewEnv(db)
+ e.Auth, err = jwt.New(p)
if err != nil {
return nil, err
}
- return &Env{
- backend: backend,
- }, nil
+
+ e.backend, err = backend.NewEnv(c.Backend)
+ return &e, err
}
func (e *Env) Free() {
diff --git a/cmd/api/examples/api.config b/cmd/api/examples/api.config
@@ -0,0 +1,18 @@
+{
+ "jwt": {
+ "key": "example JWT key"
+ },
+ "backend": {
+ "database": "postgres://_spaceplanner@127.0.0.1/api.spaceplanner.app?sslmode=disable",
+ "stripe": {
+ "key": "[your stripe key]"
+ },
+ "smtp": {
+ "server": "spaceplanner.app"
+ "port": "587",
+ "user": "spaceplanner"
+ "password": ""
+ "name": "Spaceplanner.App"
+ }
+ }
+}
diff --git a/cmd/api/go.mod b/cmd/api/go.mod
@@ -6,6 +6,7 @@ replace jacobedwards.org/spaceplanner.app/internal/backend => ../../internal/bac
require (
github.com/appleboy/gin-jwt/v2 v2.9.2
+ github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/lib/pq v1.10.9
jacobedwards.org/spaceplanner.app/internal/backend v0.0.0-00010101000000-000000000000
@@ -17,7 +18,6 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
- github.com/gin-contrib/cors v1.7.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -32,6 +32,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/stripe/stripe-go/v72 v72.122.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
diff --git a/cmd/api/go.sum b/cmd/api/go.sum
@@ -69,6 +69,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -76,6 +77,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stripe/stripe-go/v72 v72.122.0 h1:eRXWqnEwGny6dneQ5BsxGzUCED5n180u8n665JHlut8=
+github.com/stripe/stripe-go/v72 v72.122.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0=
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -89,14 +92,19 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
@@ -106,6 +114,7 @@ google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cmd/api/main.go b/cmd/api/main.go
@@ -1,34 +1,64 @@
package main
import (
- "database/sql"
+ "encoding/json"
+ "flag"
+ "io"
"log"
"net/http"
"net/http/fcgi"
"os"
- "github.com/gin-gonic/gin"
+ "jacobedwards.org/spaceplanner.app/internal/backend"
+
"github.com/gin-contrib/cors"
- _ "github.com/lib/pq"
- jwt "github.com/appleboy/gin-jwt/v2"
+ "github.com/gin-gonic/gin"
)
+/*
+ * WARNING: This is just a temporary measure. In the future
+ * this file will either be readable only by root (and have
+ * privledge dropping in this server) or outside the chroot
+ * (by figuring out a solution with kfcgi(8).
+ */
+const configFile = "/etc/spaceplanner.app/api.config"
+
func main() {
- env, err := initEnv("postgres://_spaceplanner@127.0.0.1/api.spaceplanner.app?sslmode=disable")
- if err != nil {
- log.Fatalf("Unable to initialize environment: %s", err.Error())
- }
- defer env.Free()
+ var config Config
+ var useStdin bool
- authparams, err := env.AuthParams([]byte("Temporary Key"))
- if err != nil {
- log.Fatal(err.Error())
+ flag.BoolVar(&useStdin, "s", false, "Read config from stdin")
+
+ var f io.Reader
+ if useStdin {
+ f = os.Stdin
+ } else {
+ var err error
+ f, err = os.Open(configFile)
+ if err != nil {
+ log.Fatalf("%s: %s", configFile, err.Error())
+ }
+ }
+ if err := json.NewDecoder(f).Decode(&config); err != nil {
+ log.Fatalf("Unable to read config from stdin: %s", err.Error())
}
- auth, err := jwt.New(authparams)
+ e, err := NewEnv(config)
if err != nil {
- log.Fatalf("JWT error: %s", err.Error())
+ log.Fatalf("Unable to initialize environment: %s", err.Error())
}
+ defer e.Free()
+
+ e.backend.AddSetting("email", backend.SettingDef{
+ Primitive: "string",
+ Validator: validateEmail,
+ Memo: "Email address",
+ })
+ e.backend.AddSetting("email_strict", backend.SettingDef{
+ Primitive: "bool",
+ DefaultValue: false,
+ Memo: "Whether to only send necessary emails",
+ })
corsConfig := cors.DefaultConfig()
corsConfig.AllowAllOrigins = true
@@ -37,8 +67,8 @@ func main() {
engine := gin.Default()
engine.NoRoute(noRoute)
engine.Use(cors.New(corsConfig))
- engine.Use(AuthMiddleware(auth))
- setRoutes(env, engine.Group("/v0"), auth)
+ engine.Use(AuthMiddleware(e.Auth))
+ setRoutes(e, engine.Group("/v0"))
args := os.Args[1:]
if len(args) > 1 {
@@ -52,32 +82,17 @@ func main() {
}
}
-func initEnv(database string) (*Env, error) {
- db, err := sql.Open("postgres", database)
- if err != nil {
- return nil, err
- }
-
- env, err := NewEnv(db)
- if err != nil {
- db.Close()
- return nil, err
-
- }
-
- return env, nil
-}
-
-func setRoutes(env *Env, r *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
- r.POST("/tokens", auth.LoginHandler)
- r.GET("/tokens", auth.RefreshHandler)
+func setRoutes(env *Env, r *gin.RouterGroup) {
+ r.POST("/tokens", env.Auth.LoginHandler)
+ r.GET("/tokens", env.Auth.RefreshHandler)
r.GET("/settings", env.GetSettings)
+ r.GET("/services", env.GetServices)
users := r.Group("/users")
users.GET("/:user", env.GetUser)
users.POST("", env.CreateUser)
- setAuthenticatedRoutes(env, r.Group("", auth.MiddlewareFunc()))
+ setAuthenticatedRoutes(env, r.Group("", env.Auth.MiddlewareFunc()))
}
func setAuthenticatedRoutes(env *Env, r *gin.RouterGroup) {
@@ -86,6 +101,13 @@ func setAuthenticatedRoutes(env *Env, r *gin.RouterGroup) {
user := r.Group("/users/:user/")
user.PATCH("/settings", env.UpdateUserSettings)
user.GET("/settings", env.GetUserSettings)
+ user.POST("/services/checkout", env.CreateCheckout)
+ user.POST("/services/billingportal", env.CreatePortal)
+
+ email := user.Group("/email")
+ email.GET("/code", env.SendUserEmailCode)
+ email.POST("/code", env.VerifyUserEmailCode)
+ email.GET("/verified", env.VerifiedUserEmail)
fp := r.Group("/floorplans/:user")
fp.GET("", env.GetFloorplans)
diff --git a/cmd/api/migration/2024-08-22T02:27:02.sql b/cmd/api/migration/2024-08-22T02:27:02.sql
@@ -0,0 +1,4 @@
+BEGIN;
+ALTER TABLE spaceplanner.floorplan_pointmaps DROP CONSTRAINT unique_pointmap ;
+ALTER TABLE spaceplanner.floorplan_pointmaps ADD CONSTRAINT unique_pointmap UNIQUE (floorplan, a, b) ;
+COMMIT;
diff --git a/cmd/api/migration/2024-08-26T21:38:46.sql b/cmd/api/migration/2024-08-26T21:38:46.sql
@@ -0,0 +1,14 @@
+BEGIN;
+
+ALTER TABLE spaceplanner.users ADD COLUMN stripe_customer_id varchar UNIQUE;
+ALTER TABLE spaceplanner.users ADD COLUMN verified_email varchar;
+
+CREATE TABLE spaceplanner.email_codes (
+ username varchar REFERENCES spaceplanner.users(name) ON DELETE CASCADE NOT NULL,
+ email varchar NOT NULL,
+ code varchar NOT NULL,
+ created timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT unique_user_email_pair UNIQUE (username, email)
+) ;
+
+END;
diff --git a/cmd/api/order.go b/cmd/api/order.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "github.com/gin-gonic/gin"
+)
+
+type order struct {
+ // May have more fields in the future
+ Prices []string `json:"prices"`
+}
+
+func (e *Env) GetServices(c *gin.Context) {
+ if services, err := e.backend.Services(); err != nil {
+ RespondError(c, 500, "Unable to get services: %s", err.Error())
+ } else {
+ Respond(c, 200, services)
+ }
+}
+
+func (e *Env) CreateCheckout(c *gin.Context) {
+ user := c.Param("user")
+
+ var order order
+ if err := c.ShouldBind(&order); err != nil {
+ RespondError(c, 400, "Invalid order: %s", err.Error())
+ return
+ }
+ if len(order.Prices) != 1 {
+ RespondError(c, 400, "Must contain one and only one price")
+ return
+ }
+
+ if portal, err := e.backend.CreateCheckout(user, order.Prices[0]); err != nil {
+ RespondError(c, 500, "Unable to create order: %s", err.Error())
+ } else {
+ Respond(c, 200, portal)
+ }
+}
+
+func (e *Env) CreatePortal(c *gin.Context) {
+ user := c.Param("user")
+
+ if portal, err := e.backend.CreatePortal(user); err != nil {
+ RespondError(c, 500, "Unable to create order: %s", err.Error())
+ } else {
+ Respond(c, 200, portal)
+ }
+}
diff --git a/cmd/api/settings.go b/cmd/api/settings.go
@@ -7,64 +7,42 @@ import (
"github.com/gin-gonic/gin"
)
-type settingDef struct {
- primitive string
- valid func(interface{}) (bool, error)
- defaultValue interface{}
- // min *int, max *int // for integer types, min and max value, for string, length
- memo string
-}
-
-var userSettings = map[string]settingDef {
- "email": { "string", validateEmail, nil, "email address", },
+type SettingReturn struct {
+ Type string `json:"type"`
+ Default any `json:"default"`
+ Memo string `json:"description"`
}
func (e *Env) GetSettings(c *gin.Context) {
- settings := make(gin.H)
- for name, def := range userSettings {
- settings[name] = gin.H{ "type": def.primitive, "description": def.memo }
+ r := make(map[string]SettingReturn)
+ s := e.backend.Settings()
+ for k, v := range s {
+ r[k] = SettingReturn{Type: v.Primitive, Memo: v.Memo, Default: v.DefaultValue}
}
- Respond(c, 200, settings)
+ Respond(c, 200, r)
}
-func splitInvalidSettings(settings map[string]interface{}) (purged map[string]interface{}, err error) {
- purged = make(map[string]interface{})
- for k, v := range settings {
- valid, err := validateSetting(k, v)
- if err != nil {
- return nil, err
- }
- if !valid {
- purged[k] = v
- }
- }
-
- // Doing this afterwords incase validateSetting returns an
- // error so settings won't be altered on error
- for k := range purged {
- settings[k] = nil
+func validateEmail(value interface{}) (bool, error) {
+ s, ok := value.(string)
+ if !ok {
+ return false, errors.New("Invalid type")
}
- return purged, nil
-}
-func validateSetting(name string, value interface{}) (bool, error) {
- def, exists := userSettings[name]
- if !exists {
- return false, errors.New("Setting does not exist")
+ _, err := mail.ParseAddress(s)
+ if err != nil {
+ return false, err
}
-
- return def.valid(value)
+ return true, nil
}
-func validateEmail(value interface{}) (bool, error) {
+func validateEmailPolicy(value any) (bool, error) {
s, ok := value.(string)
if !ok {
return false, errors.New("Invalid type")
}
- _, err := mail.ParseAddress(s)
- if err != nil {
- return false, err
+ if s != "strict" {
+ return false, errors.New(s + ": Invalid policy. Must be strict.")
}
return true, nil
}
diff --git a/cmd/api/users.go b/cmd/api/users.go
@@ -8,20 +8,84 @@ import (
"jacobedwards.org/spaceplanner.app/internal/backend"
)
+type CreateUserParams struct {
+ Creds Credentials `json:"credentials" binding:"required"`
+ Email string `json:"email" binding:"required"`
+ EmailStrict *bool `json:"email_strict"`
+}
+
+type CodeReq struct {
+ Code string `json:"code"`
+}
+
func (e *Env) CreateUser(c *gin.Context) {
- var creds Credentials
- if err := c.ShouldBind(&creds); err != nil {
- RespondError(c, http.StatusUnauthorized, "Credentials not provided")
+ var params CreateUserParams
+ if err := c.ShouldBind(¶ms); err != nil {
+ RespondError(c, http.StatusUnauthorized, "Unable to create user: %s", err.Error())
return
}
- err := e.backend.CreateUser(creds.Username, creds.Password)
+ user, err := e.backend.CreateUser(params.Creds.Username, params.Creds.Password)
if err != nil {
RespondError(c, http.StatusBadRequest,
"Unable to create user: %s", err.Error())
return
}
- Respond(c, http.StatusCreated, backend.User{ Name: creds.Username })
+
+ err = e.backend.UpdateUserSetting(nil, user.Name, "email", params.Email)
+ if err == nil && params.EmailStrict != nil {
+ err = e.backend.UpdateUserSetting(nil, user.Name, "email_strict", *params.EmailStrict)
+ }
+ if err != nil {
+ RespondError(c, 400, "Invalid Email or Email Policy: %s", err.Error())
+ err2 := e.backend.DeleteUser(user.Name)
+ if err2 != nil {
+ log.Printf("%s: User cannot be deleted: %s", user.Name, err2.Error())
+ }
+ return
+ }
+
+ if _, err := e.backend.VerifyEmail(user.Name, nil); err != nil {
+ log.Printf("Unable to send verification email: %s", err.Error())
+ }
+
+ Respond(c, http.StatusCreated, user)
+}
+
+func (e *Env) VerifiedUserEmail(c *gin.Context) {
+ user := c.Param("user")
+
+ emails, err := e.backend.UserEmails(user)
+ if err != nil {
+ RespondError(c, 500, "Unable to get user's emails")
+ } else {
+ Respond(c, 200, emails.Verified)
+ }
+}
+
+func (e *Env) VerifyUserEmailCode(c *gin.Context) {
+ user := c.Param("user")
+ var req CodeReq
+
+ if err := c.ShouldBind(&req); err != nil {
+ RespondError(c, 400, "Unable to read request: %s", err.Error())
+ return
+ }
+ if verified, err := e.backend.VerifyEmail(user, &req.Code); err != nil {
+ RespondError(c, 500, "Unable to verify email: %s", err.Error())
+ } else {
+ Respond(c, 200, gin.H{"verified": verified})
+ }
+}
+
+func (e *Env) SendUserEmailCode(c *gin.Context) {
+ user := c.Param("user")
+
+ if err := e.backend.SendVerificationEmail(user); err != nil {
+ RespondError(c, 500, "Unable to send verification email: %s", err.Error())
+ } else {
+ Respond(c, 200, nil)
+ }
}
func (e *Env) DeleteUser(c *gin.Context) {
@@ -41,7 +105,7 @@ func (e *Env) GetUser(c *gin.Context) {
return
}
- user, err := e.backend.GetUser(name)
+ user, err := e.backend.GetUser(nil, name)
if err != nil {
RespondError(c, http.StatusOK, "%q: Unable to get user: %s",
name, err.Error())
@@ -61,15 +125,7 @@ func (e *Env) GetUserSettings(c *gin.Context) {
settings, err := e.backend.GetUserSettings(nil, user)
if err != nil {
RespondError(c, 400, "Unable to get settings: %s", err.Error())
- return
- }
- invalids, err := splitInvalidSettings(settings)
- if err != nil {
- RespondError(c, 500, "Unable to validate settings")
} else {
- for name, value := range invalids {
- log.Printf("WARNING: Setting has invalid value (%q = %v)", name, value)
- }
Respond(c, http.StatusOK, settings)
}
}
@@ -101,22 +157,7 @@ func (e *Env) UpdateUserSettings(c *gin.Context) {
}
if err = applyPatchset(settings, patches); err != nil {
- RespondError(c, 400, "Unable to apply patches")
- return
- }
-
- invalids, err := splitInvalidSettings(settings)
- if err != nil {
- RespondError(c, 400, "Unable to validate setting: %s", err.Error())
- return
- }
- if len(invalids) > 0 {
- var one string
- // Is there a better way?
- for one = range(invalids) {
- break
- }
- RespondError(c, 400, "%d invalid settings, including %q", len(invalids), one)
+ RespondError(c, 400, "Unable to apply patches: %s", err.Error())
return
}
diff --git a/internal/backend/config.go b/internal/backend/config.go
@@ -0,0 +1,26 @@
+package backend
+
+type Config struct {
+ Stripe StripeConfig `json:"stripe" binding:"required"`
+ SMTP SMTPConfig `json:"smtp" binding:"required"`
+ // Database connection string
+ Database string `json:"database"`
+}
+
+type StripeConfig struct {
+ // Stripe API key
+ Key string `json:"key" binding:"required"`
+}
+
+type SMTPConfig struct {
+ // Server for mail submission
+ Server string `json:"server" binding:"required"`
+ // Port for mail submission
+ Port string `json:"port" binding:"required"`
+ // User to authenticate for
+ User string `json:"user" binding:"required"`
+ // Password to authenticate with
+ Password string `json:"password" binding:"required"`
+ // Name given in From: header
+ Name string `json:"from" binding:"required"`
+}
diff --git a/internal/backend/env.go b/internal/backend/env.go
@@ -1,25 +1,42 @@
package backend
import (
+ "net/smtp"
"database/sql"
- "errors"
+ _ "github.com/lib/pq"
+ "github.com/stripe/stripe-go/v72/client"
)
type Env struct {
+ Config Config
DB *sql.DB
+ Stripe *client.API
+ SMTPAuth smtp.Auth
+ // Private
stmts map[string]*sql.Stmt
+ settingDefs map[string]SettingDef
}
// Creates new backend environemnt with the given database
// (which will be managed by these functions afterwords
-func NewEnv(db *sql.DB) (*Env, error) {
- if db == nil {
- return nil, errors.New("No database")
+func NewEnv(c Config) (*Env, error) {
+ var e Env
+ var err error
+
+ e.DB, err = sql.Open("postgres", c.Database)
+ if err != nil {
+ return &e, err
}
- return &Env{
- DB: db,
- stmts: make(map[string]*sql.Stmt),
- }, nil
+ e.SMTPAuth = smtp.PlainAuth("", c.SMTP.User, c.SMTP.Password, c.SMTP.Server)
+ e.Stripe = client.New(c.Stripe.Key, nil)
+ e.stmts = make(map[string]*sql.Stmt)
+ e.settingDefs = make(map[string]SettingDef)
+ e.Config = c
+ return &e, nil
+}
+
+func (e *Env) AddSetting(key string, def SettingDef) {
+ e.settingDefs[key] = def
}
func (e *Env) CacheStmt(name, sql string) (*sql.Stmt, error) {
diff --git a/internal/backend/go.mod b/internal/backend/go.mod
@@ -2,6 +2,9 @@ module jacobedwards.org/spaceplanner.app/internal/backend
go 1.22.1
-require golang.org/x/crypto v0.25.0
+require (
+ github.com/stripe/stripe-go/v72 v72.122.0
+ golang.org/x/crypto v0.25.0
+)
-require github.com/lib/pq v1.10.9 // indirect
+require github.com/lib/pq v1.10.9
diff --git a/internal/backend/go.sum b/internal/backend/go.sum
@@ -1,4 +1,25 @@
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stripe/stripe-go/v72 v72.122.0 h1:eRXWqnEwGny6dneQ5BsxGzUCED5n180u8n665JHlut8=
+github.com/stripe/stripe-go/v72 v72.122.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/internal/backend/order.go b/internal/backend/order.go
@@ -0,0 +1,44 @@
+package backend
+
+import (
+ "github.com/stripe/stripe-go/v72"
+)
+
+func (e *Env) CreatePortal(username string) (*stripe.BillingPortalSession, error) {
+ user, err := e.GetUser(nil, username)
+ if err != nil {
+ return nil, err
+ }
+
+ params := &stripe.BillingPortalSessionParams{
+ Customer: stripe.String(user.stripeCustomerID),
+ ReturnURL: stripe.String("http://www.spaceplanner.app"),
+ };
+ return e.Stripe.BillingPortalSessions.New(params);
+}
+
+func (e *Env) CreateCheckout(username string, price string) (*stripe.CheckoutSession, error) {
+ user, err := e.GetUser(nil, username)
+ if err != nil {
+ return nil, err
+ }
+
+ p, err := e.Price(price)
+ if err != nil {
+ return nil, err
+ }
+
+ params := &stripe.CheckoutSessionParams{
+ Customer: stripe.String(user.stripeCustomerID),
+ LineItems: []*stripe.CheckoutSessionLineItemParams{
+ &stripe.CheckoutSessionLineItemParams{
+ Price: stripe.String(p.ID),
+ Quantity: stripe.Int64(1),
+ },
+ },
+ Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
+ SuccessURL: stripe.String("http://www.spaceplanner.app/floorplans"),
+ CancelURL: stripe.String("http://www.spaceplanner.app/services"),
+ }
+ return e.Stripe.CheckoutSessions.New(params);
+}
diff --git a/internal/backend/product.go b/internal/backend/product.go
@@ -0,0 +1,75 @@
+package backend
+
+import (
+ "github.com/stripe/stripe-go/v72"
+)
+
+/*
+ * NOTE: For now I'm not caching anything in the database, and each call
+ * to these functions retrieves the information anew from Stripe's servers.
+ *
+ * TODO: Implement a cache
+ */
+type Service struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Prices []Price `json:"prices"`
+}
+
+type Price struct {
+ ID string `json:"id"`
+ ServiceID string `json:"service"`
+ Amount int64 `json:"amount"`
+ Interval string `json:"interval"`
+ IntervalCount int64 `json:"intervalCount"`
+}
+
+func (e *Env) Services() ([]Service, error) {
+ p := &stripe.ProductListParams{
+ Active: stripe.Bool(true),
+ }
+ p.AddExpand("data.default_price")
+
+ services := make([]Service, 0, 8)
+ products := e.Stripe.Products.List(p)
+ for products.Next() {
+ services = append(services, toService(products.Product()))
+ }
+
+ if err := products.Err(); err != nil {
+ return nil, err
+ }
+ return services, nil
+}
+
+func (e *Env) Price(id string) (Price, error) {
+ p, err := e.Stripe.Prices.Get(id, nil)
+ if err != nil {
+ return Price{}, err
+ }
+ return toPrice(p), nil
+}
+
+func toService(p *stripe.Product) Service {
+ s := Service{
+ ID: p.ID,
+ Name: p.Name,
+ Description: p.Description,
+ }
+ if p.DefaultPrice != nil {
+ a := []Price{toPrice(p.DefaultPrice)}
+ s.Prices = a
+ }
+ return s
+}
+
+func toPrice(p *stripe.Price) Price {
+ return Price{
+ ID: p.ID,
+ ServiceID: p.Product.ID,
+ Amount: p.UnitAmount,
+ Interval: string(p.Recurring.Interval),
+ IntervalCount: p.Recurring.IntervalCount,
+ }
+}
diff --git a/internal/backend/settings.go b/internal/backend/settings.go
@@ -0,0 +1,174 @@
+package backend
+
+import (
+ "errors"
+ "database/sql"
+ "github.com/lib/pq"
+)
+
+type SettingDef struct {
+ Primitive string `json:"type"`
+ Validator func(interface{}) (bool, error)
+ DefaultValue interface{}
+ // min *int, max *int // for integer types, min and max value, for string, length
+ Memo string `json:"description"`
+}
+
+type rawSetting struct {
+ username *string
+ name *string
+ strval *string
+ numval *int
+ boolval *bool
+}
+
+func (e *Env) Settings() map[string]SettingDef {
+ return e.settingDefs
+}
+
+func (e *Env) UpdateUserSetting(tx *sql.Tx, username string, key string, value any) error {
+ m := make(map[string]any)
+ m[key] = value
+ return e.UpdateUserSettings(tx, username, m)
+}
+
+func (e *Env) UpdateUserSettings(tx *sql.Tx, username string, settings map[string]interface{}) error {
+ stmt, err := e.CacheTxStmt(tx, "set_user_settings", `INSERT INTO user_settings VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (username, name) DO UPDATE
+ SET (strval, numval, boolval) = (EXCLUDED.strval, EXCLUDED.numval, EXCLUDED.boolval)`)
+ if err != nil {
+ return err
+ }
+
+ invalid, err := e.splitInvalidSettings(settings)
+ if err != nil || len(invalid) > 0 {
+ // Helpful I know
+ return errors.New("Unable to validate all settings")
+ }
+
+ for name, setting := range settings {
+ r := toRawSetting(username, name, setting)
+ _, err = stmt.Exec(r.username, r.name, r.strval, r.numval, r.boolval)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (e *Env) GetUserSettings(tx *sql.Tx, username string, names ...string) (map[string]interface{}, error) {
+ stmt, err := e.CacheTxStmt(tx, "get_user_settings", `SELECT * FROM user_settings WHERE
+ user_settings.username = $1 AND
+ (ARRAY_LENGTH($2::varchar[], 1) IS NULL OR user_settings.name = ANY ($2))`)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := stmt.Query(username, pq.Array(names))
+ if err != nil {
+ return nil, err
+ }
+
+ return collectSettings(rows)
+}
+
+func (e *Env) DeleteUserSettings(tx *sql.Tx, username string, names ...string) (map[string]interface{}, error) {
+ var deleted map[string]interface{}
+ stmt, err := e.CacheTxStmt(tx, "del_user_settings", `DELETE FROM user_settings WHERE user_settings.username = $1 AND
+ (ARRAY_LENGTH($2::varchar[], 1) IS NULL OR user_settings.name = ANY ($2)) RETURNING *`)
+ if err != nil {
+ return deleted, err
+ }
+
+ rows, err := stmt.Query(username, pq.Array(names))
+ if err != nil {
+ return deleted, err
+ }
+ return collectSettings(rows)
+}
+
+func toRawSetting(username string, name string, setting interface{}) *rawSetting {
+ var b *bool
+ var i *int
+ var s *string
+
+ switch v := setting.(type) {
+ case string:
+ s = &v
+ case int:
+ i = &v
+ case bool:
+ b = &v
+ case nil:
+ ;
+ default:
+ panic("Setting.Value: Unexpected type")
+ }
+
+ return &rawSetting{
+ username: &username,
+ name: &name,
+ strval: s,
+ numval: i,
+ boolval: b,
+ }
+}
+
+func collectSettings(rows *sql.Rows) (map[string]interface{}, error) {
+ settings := make(map[string]interface{})
+ for rows.Next() {
+ var setting interface{}
+ var raw rawSetting
+ err := rows.Scan(&raw.username, &raw.name, &raw.strval, &raw.numval, &raw.boolval)
+ if err != nil {
+ return settings, err
+ }
+
+ if raw.strval != nil {
+ setting = *raw.strval
+ } else if raw.numval != nil {
+ setting = *raw.numval
+ } else if raw.boolval != nil {
+ setting = *raw.boolval
+ } else {
+ return settings, errors.New("Database constraint error: no setting set")
+ }
+ settings[*raw.name] = setting
+ }
+
+ return settings, nil
+}
+
+func (e *Env) splitInvalidSettings(settings map[string]interface{}) (purged map[string]interface{}, err error) {
+ purged = make(map[string]interface{})
+ for k, v := range settings {
+ valid, err := e.validateSetting(k, v)
+ if err != nil {
+ return nil, err
+ }
+ if !valid {
+ purged[k] = v
+ }
+ }
+
+ // Doing this afterwords incase validateSetting returns an
+ // error so settings won't be altered on error
+ for k := range purged {
+ settings[k] = nil
+ }
+ return purged, nil
+}
+
+func (e *Env) validateSetting(name string, value interface{}) (bool, error) {
+ def, exists := e.settingDefs[name]
+ if !exists {
+ return false, errors.New("Setting does not exist")
+ }
+
+ if def.Validator == nil {
+ return true, nil
+ }
+ return def.Validator(value)
+}
+
diff --git a/internal/backend/user.go b/internal/backend/user.go
@@ -2,71 +2,104 @@ package backend
import (
"errors"
+ "fmt"
+ "log"
+ "crypto/rand"
+ "math/big"
+ "net/smtp"
"database/sql"
"golang.org/x/crypto/bcrypt"
- "github.com/lib/pq"
+ "github.com/stripe/stripe-go/v72"
)
// Database representation of user
type User struct {
Name string `json:"name"`
hash string
+ stripeCustomerID string
}
-type rawSetting struct {
- username *string
- name *string
- strval *string
- numval *int
- boolval *bool
+type UserEmails struct {
+ Verified *string
+ Setting *string
}
-func (e *Env) CreateUser(username string, password string) error {
+func (e *Env) CreateUser(username string, password string) (*User, error) {
if username == "" {
- return errors.New("Empty username")
+ return nil, errors.New("Empty username")
+ }
+ if len(password) < 8 {
+ return nil, errors.New("Password must be at least 8 characters long")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
- return err
+ return nil, err
}
- user := User{ Name: username, hash: string(hash) }
- return e.insertUser(user)
+ tx, err := e.DB.Begin()
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+
+ c, err := e.Stripe.Customers.New(&stripe.CustomerParams{})
+ if err != nil {
+ return nil, err
+ }
+
+ user := User{ Name: username, hash: string(hash), stripeCustomerID: c.ID }
+ if err := e.insertUser(tx, user); err != nil {
+ if _, err := e.Stripe.Customers.Del(c.ID, nil); err != nil {
+ log.Printf("%s: Unable to delete customer on error", c.ID)
+ }
+ return nil, err
+ }
+ return &user, tx.Commit()
}
func (e *Env) DeleteUser(username string) error {
- stmt, err := e.CacheStmt("delete_user", "DELETE FROM users WHERE users.name = $1")
+ tx, err := e.DB.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ del, err := e.CacheTxStmt(tx, "delete_user", "DELETE FROM users WHERE users.name = $1")
if err != nil {
return err
}
- res, err := stmt.Exec(username)
+
+ user, err := e.GetUser(tx, username)
if err != nil {
return err
}
- if n, err := res.RowsAffected(); err != nil || n == 1 {
- return nil
+
+ if _, err := del.Exec(username); err != nil {
+ return err
}
- return errors.New("No user with that name")
-}
-func (e *Env) GetUser(username string) (User, error) {
- var user User
+ if _, err := e.Stripe.Customers.Del(user.stripeCustomerID, nil); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+func (e *Env) GetUser(tx *sql.Tx, username string) (User, error) {
if username == "" {
return User{}, errors.New("Empty username")
}
- stmt, err := e.CacheStmt("get_user", "SELECT * FROM users WHERE users.name = $1")
+
+ stmt, err := e.CacheTxStmt(tx, "get_user", "SELECT name, hash, stripe_customer_id FROM users WHERE users.name = $1")
if err != nil {
return User{}, err
}
- row := stmt.QueryRow(username)
- err = row.Scan(&user.Name, &user.hash)
- return user, err
+ return scanUser(stmt.QueryRow(username))
}
func (e *Env) LoginUser(username string, password string) (User, error) {
- user, err := e.GetUser(username)
+ user, err := e.GetUser(nil, username)
if err != nil {
return User{}, err;
}
@@ -78,124 +111,162 @@ func (e *Env) LoginUser(username string, password string) (User, error) {
return user, nil;
}
-
-func (e *Env) UpdateUserSettings(tx *sql.Tx, username string, settings map[string]interface{}) error {
- stmt, err := e.CacheStmt("set_user_settings", `INSERT INTO user_settings VALUES ($1, $2, $3, $4, $5)
- ON CONFLICT (username, name) DO UPDATE
- SET (strval, numval, boolval) = (EXCLUDED.strval, EXCLUDED.numval, EXCLUDED.boolval)`)
+func (e *Env) VerifyEmail(username string, code *string) (bool, error) {
+ emails, err := e.UserEmails(username)
if err != nil {
- return err
+ return false, err
}
- if tx != nil {
- stmt = tx.Stmt(stmt)
+ /* Unchanged; emails.Verified */
+ if (emails.Setting == nil || emails.Verified == emails.Setting) {
+ return true, nil
}
- for name, setting := range settings {
- r := toRawSetting(username, name, setting)
- _, err = stmt.Exec(r.username, r.name, r.strval, r.numval, r.boolval)
- if err != nil {
- return err
- }
+ if code == nil {
+ /* There is unemails.Verified email */
+ return false, nil
}
- return nil
-}
-
-func (e *Env) GetUserSettings(tx *sql.Tx, username string, names ...string) (map[string]interface{}, error) {
- stmt, err := e.CacheStmt("get_user_settings", `SELECT * FROM user_settings WHERE
- user_settings.username = $1 AND
- (ARRAY_LENGTH($2::varchar[], 1) IS NULL OR user_settings.name = ANY ($2))`)
+ target, err := e.emailCode(username, *emails.Setting, nil)
if err != nil {
- return nil, err
+ return false, err
}
- if tx != nil {
- stmt = tx.Stmt(stmt)
+ /* No match; unemails.Verified */
+ if *code != target {
+ return false, nil
}
- rows, err := stmt.Query(username, pq.Array(names))
+ tx, err := e.DB.Begin()
if err != nil {
- return nil, err
+ return false, err
+ }
+ defer tx.Rollback()
+
+ _, err = tx.Exec(`DELETE FROM spaceplanner.email_codes WHERE email = $1`, emails.Setting)
+ if err != nil {
+ return false, err
+ }
+ _, err = tx.Exec(`UPDATE spaceplanner.users SET emails.Verified_email = $2 WHERE name = $1`,
+ username, emails.Setting)
+ if err != nil {
+ return false, errors.New("UPDATE emails.Verified_email: " + err.Error())
}
- return collectSettings(rows)
+ if err := e.updateStripeEmail(username, *emails.Setting); err != nil {
+ return false, err
+ }
+
+ /* Match; emails.Verified if successful db update */
+ return true, tx.Commit()
}
-func (e *Env) DeleteUserSettings(tx *sql.Tx, username string, names ...string) (map[string]interface{}, error) {
- var deleted map[string]interface{}
- stmt, err := e.CacheStmt("del_user_settings", `DELETE FROM user_settings WHERE user_settings.username = $1 AND
- (ARRAY_LENGTH($2::varchar[], 1) IS NULL OR user_settings.name = ANY ($2)) RETURNING *`)
+func (e *Env) SendVerificationEmail(username string) error {
+ emails, err := e.UserEmails(username)
if err != nil {
- return deleted, err
+ return err
}
- if tx != nil {
- tx.Stmt(stmt)
+ // In the future there may be more reasons to send a code (though I doubt it)
+ // but for now only if Setting differs from Verified and is not nil
+ if emails.Setting == nil {
+ return errors.New("No email to verify")
+ }
+ if emails.Verified != nil && emails.Verified == emails.Setting {
+ return errors.New("Email already verified")
}
- rows, err := stmt.Query(username, pq.Array(names))
+ code, err := genCode()
if err != nil {
- return deleted, err
+ return err
}
- return collectSettings(rows)
+
+ if _, err := e.emailCode(username, *emails.Setting, &code); err != nil {
+ return err
+ }
+
+ body := fmt.Sprintf(
+`From: %s <%s>
+To: %s
+Subject: Your Spaceplanner.app verification code
+
+Your email verification code is
+
+ %s
+
+It expires after 15 minutes.
+
+If you did not try and set your email for spaceplanner.app, you can
+safely ignore this message (and sorry to bother).`, e.Config.SMTP.Name, e.Config.SMTP.Server, *emails.Setting, code)
+
+ addrs := []string{*emails.Setting}
+ log.Printf("Sending authentication code to %s at request of %s", *emails.Setting, username)
+ return smtp.SendMail(e.Config.SMTP.Server, e.SMTPAuth, e.Config.SMTP.Name, addrs, []byte(body))
}
-func toRawSetting(username string, name string, setting interface{}) *rawSetting {
- var b *bool
- var i *int
- var s *string
-
- switch v := setting.(type) {
- case string:
- s = &v
- case int:
- i = &v
- case bool:
- b = &v
- default:
- panic("Setting.Value: Unexpected type")
- }
-
- return &rawSetting{
- username: &username,
- name: &name,
- strval: s,
- numval: i,
- boolval: b,
+func (e *Env) UserEmails(username string) (UserEmails, error) {
+ get, err := e.CacheTxStmt(nil, "get_both_emails", `
+ SELECT users.verified_email, user_settings.strval AS setting
+ FROM users LEFT OUTER JOIN user_settings
+ ON users.name = user_settings.username AND user_settings.name = 'email'
+ WHERE users.name = $1`)
+ if err != nil {
+ return UserEmails{}, err
}
+
+ var r UserEmails
+ return r, get.QueryRow(username).Scan(&r.Verified, &r.Setting)
}
-func collectSettings(rows *sql.Rows) (map[string]interface{}, error) {
- settings := make(map[string]interface{})
- for rows.Next() {
- var setting interface{}
- var raw rawSetting
- err := rows.Scan(&raw.username, &raw.name, &raw.strval, &raw.numval, &raw.boolval)
- if err != nil {
- return settings, err
- }
+func (e *Env) updateStripeEmail(username, email string) error {
+ user, err := e.GetUser(nil, username)
+ if err != nil {
+ return err
+ }
- if raw.strval != nil {
- setting = *raw.strval
- } else if raw.numval != nil {
- setting = *raw.numval
- } else if raw.boolval != nil {
- setting = *raw.boolval
- } else {
- return settings, errors.New("Database constraint error: no setting set")
- }
- settings[*raw.name] = setting
+ params := &stripe.CustomerParams{Email: stripe.String(email)}
+ _, err = e.Stripe.Customers.Update(user.stripeCustomerID, params)
+ return err
+}
+
+func (e *Env) emailCode(username, email string, code *string) (string, error) {
+ _, err := e.DB.Exec("DELETE FROM spaceplanner.email_codes WHERE age(created) > '15 minutes'")
+ if err != nil {
+ return "", err
}
- return settings, nil
+ if code != nil {
+ _, err := e.DB.Exec(`INSERT INTO spaceplanner.email_codes (username, email, code)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (username, email) DO UPDATE
+ SET code = EXCLUDED.code`, username, email, code)
+ return "", err
+ }
+
+ var c string
+ err = e.DB.QueryRow(`SELECT code FROM spaceplanner.email_codes
+ WHERE username = $1 AND email = $2`, username, email).Scan(&c)
+ return c, err
+}
+
+func genCode() (string, error) {
+ n, err := rand.Int(rand.Reader, big.NewInt(999999))
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%.6d", n), nil
+}
+
+func scanUser(s Scanner) (User, error) {
+ var u User
+ return u, s.Scan(&u.Name, &u.hash, &u.stripeCustomerID)
}
-func (e *Env) insertUser(user User) error {
- stmt, err := e.CacheStmt("insert_user", "INSERT INTO users VALUES ($1, $2)")
+func (e *Env) insertUser(tx *sql.Tx, user User) error {
+ stmt, err := e.CacheTxStmt(tx, "insert_user", "INSERT INTO users (name, hash, stripe_customer_id) VALUES ($1, $2, $3)")
if err != nil {
return err
}
- _, err = stmt.Exec(user.Name, user.hash)
+ _, err = stmt.Exec(user.Name, user.hash, user.stripeCustomerID)
return err
}