diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/fitness.go b/fitness.go new file mode 100644 index 0000000..1f791fa --- /dev/null +++ b/fitness.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "fmt" + "github.com/gin-gonic/contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/snabb/isoweek" + "google.golang.org/api/fitness/v1" + "google.golang.org/api/option" + "net/http" + "time" +) + +type stepsDBElement struct { + string + int64 +} + +// getFitnessService returns the service object google fitness with apt permission to read steps +func getFitnessService(user OAuthUser) (*fitness.Service, error) { + + tokenSource := config.TokenSource(context.TODO(), user.Token) + service, err := fitness.NewService( + context.TODO(), + option.WithScopes(fitness.FitnessActivityReadScope), + option.WithTokenSource(tokenSource), + ) + if err != nil { + err = fmt.Errorf("fitness api error: unable create service \"%v\"", err) + } + return service, err +} + +// getStepCount returns a single int representing the numbers of steps between startTime and endTime +func getStepCount(user OAuthUser, startTime time.Time, endTime time.Time) (int64, error) { + service, err := getFitnessService(user) + if err != nil { + return -4, nil + } + + // make a request to get steps + stepsAggregateResult, err := service.Users.Dataset.Aggregate("me", &fitness.AggregateRequest{ + AggregateBy: []*fitness.AggregateBy{ + { + DataSourceId: "derived:com.google.step_count.delta:com.google.android.gms:estimated_steps", + DataTypeName: "com.google.step_count.delta", + }, + }, + BucketByTime: &fitness.BucketByTime{ + DurationMillis: endTime.Sub(startTime).Milliseconds(), + }, + EndTimeMillis: endTime.UnixNano() / nanosPerMilli, + StartTimeMillis: startTime.UnixNano() / nanosPerMilli, + }).Do() + if err != nil { + err = fmt.Errorf("fitness api error: unable to fetch required data due to \"%v\"", err) + return -3, err + } + + var steps int64 + // extract the time of one bucket + for _, bucket := range stepsAggregateResult.Bucket { + for _, data := range bucket.Dataset { + for _, point := range data.Point { + for _, value := range point.Value { + steps = value.IntVal + goto endLoop + } + } + } + } + +endLoop: + return steps, err +} + +// getStepCountCurrentWeek returns step count for the current week +func getStepCountCurrentWeek(user OAuthUser) (int64, error) { + var currentYear, currentWeek = time.Now().In(timeLocation).ISOWeek() + startOfWeek := isoweek.StartTime(currentYear, currentWeek, timeLocation) + endOfWeek := startOfWeek.AddDate(0, 0, 7) + return getStepCount(user, startOfWeek, endOfWeek) +} + +// getStepCountCurrentWeek returns step count for the current day +func getStepCountCurrentDay(user OAuthUser) (int64, error) { + year, month, day := time.Now().In(timeLocation).Date() + startOfDay := time.Date(year, month, day, 0, 0, 0, 0, timeLocation) + endOfDay := startOfDay.AddDate(0, 0, 1) + return getStepCount(user, startOfDay, endOfDay) +} + +// geAllDetailsOfUser returns email-id, step count of the current week, step count of the current day +// It's supposed to use all the functionality we have +// TODO: update new functionality +func geAllDetailsOfUser(user OAuthUser) (string, int64, int64, error) { + + currentWeekCount, err := getStepCountCurrentWeek(user) + if err != nil { + return "", 0, 0, err + } + + currentDayCount, err := getStepCountCurrentDay(user) + if err != nil { + return "", 0, 0, err + } + + return user.Email, currentWeekCount, currentDayCount, err +} + +// getStepCountWrapper puts the results of calling "getStepCountFunc" on "user" inside "resultQueue" channel +// TODO: wg.Done() didn't work, why? +// TODO: How to write this without using thread variable +func getStepCountWrapper( + resultQueue chan stepsDBElement, + user OAuthUser, + getStepCountFunc func(authUser OAuthUser) (int64, error)) { + + steps, err := getStepCountFunc(user) + if err != nil { + resultQueue <- stepsDBElement{user.Email, -1} + return + } + + resultQueue <- stepsDBElement{user.Email, steps} + return +} + +func getAll() (map[string]int64, error) { + + // get all users in usersChannels + usersChannels := make(chan OAuthUser) + go getUsersFromDB(usersChannels) + + // resultQueue : the steps of each user will be stored in it + resultQueue := make(chan stepsDBElement) + numbersOfUsers := 0 + for user := range usersChannels { + numbersOfUsers++ + go getStepCountWrapper(resultQueue, user, getStepCountCurrentWeek) + } + + result := make(map[string]int64) + for i := 0; i < numbersOfUsers; i++ { + userSteps := <-resultQueue + result[userSteps.string] = userSteps.int64 + } + + return result, nil +} +func list(ctx *gin.Context) { + _ = sessions.Default(ctx) + + result, err := getAll() + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + ctx.IndentedJSON(http.StatusOK, result) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ce0c931 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/sedflix/fit + +go 1.15 + +require ( + cloud.google.com/go v0.66.0 // indirect + github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect + github.com/coreos/go-oidc v2.2.1+incompatible + github.com/gin-contrib/pprof v1.3.0 + github.com/gin-gonic/contrib v0.0.0-20200913005814-1c32036e7ea4 + github.com/gin-gonic/gin v1.6.3 + github.com/gorilla/sessions v1.2.1 // indirect + github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f // indirect + github.com/snabb/isoweek v1.0.0 + go.mongodb.org/mongo-driver v1.4.1 + golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 + golang.org/x/sys v0.0.0-20200918174421-af09f7315aff // indirect + google.golang.org/api v0.31.0 + gopkg.in/square/go-jose.v2 v2.5.1 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..31bebfd --- /dev/null +++ b/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "github.com/gin-gonic/contrib/sessions" + "github.com/gin-gonic/gin" +) + +func ErrorHandle() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + err := c.Errors.Last() + if err == nil { + return + } + + c.JSON(c.Writer.Status(), + gin.H{ + "error": fmt.Sprintf("Error at the backend %v", err), + }) + return + } +} + +func main() { + + //setTimeZone + err := setTimezone() + if err != nil { + return + } + + // mongodb connection + mongoURI := "mongodb://localhost:27017" + err = setupMongo(mongoURI) + if err != nil { + return + } + + // oauth connection + err = setupOAuthClientCredentials("./credentials.json") + if err != nil { + return + } + + router := gin.Default() + + // setup session cookie storage + var store = sessions.NewCookieStore([]byte("secret")) + router.Use(sessions.Sessions("goquestsession", store)) + + // custom error handling TODO: add ui + router.Use(ErrorHandle()) + + // index page + //router.Static("/css", "./static/css") + //router.Static("/img", "./static/img") + //router.LoadHTMLGlob("templates/*") + + router.GET("/list", list) + router.GET("/login", authoriseUserHandler) + router.GET("/auth", oAuthCallbackHandler) + + // Add the pprof routes + //pprof.Register(router) + + _ = router.Run("127.0.0.1:9090") +} diff --git a/mongo.go b/mongo.go new file mode 100644 index 0000000..02e5a6f --- /dev/null +++ b/mongo.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "fmt" + "github.com/coreos/go-oidc" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/oauth2" + "log" + "time" +) + +// oAuthUserCollection points to mongodb collection for storing our data +var oAuthUserCollection *mongo.Collection + +// mongoClient is mongodb client +var mongoClient *mongo.Client + +// OAuthUser stored in mongodb +type OAuthUser struct { + Email string `json:"email"` + UserInfo *oidc.UserInfo + Token *oauth2.Token +} + +// setupMongo connects to the mongodb, creating database:users and collection:oauth +// with index for email +func setupMongo(mongoURI string) (err error) { + + mongoClient, err = mongo.NewClient(options.Client().ApplyURI(mongoURI)) + if err != nil { + log.Fatalf("unable to make monodb client with %s due to err %v", mongoURI, err) + return err + } + + ctx, _ := context.WithTimeout(context.Background(), 2*time.Second) + err = mongoClient.Connect(ctx) + if err != nil { + log.Fatalf("unable to connect monodb at %s due to err %v", mongoURI, err) + return err + } + + // create database and collection + oAuthUserCollection = mongoClient.Database("users").Collection("oauth") + + // create index for email + _, err = oAuthUserCollection.Indexes().CreateOne(context.Background(), + mongo.IndexModel{ + Keys: bson.M{ + "email": 1, + }, + Options: options.Index().SetUnique(true), + }, + ) + if err != nil { + log.Fatalf("Unable to create index email due to %v", err) + return err + } + return nil +} + +// addUserToDB will update the user record in the db or update if already present +// record will be `upsert` to oauth package +func addUserToDB(user OAuthUser) error { + + upsert := true + updateResult, err := oAuthUserCollection.UpdateOne( + context.TODO(), + bson.M{"email": user.Email}, + bson.M{"$set": user}, + &options.UpdateOptions{Upsert: &upsert}, + ) + if err != nil { + return fmt.Errorf("unable to insert/update user %s in db due to %v", user.Email, err) + } + + if updateResult.MatchedCount == 1 { + log.Printf("UPDATED EXISTING USER: %s", user.Email) + } + if updateResult.UpsertedCount == 1 { + log.Printf("INSERTED NEW USER: %s", user.Email) + } + + return nil +} + +// getUsersFromDB get users from the database and and put it in usersChannels for consumption +func getUsersFromDB(usersChannels chan OAuthUser) { + defer close(usersChannels) + + findOptions := options.Find() + cur, err := oAuthUserCollection.Find(context.TODO(), bson.D{{}}, findOptions) + if err != nil { + log.Printf("[ERROR]: Coun't get users from db due to %v\n", err) + return + } + + for cur.Next(context.TODO()) { + var user OAuthUser + err := cur.Decode(&user) + if err != nil { + log.Printf("[WARNING] unable to decode the output of user from mongodb due to %v", err) + continue + } + usersChannels <- user + } + + return + +} diff --git a/oauth.go b/oauth.go new file mode 100644 index 0000000..146edb8 --- /dev/null +++ b/oauth.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "fmt" + oidc "github.com/coreos/go-oidc" + "github.com/gin-gonic/contrib/sessions" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/fitness/v1" + "io/ioutil" + "log" + "net/http" +) + +// config oauth2.Config for storing client credentials +var config *oauth2.Config + +// oidcProvider used for the role purpose of getting email id +var oidcProvider *oidc.Provider + +// scope we will be asking user for the following permissions +var scope = []string{ + fitness.FitnessActivityReadScope, + fitness.FitnessBodyReadScope, + oidc.ScopeOpenID, + "profile", + "email", +} + +// setupOAuthClientCredentials will create oauth2.config and oidc.Provider +// using fileName the path to a json storing credentials obtained from google console +func setupOAuthClientCredentials(fileName string) (err error) { + + // read credentials obtained from google console and apt callback + credentialsBytes, err := ioutil.ReadFile(fileName) + if err != nil { + log.Fatalf("unable to read file %s : err %v", fileName, err) + return err + } + + // make oauth.config + config, err = google.ConfigFromJSON(credentialsBytes, scope...) + if err != nil { + log.Fatalf("Unable to parse client credientials to oauth config: %v", err) + return err + } + + // make odic provider: used to get email id and stuff + oidcProvider, err = oidc.NewProvider(context.Background(), "https://accounts.google.com") + if err != nil { + log.Fatalf("oidc: Unable to setup odic google rovider %v", err) + return err + } + + log.Println("Oauth2 Client Setup Completed") + return err +} + +// oAuthCallbackHandler handles the callback from google. +// Responsibilities: check state, get offline token, get oidc user info, save this information in db +func oAuthCallbackHandler(ctx *gin.Context) { + + // oauth state check + session := sessions.Default(ctx) + retrievedState := session.Get("state") + if retrievedState != ctx.Query("state") { + // check if session state is same as the state in teh url + _ = ctx.AbortWithError(http.StatusUnauthorized, fmt.Errorf("oauth: state paramerter is not right %s", retrievedState)) + return + } + + // get oauth TOKEN + code := ctx.Query("code") + token, err := config.Exchange(context.TODO(), code, oauth2.AccessTypeOffline) + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, + fmt.Errorf("oauth code-token exchange failed due to %v", err)) + return + } + + // make token source for using it in oidc call + tokenSource := config.TokenSource(context.TODO(), token) + userInfo, err := oidcProvider.UserInfo(context.Background(), tokenSource) + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, + fmt.Errorf("unable to fetch user info due to %v", err)) + return + } + + // create data and insert in db + user := OAuthUser{ + Email: userInfo.Email, + UserInfo: userInfo, + Token: token, + } + if addUserToDB(user) != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "user": user.Email, + }) +} + +// authoriseUserHandler redirects the user to appropriate url for auth +// sets "state" in the session cookie +func authoriseUserHandler(ctx *gin.Context) { + + // create random string for oauth state and store it in session + session := sessions.Default(ctx) + oauthState := getRandomString() + session.Set("state", oauthState) + err := session.Save() + if err != nil { + _ = ctx.AbortWithError(http.StatusInternalServerError, + fmt.Errorf("unable to save state key in session %v", err)) + return + } + + // redirect the user to consent page + authorisationURL := config.AuthCodeURL(oauthState, oauth2.AccessTypeOffline) + ctx.Redirect(http.StatusTemporaryRedirect, authorisationURL) + return +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..dc05885 --- /dev/null +++ b/utils.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/base64" + "log" + "math/rand" + "os" + "time" +) + +const nanosPerMilli = 1e6 + +var timeLocation *time.Location + +// setTimezone set environment variable for IST time +// and initializes timeLocation for the same +func setTimezone() (err error) { + err = os.Setenv("TZ", "Asia/Kolkata") + if err != nil { + log.Fatalf("unable to set location due to %v", err) + } + + timeLocation, err = time.LoadLocation("Asia/Kolkata") + if err != nil { + log.Fatalf("unable to set location due to %v", err) + } + + return err +} + +// millisToTime converts Unix millis to time.Time. +func millisToTime(t int64) time.Time { + return time.Unix(0, t*nanosPerMilli) +} + +// getRandomString return a random string of 32 length +func getRandomString() string { + b := make([]byte, 32) + rand.Read(b) + return base64.StdEncoding.EncodeToString(b) +}