diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19cbc48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +public +vendor +streaking +nohup.out +log +pid +tmp diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..42c3c25 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,165 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "cloud.google.com/go" + packages = ["compute/metadata"] + revision = "056a55f54a6cc77b440b31a56a5e7c3982d32811" + version = "v0.22.0" + +[[projects]] + name = "github.com/dgrijalva/jwt-go" + packages = ["."] + revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" + version = "v3.2.0" + +[[projects]] + name = "github.com/go-sql-driver/mysql" + packages = ["."] + revision = "a0583e0143b1624142adab07e0e97fe106d99561" + version = "v1.3" + +[[projects]] + name = "github.com/golang/protobuf" + packages = ["proto"] + revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" + version = "v1.1.0" + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/securecookie" + packages = ["."] + revision = "e59506cc896acb7f7bf732d4fdf5e25f7ccd8983" + version = "v1.1.1" + +[[projects]] + name = "github.com/gorilla/sessions" + packages = ["."] + revision = "ca9ada44574153444b00d3fd9c8559e4cc95f896" + version = "v1.1" + +[[projects]] + branch = "master" + name = "github.com/jmoiron/sqlx" + packages = [ + ".", + "reflectx" + ] + revision = "2aeb6a910c2b94f2d5eb53d9895d80e27264ec41" + +[[projects]] + name = "github.com/labstack/echo" + packages = [ + ".", + "middleware" + ] + revision = "6d227dfea4d2e52cb76856120b3c17f758139b4e" + version = "3.3.5" + +[[projects]] + name = "github.com/labstack/echo-contrib" + packages = ["session"] + revision = "7d9d9632a4aadf9b026436e43d5f96eca2d895a6" + version = "0.5.2" + +[[projects]] + name = "github.com/labstack/gommon" + packages = [ + "bytes", + "color", + "log", + "random" + ] + revision = "588f4e8bddc6cb45c27b448e925c7fd6a5545434" + version = "0.2.5" + +[[projects]] + name = "github.com/mattn/go-colorable" + packages = ["."] + revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" + version = "v0.0.9" + +[[projects]] + name = "github.com/mattn/go-isatty" + packages = ["."] + revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" + version = "v0.0.3" + +[[projects]] + branch = "master" + name = "github.com/valyala/bytebufferpool" + packages = ["."] + revision = "e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7" + +[[projects]] + branch = "master" + name = "github.com/valyala/fasttemplate" + packages = ["."] + revision = "dcecefd839c4193db0d35b88ec65b4c12d360ab0" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = [ + "acme", + "acme/autocert" + ] + revision = "2d027ae1dddd4694d54f7a8b6cbe78dca8720226" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "context/ctxhttp" + ] + revision = "2491c5de3490fced2f6cff376127c667efeed857" + +[[projects]] + branch = "master" + name = "golang.org/x/oauth2" + packages = [ + ".", + "facebook", + "github", + "google", + "internal", + "jws", + "jwt" + ] + revision = "cdc340f7c179dbbfa4afd43b7614e8fcadde4269" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = ["unix"] + revision = "d0faeb539838e250bd0a9db4182d48d4a1915181" + +[[projects]] + name = "google.golang.org/appengine" + packages = [ + ".", + "internal", + "internal/app_identity", + "internal/base", + "internal/datastore", + "internal/log", + "internal/modules", + "internal/remote_api", + "internal/urlfetch", + "urlfetch" + ] + revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" + version = "v1.0.0" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "7c8ab5114679d6a2cf7c42a21489869cca06a450557467f8e67c2e7d16a66d8d" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..815656c --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,54 @@ +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/go-sql-driver/mysql" + version = "1.3.0" + +[[constraint]] + name = "github.com/gorilla/sessions" + version = "1.1.0" + +[[constraint]] + branch = "master" + name = "github.com/jmoiron/sqlx" + +[[constraint]] + name = "github.com/labstack/echo" + version = "3.3.5" + +[[constraint]] + name = "github.com/labstack/echo-contrib" + version = "0.5.2" + +[[constraint]] + branch = "master" + name = "golang.org/x/oauth2" + +[prune] + go-tests = true + unused-packages = true diff --git a/api.go b/api.go new file mode 100644 index 0000000..759c21a --- /dev/null +++ b/api.go @@ -0,0 +1,192 @@ +package main + +import ( + "bh/streaking/models" + "fmt" + "net/http" + "strconv" + + "github.com/gorilla/sessions" + "github.com/jmoiron/sqlx" + "github.com/labstack/echo" + "github.com/labstack/echo-contrib/session" +) + +type handler struct { + db *sqlx.DB +} + +type successResponse struct { + Success bool `json:"success"` +} + +// GET /me +func (h *handler) getUser(c echo.Context) error { + um := models.Users{DB: h.db} + gm := models.Goals{DB: h.db} + sm := models.Streaks{DB: h.db} + + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + uid := sess.Values["user"] + + var us []models.User + var gs []models.Goal + var ss []models.Streak + var err error + + if us, err = um.Read(map[string]interface{}{"id": uid}); err != nil { + fmt.Println(err) + return err + } + if gs, err = gm.Read(map[string]interface{}{"user_id": uid}); err != nil { + fmt.Println(err) + return err + } + if ss, err = sm.Read(map[string]interface{}{"user_id": uid}); err != nil { + fmt.Println(err) + return err + } + + return c.JSON(http.StatusOK, &struct { + Users models.User `json:"user"` + Goals []models.Goal `json:"goals"` + Streaks []models.Streak `json:"streaks"` + }{us[0], gs, ss}) +} + +// POST /me/goals +func (h *handler) createGoal(c echo.Context) error { + g := models.Goal{} + if err := c.Bind(&g); err != nil { + return err + } + + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + uid := sess.Values["user"].(int) + g.UserID = uid + + gm := models.Goals{DB: h.db} + if err := gm.Create(g); err != nil { + return err + } + + return c.JSON(http.StatusOK, successResponse{true}) +} + +// POST /me/streaks +func (h *handler) createStreak(c echo.Context) error { + s := models.Streak{} + if err := c.Bind(&s); err != nil { + return err + } + + sm := models.Streaks{DB: h.db} + + if err := sm.Create(s); err != nil { + return err + } + + return c.JSON(http.StatusOK, successResponse{true}) +} + +// PUT /me/goals/:goal_id +func (h *handler) updateGoal(c echo.Context) error { + g := models.Goal{} + if err := c.Bind(&g); err != nil { + return err + } + + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + uid := sess.Values["user"].(int) + gid, err := strconv.Atoi(c.Param("goal_id")) + + if err != nil { + return err + } + g.UserID = uid + + gm := models.Goals{DB: h.db} + if err := gm.Update(gid, g); err != nil { + return err + } + + return c.JSON(http.StatusOK, successResponse{true}) +} + +// PUT /me/streaks/:streak_id +func (h *handler) updateStreak(c echo.Context) error { + s := models.Streak{} + if err := c.Bind(&s); err != nil { + return err + } + + sid, err := strconv.Atoi(c.Param("streak_id")) + if err != nil { + return err + } + + sm := models.Streaks{DB: h.db} + if err := sm.Update(sid, s); err != nil { + fmt.Println(err) + return err + } + + return c.JSON(http.StatusOK, successResponse{true}) +} + +// DELTE /users/:user_id +func (h *handler) deleteUser(c echo.Context) error { + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + uid := sess.Values["user"].(int) + + um := models.Users{DB: h.db} + um.Delete(uid) + + return c.JSON(http.StatusOK, successResponse{true}) +} + +// DELETE /me/goals/:goal_id +func (h *handler) deleteGoal(c echo.Context) error { + gid, err := strconv.Atoi(c.Param("goal_id")) + if err != nil { + return err + } + + gm := models.Goals{DB: h.db} + gm.Delete(gid) + + return c.JSON(http.StatusOK, successResponse{true}) +} + +// DELETE /me/streaks/:streak_id +func (h *handler) deleteStreak(c echo.Context) error { + sid, err := strconv.Atoi(c.Param("streak_id")) + if err != nil { + return err + } + + sm := models.Streaks{DB: h.db} + sm.Delete(sid) + + return c.JSON(http.StatusOK, successResponse{true}) +} diff --git a/auth/facebook/handlers.go b/auth/facebook/handlers.go new file mode 100644 index 0000000..749a56c --- /dev/null +++ b/auth/facebook/handlers.go @@ -0,0 +1,64 @@ +package facebook + +import ( + "bh/streaking/auth" + "bh/streaking/models" + "encoding/json" + "log" + "os" + + "github.com/jmoiron/sqlx" + + "github.com/labstack/echo" + "golang.org/x/oauth2" + "golang.org/x/oauth2/facebook" +) + +var settings = auth.Settings{ + OauthConf: &oauth2.Config{ + ClientID: "226042608152816", + ClientSecret: "617e257795853d28e562ebecd14e400f", + RedirectURL: os.Getenv("BASE_URL") + "/callback/facebook", + Scopes: []string{"public_profile", "email"}, + Endpoint: facebook.Endpoint, + }, + OauthStateString: "thisshouldberandom", + BaseURL: "https://graph.facebook.com/me?access_token=", + GetUser: getUser, +} + +func getUser(res string) models.User { + temp := new(struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + }) + if err := json.Unmarshal([]byte(res), &temp); err != nil { + log.Fatal(err) + } + + if temp.Name == "" { + temp.Name = "NO_NAME_GIVEN" + } + if temp.Email == "" { + temp.Email = "NO_EMAIL_GIVEN" + } + + return models.User{ + Name: temp.Name, + Email: temp.Email, + Source: "FACEBOOK", + ExternalID: temp.ID, + } +} + +// HandleLogin - handle facebook login +func HandleLogin() echo.HandlerFunc { + return auth.BuildLoginHandler(settings) +} + +// HandleCallback - handle facebook callback +func HandleCallback(db *sqlx.DB) echo.HandlerFunc { + settings.DB = db + return auth.BuildCallbackHandler(settings) +} diff --git a/auth/github/handlers.go b/auth/github/handlers.go new file mode 100644 index 0000000..7d5c5b2 --- /dev/null +++ b/auth/github/handlers.go @@ -0,0 +1,65 @@ +package github + +import ( + "bh/streaking/auth" + "bh/streaking/models" + "encoding/json" + "log" + "os" + "strconv" + + "github.com/jmoiron/sqlx" + + "github.com/labstack/echo" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" +) + +var settings = auth.Settings{ + OauthConf: &oauth2.Config{ + ClientID: "27664cbca31fbcd886db", + ClientSecret: "9535df4affb9bd25ec44f6d00a32480a4fd9a078", + RedirectURL: os.Getenv("BASE_URL") + "/callback/github", + Scopes: []string{"public_profile"}, + Endpoint: github.Endpoint, + }, + OauthStateString: "thisshouldberandom", + BaseURL: "https://api.github.com/user?access_token=", + GetUser: getUser, +} + +func getUser(res string) models.User { + temp := new(struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + }) + if err := json.Unmarshal([]byte(res), &temp); err != nil { + log.Fatal(err) + } + + if temp.Name == "" { + temp.Name = "NO_NAME_GIVEN" + } + if temp.Email == "" { + temp.Email = "NO_EMAIL_GIVEN" + } + + return models.User{ + Name: temp.Name, + Email: temp.Email, + Source: "GITHUB", + ExternalID: strconv.Itoa(temp.ID), + } +} + +// HandleLogin - handle facebook login +func HandleLogin() echo.HandlerFunc { + return auth.BuildLoginHandler(settings) +} + +// HandleCallback - handle facebook callback +func HandleCallback(db *sqlx.DB) echo.HandlerFunc { + settings.DB = db + return auth.BuildCallbackHandler(settings) +} diff --git a/auth/google/handlers.go b/auth/google/handlers.go new file mode 100644 index 0000000..567e341 --- /dev/null +++ b/auth/google/handlers.go @@ -0,0 +1,64 @@ +package google + +import ( + "bh/streaking/auth" + "bh/streaking/models" + "encoding/json" + "log" + "os" + + "github.com/jmoiron/sqlx" + + "github.com/labstack/echo" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +var settings = auth.Settings{ + OauthConf: &oauth2.Config{ + ClientID: "443546063879-ocoa94kseo25apobl1dol3kqi2vkaqq1.apps.googleusercontent.com", + ClientSecret: "WtR0ABtcDhWwfVcV3FR14SUI", + RedirectURL: os.Getenv("BASE_URL") + "/callback/google", + Scopes: []string{"profile", "email"}, + Endpoint: google.Endpoint, + }, + OauthStateString: "thisshouldberandom", + BaseURL: "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=", + GetUser: getUser, +} + +func getUser(res string) models.User { + temp := new(struct { + ID string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + }) + if err := json.Unmarshal([]byte(res), &temp); err != nil { + log.Fatal(err) + } + + if temp.Name == "" { + temp.Name = "NO_NAME_GIVEN" + } + if temp.Email == "" { + temp.Email = "NO_EMAIL_GIVEN" + } + + return models.User{ + Name: temp.Name, + Email: temp.Email, + Source: "GOOGLE", + ExternalID: temp.ID, + } +} + +// HandleLogin - handle facebook login +func HandleLogin() echo.HandlerFunc { + return auth.BuildLoginHandler(settings) +} + +// HandleCallback - handle facebook callback +func HandleCallback(db *sqlx.DB) echo.HandlerFunc { + settings.DB = db + return auth.BuildCallbackHandler(settings) +} diff --git a/auth/init.go b/auth/init.go new file mode 100644 index 0000000..d2f1143 --- /dev/null +++ b/auth/init.go @@ -0,0 +1,180 @@ +package auth + +import ( + "bh/streaking/models" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "strings" + + "github.com/jmoiron/sqlx" + + "github.com/gorilla/sessions" + "github.com/labstack/echo" + "github.com/labstack/echo-contrib/session" + "golang.org/x/oauth2" +) + +type errorResponse struct { + Message bool `json:"success"` +} + +var skipRoutes = map[string]bool{ + "/login": true, + "/logout": true, + "/login/facebook": true, + "/login/github": true, + "/login/google": true, + "/callback/facebook": true, + "/callback/github": true, + "/callback/google": true, + "/logo.png": true, + "/logo_side.png": true, + "/favicon.ico": true, +} + +// Settings - settings for various login schemes +type Settings struct { + OauthConf *oauth2.Config + OauthStateString string + BaseURL string + DB *sqlx.DB + GetUser func(string) models.User +} + +// BuildLoginHandler build login handler given oauth conf and oauth state string +func BuildLoginHandler(settings Settings) echo.HandlerFunc { + return func(c echo.Context) error { + URL, err := url.Parse(settings.OauthConf.Endpoint.AuthURL) + if err != nil { + log.Fatal("Parse: ", err) + } + parameters := url.Values{} + parameters.Add("client_id", settings.OauthConf.ClientID) + parameters.Add("redirect_uri", settings.OauthConf.RedirectURL) + parameters.Add("scope", strings.Join(settings.OauthConf.Scopes, " ")) + parameters.Add("response_type", "code") + parameters.Add("invalid", "offline") + parameters.Add("state", settings.OauthStateString) + URL.RawQuery = parameters.Encode() + url := URL.String() + return c.Redirect(http.StatusTemporaryRedirect, url) + } +} + +// BuildCallbackHandler build callback handler given oauth conf and oauth state string +func BuildCallbackHandler(settings Settings) echo.HandlerFunc { + return func(c echo.Context) error { + // pull state and code params out of request + query := new(struct { + State string `query:"state"` + Code string `query:"code"` + }) + if err := c.Bind(query); err != nil { + return err + } + + // ensure state matches what we set, to prevent phishing attacks + if query.State != settings.OauthStateString { + fmt.Printf("invalid oauth state, expected '%s', got '%s'\n", settings.OauthStateString, query.State) + return c.Redirect(http.StatusTemporaryRedirect, "/") + } + + // exchange code for access token (oauth step) + token, err := settings.OauthConf.Exchange(oauth2.NoContext, query.Code) + if err != nil { + fmt.Printf("oauthConf.Exchange() failed with '%s'\n", err) + return c.Redirect(http.StatusTemporaryRedirect, "/") + } + + // grab user info with said access token + resp, err := http.Get(settings.BaseURL + url.QueryEscape(token.AccessToken)) + if err != nil { + fmt.Printf("Get: %s\n", err) + return c.Redirect(http.StatusTemporaryRedirect, "/") + } + defer resp.Body.Close() + response, err := ioutil.ReadAll(resp.Body) + if err != nil { + fmt.Printf("ReadAll: %s\n", err) + return c.Redirect(http.StatusTemporaryRedirect, "/") + } + + // parse user object out of user info response and insert/get from db + u := settings.GetUser(string(response)) + um := models.Users{DB: settings.DB} + if err := um.Create(u); err != nil { + log.Fatal(err) + } + users, err := um.Read(map[string]interface{}{ + "name": u.Name, + "email": u.Email, + "source": u.Source, + "external_id": u.ExternalID, + }) + if err != nil { + return err + } + if len(users) != 1 { + return fmt.Errorf("invalid user records: %v", users) + } + user := users[0] + fmt.Println(user) + + // set inserted user id in session + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + sess.Values["user"] = user.ID + sess.Save(c.Request(), c.Response()) + + // should redirect to app url + return c.Redirect(http.StatusFound, "/") + } +} + +// CheckLogIn - middleware to ensure user is logged in +func CheckLogIn(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // skip check for some routes + if skipRoutes[c.Request().URL.Path] == true { + return next(c) + } + + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + user := sess.Values["user"] + + if user == nil { + if c.Request().URL.Path == "/" { + return c.Redirect(http.StatusFound, "/login") + } + return echo.NewHTTPError(http.StatusUnauthorized, "please log in pls") + } + + return next(c) + } +} + +// Logout - log user out +func Logout(c echo.Context) error { + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + sess.Values["user"] = nil + sess.Save(c.Request(), c.Response()) + + return c.Redirect(http.StatusFound, "/login") +} diff --git a/db.sql b/db.sql deleted file mode 100644 index 3d40aca..0000000 --- a/db.sql +++ /dev/null @@ -1 +0,0 @@ --- there will be things here diff --git a/db/clean.sh b/db/clean.sh new file mode 100755 index 0000000..b653ec0 --- /dev/null +++ b/db/clean.sh @@ -0,0 +1,4 @@ +#! /bin/bash + +mysql -u streaking -pstreaking < migrate.sql +mysql -u streaking -pstreaking < seed.sql \ No newline at end of file diff --git a/db/init.sh b/db/init.sh new file mode 100755 index 0000000..db8a06d --- /dev/null +++ b/db/init.sh @@ -0,0 +1,5 @@ +#! /bin/bash + +mysql -u root -p < init.sql +mysql -u streaking -pstreaking < migrate.sql +mysql -u streaking -pstreaking < seed.sql \ No newline at end of file diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..02ab54e --- /dev/null +++ b/db/init.sql @@ -0,0 +1,4 @@ +CREATE DATABASE IF NOT EXISTS streaking; +DROP USER IF EXISTS streaking; +CREATE USER 'streaking'@'%' IDENTIFIED BY 'streaking'; +GRANT ALL ON `streaking`.* TO 'streaking'@'%' IDENTIFIED BY 'streaking'; diff --git a/db/migrate.sql b/db/migrate.sql new file mode 100644 index 0000000..440ac18 --- /dev/null +++ b/db/migrate.sql @@ -0,0 +1,94 @@ +/* + Streaking - productivity/etc streak tracking + Brent Hamilton + + +-----------------------+ + | users | + +-----------------------| + | id (int) | + | name (varchar) | + | email (varchar) | + | source (varchar) | + | external_id (varchar) | + +-----------------------+ + +-----------------------------------+ + | goals | + +-----------------------------------+ + | id (int) | + | user_id (int) | + | name (varchar) | + | description (text) | + | color (varchar) | + | update_interval (string) | + | accumulator_key (varchar) | * + | accumulator_increment (text) | * + | accumulator_description (text) | * + +-----------------------------------+ + +-----------------------------------+ + | streaks | + +-----------------------------------+ + | id (int) | + | date_start (date) | + | date_end (date) | + | goal_id (int) | + +-----------------------------------+ + * think money saved not buying cigarettes +*/ + + +use streaking; + + +DROP TABLE IF EXISTS streaks; +DROP TABLE IF EXISTS users_goals; +DROP TABLE IF EXISTS goals; +DROP TABLE IF EXISTS users; + + +CREATE TABLE users ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(191), + email VARCHAR(191), + source VARCHAR(191), + external_id VARCHAR(191), + + PRIMARY KEY (id), + + UNIQUE KEY (email, source, external_id) +); + + +CREATE TABLE goals ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT, + name VARCHAR(191), + description text, + color VARCHAR(191), + update_interval VARCHAR(191), + accumulator_key VARCHAR(191), + accumulator_increment text, + accumulator_description text, + + PRIMARY KEY (id), + + UNIQUE KEY (user_id, name), + + FOREIGN KEY (user_id) + REFERENCES users(id) +); + + +CREATE TABLE streaks ( + id BIGINT NOT NULL AUTO_INCREMENT, + goal_id BIGINT, + date_start DATE, + date_end DATE, + + PRIMARY KEY (id), + + UNIQUE KEY (goal_id, date_start), + + FOREIGN KEY (goal_id) + REFERENCES goals(id) + ON DELETE CASCADE +); \ No newline at end of file diff --git a/db/seed.sql b/db/seed.sql new file mode 100644 index 0000000..7b13e31 --- /dev/null +++ b/db/seed.sql @@ -0,0 +1,36 @@ +/* + Streaking - productivity/etc streak tracking + Brent Hamilton +*/ + + +use streaking; + + +-- clean up +DELETE FROM streaks; +DELETE FROM goals; +DELETE FROM users; + + +-- let there be insertions +INSERT INTO users VALUES + (1, 'brent 01', 'bh.01@hhindustries.ca', 'STREAKING', '12345'), + (2, 'brent 02', 'bh.02@hhindustries.ca', 'STREAKING', '23456'), + (3, 'brent 03', 'bh.03@hhindustries.ca', 'STREAKING', '34567'); + +INSERT INTO goals VALUES + (1, 1, '01 first goal', 'the first thing 01 want to get done', 'teal', 'day', 'cigarette money', '100', 'how much i would have spent on cigarettes'), + (2, 2, '02 first goal', 'the first thing 02 want to get done', 'indigo', 'day', 'cigarette money', '120', 'how much i would have spent on cigarettes'), + (3, 2, '02 second goal', 'the second thing 02 want to get done', 'light-blue', 'day', 'booze money', '230', 'how much i would have spent on booze'), + (4, 2, '02 third goal', 'the third thing 02 want to get done', 'teal', 'week', 'miles run', '5', "miles i've run"), + (5, 3, '03 first goal', 'the first thing 03 want to get done', 'indigo', 'week', 'booze money', '150', 'how much i would have spent on booze'), + (6, 3, '03 second goal', 'the second thing 02 want to get done', 'light-blue', 'week', 'miles run', '3', "miles i've run"); + +INSERT INTO streaks VALUES + (1, 1, '2018-04-01', '2018-04-13'), + (2, 2, '2018-03-01', '2018-04-28'), + (3, 3, '2018-02-01', '2018-03-20'), + (4, 4, '2017-12-01', '2017-12-20'), + (5, 4, '2018-01-10', '2018-03-20'), + (6, 5, '2018-01-01', '2018-04-11'); diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..c4b2a02 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,13 @@ +#! /bin/bash + +WORK_DIR=/home/streaking/streaking +EXE_NAME=streaking + +cd $WORK_DIR +git pull origin master +kill -9 `cat pid` > /dev/null 2>&1 +rm pid > /dev/null 2>&1 +rm $WORK_DIR/$EXE_NAME +/usr/local/go/bin/go build -o $WORK_DIR/$EXE_NAME +PORT=8080 BASE_URL=http://streakingapp.com nohup $WORK_DIR/$EXE_NAME > $WORK_DIR/log 2>&1 & +echo $! > pid diff --git a/login.html b/login.html new file mode 100644 index 0000000..2b7fd92 --- /dev/null +++ b/login.html @@ -0,0 +1,55 @@ + + + + + + + Streaking Login + + + + +

