From cf2113e77edabf8e3a632c7b76c769752039ba88 Mon Sep 17 00:00:00 2001 From: Zach Berwaldt Date: Sat, 2 Mar 2024 16:52:55 -0500 Subject: feat: Add API logging Add logging to the API to keep track of specific requests and headers. Also added CORS middleware to handle OPTIONS requests. --- The commit adds logging functionality to the API and includes a middleware function to handle CORS OPTIONS requests. This will allow us to track specific requests and headers received by the API. [API/main.go](/api/main.go): - Added import for the 'log' package - Added logging statements to print the request headers and "_I am here_" message - Removed unnecessary newlines and comments [fe/src/app.css](/fe/src/app.css): - Added a new style for button hover effects [fe/src/lib/Card.svelte](/fe/src/lib/Card.svelte): - Added a new `height` prop to the Card component [fe/src/lib/Column.svelte](/fe/src/lib/Column.svelte): - Added a new CSS class for column layout - Set the width and gap using CSS variables [fe/src/lib/DataView.svelte](/fe/src/lib/DataView.svelte): - Updated the 'fetchData' function to also fetch 'totals' and 'userStats' data - Added canvas references and chart variables for bar and line charts - Added a new 'getLastSevenDays' function to calculate the labels for the charts - Updated the 'onMount' function to initialize the bar and line charts using the canvas references and data - Updated the 'onDestroy' function to destroy the bar and line charts - Added a new 'addFormOpen' store and imported it - Added a new 'onClick' handler for the Add button to open the AddForm modal - Updated the layout and added Card components to display the bar and line charts and the JSON data - Added a new 'fetchTotals' function to fetch data for the 'totals' section - Added a new 'fetchStatsForUser' function to fetch data for the 'userStats' section [fe/src/lib/Layout.svelte](/fe/src/lib/Layout.svelte): - Added a new 'preferenceFormOpen' variable and initialized it to 'false' - Added a new 'showPreferencesDialog' function to set 'preferenceFormOpen' to 'true' - Added a new 'closePreferenceDialog' function to set 'preferenceFormOpen' to 'false' - Added a new 'showAddDialog' function to open the AddForm modal --- api/main.go | 384 +++++++++++++++++++++++++++------------------ fe/src/app.css | 101 ++++++------ fe/src/lib/Card.svelte | 30 ++-- fe/src/lib/Column.svelte | 13 ++ fe/src/lib/DataView.svelte | 129 ++++++++++++--- fe/src/lib/Layout.svelte | 116 +++++++------- fe/src/lib/Table.svelte | 172 ++++++++++---------- fe/src/stores/forms.ts | 6 + 8 files changed, 574 insertions(+), 377 deletions(-) create mode 100644 fe/src/lib/Column.svelte create mode 100644 fe/src/stores/forms.ts diff --git a/api/main.go b/api/main.go index 91b7929..57feb09 100644 --- a/api/main.go +++ b/api/main.go @@ -2,193 +2,267 @@ package main import ( "net/http" - "crypto/rand" - "encoding/base64" - "database/sql" - "strings" - "errors" + "crypto/rand" + "encoding/base64" + "database/sql" + "strings" + "errors" + "log" "github.com/gin-gonic/gin" - _ "github.com/mattn/go-sqlite3" - "golang.org/x/crypto/bcrypt" - "water/api/lib" + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" + "water/api/lib" ) func CORSMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") - c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") - - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - - c.Next() - } + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") + + log.Println("I am here") + + if c.Request.Method == "OPTIONS" { + log.Println(c.Request.Header) + c.AbortWithStatus(204) + return + } + + log.Println(c.Request.Header) + c.Next() + } } // generatToken will g func generateToken() string { - token := make([]byte, 32) - rand.Read(token) - return base64.StdEncoding.EncodeToString(token) + token := make([]byte, 32) + rand.Read(token) + return base64.StdEncoding.EncodeToString(token) } func establishDBConnection() *sql.DB { - db, err := sql.Open("sqlite3", "../db/water.sqlite3") - if err != nil { - panic(err) - } - return db + db, err := sql.Open("sqlite3", "../db/water.sqlite3") + if err != nil { + panic(err) + } + return db } - func checkForTokenInContext(c *gin.Context) (string, error) { - authorizationHeader := c.GetHeader("Authorization") - if authorizationHeader == "" { - return "", errors.New("Authorization header is missing") - } + authorizationHeader := c.GetHeader("Authorization") + if authorizationHeader == "" { + return "", errors.New("Authorization header is missing") + } - parts := strings.Split(authorizationHeader, " ") + parts := strings.Split(authorizationHeader, " ") - if len(parts) != 2 || parts[0] != "Bearer" { - return "", errors.New("Invalid Authorization header format") - } + if len(parts) != 2 || parts[0] != "Bearer" { + return "", errors.New("Invalid Authorization header format") + } - - return parts[1], nil + return parts[1], nil } - func TokenRequired() gin.HandlerFunc { - return func(c *gin.Context) { - _, err := checkForTokenInContext(c) + return func(c *gin.Context) { + _, err := checkForTokenInContext(c) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - c.Abort() - return - } + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + c.Abort() + return + } - c.Next() - } + c.Next() + } } func setupRouter() *gin.Engine { // Disable Console Color // gin.DisableConsoleColor() r := gin.Default() - r.Use(CORSMiddleware()) - r.Use(gin.Logger()) - r.Use(gin.Recovery()) - - api := r.Group("api/v1") - - api.POST("/auth", func(c *gin.Context) { - username, password, ok := c.Request.BasicAuth() - if !ok { - c.Header("WWW-Authenticate", `Basic realm="Please enter your username and password."`) - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - db := establishDBConnection() - defer db.Close() - - var user models.User - var preference models.Preference - var size models.Size - - row := db.QueryRow("SELECT name, uuid, password, color, size, unit FROM Users u INNER JOIN Preferences p ON p.user_id = u.id INNER JOIN Sizes s ON p.size_id = s.id WHERE u.name = ?", username) - if err := row.Scan(&user.Name, &user.UUID, &user.Password, &preference.Color, &size.Size, &size.Unit); err != nil { - if err == sql.ErrNoRows { - c.AbortWithStatus(http.StatusUnauthorized) - return - } - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - preference.Size = size + r.Use(CORSMiddleware()) + r.Use(gin.Logger()) + r.Use(gin.Recovery()) + + api := r.Group("api/v1") + + api.POST("/auth", func(c *gin.Context) { + username, password, ok := c.Request.BasicAuth() + if !ok { + c.Header("WWW-Authenticate", `Basic realm="Please enter your username and password."`) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + db := establishDBConnection() + defer db.Close() + + var user models.User + var preference models.Preference + var size models.Size + + row := db.QueryRow("SELECT name, uuid, password, color, size, unit FROM Users u INNER JOIN Preferences p ON p.user_id = u.id INNER JOIN Sizes s ON p.size_id = s.id WHERE u.name = ?", username) + if err := row.Scan(&user.Name, &user.UUID, &user.Password, &preference.Color, &size.Size, &size.Unit); err != nil { + if err == sql.ErrNoRows { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + preference.Size = size // Generate a simple API token apiToken := generateToken() - c.JSON(http.StatusOK, gin.H{"token": apiToken, "user": user, "preferences": preference}) - }) - - stats := api.Group("stats") - stats.Use(TokenRequired()) - { - stats.GET("/", func(c *gin.Context) { - db := establishDBConnection() - defer db.Close() - - rows, err := db.Query("SELECT s.date, s.quantity, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id"); - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - defer rows.Close() - - var data []models.Statistic - for rows.Next() { - var stat models.Statistic - var user models.User - if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - stat.User = user - data = append(data, stat) - } - - c.JSON(http.StatusOK, data) - }) - - stats.POST("/", func(c *gin.Context) { - var stat models.Statistic - - if err := c.BindJSON(&stat); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - db := establishDBConnection() - defer db.Close() - - result, err := db.Exec("INSERT INTO statistics (date, user_id, quantity) values (?, ?, ?)", stat.Date, 1, stat.Quantity) - - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - } - - id, err := result.LastInsertId() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - } - - c.JSON(http.StatusCreated, gin.H{"status": "created", "id": id}) - }) - - stats.GET("/:uuid", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok", "uuid": c.Param("uuid")}) - }) - - stats.PATCH("/:uuid", func(c *gin.Context) { - c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) - }) - - stats.DELETE("/:uuid", func(c *gin.Context) { - c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) - }) - } - + c.JSON(http.StatusOK, gin.H{"token": apiToken, "user": user, "preferences": preference}) + }) + + stats := api.Group("/stats") + stats.Use(TokenRequired()) + { + stats.GET("/", func(c *gin.Context) { + db := establishDBConnection() + defer db.Close() + + rows, err := db.Query("SELECT s.date, s.quantity, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id") + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + + var data []models.Statistic + + for rows.Next() { + var stat models.Statistic + var user models.User + if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + stat.User = user + data = append(data, stat) + } + + + // TODO: return to this and figure out how to best collect the data you are looking for for each user (zach and parker) + rows, err = db.Query("SELECT date(s.date), SUM(s.quantity) as total FROM Statistics s WHERE s.date >= date('now', '-7 days') GROUP BY DATE(s.date)") + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + + var dailySummaries []models.DailySummary + for rows.Next() { + var summary models.DailySummary + if err := rows.Scan(&summary.Date, &summary.Total); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + dailySummaries = append(dailySummaries, summary) + } + + c.JSON(http.StatusOK, gin.H{"stats": data, "totals": dailySummaries}) + rows, err = db.Query("SELECT s.date, SUM(s.quantity) as total, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id WHERE s.date >= date('now', '-7 days') GROUP BY s.date, s.user_id") + + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + + var totals []interface{} + for rows.Next() { + var stat models.Statistic + var user models.User + if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + stat.User = user + totals = append(totals, stat) + } + + c.JSON(http.StatusOK, gin.H{"stats": data, "totals": totals}) + }) + + stats.POST("/", func(c *gin.Context) { + var stat models.Statistic + + if err := c.BindJSON(&stat); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := establishDBConnection() + defer db.Close() + + result, err := db.Exec("INSERT INTO statistics (date, user_id, quantity) values (?, ?, ?)", stat.Date, 1, stat.Quantity) + + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } + + id, err := result.LastInsertId() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } + + c.JSON(http.StatusCreated, gin.H{"status": "created", "id": id}) + }) + + stats.GET("/totals/", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // stats.GET("/totals/", func(c *gin.Context) { + // db := establishDBConnection() + // defer db.Close() + // + // rows, err := db.Query("SELECT s.date, SUM(s.quantity) as total, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id WHERE s.date >= date('now', '-7 days') GROUP BY s.date, s.user_id") + // + // if err != nil { + // c.JSON(500, gin.H{"error": err.Error()}) + // return + // } + // defer rows.Close() + // + // var data []models.Statistic + // for rows.Next() { + // var stat models.Statistic + // var user models.User + // if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { + // c.JSON(500, gin.H{"error": err.Error()}) + // return + // } + // stat.User = user + // data = append(data, stat) + // } + // + // c.JSON(http.StatusOK, data) + // + // }) + + stats.GET("user/:uuid", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok", "uuid": c.Param("uuid")}) + }) + + stats.PATCH("user/:uuid", func(c *gin.Context) { + c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) + }) + + stats.DELETE("user/:uuid", func(c *gin.Context) { + c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) + }) + } return r } diff --git a/fe/src/app.css b/fe/src/app.css index 0d5fa90..de19b52 100644 --- a/fe/src/app.css +++ b/fe/src/app.css @@ -1,82 +1,87 @@ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; - --submit: #28a745; + --submit: #28a745; } a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; + font-weight: 500; + color: #646cff; + text-decoration: inherit; } + a:hover { - color: #535bf2; + color: #535bf2; } body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; } h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 3.2em; + line-height: 1.1; } .card { - padding: 2em; + padding: 2em; } #app { - flex-grow: 2; - max-width: 1280px; - margin: 0 auto; + flex-grow: 2; + max-width: 1280px; + margin: 0 auto; } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; } + button:hover { - border-color: #646cff; + border-color: #646cff; } + button:focus, button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + outline: 4px auto -webkit-focus-ring-color; } @media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } + :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } } @media (prefers-color-scheme: dark) { @@ -97,7 +102,7 @@ button:focus-visible { } .form.input.group label { - margin-bottom: .5em; + margin-bottom: .5em; } .form.input.group input { diff --git a/fe/src/lib/Card.svelte b/fe/src/lib/Card.svelte index 0835940..d7cd900 100644 --- a/fe/src/lib/Card.svelte +++ b/fe/src/lib/Card.svelte @@ -1,22 +1,24 @@
- {#if title} -

{title}

- {/if} - + {#if title} +

{title}

+ {/if} +
diff --git a/fe/src/lib/Column.svelte b/fe/src/lib/Column.svelte new file mode 100644 index 0000000..f036073 --- /dev/null +++ b/fe/src/lib/Column.svelte @@ -0,0 +1,13 @@ +
+ +
+ + \ No newline at end of file diff --git a/fe/src/lib/DataView.svelte b/fe/src/lib/DataView.svelte index 5182a85..2b1b8b9 100644 --- a/fe/src/lib/DataView.svelte +++ b/fe/src/lib/DataView.svelte @@ -1,16 +1,21 @@ -
- - - - {#await json then data} - - {:catch error} -

{error}

- {/await} - - + + + + + + + + + + + + + {#await json then data} +
+ {:catch error} +

{error}

+ {/await} + + + + {#await totals then data} + {JSON.stringify(data)} + {:catch error} +

{error}

+ {/await} +
+ + + diff --git a/fe/src/lib/Table.svelte b/fe/src/lib/Table.svelte index 3a66e0d..d1cd7da 100644 --- a/fe/src/lib/Table.svelte +++ b/fe/src/lib/Table.svelte @@ -1,105 +1,113 @@
- {#if title} -

{title}

- {/if} - {#if !noheader && data} - - - {#each getDataKeys(data) as header} - - {/each} - - - {/if} - - {#if data} - {#each data as row} + {#if title} +

{title}

+ {/if} + {#if !noheader && data} + - {#each getRow(row) as datum} - - {/each} + {#each getDataKeys(data) as header} + + {/each} - {/each} + + {/if} + + {#if data} + {#each limitedData as row} + + {#each getRow(row) as datum} + + {/each} + + {/each} {:else} - There is not data. + There is not data. + {/if} + + {#if !nofooter} + + + + + + + {/if} - - {#if !nofooter} - - - - - - - - {/if}
{header}
{formatDatum(datum)}{header}
{formatDatum(datum)}
Table Footer
Table Footer
diff --git a/fe/src/stores/forms.ts b/fe/src/stores/forms.ts new file mode 100644 index 0000000..daf9181 --- /dev/null +++ b/fe/src/stores/forms.ts @@ -0,0 +1,6 @@ +import type { Writable } from "svelte/store"; +import { writable } from "svelte/store"; + + +export const preferencesFormOpen: Writable = writable(false); +export const addFormOpen: Writable = writable(false); \ No newline at end of file -- cgit v1.1