From 0807aa300abcab6cdd6ae8f5885847f3ade3ebc4 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 17 Jan 2018 23:00:26 -0600 Subject: [PATCH 01/15] Data and structures converted --- src/api/data/data.go | 326 +++++++++++++++++++++++++++++++++++++++ src/api/data/entities.go | 35 +++++ src/my-prayer-journal.go | 11 ++ 3 files changed, 372 insertions(+) create mode 100644 src/api/data/data.go create mode 100644 src/api/data/entities.go create mode 100644 src/my-prayer-journal.go diff --git a/src/api/data/data.go b/src/api/data/data.go new file mode 100644 index 0000000..bf03927 --- /dev/null +++ b/src/api/data/data.go @@ -0,0 +1,326 @@ +// Package data contains data access functions for myPrayerJournal. +package data + +import ( + "database/sql" + "fmt" + "log" + "time" + + "github.com/lucsky/cuid" +) + +const ( + currentRequestSQL = ` + SELECT "requestId", "text", "asOf", "lastStatus" + FROM mpj.journal` + journalSQL = ` + SELECT "requestId", "text", "asOf", "lastStatus" + FROM mpj.journal + WHERE "userId" = $1 + AND "lastStatus" <> 'Answered'` +) + +/* Data Access */ + +// Retrieve a basic request +func retrieveRequest(db *sql.DB, reqID, userID string) (*Request, bool) { + req := Request{} + err := db.QueryRow(` + SELECT "requestId", "enteredOn" + FROM mpj.request + WHERE "requestId" = $1 + AND "userId" = $2`, reqID, userID).Scan( + &req.ID, &req.EnteredOn, + ) + if err != nil { + if err != sql.ErrNoRows { + log.Print(err) + } + return nil, false + } + req.UserID = userID + return &req, true +} + +// Unix time in JavaScript Date.now() precision. +func jsNow() int64 { + return time.Now().UnixNano() / int64(1000000) +} + +// Loop through rows and create journal requests from them. +func makeJournal(rows *sql.Rows, userID string) []JournalRequest { + var out []JournalRequest + for rows.Next() { + req := JournalRequest{} + err := rows.Scan(&req.RequestID, &req.Text, &req.AsOf, &req.LastStatus) + if err != nil { + log.Print(err) + continue + } + out = append(out, req) + } + if rows.Err() != nil { + log.Print(rows.Err()) + return nil + } + return out +} + +// AddHistory creates a history entry for a prayer request, given the status and updated text. +func AddHistory(db *sql.DB, userID, reqID, status, text string) int { + if _, ok := retrieveRequest(db, reqID, userID); !ok { + return 404 + } + _, err := db.Exec(` + INSERT INTO mpj.history + ("requestId", "asOf", "status", "text") + VALUES + ($1, $2, $3, NULLIF($4, ''))`, + reqID, jsNow(), status, text) + if err != nil { + log.Print(err) + return 500 + } + return 204 +} + +// AddNew stores a new prayer request and its initial history record. +func AddNew(db *sql.DB, userID, text string) (*JournalRequest, bool) { + id := cuid.New() + now := jsNow() + tx, err := db.Begin() + if err != nil { + log.Print(err) + return nil, false + } + defer func() { + if err != nil { + log.Print(err) + tx.Rollback() + } else { + tx.Commit() + } + }() + _, err = tx.Exec( + `INSERT INTO mpj.request ("requestId", "enteredOn", "userId") VALUES ($1, $2, $3)`, + id, now, userID) + if err != nil { + return nil, false + } + _, err = tx.Exec( + `INSERT INTO mpj.history ("requestId", "asOf", "status", "text") VALUES ($1, $2, 'Created', $3)`, + id, now, text) + if err != nil { + return nil, false + } + return &JournalRequest{RequestID: id, Text: text, AsOf: now, LastStatus: `Created`}, true +} + +// AddNote adds a note to a prayer request. +func AddNote(db *sql.DB, userID, reqID, note string) int { + if _, ok := retrieveRequest(db, reqID, userID); !ok { + return 404 + } + _, err := db.Exec(` + INSERT INTO mpj.note + ("requestId", "asOf", "notes") + VALUES + ($1, $2, $3)`, + reqID, jsNow(), note) + if err != nil { + log.Print(err) + return 500 + } + return 204 +} + +// Answered retrieves all answered requests for the given user. +func Answered(db *sql.DB, userID string) []JournalRequest { + rows, err := db.Query(currentRequestSQL+ + `WHERE "userId" = $1 + AND "lastStatus" = 'Answered' + ORDER BY "asOf" DESC`, + userID) + if err != nil { + log.Print(err) + return nil + } + defer rows.Close() + return makeJournal(rows, userID) +} + +// ByID retrieves a journal request by its ID. +func ByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { + req := JournalRequest{} + err := db.QueryRow(currentRequestSQL+ + `WHERE "requestId" = $1 + AND "userId = $2`, + userID, reqID).Scan( + &req.RequestID, &req.Text, &req.AsOf, &req.LastStatus, + ) + if err != nil { + log.Print(err) + return nil, false + } + return &req, true +} + +// FullByID retrieves a journal request, including its full history and notes. +func FullByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { + req, ok := ByID(db, userID, reqID) + if !ok { + return nil, false + } + hRows, err := db.Query(` + SELECT "asOf", "status", COALESCE("text", '') AS "text" + FROM mpj.history + WHERE "requestId" = $1 + ORDER BY "asOf"`, + reqID) + if err != nil { + log.Print(err) + return nil, false + } + defer hRows.Close() + for hRows.Next() { + hist := History{} + err = hRows.Scan(&hist.AsOf, &hist.Status, &hist.Text) + if err != nil { + log.Print(err) + continue + } + req.History = append(req.History, hist) + } + if hRows.Err() != nil { + log.Print(hRows.Err()) + return nil, false + } + req.Notes = NotesByID(db, userID, reqID) + return req, true +} + +// Journal retrieves the current user's active prayer journal. +func Journal(db *sql.DB, userID string) []JournalRequest { + rows, err := db.Query(journalSQL, userID) + if err != nil { + log.Print(err) + return nil + } + defer rows.Close() + return makeJournal(rows, userID) +} + +// NotesByID retrieves the notes for a given prayer request +func NotesByID(db *sql.DB, userID, reqID string) []Note { + if _, ok := retrieveRequest(db, reqID, userID); !ok { + return nil + } + rows, err := db.Query(` + SELECT "asOf", "notes" + FROM mpj.note + WHERE "requestId" = $1 + ORDER BY "asOf" DESC`, + reqID) + if err != nil { + log.Print(err) + return nil + } + defer rows.Close() + var notes []Note + for rows.Next() { + note := Note{} + err = rows.Scan(¬e.AsOf, ¬e.Notes) + if err != nil { + log.Print(err) + continue + } + notes = append(notes, note) + } + if rows.Err() != nil { + log.Print(rows.Err()) + return nil + } + return notes +} + +/* DDL */ + +// EnsureDB makes sure we have a known state of data structures. +func EnsureDB(db *sql.DB) { + tableSQL := func(table string) string { + return fmt.Sprintf(`SELECT 1 FROM pg_tables WHERE schemaname='mpj' AND tablename='%s'`, table) + } + indexSQL := func(table, index string) string { + return fmt.Sprintf(`SELECT 1 FROM pg_indexes WHERE schemaname='mpj' AND tablename='%s' AND indexname='%s'`, + table, index) + } + check := func(name, test, fix string) { + count := 0 + err := db.QueryRow(test).Scan(&count) + if err != nil { + if err == sql.ErrNoRows { + log.Printf("Fixing up %s...\n", name) + _, err = db.Exec(fix) + if err != nil { + log.Fatal(err) + } + } else { + log.Fatal(err) + } + } + } + check(`myPrayerJournal Schema`, `SELECT 1 FROM pg_namespace WHERE nspname='mpj'`, + `CREATE SCHEMA mpj; + COMMENT ON SCHEMA mpj IS 'myPrayerJournal data'`) + if _, err := db.Exec(`SET search_path TO mpj`); err != nil { + log.Fatal(err) + } + check(`request Table`, tableSQL(`request`), + `CREATE TABLE mpj.request ( + "requestId" varchar(25) PRIMARY KEY, + "enteredOn" bigint NOT NULL, + "userId" varchar(100) NOT NULL); + COMMENT ON TABLE mpj.request IS 'Requests'`) + check(`history Table`, tableSQL(`history`), + `CREATE TABLE mpj.history ( + "requestId" varchar(25) NOT NULL REFERENCES mpj.request, + "asOf" bigint NOT NULL, + "status" varchar(25), + "text" text, + PRIMARY KEY ("requestId", "asOf")); + COMMENT ON TABLE mpj.history IS 'Request update history'`) + check(`note Table`, tableSQL(`note`), + `CREATE TABLE mpj.note ( + "requestId" varchar(25) NOT NULL REFERENCES mpj.request, + "asOf" bigint NOT NULL, + "notes" text NOT NULL, + PRIMARY KEY ("requestId", "asOf")); + COMMENT ON TABLE mpj.note IS 'Notes regarding a request'`) + check(`request.userId Index`, indexSQL(`request`, `idx_request_userId`), + `CREATE INDEX "idx_request_userId" ON mpj.request ("userId"); + COMMENT ON INDEX "idx_request_userId" IS 'Requests are retrieved by user'`) + check(`journal View`, `SELECT 1 FROM pg_views WHERE schemaname='mpj' AND viewname='journal'`, + `CREATE VIEW mpj.journal AS + SELECT + request."requestId", + request."userId", + (SELECT "text" + FROM mpj.history + WHERE history."requestId" = request."requestId" + AND "text" IS NOT NULL + ORDER BY "asOf" DESC + LIMIT 1) AS "text", + (SELECT "asOf" + FROM mpj.history + WHERE history."requestId" = request."requestId" + ORDER BY "asOf" DESC + LIMIT 1) AS "asOf", + (SELECT "status" + FROM mpj.history + WHERE history."requestId" = request."requestId" + ORDER BY "asOf" DESC + LIMIT 1) AS "lastStatus" + FROM mpj.request; + COMMENT ON VIEW mpj.journal IS 'Requests with latest text'`) +} diff --git a/src/api/data/entities.go b/src/api/data/entities.go new file mode 100644 index 0000000..66e9bca --- /dev/null +++ b/src/api/data/entities.go @@ -0,0 +1,35 @@ +package data + +// History is a record of action taken on a prayer request, including updates to its text. +type History struct { + RequestID string `json:"requestId"` + AsOf int64 `json:"asOf"` + Status string `json:"status"` + Text string `json:"text"` +} + +// Note is a note regarding a prayer request that does not result in an update to its text. +type Note struct { + RequestID string `json:"requestId"` + AsOf int64 `json:"asOf"` + Notes string `json:"notes"` +} + +// Request is the identifying record for a prayer request. +type Request struct { + ID string `json:"requestId"` + EnteredOn int64 `json:"enteredOn"` + UserID string `json:"userId"` +} + +// JournalRequest is the form of a prayer request returned for the request journal display. It also contains +// properties that may be filled for history and notes. +type JournalRequest struct { + RequestID string `json:"requestId"` + UserID string `json:"userId"` + Text string `json:"text"` + AsOf int64 `json:"asOf"` + LastStatus string `json:"lastStatus"` + History []History `json:"history"` + Notes []Note `json:"notes"` +} diff --git a/src/my-prayer-journal.go b/src/my-prayer-journal.go new file mode 100644 index 0000000..8d2b152 --- /dev/null +++ b/src/my-prayer-journal.go @@ -0,0 +1,11 @@ +// myPrayerJournal API Server +package main + +import ( + "fmt" + "time" +) + +func main() { + fmt.Print(time.Now().UnixNano() / int64(1000000)) +} From 8c801ea49ffd05e49f8c676566f69d68ea136d72 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 18 Jan 2018 11:29:01 -0600 Subject: [PATCH 02/15] Interim commit; started work on routes --- .gitignore | 3 +- src/api/data/data.go | 37 ++++++++++++++++++++--- src/api/routes/routes.go | 64 ++++++++++++++++++++++++++++++++++++++++ src/my-prayer-journal.go | 13 ++++++-- 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 src/api/routes/routes.go diff --git a/.gitignore b/.gitignore index 3814ec6..0ece221 100644 --- a/.gitignore +++ b/.gitignore @@ -256,4 +256,5 @@ paket-files/ src/api/public/index.html src/api/public/static src/api/appsettings.json -build/ \ No newline at end of file +build/ +src/*.exe \ No newline at end of file diff --git a/src/api/data/data.go b/src/api/data/data.go index bf03927..127e12a 100644 --- a/src/api/data/data.go +++ b/src/api/data/data.go @@ -7,6 +7,8 @@ import ( "log" "time" + // Register the PostgreSQL driver. + _ "github.com/lib/pq" "github.com/lucsky/cuid" ) @@ -21,6 +23,15 @@ const ( AND "lastStatus" <> 'Answered'` ) +// Settings holds the PostgreSQL configuration for myPrayerJournal. +type Settings struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + DbName string `json:"dbname"` +} + /* Data Access */ // Retrieve a basic request @@ -74,7 +85,7 @@ func AddHistory(db *sql.DB, userID, reqID, status, text string) int { } _, err := db.Exec(` INSERT INTO mpj.history - ("requestId", "asOf", "status", "text") + ("requestId", "asOf", "status", "text") VALUES ($1, $2, $3, NULLIF($4, ''))`, reqID, jsNow(), status, text) @@ -123,9 +134,9 @@ func AddNote(db *sql.DB, userID, reqID, note string) int { return 404 } _, err := db.Exec(` - INSERT INTO mpj.note - ("requestId", "asOf", "notes") - VALUES + INSERT INTO mpj.note + ("requestId", "asOf", "notes") + VALUES ($1, $2, $3)`, reqID, jsNow(), note) if err != nil { @@ -166,6 +177,24 @@ func ByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { return &req, true } +// Connect establishes a connection to the database. +func Connect(s *Settings) (*sql.DB, bool) { + connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + s.Host, s.Port, s.User, s.Password, s.DbName) + db, err := sql.Open("postgres", connStr) + if err != nil { + log.Print(err) + return nil, false + } + err = db.Ping() + if err != nil { + log.Print(err) + return nil, false + } + log.Printf("Connected to postgres://%s@%s:%d/%s\n", s.User, s.Host, s.Port, s.DbName) + return db, true +} + // FullByID retrieves a journal request, including its full history and notes. func FullByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { req, ok := ByID(db, userID, reqID) diff --git a/src/api/routes/routes.go b/src/api/routes/routes.go new file mode 100644 index 0000000..7a02cc1 --- /dev/null +++ b/src/api/routes/routes.go @@ -0,0 +1,64 @@ +// Package routes contains endpoint handlers for the myPrayerJournal API. +package routes + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + + "github.com/danieljsummers/myPrayerJournal/src/api/data" + "github.com/julienschmidt/httprouter" +) + +/* Support */ + +// Set the content type, the HTTP error code, and return the error message. +func sendError(w http.ResponseWriter, r *http.Request, err error) { + m := map[string]string{"error": err.Error()} + j, jErr := json.Marshal(m) + if jErr != nil { + log.Print("Error creating error JSON: " + jErr.Error()) + } + w.WriteHeader(500) + w.Header().Set("Content-Type", "application/json") + w.Write(j) +} + +// Set the content type and return the JSON to the user. +func sendJSON(w http.ResponseWriter, r *http.Request, result interface{}) { + payload, err := json.Marshal(result) + if err != nil { + sendError(w, r, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{ data: ")) + w.Write(payload) + w.Write([]byte(" }")) +} + +/* Handlers */ + +func journal(w http.ResponseWriter, r *http.Request, _ httprouter.Params, db *sql.DB) { + reqs := data.Journal(db, "TODO: get user ID") + if reqs == nil { + reqs = []data.JournalRequest{} + } + sendJSON(w, r, reqs) +} + +/* Wrappers */ + +func withDB(fn func(w http.ResponseWriter, r *http.Request, p httprouter.Params, db *sql.DB), db *sql.DB) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + fn(w, r, p, db) + } +} + +// Routes returns a configured router to handle all incoming requests. +func Routes(db *sql.DB) *httprouter.Router { + router := httprouter.New() + router.GET("/journal", withDB(journal, db)) + return router +} diff --git a/src/my-prayer-journal.go b/src/my-prayer-journal.go index 8d2b152..4993504 100644 --- a/src/my-prayer-journal.go +++ b/src/my-prayer-journal.go @@ -2,10 +2,17 @@ package main import ( - "fmt" - "time" + "log" + + "github.com/danieljsummers/myPrayerJournal/src/api/data" + "github.com/danieljsummers/myPrayerJournal/src/api/routes" ) func main() { - fmt.Print(time.Now().UnixNano() / int64(1000000)) + db, ok := data.Connect(&data.Settings{}) + if !ok { + log.Fatal("Unable to connect to database; exiting") + } + router := routes.Routes(db) + _ = router // TODO: remove } From d92ac4430e32a764852debc6323aa8fcf5161836 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 11 Mar 2018 22:38:13 -0500 Subject: [PATCH 03/15] Split routes, router, and handlers into different files --- src/api/routes/handlers.go | 41 ++++++++++++++++++++ src/api/routes/router.go | 66 ++++++++++++++++++++++++++++++++ src/api/routes/routes.go | 77 ++++++++++---------------------------- src/my-prayer-journal.go | 37 ++++++++++++++++-- 4 files changed, 160 insertions(+), 61 deletions(-) create mode 100644 src/api/routes/handlers.go create mode 100644 src/api/routes/router.go diff --git a/src/api/routes/handlers.go b/src/api/routes/handlers.go new file mode 100644 index 0000000..6d0e4d3 --- /dev/null +++ b/src/api/routes/handlers.go @@ -0,0 +1,41 @@ +package routes + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + + "github.com/danieljsummers/myPrayerJournal/src/api/data" + "github.com/julienschmidt/httprouter" +) + +/* Support */ + +// Set the content type, the HTTP error code, and return the error message. +func sendError(w http.ResponseWriter, r *http.Request, err error) { + w.Header().Set("Content-Type", "application/json; encoding=UTF-8") + w.WriteHeader(http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}); err != nil { + log.Print("Error creating error JSON: " + err.Error()) + } +} + +// Set the content type and return the JSON to the user. +func sendJSON(w http.ResponseWriter, r *http.Request, result interface{}) { + w.Header().Set("Content-Type", "application/json; encoding=UTF-8") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]interface{}{"data": result}); err != nil { + sendError(w, r, err) + } +} + +/* Handlers */ + +func journal(w http.ResponseWriter, r *http.Request, _ httprouter.Params, db *sql.DB) { + reqs := data.Journal(db, "TODO: get user ID") + if reqs == nil { + reqs = []data.JournalRequest{} + } + sendJSON(w, r, reqs) +} diff --git a/src/api/routes/router.go b/src/api/routes/router.go new file mode 100644 index 0000000..7390156 --- /dev/null +++ b/src/api/routes/router.go @@ -0,0 +1,66 @@ +package routes + +import ( + "database/sql" + "fmt" + "net/http" + + auth0 "github.com/auth0-community/go-auth0" + "github.com/julienschmidt/httprouter" + jose "gopkg.in/square/go-jose.v2" +) + +// AuthConfig contains the Auth0 configuration passed from the "auth" JSON object. +type AuthConfig struct { + Domain string `json:"domain"` + ClientID string `json:"id"` + ClientSecret string `json:"secret"` +} + +// DBHandler extends httprouter's handler with a DB instance +type DBHandler func(http.ResponseWriter, *http.Request, httprouter.Params, *sql.DB) + +//type APIHandler func(http.ResponseWriter, *http.Request, httprouter.Params, *sql.DB, string) + +func withDB(next DBHandler, db *sql.DB) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + next(w, r, p, db) + } +} + +func withAuth(next DBHandler, cfg *AuthConfig) DBHandler { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params, db *sql.DB) { + secret := []byte(cfg.ClientSecret) + secretProvider := auth0.NewKeyProvider(secret) + audience := []string{"{YOUR-AUTH0-API-AUDIENCE}"} + + configuration := auth0.NewConfiguration(secretProvider, audience, fmt.Sprintf("https://%s.auth0.com/", cfg.Domain), jose.HS256) + validator := auth0.NewValidator(configuration) + + token, err := validator.ValidateRequest(r) + + if err != nil { + fmt.Println(err) + fmt.Println("Token is not valid:", token) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorized")) + } else { + // TODO pass the user ID (sub) along; this -> doesn't work | r.Header.Add("user-id", token.Claims("sub")) + next(w, r, p, db) + } + } + +} + +// NewRouter returns a configured router to handle all incoming requests. +func NewRouter(db *sql.DB, cfg *AuthConfig) *httprouter.Router { + router := httprouter.New() + for _, route := range routes { + if route.IsPublic { + router.Handle(route.Method, route.Pattern, withDB(route.Func, db)) + } else { + router.Handle(route.Method, route.Pattern, withDB(withAuth(route.Func, cfg), db)) + } + } + return router +} diff --git a/src/api/routes/routes.go b/src/api/routes/routes.go index 7a02cc1..6d9add8 100644 --- a/src/api/routes/routes.go +++ b/src/api/routes/routes.go @@ -1,64 +1,25 @@ // Package routes contains endpoint handlers for the myPrayerJournal API. package routes -import ( - "database/sql" - "encoding/json" - "log" - "net/http" - - "github.com/danieljsummers/myPrayerJournal/src/api/data" - "github.com/julienschmidt/httprouter" -) - -/* Support */ - -// Set the content type, the HTTP error code, and return the error message. -func sendError(w http.ResponseWriter, r *http.Request, err error) { - m := map[string]string{"error": err.Error()} - j, jErr := json.Marshal(m) - if jErr != nil { - log.Print("Error creating error JSON: " + jErr.Error()) - } - w.WriteHeader(500) - w.Header().Set("Content-Type", "application/json") - w.Write(j) +// Route is a route served in the application. +type Route struct { + Name string + Method string + Pattern string + Func DBHandler + IsPublic bool } -// Set the content type and return the JSON to the user. -func sendJSON(w http.ResponseWriter, r *http.Request, result interface{}) { - payload, err := json.Marshal(result) - if err != nil { - sendError(w, r, err) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("{ data: ")) - w.Write(payload) - w.Write([]byte(" }")) -} - -/* Handlers */ - -func journal(w http.ResponseWriter, r *http.Request, _ httprouter.Params, db *sql.DB) { - reqs := data.Journal(db, "TODO: get user ID") - if reqs == nil { - reqs = []data.JournalRequest{} - } - sendJSON(w, r, reqs) -} - -/* Wrappers */ - -func withDB(fn func(w http.ResponseWriter, r *http.Request, p httprouter.Params, db *sql.DB), db *sql.DB) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - fn(w, r, p, db) - } -} - -// Routes returns a configured router to handle all incoming requests. -func Routes(db *sql.DB) *httprouter.Router { - router := httprouter.New() - router.GET("/journal", withDB(journal, db)) - return router +// Routes is the collection of all routes served in the application. +type Routes []Route + +// routes is the actual list of routes for the application. +var routes = Routes{ + Route{ + "Journal", + "GET", + "/journal", + journal, + false, + }, } diff --git a/src/my-prayer-journal.go b/src/my-prayer-journal.go index 4993504..647587e 100644 --- a/src/my-prayer-journal.go +++ b/src/my-prayer-journal.go @@ -2,17 +2,48 @@ package main import ( + "encoding/json" "log" + "net/http" + "os" "github.com/danieljsummers/myPrayerJournal/src/api/data" "github.com/danieljsummers/myPrayerJournal/src/api/routes" ) +// Web contains configuration for the web server. +type Web struct { + Port string `json:"port"` +} + +// Settings contains configuration for the myPrayerJournal API. +type Settings struct { + Data *data.Settings `json:"data"` + Web *Web `json:"web"` + Auth *routes.AuthConfig `json:"auth"` +} + +// readSettings parses the JSON configuration file into the Settings struct. +func readSettings(f string) *Settings { + config, err := os.Open(f) + if err != nil { + log.Fatal(err) + } + defer config.Close() + parser := json.NewDecoder(config) + settings := Settings{} + if err = parser.Decode(&settings); err != nil { + log.Fatal(err) + } + return &settings +} + func main() { - db, ok := data.Connect(&data.Settings{}) + cfg := readSettings("config.json") + db, ok := data.Connect(cfg.Data) if !ok { log.Fatal("Unable to connect to database; exiting") } - router := routes.Routes(db) - _ = router // TODO: remove + log.Printf("myPrayerJournal API listening on %s", cfg.Web.Port) + log.Fatal(http.ListenAndServe(cfg.Web.Port, routes.NewRouter(db, cfg.Auth))) } From b7406bd82786a1481092cd898a05cc3850753c89 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 12 Mar 2018 21:44:43 -0500 Subject: [PATCH 04/15] More work on auth and req ctx --- .gitignore | 2 +- src/api/routes/router.go | 23 +++++++++++++++++++---- src/my-prayer-journal.go | 3 +-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 0583941..fe673e2 100644 --- a/.gitignore +++ b/.gitignore @@ -256,6 +256,6 @@ paket-files/ src/api/build src/api/public/index.html src/api/public/static -src/api/appsettings.json +src/config.json /build src/*.exe diff --git a/src/api/routes/router.go b/src/api/routes/router.go index 7390156..6d1e8fb 100644 --- a/src/api/routes/router.go +++ b/src/api/routes/router.go @@ -1,9 +1,11 @@ package routes import ( + "context" "database/sql" "fmt" "net/http" + "time" auth0 "github.com/auth0-community/go-auth0" "github.com/julienschmidt/httprouter" @@ -17,14 +19,22 @@ type AuthConfig struct { ClientSecret string `json:"secret"` } -// DBHandler extends httprouter's handler with a DB instance +// DBHandler extends httprouter's handler with a DB instance. type DBHandler func(http.ResponseWriter, *http.Request, httprouter.Params, *sql.DB) //type APIHandler func(http.ResponseWriter, *http.Request, httprouter.Params, *sql.DB, string) +// ContextKey is the type of key used in our contexts. +type ContextKey string + +// ContextUserKey is the key for the current user in the context. +const ContextUserKey ContextKey = "user" + func withDB(next DBHandler, db *sql.DB) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - next(w, r, p, db) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60*time.Second)) + defer cancel() + next(w, r.WithContext(ctx), p, db) } } @@ -32,9 +42,9 @@ func withAuth(next DBHandler, cfg *AuthConfig) DBHandler { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params, db *sql.DB) { secret := []byte(cfg.ClientSecret) secretProvider := auth0.NewKeyProvider(secret) - audience := []string{"{YOUR-AUTH0-API-AUDIENCE}"} + audience := []string{fmt.Sprintf("https://%s/userinfo", cfg.Domain)} - configuration := auth0.NewConfiguration(secretProvider, audience, fmt.Sprintf("https://%s.auth0.com/", cfg.Domain), jose.HS256) + configuration := auth0.NewConfiguration(secretProvider, audience, fmt.Sprintf("https://%s/", cfg.Domain), jose.HS256) validator := auth0.NewValidator(configuration) token, err := validator.ValidateRequest(r) @@ -45,6 +55,11 @@ func withAuth(next DBHandler, cfg *AuthConfig) DBHandler { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("Unauthorized")) } else { + values := make(map[string]interface{}) + if err := token.Claims(secret, &values); err != nil { + sendError(w, r, err) + } + r = r.WithContext(context.WithValue(r.Context(), ContextUserKey, values["sub"])) // TODO pass the user ID (sub) along; this -> doesn't work | r.Header.Add("user-id", token.Claims("sub")) next(w, r, p, db) } diff --git a/src/my-prayer-journal.go b/src/my-prayer-journal.go index 647587e..8e94488 100644 --- a/src/my-prayer-journal.go +++ b/src/my-prayer-journal.go @@ -30,9 +30,8 @@ func readSettings(f string) *Settings { log.Fatal(err) } defer config.Close() - parser := json.NewDecoder(config) settings := Settings{} - if err = parser.Decode(&settings); err != nil { + if err = json.NewDecoder(config).Decode(&settings); err != nil { log.Fatal(err) } return &settings From 8d84bdb2e6e458b4a61ab38b14342cdfa68215ba Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 12 Mar 2018 23:14:16 -0500 Subject: [PATCH 05/15] app/API build adjustments; get user from ctx --- .gitignore | 4 ++-- src/api/routes/handlers.go | 3 ++- src/app/config/index.js | 4 ++-- src/app/package.json | 4 +++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index fe673e2..4e63cca 100644 --- a/.gitignore +++ b/.gitignore @@ -254,8 +254,8 @@ paket-files/ # Compiled files / application src/api/build -src/api/public/index.html -src/api/public/static +src/public/index.html +src/public/static src/config.json /build src/*.exe diff --git a/src/api/routes/handlers.go b/src/api/routes/handlers.go index 6d0e4d3..ee6a719 100644 --- a/src/api/routes/handlers.go +++ b/src/api/routes/handlers.go @@ -33,7 +33,8 @@ func sendJSON(w http.ResponseWriter, r *http.Request, result interface{}) { /* Handlers */ func journal(w http.ResponseWriter, r *http.Request, _ httprouter.Params, db *sql.DB) { - reqs := data.Journal(db, "TODO: get user ID") + user := r.Context().Value(ContextUserKey) + reqs := data.Journal(db, user.(string)) if reqs == nil { reqs = []data.JournalRequest{} } diff --git a/src/app/config/index.js b/src/app/config/index.js index 2333cd3..c6e5523 100644 --- a/src/app/config/index.js +++ b/src/app/config/index.js @@ -4,8 +4,8 @@ var path = require('path') module.exports = { build: { env: require('./prod.env'), - index: path.resolve(__dirname, '../../api/public/index.html'), - assetsRoot: path.resolve(__dirname, '../../api/public'), + index: path.resolve(__dirname, '../../public/index.html'), + assetsRoot: path.resolve(__dirname, '../../public'), assetsSubDirectory: 'static', assetsPublicPath: '/', productionSourceMap: true, diff --git a/src/app/package.json b/src/app/package.json index ca5a2bf..a5e4424 100644 --- a/src/app/package.json +++ b/src/app/package.json @@ -11,7 +11,9 @@ "unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run", "e2e": "node test/e2e/runner.js", "test": "npm run unit && npm run e2e", - "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs" + "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs", + "apistart": "cd .. && go build -o mpj-api.exe && mpj-api.exe", + "vue": "node build/build.js prod && go build -o mpj-api.exe && mpj-api.exe" }, "dependencies": { "auth0-js": "^9.3.3", From b248f7ca7fe4a9e154947542ed3dc8ffb08feb5a Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 20 Mar 2018 20:26:02 -0500 Subject: [PATCH 06/15] minor tweaks towards JWTs time box expired before completing this; one of these days... --- src/api/routes/router.go | 7 ++++--- src/api/routes/routes.go | 2 +- src/app/package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/routes/router.go b/src/api/routes/router.go index 6d1e8fb..625a03b 100644 --- a/src/api/routes/router.go +++ b/src/api/routes/router.go @@ -7,9 +7,9 @@ import ( "net/http" "time" - auth0 "github.com/auth0-community/go-auth0" + "github.com/auth0-community/go-auth0" "github.com/julienschmidt/httprouter" - jose "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2" ) // AuthConfig contains the Auth0 configuration passed from the "auth" JSON object. @@ -45,7 +45,7 @@ func withAuth(next DBHandler, cfg *AuthConfig) DBHandler { audience := []string{fmt.Sprintf("https://%s/userinfo", cfg.Domain)} configuration := auth0.NewConfiguration(secretProvider, audience, fmt.Sprintf("https://%s/", cfg.Domain), jose.HS256) - validator := auth0.NewValidator(configuration) + validator := auth0.NewValidator(configuration, nil) token, err := validator.ValidateRequest(r) @@ -77,5 +77,6 @@ func NewRouter(db *sql.DB, cfg *AuthConfig) *httprouter.Router { router.Handle(route.Method, route.Pattern, withDB(withAuth(route.Func, cfg), db)) } } + // router.ServeFiles("/*filepath", http.Dir("/public")) return router } diff --git a/src/api/routes/routes.go b/src/api/routes/routes.go index 6d9add8..57e8add 100644 --- a/src/api/routes/routes.go +++ b/src/api/routes/routes.go @@ -18,7 +18,7 @@ var routes = Routes{ Route{ "Journal", "GET", - "/journal", + "/api/journal", journal, false, }, diff --git a/src/app/package.json b/src/app/package.json index a5e4424..f90bacd 100644 --- a/src/app/package.json +++ b/src/app/package.json @@ -13,7 +13,7 @@ "test": "npm run unit && npm run e2e", "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs", "apistart": "cd .. && go build -o mpj-api.exe && mpj-api.exe", - "vue": "node build/build.js prod && go build -o mpj-api.exe && mpj-api.exe" + "vue": "node build/build.js prod && cd .. && go build -o mpj-api.exe && mpj-api.exe" }, "dependencies": { "auth0-js": "^9.3.3", From 59b5574b16b790f04b6134fa28e11b43fd674ddd Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 22 Mar 2018 22:11:38 -0500 Subject: [PATCH 07/15] Switched to vestigo router Also moved db reference to data module; it now starts, but doesn't serve index.html for root yet --- src/api/data/data.go | 44 +++++++++++++++++++++----------------- src/api/routes/handlers.go | 6 ++---- src/api/routes/router.go | 33 ++++++++-------------------- src/api/routes/routes.go | 6 +++++- src/my-prayer-journal.go | 6 +++--- 5 files changed, 43 insertions(+), 52 deletions(-) diff --git a/src/api/data/data.go b/src/api/data/data.go index 127e12a..bc12d97 100644 --- a/src/api/data/data.go +++ b/src/api/data/data.go @@ -23,6 +23,9 @@ const ( AND "lastStatus" <> 'Answered'` ) +// db is a connection to the database for the entire application. +var db *sql.DB + // Settings holds the PostgreSQL configuration for myPrayerJournal. type Settings struct { Host string `json:"host"` @@ -35,7 +38,7 @@ type Settings struct { /* Data Access */ // Retrieve a basic request -func retrieveRequest(db *sql.DB, reqID, userID string) (*Request, bool) { +func retrieveRequest(reqID, userID string) (*Request, bool) { req := Request{} err := db.QueryRow(` SELECT "requestId", "enteredOn" @@ -79,8 +82,8 @@ func makeJournal(rows *sql.Rows, userID string) []JournalRequest { } // AddHistory creates a history entry for a prayer request, given the status and updated text. -func AddHistory(db *sql.DB, userID, reqID, status, text string) int { - if _, ok := retrieveRequest(db, reqID, userID); !ok { +func AddHistory(userID, reqID, status, text string) int { + if _, ok := retrieveRequest(reqID, userID); !ok { return 404 } _, err := db.Exec(` @@ -97,7 +100,7 @@ func AddHistory(db *sql.DB, userID, reqID, status, text string) int { } // AddNew stores a new prayer request and its initial history record. -func AddNew(db *sql.DB, userID, text string) (*JournalRequest, bool) { +func AddNew(userID, text string) (*JournalRequest, bool) { id := cuid.New() now := jsNow() tx, err := db.Begin() @@ -129,8 +132,8 @@ func AddNew(db *sql.DB, userID, text string) (*JournalRequest, bool) { } // AddNote adds a note to a prayer request. -func AddNote(db *sql.DB, userID, reqID, note string) int { - if _, ok := retrieveRequest(db, reqID, userID); !ok { +func AddNote(userID, reqID, note string) int { + if _, ok := retrieveRequest(reqID, userID); !ok { return 404 } _, err := db.Exec(` @@ -147,7 +150,7 @@ func AddNote(db *sql.DB, userID, reqID, note string) int { } // Answered retrieves all answered requests for the given user. -func Answered(db *sql.DB, userID string) []JournalRequest { +func Answered(userID string) []JournalRequest { rows, err := db.Query(currentRequestSQL+ `WHERE "userId" = $1 AND "lastStatus" = 'Answered' @@ -162,7 +165,7 @@ func Answered(db *sql.DB, userID string) []JournalRequest { } // ByID retrieves a journal request by its ID. -func ByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { +func ByID(userID, reqID string) (*JournalRequest, bool) { req := JournalRequest{} err := db.QueryRow(currentRequestSQL+ `WHERE "requestId" = $1 @@ -178,26 +181,27 @@ func ByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { } // Connect establishes a connection to the database. -func Connect(s *Settings) (*sql.DB, bool) { +func Connect(s *Settings) bool { connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", s.Host, s.Port, s.User, s.Password, s.DbName) - db, err := sql.Open("postgres", connStr) + var err error + db, err = sql.Open("postgres", connStr) if err != nil { log.Print(err) - return nil, false + return false } err = db.Ping() if err != nil { log.Print(err) - return nil, false + return false } log.Printf("Connected to postgres://%s@%s:%d/%s\n", s.User, s.Host, s.Port, s.DbName) - return db, true + return true } // FullByID retrieves a journal request, including its full history and notes. -func FullByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { - req, ok := ByID(db, userID, reqID) +func FullByID(userID, reqID string) (*JournalRequest, bool) { + req, ok := ByID(userID, reqID) if !ok { return nil, false } @@ -225,12 +229,12 @@ func FullByID(db *sql.DB, userID, reqID string) (*JournalRequest, bool) { log.Print(hRows.Err()) return nil, false } - req.Notes = NotesByID(db, userID, reqID) + req.Notes = NotesByID(userID, reqID) return req, true } // Journal retrieves the current user's active prayer journal. -func Journal(db *sql.DB, userID string) []JournalRequest { +func Journal(userID string) []JournalRequest { rows, err := db.Query(journalSQL, userID) if err != nil { log.Print(err) @@ -241,8 +245,8 @@ func Journal(db *sql.DB, userID string) []JournalRequest { } // NotesByID retrieves the notes for a given prayer request -func NotesByID(db *sql.DB, userID, reqID string) []Note { - if _, ok := retrieveRequest(db, reqID, userID); !ok { +func NotesByID(userID, reqID string) []Note { + if _, ok := retrieveRequest(reqID, userID); !ok { return nil } rows, err := db.Query(` @@ -276,7 +280,7 @@ func NotesByID(db *sql.DB, userID, reqID string) []Note { /* DDL */ // EnsureDB makes sure we have a known state of data structures. -func EnsureDB(db *sql.DB) { +func EnsureDB() { tableSQL := func(table string) string { return fmt.Sprintf(`SELECT 1 FROM pg_tables WHERE schemaname='mpj' AND tablename='%s'`, table) } diff --git a/src/api/routes/handlers.go b/src/api/routes/handlers.go index ee6a719..4325a05 100644 --- a/src/api/routes/handlers.go +++ b/src/api/routes/handlers.go @@ -1,13 +1,11 @@ package routes import ( - "database/sql" "encoding/json" "log" "net/http" "github.com/danieljsummers/myPrayerJournal/src/api/data" - "github.com/julienschmidt/httprouter" ) /* Support */ @@ -32,9 +30,9 @@ func sendJSON(w http.ResponseWriter, r *http.Request, result interface{}) { /* Handlers */ -func journal(w http.ResponseWriter, r *http.Request, _ httprouter.Params, db *sql.DB) { +func journal(w http.ResponseWriter, r *http.Request) { user := r.Context().Value(ContextUserKey) - reqs := data.Journal(db, user.(string)) + reqs := data.Journal(user.(string)) if reqs == nil { reqs = []data.JournalRequest{} } diff --git a/src/api/routes/router.go b/src/api/routes/router.go index 625a03b..a5919ab 100644 --- a/src/api/routes/router.go +++ b/src/api/routes/router.go @@ -2,13 +2,11 @@ package routes import ( "context" - "database/sql" "fmt" "net/http" - "time" "github.com/auth0-community/go-auth0" - "github.com/julienschmidt/httprouter" + "github.com/husobee/vestigo" "gopkg.in/square/go-jose.v2" ) @@ -19,27 +17,14 @@ type AuthConfig struct { ClientSecret string `json:"secret"` } -// DBHandler extends httprouter's handler with a DB instance. -type DBHandler func(http.ResponseWriter, *http.Request, httprouter.Params, *sql.DB) - -//type APIHandler func(http.ResponseWriter, *http.Request, httprouter.Params, *sql.DB, string) - // ContextKey is the type of key used in our contexts. type ContextKey string // ContextUserKey is the key for the current user in the context. const ContextUserKey ContextKey = "user" -func withDB(next DBHandler, db *sql.DB) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60*time.Second)) - defer cancel() - next(w, r.WithContext(ctx), p, db) - } -} - -func withAuth(next DBHandler, cfg *AuthConfig) DBHandler { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params, db *sql.DB) { +func withAuth(next http.HandlerFunc, cfg *AuthConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { secret := []byte(cfg.ClientSecret) secretProvider := auth0.NewKeyProvider(secret) audience := []string{fmt.Sprintf("https://%s/userinfo", cfg.Domain)} @@ -61,22 +46,22 @@ func withAuth(next DBHandler, cfg *AuthConfig) DBHandler { } r = r.WithContext(context.WithValue(r.Context(), ContextUserKey, values["sub"])) // TODO pass the user ID (sub) along; this -> doesn't work | r.Header.Add("user-id", token.Claims("sub")) - next(w, r, p, db) + next(w, r) } } } // NewRouter returns a configured router to handle all incoming requests. -func NewRouter(db *sql.DB, cfg *AuthConfig) *httprouter.Router { - router := httprouter.New() +func NewRouter(cfg *AuthConfig) *vestigo.Router { + router := vestigo.NewRouter() for _, route := range routes { if route.IsPublic { - router.Handle(route.Method, route.Pattern, withDB(route.Func, db)) + router.Add(route.Method, route.Pattern, route.Func) } else { - router.Handle(route.Method, route.Pattern, withDB(withAuth(route.Func, cfg), db)) + router.Add(route.Method, route.Pattern, withAuth(route.Func, cfg)) } } - // router.ServeFiles("/*filepath", http.Dir("/public")) + router.Get("/*", http.FileServer(http.Dir("/public")).ServeHTTP) return router } diff --git a/src/api/routes/routes.go b/src/api/routes/routes.go index 57e8add..ec8551b 100644 --- a/src/api/routes/routes.go +++ b/src/api/routes/routes.go @@ -1,12 +1,16 @@ // Package routes contains endpoint handlers for the myPrayerJournal API. package routes +import ( + "net/http" +) + // Route is a route served in the application. type Route struct { Name string Method string Pattern string - Func DBHandler + Func http.HandlerFunc IsPublic bool } diff --git a/src/my-prayer-journal.go b/src/my-prayer-journal.go index 8e94488..04ef79f 100644 --- a/src/my-prayer-journal.go +++ b/src/my-prayer-journal.go @@ -39,10 +39,10 @@ func readSettings(f string) *Settings { func main() { cfg := readSettings("config.json") - db, ok := data.Connect(cfg.Data) - if !ok { + if ok := data.Connect(cfg.Data); !ok { log.Fatal("Unable to connect to database; exiting") } + data.EnsureDB() log.Printf("myPrayerJournal API listening on %s", cfg.Web.Port) - log.Fatal(http.ListenAndServe(cfg.Web.Port, routes.NewRouter(db, cfg.Auth))) + log.Fatal(http.ListenAndServe(cfg.Web.Port, routes.NewRouter(cfg.Auth))) } From 9637b38a3fa92f815c22cc1ea8e30a672155865f Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 24 Mar 2018 13:37:18 -0500 Subject: [PATCH 08/15] Static files now served; auth is broken --- src/api/routes/handlers.go | 15 +++++++++++++++ src/api/routes/router.go | 1 - src/api/routes/routes.go | 15 +++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/api/routes/handlers.go b/src/api/routes/handlers.go index 4325a05..7fb35bd 100644 --- a/src/api/routes/handlers.go +++ b/src/api/routes/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log" "net/http" + "strings" "github.com/danieljsummers/myPrayerJournal/src/api/data" ) @@ -38,3 +39,17 @@ func journal(w http.ResponseWriter, r *http.Request) { } sendJSON(w, r, reqs) } + +func staticFiles(w http.ResponseWriter, r *http.Request) { + // serve index for known routes handled client-side by the app + for _, prefix := range ClientPrefixes { + log.Print("Checking " + r.URL.Path) + if strings.HasPrefix(r.URL.Path, prefix) { + w.Header().Add("Content-Type", "text/html") + http.ServeFile(w, r, "./public/index.html") + return + } + } + // 404 here is fine; quit hacking, y'all... + http.ServeFile(w, r, "./public"+r.URL.Path) +} diff --git a/src/api/routes/router.go b/src/api/routes/router.go index a5919ab..623f528 100644 --- a/src/api/routes/router.go +++ b/src/api/routes/router.go @@ -62,6 +62,5 @@ func NewRouter(cfg *AuthConfig) *vestigo.Router { router.Add(route.Method, route.Pattern, withAuth(route.Func, cfg)) } } - router.Get("/*", http.FileServer(http.Dir("/public")).ServeHTTP) return router } diff --git a/src/api/routes/routes.go b/src/api/routes/routes.go index ec8551b..69d05e7 100644 --- a/src/api/routes/routes.go +++ b/src/api/routes/routes.go @@ -21,9 +21,20 @@ type Routes []Route var routes = Routes{ Route{ "Journal", - "GET", - "/api/journal", + http.MethodGet, + "/api/journal/", journal, false, }, + // keep this route last + Route{ + "StaticFiles", + http.MethodGet, + "/*", + staticFiles, + true, + }, } + +// ClientPrefixes is a list of known route prefixes handled by the Vue app. +var ClientPrefixes = []string{"/answered", "/journal", "/user"} From 419c181efff061a00f143a3861ef9005fdc4e116 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 31 Mar 2018 19:58:44 -0500 Subject: [PATCH 09/15] Authorization works (yay) The journal page once again loads as it should; now to migrate the remaining routes --- src/api/data/entities.go | 5 +- src/api/routes/handlers.go | 16 ++++-- src/api/routes/router.go | 111 +++++++++++++++++++++++++++---------- 3 files changed, 97 insertions(+), 35 deletions(-) diff --git a/src/api/data/entities.go b/src/api/data/entities.go index 66e9bca..2fad45b 100644 --- a/src/api/data/entities.go +++ b/src/api/data/entities.go @@ -26,10 +26,9 @@ type Request struct { // properties that may be filled for history and notes. type JournalRequest struct { RequestID string `json:"requestId"` - UserID string `json:"userId"` Text string `json:"text"` AsOf int64 `json:"asOf"` LastStatus string `json:"lastStatus"` - History []History `json:"history"` - Notes []Note `json:"notes"` + History []History `json:"history,omitempty"` + Notes []Note `json:"notes,omitempty"` } diff --git a/src/api/routes/handlers.go b/src/api/routes/handlers.go index 7fb35bd..9cb7bd2 100644 --- a/src/api/routes/handlers.go +++ b/src/api/routes/handlers.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/danieljsummers/myPrayerJournal/src/api/data" + jwt "github.com/dgrijalva/jwt-go" ) /* Support */ @@ -24,26 +25,33 @@ func sendError(w http.ResponseWriter, r *http.Request, err error) { func sendJSON(w http.ResponseWriter, r *http.Request, result interface{}) { w.Header().Set("Content-Type", "application/json; encoding=UTF-8") w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(map[string]interface{}{"data": result}); err != nil { + if err := json.NewEncoder(w).Encode(result); err != nil { sendError(w, r, err) } } +// userID is a convenience function to extract the subscriber ID from the user's JWT. +// NOTE: Do not call this from public routes; there are a lot of type assertions that won't be true if the request +// hasn't gone through the authorization process. +func userID(r *http.Request) string { + return r.Context().Value("user").(*jwt.Token).Claims.(jwt.MapClaims)["sub"].(string) +} + /* Handlers */ +// GET: /api/journal func journal(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(ContextUserKey) - reqs := data.Journal(user.(string)) + reqs := data.Journal(userID(r)) if reqs == nil { reqs = []data.JournalRequest{} } sendJSON(w, r, reqs) } +// GET: /* func staticFiles(w http.ResponseWriter, r *http.Request) { // serve index for known routes handled client-side by the app for _, prefix := range ClientPrefixes { - log.Print("Checking " + r.URL.Path) if strings.HasPrefix(r.URL.Path, prefix) { w.Header().Add("Content-Type", "text/html") http.ServeFile(w, r, "./public/index.html") diff --git a/src/api/routes/router.go b/src/api/routes/router.go index 623f528..94d4e83 100644 --- a/src/api/routes/router.go +++ b/src/api/routes/router.go @@ -1,13 +1,15 @@ package routes import ( - "context" + "encoding/json" + "errors" "fmt" + "io/ioutil" "net/http" - "github.com/auth0-community/go-auth0" + "github.com/auth0/go-jwt-middleware" + jwt "github.com/dgrijalva/jwt-go" "github.com/husobee/vestigo" - "gopkg.in/square/go-jose.v2" ) // AuthConfig contains the Auth0 configuration passed from the "auth" JSON object. @@ -17,49 +19,102 @@ type AuthConfig struct { ClientSecret string `json:"secret"` } -// ContextKey is the type of key used in our contexts. -type ContextKey string +// JWKS is a structure into which the JSON Web Key Set is unmarshaled. +type JWKS struct { + Keys []JWK `json:"keys"` +} -// ContextUserKey is the key for the current user in the context. -const ContextUserKey ContextKey = "user" +// JWK is a structure into which a single JSON Web Key is unmarshaled. +type JWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + X5c []string `json:"x5c"` +} -func withAuth(next http.HandlerFunc, cfg *AuthConfig) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - secret := []byte(cfg.ClientSecret) - secretProvider := auth0.NewKeyProvider(secret) - audience := []string{fmt.Sprintf("https://%s/userinfo", cfg.Domain)} +// authCfg is the Auth0 configuration provided at application startup. +var authCfg *AuthConfig - configuration := auth0.NewConfiguration(secretProvider, audience, fmt.Sprintf("https://%s/", cfg.Domain), jose.HS256) - validator := auth0.NewValidator(configuration, nil) +// jwksBytes is a cache of the JSON Web Key Set for this domain. +var jwksBytes = make([]byte, 0) - token, err := validator.ValidateRequest(r) +// getPEMCert is a function to get the applicable certificate for a JSON Web Token. +func getPEMCert(token *jwt.Token) (string, error) { + cert := "" + if len(jwksBytes) == 0 { + resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/jwks.json", authCfg.Domain)) if err != nil { - fmt.Println(err) - fmt.Println("Token is not valid:", token) - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Unauthorized")) - } else { - values := make(map[string]interface{}) - if err := token.Claims(secret, &values); err != nil { - sendError(w, r, err) - } - r = r.WithContext(context.WithValue(r.Context(), ContextUserKey, values["sub"])) - // TODO pass the user ID (sub) along; this -> doesn't work | r.Header.Add("user-id", token.Claims("sub")) - next(w, r) + return cert, err + } + defer resp.Body.Close() + + if jwksBytes, err = ioutil.ReadAll(resp.Body); err != nil { + return cert, err } } + jwks := JWKS{} + if err := json.Unmarshal(jwksBytes, &jwks); err != nil { + return cert, err + } + for k, v := range jwks.Keys[0].X5c { + if token.Header["kid"] == jwks.Keys[k].Kid { + cert = fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", v) + } + } + if cert == "" { + err := errors.New("unable to find appropriate key") + return cert, err + } + + return cert, nil +} + +// authZero is an instance of Auth0's JWT middlware. Since it doesn't support the http.HandlerFunc sig, it is wrapped +// below; it's defined outside that function, though, so it does not get recreated every time. +var authZero = jwtmiddleware.New(jwtmiddleware.Options{ + ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { + if checkAud := token.Claims.(jwt.MapClaims).VerifyAudience(authCfg.ClientID, false); !checkAud { + return token, errors.New("invalid audience") + } + iss := fmt.Sprintf("https://%s/", authCfg.Domain) + if checkIss := token.Claims.(jwt.MapClaims).VerifyIssuer(iss, false); !checkIss { + return token, errors.New("invalid issuer") + } + + cert, err := getPEMCert(token) + if err != nil { + panic(err.Error()) + } + + result, _ := jwt.ParseRSAPublicKeyFromPEM([]byte(cert)) + return result, nil + }, + SigningMethod: jwt.SigningMethodRS256, +}) + +// authMiddleware is a wrapper for the Auth0 middleware above with a signature Vestigo recognizes. +func authMiddleware(f http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := authZero.CheckJWT(w, r) + if err == nil { + f(w, r) + } + } } // NewRouter returns a configured router to handle all incoming requests. func NewRouter(cfg *AuthConfig) *vestigo.Router { + authCfg = cfg router := vestigo.NewRouter() for _, route := range routes { if route.IsPublic { router.Add(route.Method, route.Pattern, route.Func) } else { - router.Add(route.Method, route.Pattern, withAuth(route.Func, cfg)) + router.Add(route.Method, route.Pattern, route.Func, authMiddleware) } } return router From 2b6f7c63d0fff07946a5207add72fa85b4b5f310 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 31 Mar 2018 22:13:26 -0500 Subject: [PATCH 10/15] route handler translation --- src/api/routes/handlers.go | 79 +++++++++++++++++++++++++++++++++++++- src/api/routes/routes.go | 56 +++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/api/routes/handlers.go b/src/api/routes/handlers.go index 9cb7bd2..1946df6 100644 --- a/src/api/routes/handlers.go +++ b/src/api/routes/handlers.go @@ -2,12 +2,14 @@ package routes import ( "encoding/json" + "errors" "log" "net/http" "strings" "github.com/danieljsummers/myPrayerJournal/src/api/data" jwt "github.com/dgrijalva/jwt-go" + "github.com/husobee/vestigo" ) /* Support */ @@ -39,7 +41,7 @@ func userID(r *http.Request) string { /* Handlers */ -// GET: /api/journal +// GET: /api/journal/ func journal(w http.ResponseWriter, r *http.Request) { reqs := data.Journal(userID(r)) if reqs == nil { @@ -48,6 +50,81 @@ func journal(w http.ResponseWriter, r *http.Request) { sendJSON(w, r, reqs) } +// POST: /api/request/ +func requestAdd(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + sendError(w, r, err) + } + result, ok := data.AddNew(userID(r), r.FormValue("requestText")) + if !ok { + sendError(w, r, errors.New("error adding request")) + } + sendJSON(w, r, result) +} + +// GET: /api/request/:id +func requestGet(w http.ResponseWriter, r *http.Request) { + request, ok := data.ByID(userID(r), vestigo.Param(r, "id")) + if !ok { + sendError(w, r, errors.New("error retrieving request")) + } + sendJSON(w, r, request) +} + +// GET: /api/request/:id/complete +func requestGetComplete(w http.ResponseWriter, r *http.Request) { + request, ok := data.FullByID(userID(r), vestigo.Param(r, "id")) + if !ok { + sendError(w, r, errors.New("error retrieving request")) + } + request.Notes = data.NotesByID(userID(r), vestigo.Param(r, "id")) + sendJSON(w, r, request) +} + +// GET: /api/request/:id/full +func requestGetFull(w http.ResponseWriter, r *http.Request) { + request, ok := data.FullByID(userID(r), vestigo.Param(r, "id")) + if !ok { + sendError(w, r, errors.New("error retrieving request")) + } + sendJSON(w, r, request) +} + +// POST: /api/request/:id/history +func requestAddHistory(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + sendError(w, r, err) + } + w.WriteHeader(data.AddHistory(userID(r), vestigo.Param(r, "id"), r.FormValue("status"), r.FormValue("updateText"))) +} + +// POST: /api/request/:id/note +func requestAddNote(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + sendError(w, r, err) + } + w.WriteHeader(data.AddNote(userID(r), vestigo.Param(r, "id"), r.FormValue("notes"))) +} + +// GET: /api/request/:id/notes +func requestGetNotes(w http.ResponseWriter, r *http.Request) { + notes := data.NotesByID(userID(r), vestigo.Param(r, "id")) + if notes == nil { + w.WriteHeader(http.StatusNotFound) + return + } + sendJSON(w, r, notes) +} + +// GET: /api/request/answered +func requestsAnswered(w http.ResponseWriter, r *http.Request) { + reqs := data.Answered(userID(r)) + if reqs == nil { + reqs = []data.JournalRequest{} + } + sendJSON(w, r, reqs) +} + // GET: /* func staticFiles(w http.ResponseWriter, r *http.Request) { // serve index for known routes handled client-side by the app diff --git a/src/api/routes/routes.go b/src/api/routes/routes.go index 69d05e7..ac8a5d6 100644 --- a/src/api/routes/routes.go +++ b/src/api/routes/routes.go @@ -26,6 +26,62 @@ var routes = Routes{ journal, false, }, + Route{ + "AddNewRequest", + http.MethodPost, + "/api/request/", + requestAdd, + false, + }, + Route{ + "GetRequestByID", + http.MethodGet, + "/api/request/:id", + requestGet, + false, + }, + Route{ + "GetCompleteRequestByID", + http.MethodGet, + "/api/request/:id/complete", + requestGetComplete, + false, + }, + Route{ + "GetFullRequestByID", + http.MethodGet, + "/api/request/:id/full", + requestGetFull, + false, + }, + Route{ + "AddNewHistoryEntry", + http.MethodPost, + "/api/request/:id/history", + requestAddHistory, + false, + }, + Route{ + "AddNewNote", + http.MethodPost, + "/api/request/:id/note", + requestAddNote, + false, + }, + Route{ + "GetNotesForRequest", + http.MethodGet, + "/api/request/:id/notes", + requestGetNotes, + false, + }, + Route{ + "GetAnsweredRequests", + http.MethodGet, + "/api/request/answered", + requestsAnswered, + false, + }, // keep this route last Route{ "StaticFiles", From bad430fc370224b6c2a10ef18c6cda1a5c248920 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 19 May 2018 23:22:44 -0500 Subject: [PATCH 11/15] GDPR update; version bump - added Terms of Service and Privacy Policy - updated deps - fixed vue-bootstrap warning --- src/api/package.json | 2 +- src/app/package.json | 2 +- src/app/src/App.vue | 2 + src/app/src/components/Home.vue | 4 +- src/app/src/components/Navigation.vue | 2 +- .../src/components/legal/PrivacyPolicy.vue | 54 + .../src/components/legal/TermsOfService.vue | 35 + src/app/src/router/index.js | 12 + src/app/yarn.lock | 1070 ++++++++--------- 9 files changed, 615 insertions(+), 568 deletions(-) create mode 100644 src/app/src/components/legal/PrivacyPolicy.vue create mode 100644 src/app/src/components/legal/TermsOfService.vue diff --git a/src/api/package.json b/src/api/package.json index f3958d0..4804b19 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -1,7 +1,7 @@ { "name": "my-prayer-journal-api", "private": true, - "version": "0.9.2", + "version": "0.9.3", "description": "Server API for myPrayerJournal", "main": "index.js", "author": "Daniel J. Summers ", diff --git a/src/app/package.json b/src/app/package.json index f90bacd..56dd428 100644 --- a/src/app/package.json +++ b/src/app/package.json @@ -1,6 +1,6 @@ { "name": "my-prayer-journal", - "version": "0.9.2", + "version": "0.9.3", "description": "myPrayerJournal - Front End", "author": "Daniel J. Summers ", "private": true, diff --git a/src/app/src/App.vue b/src/app/src/App.vue index cd016ae..f0086e9 100644 --- a/src/app/src/App.vue +++ b/src/app/src/App.vue @@ -10,6 +10,8 @@ | myPrayerJournal v{{ version }} br em: small. + #[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] • + #[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] • #[a(href='https://github.com/danieljsummers/myprayerjournal') Developed] and hosted by #[a(href='https://bitbadger.solutions') Bit Badger Solutions] diff --git a/src/app/src/components/Home.vue b/src/app/src/components/Home.vue index 1ad851d..39b9c8b 100644 --- a/src/app/src/components/Home.vue +++ b/src/app/src/components/Home.vue @@ -9,8 +9,8 @@ article individuals to review their answered prayers. p. This site is currently in beta, but it is open and available to the general public. To get started, simply click - the "Log On" link above, and log on with either a Microsoft or Google account. You can also learn more about the - site at the "Docs" link, also above. + the “Log On” link above, and log on with either a Microsoft or Google account. You can also learn more + about the site at the “Docs” link, also above.