+ +

+

+ + + +
+ + + +
+ + + +

+ + + diff --git a/main.go b/main.go index 97db610..9d64aa8 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,109 @@ package main -import "fmt" +import ( + "io/ioutil" + "log" + "net/http" + "os" + "time" + + "bh/streaking/auth" + "bh/streaking/auth/facebook" + "bh/streaking/auth/github" + "bh/streaking/auth/google" + + "github.com/gorilla/sessions" + "github.com/jmoiron/sqlx" + "github.com/labstack/echo" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/middleware" +) + +func handleMain(c echo.Context) error { + sess, _ := session.Get("session", c) + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + } + user := sess.Values["user"] + + if user != nil { + return c.Redirect(http.StatusFound, "/") + } + + htmlIndex, err := ioutil.ReadFile("login.html") + if err != nil { + return err + } + + return c.HTML(http.StatusOK, string(htmlIndex)) +} + +func noCache(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Response().Header().Set("Cache-Control", "no-cache, private, max-age=0") + c.Response().Header().Set("Expires", time.Unix(0, 0).Format(http.TimeFormat)) + c.Response().Header().Set("Pragma", "no-cache") + c.Response().Header().Set("X-Accel-Expires", "0") + + return next(c) + } +} func main() { - fmt.Println("this is the hook") -} \ No newline at end of file + if os.Getenv("PORT") == "" { + log.Fatal("$PORT must be set") + } + if os.Getenv("BASE_URL") == "" { + log.Fatal("$BASE_URL must be set") + } + + db, err := sqlx.Connect("mysql", "streaking:streaking@/streaking") + if err != nil { + log.Panic(err) + } + + e := echo.New() + a := e.Group("/api") + api := handler{db} + + // global middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Use(session.Middleware(sessions.NewCookieStore([]byte("big giant dick session secret")))) + e.Use(noCache) + e.Use(auth.CheckLogIn) + + // static as + e.Static("/", "public") + + // login/auth routes + e.GET("/login", handleMain) + e.GET("/logout", auth.Logout) + + e.GET("/login/facebook", facebook.HandleLogin()) + e.GET("/callback/facebook", facebook.HandleCallback(db)) + + e.GET("/login/github", github.HandleLogin()) + e.GET("/callback/github", github.HandleCallback(db)) + + e.GET("/login/google", google.HandleLogin()) + e.GET("/callback/google", google.HandleCallback(db)) + + // api routes + a.GET("/me", api.getUser) + + a.POST("/goals", api.createGoal) + a.POST("/streaks", api.createStreak) + + a.PUT("/goals/:goal_id", api.updateGoal) + a.PUT("/streaks/:streak_id", api.updateStreak) + + a.DELETE("/goals/:goal_id", api.deleteGoal) + a.DELETE("/streaks/:streak_id", api.deleteStreak) + + port := os.Getenv("PORT") + + e.Logger.Fatal(e.Start(":" + port)) +} diff --git a/models/crud.go b/models/crud.go new file mode 100644 index 0000000..70ee24e --- /dev/null +++ b/models/crud.go @@ -0,0 +1,267 @@ +package models + +import ( + "fmt" + + "github.com/jmoiron/sqlx" +) + +type model struct{ DB *sqlx.DB } + +// Users - users model +type Users model + +// Goals - goals model +type Goals model + +// Streaks - streaks model +type Streaks model + +func applySearch(qs string, search map[string]interface{}) string { + if search == nil { + return qs + } + + delim := "WHERE" + for k, v := range search { + if _, ok := v.(string); ok { + v = fmt.Sprintf("'%v'", v) + } + // NOTE: this is bad and not escaped. Should use prepared statements. + // This means getting something like Object.values(search) and destructuring below + qs = fmt.Sprintf("%v %v %v = %v", qs, delim, k, v) + delim = "AND" + } + return qs +} + +/* + * Read + */ +func (um Users) Read(search map[string]interface{}) ([]User, error) { + userResults := []User{} + + qs := applySearch("SELECT * FROM users", search) + fmt.Println(FormatQuery(qs)) + + if err := um.DB.Select(&userResults, qs); err != nil { + return nil, err + } + + return userResults, nil +} + +func (gm Goals) Read(search map[string]interface{}) ([]Goal, error) { + gs := []Goal{} + + selectString := ` + SELECT + goals.id, + goals.name, + goals.description, + goals.color, + goals.user_id, + goals.update_interval, + goals.accumulator_key, + goals.accumulator_increment, + goals.accumulator_description + ` + fromString := " FROM goals" + + if search["user_id"] != nil { + selectString += ", users.id AS user_id" + fromString += " INNER JOIN users ON users.id = goals.user_id" + } + + qs := applySearch(selectString+fromString, search) + fmt.Println(qs) + + if err := gm.DB.Select(&gs, qs); err != nil { + return nil, err + } + + return gs, nil +} + +func (sm Streaks) Read(search map[string]interface{}) ([]Streak, error) { + streakResults := []Streak{} + + selectString := "SELECT streaks.*" + fromString := " FROM streaks" + + if search["user_id"] != nil { + fromString += ` + INNER JOIN goals ON goals.id = streaks.goal_id + INNER JOIN users ON users.id = goals.user_id + ` + } + + qs := applySearch(selectString+fromString, search) + fmt.Println(qs) + + if err := sm.DB.Select(&streakResults, qs); err != nil { + return nil, err + } + + return streakResults, nil +} + +// Create - create given user +func (um Users) Create(u User) error { + qs := ` + INSERT INTO users (name, email, source, external_id) + VALUES (:name, :email, :source, :external_id) + ` + fmt.Println(FormatQuery(qs)) + + if _, err := um.DB.NamedExec(qs, &u); !IsErrDuplicateEntry(err) { + return err + } + + return nil +} + +// Create - creative given goal +func (gm Goals) Create(g Goal) error { + fmt.Println(g) + qs := ` + INSERT INTO goals ( + name, + description, + color, + user_id, + update_interval, + accumulator_key, + accumulator_increment, + accumulator_description + ) + VALUES ( + :name, + :description, + :color, + :user_id, + :update_interval, + :accumulator_key, + :accumulator_increment, + :accumulator_description + ) + ` + fmt.Println(FormatQuery(qs)) + + if _, err := gm.DB.NamedExec(qs, &g); !IsErrDuplicateEntry(err) { + return err + } + + return nil +} + +// Create - create given streak +func (sm Streaks) Create(s Streak) error { + qs := ` + INSERT INTO streaks ( + date_start, + date_end, + goal_id + ) VALUES ( + :date_start, + :date_end, + :goal_id + ) + ` + fmt.Println(FormatQuery(qs)) + + if _, err := sm.DB.NamedExec(qs, &s); !IsErrDuplicateEntry(err) { + return err + } + + return nil +} + +// Update - update given user +func (um Users) Update(id int, u User) error { + u.ID = id + + qs := ` + UPDATE users + SET + name = :name, + email = :email, + source = :source, + external_id = :external_id + WHERE id = :id + ` + fmt.Println(FormatQuery(qs)) + + if _, err := um.DB.NamedExec(qs, &u); err != nil { + return err + } + return nil +} + +// Update - update given goal +func (gm Goals) Update(id int, g Goal) error { + g.ID = id + + qs := ` + UPDATE goals + SET + name = :name, + description = :description, + color = :color, + user_id = :user_id, + update_interval = :update_interval, + accumulator_key = :accumulator_key, + accumulator_increment = :accumulator_increment, + accumulator_description = :accumulator_description + WHERE id = :id + ` + fmt.Println(FormatQuery(qs)) + + if _, err := gm.DB.NamedExec(qs, &g); err != nil { + return err + } + return nil +} + +// Update - update given streak +func (sm Streaks) Update(id int, s Streak) error { + s.ID = id + + qs := ` + UPDATE streaks + SET + date_start = :date_start, + date_end = :date_end, + goal_id = :goal_id + WHERE id = :id + ` + fmt.Println(FormatQuery(qs)) + + if _, err := sm.DB.NamedExec(qs, &s); err != nil { + fmt.Println(err) + return err + } + return nil +} + +/* + * Delete + */ +func delete(db *sqlx.DB, id int, table string) { + db.MustExec("DELETE FROM "+table+" WHERE id = ?", id) +} + +// Delete - delete given user +func (um Users) Delete(id int) { + delete(um.DB, id, "users") +} + +// Delete - delete given goal +func (gm Goals) Delete(id int) { + delete(gm.DB, id, "goals") +} + +// Delete - delete given streak +func (sm Streaks) Delete(id int) { + delete(sm.DB, id, "streaks") +} diff --git a/models/types.go b/models/types.go new file mode 100644 index 0000000..3733f91 --- /dev/null +++ b/models/types.go @@ -0,0 +1,35 @@ +package models + +/* + * data types, from db and written to json + */ + +// User - users +type User struct { + ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + Source string `db:"source" json:"source"` + ExternalID string `db:"external_id" json:"externalId"` +} + +// Goal - goals +type Goal struct { + ID int `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + Color string `db:"color" json:"color"` + UserID int `db:"user_id" json:"userId"` + UpdateInterval string `db:"update_interval" json:"updateInterval"` + AccumulatorKey string `db:"accumulator_key" json:"accumulatorKey"` + AccumulatorIncrement string `db:"accumulator_increment" json:"accumulatorIncrement"` + AccumulatorDescription string `db:"accumulator_description" json:"accumulatorDescription"` +} + +// Streak - streaks +type Streak struct { + ID int `db:"id" json:"id"` + DateStart string `db:"date_start" json:"dateStart"` + DateEnd string `db:"date_end" json:"dateEnd"` + GoalID int `db:"goal_id" json:"goalId"` +} diff --git a/models/util.go b/models/util.go new file mode 100644 index 0000000..1a3dc02 --- /dev/null +++ b/models/util.go @@ -0,0 +1,20 @@ +package models + +import ( + "strings" + + "github.com/go-sql-driver/mysql" +) + +// FormatQuery - remove additional whitespace for printing +func FormatQuery(qs string) string { + noTabs := strings.Replace(qs, "\t", "", -1) + noTabsOrSpaces := strings.Replace(noTabs, "\n", " ", -1) + return strings.Trim(noTabsOrSpaces, " ") +} + +// IsErrDuplicateEntry - check if error is sql error for inserting duplicate entry +func IsErrDuplicateEntry(err error) bool { + me, ok := err.(*mysql.MySQLError) + return err != nil && ok && me.Number == 1062 +} diff --git a/notes b/notes index 365e03a..64eddc4 100644 --- a/notes +++ b/notes @@ -1,30 +1,21 @@ -users ------- -id -name -email +TODO: + [X] add interval to streak + [X] accumulator_increment should be accumulator_increment + [X] accumulator_increment is now derived: (sum increment * ((end - start) * interval)) - -goals ------- -id -name -description - - -days ------- -id -date -user_id -goal_id - - -streaks --------- -id -user_id -goal_id -accumulator (think money saved not buying cigarettes) -start_date -end_date \ No newline at end of file + [X] interval should be on goal + [X] accumulator info should be on goal, not streak + [X] fix weird model names (model package, User struct type, userModel interface :gun:) + [X] login + [X] facebook + [X] github + [X] google + [X] upsert user + [X] session + [X] protected routes (middlewares) + [X] logout + [X] more rest-y put endpoints? (kind of done, consider /me) + [ ] put/post/delete streaks/goals should verify they belong to user + [ ] log with levels + [ ] streaking (email) login + [ ] completed goal(?) diff --git a/run b/run new file mode 100755 index 0000000..01f6dc0 --- /dev/null +++ b/run @@ -0,0 +1,6 @@ +#! /bin/bash + +cd db +./clean.sh +cd .. +go run *.go diff --git a/test/api_test.go b/test/api_test.go new file mode 100644 index 0000000..bdd58bc --- /dev/null +++ b/test/api_test.go @@ -0,0 +1,3 @@ +package main + +// i am still working out how to test a web server properly diff --git a/test/models_test.go b/test/models_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/test/models_test.go @@ -0,0 +1 @@ +package main diff --git a/test/util_test.go b/test/util_test.go new file mode 100644 index 0000000..b52bbfc --- /dev/null +++ b/test/util_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "strconv" + "testing" + + "bh/streaking/models" + + "github.com/jmoiron/sqlx" + + _ "github.com/go-sql-driver/mysql" +) + +func TestFormatQuery(t *testing.T) { + expected := "SELECT * FROM table" + actual := models.FormatQuery(` + SELECT * + FROM table + `) + if expected != actual { + t.Error("formatQuery: expected '" + actual + "' to equal '" + expected + "'") + } + + expected = "SELECT * FROM table" + actual = models.FormatQuery("SELECT * FROM table") + + if expected != actual { + t.Error("formatQuery: expected '" + actual + "' to equal '" + expected + "'") + } +} + +func TestIsErrDuplicateEntry(t *testing.T) { + db, err := sqlx.Connect("mysql", "streaking:streaking@/streaking") + if err != nil { + t.Error("isDuplicateEntry failed connecting to database", err) + } + + u := models.User{ID: 1, Name: "name", Email: "email", ExternalID: "id", Source: "source"} + _, err = db.NamedExec("INSERT INTO users VALUES (:id, :name, :email)", &u) + if err != nil { + t.Error("isDuplicateEntry failed insrting initial seed data", err) + } + + _, err = db.NamedExec("INSERT INTO users VALUES (:id, :name, :email)", &u) + expected := true + actual := models.IsErrDuplicateEntry(err) + if expected != actual { + t.Error("isErrDuplicateEntry: expected '" + strconv.FormatBool(actual) + "' to equal '" + strconv.FormatBool(expected) + "'") + } + + _, err = db.NamedExec("this is a nonsense query", &u) + expected = false + actual = models.IsErrDuplicateEntry(err) + if expected != actual { + t.Error("isErrDuplicateEntry: expected '" + strconv.FormatBool(actual) + "' to equal '" + strconv.FormatBool(expected) + "'") + } + + expected = false + actual = models.IsErrDuplicateEntry(nil) + if expected != actual { + t.Error("isErrDuplicateEntry: expected '" + strconv.FormatBool(actual) + "' to equal '" + strconv.FormatBool(expected) + "'") + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..0839727 --- /dev/null +++ b/util.go @@ -0,0 +1,18 @@ +package main + +import ( + "strings" + + "github.com/go-sql-driver/mysql" +) + +func formatQuery(qs string) string { + noTabs := strings.Replace(qs, "\t", "", -1) + noTabsOrSpaces := strings.Replace(noTabs, "\n", " ", -1) + return strings.Trim(noTabsOrSpaces, " ") +} + +func isErrDuplicateEntry(err error) bool { + me, ok := err.(*mysql.MySQLError) + return err != nil && ok && me.Number == 1062 +}