├── ReadME.md ├── backend ├── cmd │ ├── context.go │ ├── errors.go │ ├── handlers.go │ ├── healthcheck.go │ ├── main.go │ ├── middleware.go │ ├── routes.go │ ├── tmp │ │ ├── build-errors.log │ │ └── main │ ├── tokens.go │ └── utilities.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── jsonlog │ └── jsonlog.go ├── mailer │ ├── mailer.go │ └── templates │ │ └── user_welcome.tmpl ├── migrations │ ├── 000002_create_users_table.down.sql │ ├── 000002_create_users_table.up.sql │ ├── 000003_create_data_table.down.sql │ ├── 000003_create_data_table.up.sql │ ├── 000004_create_tokens_table.down.sql │ ├── 000004_create_tokens_table.up.sql │ ├── 000005_add_permissions.down.sql │ └── 000005_add_permissions.up.sql ├── models │ ├── filters.go │ ├── handlers.go │ ├── models.go │ ├── permissions.go │ ├── tokens.go │ └── users.go ├── types │ └── structures.go └── validator │ └── validator.go └── frontend ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── App.tsx ├── components │ ├── Login.tsx │ └── Register.tsx ├── favicon.svg ├── index.css ├── main.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /ReadME.md: -------------------------------------------------------------------------------- 1 | ## React/Go Boiler 2 | 3 | ### Setup 4 | 5 | in `main.go` configure new SMTP credentials if you want that functionality. 6 | if not remove. 7 | 8 | Modify the parameters in the `docker-compose.yml` file for what you want to name the database and connections 9 | 10 | Run `docker-compose up` 11 | This will create an instance of a postgres DN in docker 12 | 13 | If successful, the following message should be up: 14 | 15 | ``` 16 | {"level":"INFO","time":"2022-01-09T03:11:42Z","message":"Loading server..."} 17 | {"level":"INFO","time":"2022-01-09T03:11:42Z","message":"Server running on port"} 18 | ``` 19 | -------------------------------------------------------------------------------- /backend/cmd/context.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | 4 | // This is going to hold our context for our User (if anonymous or activated) 5 | import ( 6 | "context" 7 | "net/http" 8 | "backend/models" 9 | ) 10 | 11 | type contextKey string 12 | 13 | // store the value of the token 14 | const userContextKey = contextKey("user") 15 | 16 | // setUserContext 17 | func (app *application) contextSetUser(r *http.Request, user *models.User) *http.Request { 18 | ctx := context.WithValue(r.Context(), userContextKey, user) 19 | return r.WithContext(ctx) 20 | } 21 | 22 | // TODO: need to investigate this 23 | //getUserContext 24 | func (app *application) contextGetUser(r *http.Request) *models.User { 25 | user, ok := r.Context().Value(userContextKey).(*models.User) 26 | 27 | if !ok { 28 | panic("missing user value in request context") 29 | } 30 | 31 | return user 32 | } 33 | -------------------------------------------------------------------------------- /backend/cmd/errors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // Generic helper for logging an error message 9 | func (app *application) logError(r *http.Request, err error) { 10 | app.logger.PrintError(err, map[string]string{ 11 | "request_method": r.Method, 12 | "request_url": r.URL.String(), 13 | }) 14 | } 15 | 16 | // Helper for sending json-formatted error messages to clients w/status code 17 | func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message interface{}) { 18 | env := envelope{"error": message} 19 | 20 | err := app.writeJSON(w, status, env, nil) 21 | if err != nil { 22 | app.logError(r, err) 23 | w.WriteHeader(500) 24 | } 25 | } 26 | 27 | // Helper when our app encounters an unexpected problem at runtime. Send 500 28 | func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) { 29 | app.logError(r, err) 30 | message := "The server encountered a proble and could not process your request" 31 | app.errorResponse(w, r, http.StatusInternalServerError, message) 32 | } 33 | 34 | // Helper when we encounter a 404 35 | func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) { 36 | message := "The requested resource could not be found" 37 | app.errorResponse(w, r, http.StatusNotFound, message) 38 | } 39 | 40 | // Helper when request is made with incorrect method 41 | func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { 42 | message := fmt.Sprintf("the %s method is not supported for this resource", r.Method) 43 | app.errorResponse(w, r, http.StatusMethodNotAllowed, message) 44 | } 45 | 46 | // Helper for handling bad requests 47 | func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { 48 | app.errorResponse(w, r, http.StatusBadRequest, err.Error()) 49 | } 50 | 51 | // Helper for failed JSON validation responses 52 | func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { 53 | app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) 54 | } 55 | 56 | // Data race conditon for editing data 57 | func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { 58 | message := "Unable to update the record due to an edit conflict" 59 | app.errorResponse(w, r, http.StatusConflict, message) 60 | } 61 | 62 | func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { 63 | message := "rate limit exceeded" 64 | app.errorResponse(w, r, http.StatusTooManyRequests, message) 65 | } 66 | 67 | func (app *application) invalidCredentialResponse(w http.ResponseWriter, r *http.Request) { 68 | message := "invalid authentication credentials" 69 | app.errorResponse(w, r, http.StatusUnauthorized, message) 70 | } 71 | 72 | func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { 73 | message := "invalid authentication token response" 74 | app.errorResponse(w, r, http.StatusUnauthorized, message) 75 | } 76 | 77 | // This is when we make a request as non permissioned user 78 | func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { 79 | message := "you must be authorized to access this route" 80 | app.errorResponse(w, r, http.StatusUnauthorized, message) 81 | } 82 | 83 | // This is when we make a request as a non activated user 84 | func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) { 85 | message := "your account must be activated to access this route" 86 | app.errorResponse(w, r, http.StatusUnauthorized, message) 87 | } 88 | 89 | func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) { 90 | message := "You do not have the correct permissions to access this route" 91 | app.errorResponse(w, r, http.StatusForbidden, message) 92 | } 93 | 94 | -------------------------------------------------------------------------------- /backend/cmd/handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "backend/models" 5 | "backend/validator" 6 | "encoding/json" 7 | "errors" 8 | //"fmt" 9 | //"github.com/pascaldekloe/jwt" 10 | //"golang.org/x/crypto/bcrypt" 11 | "log" 12 | "net/http" 13 | "time" 14 | ) 15 | 16 | // Create a JSON message struct 17 | type JSONMessage struct { 18 | Message string `json:"message"` 19 | } 20 | 21 | // Create a register user type 22 | type UserPayload struct { 23 | Username string `json:"username"` 24 | Password string `json:"password"` 25 | } 26 | 27 | // Create a generic DBLoad type 28 | type DBLoadPayload struct { 29 | DBDataOne string `json:"db_data_one"` 30 | DBDataTwo string `json:"db_data_two"` 31 | DBDataThree string `json:"db_data_three"` 32 | } 33 | 34 | func (app *application) statusHandler(w http.ResponseWriter, r *http.Request) { 35 | response := struct { 36 | Status string 37 | }{"Curling Request"} 38 | 39 | js, err := json.MarshalIndent(response, "", "\t") 40 | if err != nil { 41 | app.logger.PrintError(err, nil) 42 | } 43 | 44 | w.Header().Set("Content-Type", "application/json") 45 | w.WriteHeader(http.StatusOK) 46 | w.Write(js) 47 | } 48 | 49 | func (app *application) registerUser(w http.ResponseWriter, r *http.Request) { 50 | var input struct { 51 | Name string `json:"name"` 52 | Email string `json:"email"` 53 | Password string `json:"password"` 54 | } 55 | 56 | err := app.readJSON(w, r, &input) 57 | if err != nil { 58 | app.badRequestResponse(w, r, err) 59 | return 60 | } 61 | 62 | user := &models.User{ 63 | Name: input.Name, 64 | Email: input.Email, 65 | Activated: false, 66 | } 67 | 68 | // generate the password hash with bcrypt 69 | err = user.Password.Set(input.Password) 70 | if err != nil { 71 | app.serverErrorResponse(w, r, err) 72 | return 73 | } 74 | 75 | v := validator.New() 76 | if models.ValidateUser(v, user); !v.Valid() { 77 | app.failedValidationResponse(w, r, v.Errors) 78 | return 79 | } 80 | 81 | err = app.models.DB.Insert(user) 82 | if err != nil { 83 | switch { 84 | case errors.Is(err, models.ErrDuplicateEmail): 85 | v.AddError("email", "a user with this email already exists") 86 | app.failedValidationResponse(w, r, v.Errors) 87 | default: 88 | app.serverErrorResponse(w, r, err) 89 | } 90 | return 91 | } 92 | 93 | token, err := app.models.DB.NewToken(user.ID, 3*24*time.Hour, models.ScopeActivation) 94 | if err != nil { 95 | app.serverErrorResponse(w, r, err) 96 | return 97 | } 98 | 99 | // THIS IS WHERE WE SEND TO OUR SMTP 100 | // TODO: How to recover PANICS 101 | 102 | go func(){ 103 | data := map[string]interface{}{ 104 | "activationToken": token.Plaintext, 105 | "userID": user.ID, 106 | } 107 | 108 | err = app.mailer.Send(user.Email, "user_welcome.tmpl", data) 109 | if err != nil { 110 | app.logger.PrintError(err, nil) 111 | } 112 | }() 113 | 114 | err = app.writeJSON(w, http.StatusAccepted, envelope{"user":user}, nil) 115 | if err != nil { 116 | app.serverErrorResponse(w, r, err) 117 | } 118 | } 119 | 120 | // This function is client side - to - database 121 | func (app *application) getData(w http.ResponseWriter, r *http.Request) { 122 | id, err := app.readIDParam(r) 123 | if err != nil { 124 | app.notFoundResponse(w, r) 125 | return 126 | } 127 | 128 | data, err := app.models.DB.GetData(id) 129 | if err != nil { 130 | switch { 131 | case errors.Is(err, models.ErrRecordNotFound): 132 | app.notFoundResponse(w, r) 133 | default: 134 | app.serverErrorResponse(w, r, err) 135 | } 136 | return 137 | } 138 | 139 | err = app.writeJSON(w, http.StatusOK, envelope{"data": data}, nil) 140 | if err != nil { 141 | app.serverErrorResponse(w, r, err) 142 | } 143 | } 144 | 145 | // TODO: Refactor this aswell 146 | //func (app *application) login(w http.ResponseWriter, r *http.Request) { 147 | //var payload UserPayload 148 | 149 | //err := json.NewDecoder(r.Body).Decode(&payload) 150 | //if err != nil { 151 | //app.logger.PrintError(err, nil) 152 | //} 153 | 154 | ////we need to get the user 155 | //// TODO: replace with get usr by email 156 | //user, err := app.models.DB.GetUser(payload.Username) 157 | //if err != nil { 158 | //app.logger.PrintInfo("User does not exist", nil) 159 | //return 160 | //} 161 | 162 | //hashPassword := user.Password 163 | 164 | //err = bcrypt.CompareHashAndPassword([]byte(hashPassword), []byte(payload.Password)) 165 | //// Handle the error for hasing and comparing 166 | //if err != nil { 167 | //log.Println(err) 168 | //_message := JSONMessage{ 169 | //Message: "Unauthorized", 170 | //} 171 | 172 | //js, err := json.MarshalIndent(_message, "", "\t") 173 | //if err != nil { 174 | //app.logger.PrintError(err, nil) 175 | //} 176 | 177 | //w.Header().Set("Context-Type", "application/json") 178 | //w.WriteHeader(http.StatusOK) 179 | //w.Write(js) 180 | //return 181 | //} 182 | 183 | //// Validating a users token 184 | //var claims jwt.Claims 185 | //claims.Subject = fmt.Sprint(user.ID) 186 | //claims.Issued = jwt.NewNumericTime(time.Now()) 187 | //claims.NotBefore = jwt.NewNumericTime(time.Now()) 188 | //claims.Expires = jwt.NewNumericTime(time.Now().Add(24 * time.Hour)) 189 | //// supposed to be a unique domain you own 190 | //claims.Issuer = "github.com/melkeydev" 191 | //claims.Audiences = []string{"github.com/melkeydev"} 192 | 193 | //jwtBytes, err := claims.HMACSign(jwt.HS256, []byte(app.config.Jwt.Secret)) 194 | //if err != nil { 195 | //fmt.Println(err) 196 | //message := "Could not generate proper access" 197 | //app.errorResponse(w, r, http.StatusInternalServerError, message) 198 | //return 199 | //} 200 | 201 | ////app.writeJSON(w, http.StatusOK, string(jwtBytes), "Successfully logged in") 202 | //_message := JSONMessage{ 203 | //Message: string(jwtBytes), 204 | //} 205 | 206 | //js, err := json.MarshalIndent(_message, "", "\t") 207 | //if err != nil { 208 | //app.logger.PrintError(err, nil) 209 | //} 210 | 211 | //w.Header().Set("Context-Type", "application/json") 212 | //w.WriteHeader(http.StatusOK) 213 | //w.Write(js) 214 | 215 | //} 216 | 217 | func (app *application) insertPayload(w http.ResponseWriter, r *http.Request) { 218 | var payload DBLoadPayload 219 | 220 | err := json.NewDecoder(r.Body).Decode(&payload) 221 | if err != nil { 222 | log.Println(err) 223 | return 224 | } 225 | 226 | dbload := &models.DBLoad{ 227 | DBDataOne: payload.DBDataOne, 228 | DBDataTwo: payload.DBDataTwo, 229 | DBDataThree: payload.DBDataThree, 230 | } 231 | 232 | v := validator.New() 233 | 234 | if models.ValidateDBLoad(v, dbload); !v.Valid() { 235 | app.failedValidationResponse(w, r, v.Errors) 236 | return 237 | } 238 | 239 | err = app.models.DB.InsertDBLoad(dbload) 240 | if err != nil { 241 | app.serverErrorResponse(w, r, err) 242 | return 243 | } 244 | 245 | err = app.writeJSON(w, http.StatusOK, envelope{"data": dbload}, nil) 246 | if err != nil { 247 | app.serverErrorResponse(w, r, err) 248 | } 249 | } 250 | 251 | func (app *application) deleteDBload(w http.ResponseWriter, r *http.Request) { 252 | id, err := app.readIDParam(r) 253 | if err != nil { 254 | app.notFoundResponse(w, r) 255 | } 256 | 257 | err = app.models.DB.Delete(id) 258 | if err != nil { 259 | switch { 260 | case errors.Is(err, models.ErrRecordNotFound): 261 | app.notFoundResponse(w, r) 262 | default: 263 | app.serverErrorResponse(w, r, err) 264 | } 265 | return 266 | } 267 | 268 | err = app.writeJSON(w, http.StatusOK, envelope{"message": "data deleted Succesfully"}, nil) 269 | if err != nil { 270 | app.serverErrorResponse(w, r, err) 271 | } 272 | } 273 | 274 | func (app *application) updateDBData(w http.ResponseWriter, r *http.Request) { 275 | id, err := app.readIDParam(r) 276 | if err != nil { 277 | app.notFoundResponse(w, r) 278 | return 279 | } 280 | 281 | // This is where we pull all the data first to update 282 | data, err := app.models.DB.GetData(id) 283 | if err != nil { 284 | switch { 285 | case errors.Is(err, models.ErrRecordNotFound): 286 | app.notFoundResponse(w, r) 287 | default: 288 | app.serverErrorResponse(w, r, err) 289 | } 290 | return 291 | } 292 | 293 | var input struct { 294 | DBDataOne *string `json:"db_data_one"` 295 | DBDataTwo *string `json:"db_data_two"` 296 | DBDataThree *string `json:"db_data_three"` 297 | } 298 | 299 | err = app.readJSON(w, r, &input) 300 | if err != nil { 301 | app.badRequestResponse(w, r, err) 302 | return 303 | } 304 | 305 | // Explicitly check each input 306 | if input.DBDataOne != nil { 307 | data.DBDataOne = *input.DBDataOne 308 | } 309 | 310 | if input.DBDataTwo != nil { 311 | data.DBDataTwo = *input.DBDataTwo 312 | } 313 | 314 | if input.DBDataThree != nil { 315 | data.DBDataThree = *input.DBDataThree 316 | } 317 | 318 | data.ID = id 319 | 320 | v := validator.New() 321 | 322 | // validate the json data 323 | if models.ValidateDBLoad(v, data); !v.Valid() { 324 | app.failedValidationResponse(w, r, v.Errors) 325 | return 326 | } 327 | 328 | // This needs to change 329 | err = app.models.DB.Update(data) 330 | if err != nil { 331 | switch { 332 | // the race condition editing error message 333 | case errors.Is(err, models.ErrEditConflict): 334 | app.editConflictResponse(w, r) 335 | default: 336 | app.serverErrorResponse(w, r, err) 337 | } 338 | return 339 | } 340 | 341 | err = app.writeJSON(w, http.StatusOK, envelope{"data": data}, nil) 342 | if err != nil { 343 | app.serverErrorResponse(w, r, err) 344 | } 345 | } 346 | 347 | func (app *application) listAllDBData(w http.ResponseWriter, r *http.Request) { 348 | var input struct { 349 | DBDataOne string 350 | models.Filters 351 | } 352 | 353 | v := validator.New() 354 | qs := r.URL.Query() 355 | 356 | // Query string readers go below 357 | input.DBDataOne = app.readString(qs, "dbdataone", "") 358 | 359 | input.Filters.Page = app.readInt(qs, "page", 1, v) 360 | input.Filters.PageSize = app.readInt(qs, "page_size", 20, v) 361 | 362 | input.Filters.Sort = app.readString(qs, "sort", "id") 363 | input.Filters.SortSafeList = []string{"id", "DBDataOne"} 364 | 365 | if models.ValidateFilters(v, input.Filters); !v.Valid() { 366 | app.failedValidationResponse(w, r, v.Errors) 367 | return 368 | } 369 | // We need to get all 370 | 371 | DBdata, metadata, err := app.models.DB.GetAll(input.DBDataOne, input.Filters) 372 | if err != nil { 373 | app.serverErrorResponse(w, r, err) 374 | return 375 | } 376 | 377 | err = app.writeJSON(w, http.StatusOK, envelope{"DBdata": DBdata, "metadata": metadata}, nil) 378 | 379 | if err != nil { 380 | app.serverErrorResponse(w, r, err) 381 | } 382 | } 383 | 384 | func (app *application) activateUser(w http.ResponseWriter, r *http.Request) { 385 | var input struct { 386 | TokenPlaintext string `json:"token"` 387 | } 388 | 389 | err := app.readJSON(w, r, &input) 390 | if err != nil { 391 | app.badRequestResponse(w, r, err) 392 | return 393 | } 394 | 395 | v := validator.New() 396 | 397 | if models.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() { 398 | app.failedValidationResponse(w, r, v.Errors) 399 | return 400 | } 401 | 402 | user, err := app.models.DB.GetForToken(models.ScopeActivation, input.TokenPlaintext) 403 | if err != nil { 404 | switch{ 405 | case errors.Is(err, models.ErrRecordNotFound): 406 | v.AddError("token", "invalid or expire auth token") 407 | app.failedValidationResponse(w, r, v.Errors) 408 | default: 409 | app.serverErrorResponse(w, r, err) 410 | } 411 | return 412 | } 413 | 414 | user.Activated = true 415 | 416 | // This needs to change 417 | err = app.models.DB.UpdateUser(user) 418 | if err != nil { 419 | switch { 420 | case errors.Is(err, models.ErrEditConflict): 421 | app.editConflictResponse(w, r) 422 | default: 423 | app.serverErrorResponse(w, r, err) 424 | } 425 | return 426 | } 427 | 428 | err = app.models.DB.DeleteAlForUser(models.ScopeActivation, user.ID) 429 | if err != nil { 430 | app.serverErrorResponse(w, r, err) 431 | return 432 | } 433 | 434 | err = app.writeJSON(w, http.StatusOK, envelope{"user":user}, nil) 435 | if err != nil { 436 | app.serverErrorResponse(w, r, err) 437 | } 438 | } 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | -------------------------------------------------------------------------------- /backend/cmd/healthcheck.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) { 8 | 9 | env := envelope{ 10 | "status": "available", 11 | "system_info": map[string]string{ 12 | "environment": app.config.Env, 13 | }, 14 | } 15 | 16 | err := app.writeJSON(w, http.StatusOK, env, nil) 17 | if err != nil { 18 | app.serverErrorResponse(w, r, err) 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /backend/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "backend/jsonlog" 5 | "backend/mailer" 6 | "backend/models" 7 | "backend/types" 8 | "flag" 9 | "fmt" 10 | _ "github.com/lib/pq" 11 | "net/http" 12 | "os" 13 | "time" 14 | ) 15 | 16 | type application struct { 17 | config types.Config 18 | logger *jsonlog.Logger 19 | models models.Models 20 | mailer mailer.Mailer 21 | } 22 | 23 | func main() { 24 | var cfg types.Config 25 | var port = 4000 26 | logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo) 27 | 28 | logger.PrintInfo("Loading server...", nil) 29 | 30 | flag.IntVar(&cfg.Port, "port", port, "server for port to listen") 31 | flag.StringVar(&cfg.Env, "env", "development", "app environment") 32 | // TODO: Add to note to the readme 33 | // CHANGE DSN to your database setting 34 | flag.StringVar(&cfg.Db.Dsn, "dsn", "host=localhost user=postgres password=postgres dbname=postgres port=5432 sslmode=disable", "Database connection string") 35 | flag.StringVar(&cfg.Jwt.Secret, "jwt-secret", "default-secret", "secret-key") 36 | 37 | // create flags 38 | flag.StringVar(&cfg.SMTP.Host, "smtp-host", "smtp.mailtrap.io", "SMTP host") 39 | flag.IntVar(&cfg.SMTP.Port, "smtp-port", 587, "SMTP Port") 40 | // I need to actually put in my credentials 41 | flag.StringVar(&cfg.SMTP.Username, "smtp-username", "foo", "SMTP host") 42 | flag.StringVar(&cfg.SMTP.Password, "smtp-password", "foo", "SMTP host") 43 | flag.StringVar(&cfg.SMTP.Sender, "smtp-sender", "Thundercock ", "SMTP host") 44 | 45 | flag.Parse() 46 | 47 | db, err := connectDB(cfg) 48 | if err != nil { 49 | logger.PrintFatal(err, nil) 50 | } 51 | 52 | defer db.Close() 53 | 54 | app := &application{ 55 | config: cfg, 56 | logger: logger, 57 | models: models.NewModels(db), 58 | mailer: mailer.New(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.Sender), 59 | } 60 | 61 | // Declare Server config 62 | server := http.Server{ 63 | Addr: fmt.Sprintf(":%d", cfg.Port), 64 | Handler: app.routes(), 65 | IdleTimeout: time.Minute, 66 | ReadTimeout: 10 * time.Second, 67 | WriteTimeout: 30 * time.Second, 68 | } 69 | 70 | // Run the server 71 | logger.PrintInfo("Server running on port", nil) 72 | err = server.ListenAndServe() 73 | if err != nil { 74 | logger.PrintFatal(err, nil) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /backend/cmd/middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "backend/models" 5 | "backend/validator" 6 | "errors" 7 | "fmt" 8 | "golang.org/x/time/rate" 9 | "net" 10 | "net/http" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | // We did not fully test this 17 | func (app *application) enableCORS(next http.Handler) http.Handler { 18 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | w.Header().Set("Access-Control-Allow-Origin", "*") 20 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") 21 | next.ServeHTTP(w, r) 22 | }) 23 | } 24 | 25 | // route and handle panics better 26 | func (app *application) recoverPanic(next http.Handler) http.Handler { 27 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | defer func() { 29 | if err := recover(); err != nil { 30 | w.Header().Set("Connection", "close") 31 | app.serverErrorResponse(w, r, fmt.Errorf("%s", err)) 32 | } 33 | }() 34 | next.ServeHTTP(w, r) 35 | }) 36 | 37 | } 38 | 39 | func (app *application) rateLimit(next http.Handler) http.Handler { 40 | type client struct { 41 | limiter *rate.Limiter 42 | lastSeen time.Time 43 | } 44 | 45 | var ( 46 | mu sync.Mutex 47 | clients = make(map[string]*client) 48 | ) 49 | 50 | go func() { 51 | for { 52 | time.Sleep(time.Minute) 53 | mu.Lock() 54 | 55 | for ip, client := range clients { 56 | if time.Since(client.lastSeen) > 3*time.Second { 57 | delete(clients, ip) 58 | } 59 | } 60 | mu.Unlock() 61 | } 62 | }() 63 | 64 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 65 | if app.config.Limiter.Enabled { 66 | // get the ip addy from each request 67 | ip, _, err := net.SplitHostPort(r.RemoteAddr) 68 | if err != nil { 69 | app.serverErrorResponse(w, r, err) 70 | return 71 | } 72 | 73 | mu.Lock() 74 | 75 | if _, found := clients[ip]; !found { 76 | clients[ip] = &client{ 77 | limiter: rate.NewLimiter(rate.Limit(app.config.Limiter.Rps), app.config.Limiter.Burst), 78 | } 79 | } 80 | 81 | // Every new ip that gets added to our clients slice gets a time stamp 82 | clients[ip].lastSeen = time.Now() 83 | 84 | if !clients[ip].limiter.Allow() { 85 | mu.Unlock() 86 | app.rateLimitExceededResponse(w, r) 87 | return 88 | } 89 | 90 | mu.Unlock() 91 | } 92 | 93 | next.ServeHTTP(w, r) 94 | }) 95 | } 96 | 97 | // Create a authenticate middleware 98 | func (app *application) authenticate(next http.Handler) http.Handler { 99 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 100 | // Adding ther VARY and AUTHORIZATION 101 | // This indicates to any cache that the request may vary 102 | w.Header().Add("Vary", "Authorization") 103 | 104 | // Retrieve the value of the Authorization header 105 | authorizationHeader := r.Header.Get("Authorization") 106 | 107 | // The pointer reference might be questionable 108 | // IF there is no authorizationHeader we will set the context 109 | // this will hold an AnonymousUser - gifting bare minimum 110 | if authorizationHeader == "" { 111 | r = app.contextSetUser(r, models.AnonymousUser) 112 | next.ServeHTTP(w, r) 113 | return 114 | } 115 | 116 | headerParts := strings.Split(authorizationHeader, " ") 117 | if len(headerParts) != 2 || headerParts[0] != "Bearer" { 118 | app.invalidCredentialResponse(w, r) 119 | return 120 | } 121 | 122 | token := headerParts[1] 123 | v := validator.New() 124 | 125 | // We need to validate the token to make sure it is correct format 126 | if models.ValidateTokenPlaintext(v, token); !v.Valid() { 127 | app.invalidCredentialResponse(w, r) 128 | return 129 | } 130 | 131 | // then we need to get the user 132 | user, err := app.models.DB.GetForToken(models.ScopeAuthentication, token) 133 | if err != nil { 134 | switch { 135 | case errors.Is(err, models.ErrRecordNotFound): 136 | app.invalidAuthenticationTokenResponse(w, r) 137 | default: 138 | app.serverErrorResponse(w, r, err) 139 | } 140 | return 141 | } 142 | 143 | // Set the user here pointer ref is questionable 144 | r = app.contextSetUser(r, user) 145 | 146 | // Because this is a MW wrapperm we need to pass to the next http handler 147 | next.ServeHTTP(w, r) 148 | }) 149 | } 150 | 151 | // We need to split our auth to handle activated routes and authenticated routes 152 | func(app *application) requireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc { 153 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 154 | user := app.contextGetUser(r) 155 | 156 | // If this is true; You arent authorized 157 | if user.IsAnonymous() { 158 | app.authenticationRequiredResponse(w, r) 159 | return 160 | } 161 | 162 | next.ServeHTTP(w, r) 163 | }) 164 | } 165 | 166 | // We need this to wrap and call our requireAuthenticatedUser MW 167 | func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc { 168 | fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 169 | user := app.contextGetUser(r) 170 | 171 | if !user.Activated { 172 | app.inactiveAccountResponse(w, r) 173 | return 174 | } 175 | 176 | next.ServeHTTP(w,r ) 177 | }) 178 | 179 | return app.requireAuthenticatedUser(fn) 180 | } 181 | -------------------------------------------------------------------------------- /backend/cmd/routes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "github.com/julienschmidt/httprouter" 6 | ) 7 | 8 | func (app *application) routes() http.Handler { 9 | router := httprouter.New() 10 | //Add our custom error handling 11 | router.NotFound = http.HandlerFunc(app.notFoundResponse) 12 | router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) 13 | 14 | // we need to put the authetnication wrapper on each route 15 | 16 | router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) 17 | router.HandlerFunc(http.MethodGet, "/v1/status", app.statusHandler) 18 | router.HandlerFunc(http.MethodGet, "/v1/data/:id", app.requireActivatedUser(app.getData)) 19 | router.HandlerFunc(http.MethodGet, "/v1/data", app.requireActivatedUser(app.listAllDBData)) 20 | router.HandlerFunc(http.MethodPost, "/v1/register", app.registerUser) 21 | //router.HandlerFunc(http.MethodPost, "/v1/login/", app.login) 22 | router.HandlerFunc(http.MethodPost, "/v1/post_data/", app.requireActivatedUser(app.insertPayload)) 23 | router.HandlerFunc(http.MethodPatch, "/v1/data/:id", app.requireActivatedUser(app.updateDBData)) 24 | router.HandlerFunc(http.MethodDelete, "/v1/data/:id", app.requireActivatedUser(app.deleteDBload)) 25 | router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUser) 26 | router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) 27 | 28 | return app.recoverPanic(app.rateLimit(app.enableCORS(app.authenticate(router)))) 29 | } 30 | -------------------------------------------------------------------------------- /backend/cmd/tmp/build-errors.log: -------------------------------------------------------------------------------- 1 | exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2exit status 2 -------------------------------------------------------------------------------- /backend/cmd/tmp/main: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Melkeydev/React_Go_Boiler/c6080f2c25cd956ebba7924c665458b436dea5d9/backend/cmd/tmp/main -------------------------------------------------------------------------------- /backend/cmd/tokens.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "backend/models" 5 | "backend/validator" 6 | "errors" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) { 12 | var input struct { 13 | Email string `json:"email"` 14 | Password string `json:"password"` 15 | } 16 | 17 | err := app.readJSON(w, r, &input) 18 | 19 | if err != nil { 20 | app.badRequestResponse(w, r, err) 21 | return 22 | } 23 | 24 | // Validate the email and password 25 | v := validator.New() 26 | 27 | models.ValidateEmail(v, input.Email) 28 | models.ValidatePasswordPlaintext(v, input.Password) 29 | 30 | if !v.Valid() { 31 | app.failedValidationResponse(w, r, v.Errors) 32 | return 33 | } 34 | 35 | user, err := app.models.DB.GetUserByEmail(input.Email) 36 | if err != nil { 37 | switch { 38 | case errors.Is(err, models.ErrRecordNotFound): 39 | app.invalidCredentialResponse(w, r) 40 | default: 41 | app.serverErrorResponse(w, r, err) 42 | } 43 | return 44 | } 45 | 46 | // Compare and match the hash passwords 47 | match, err := user.Password.Matches(input.Password) 48 | if err != nil { 49 | app.serverErrorResponse(w, r, err) 50 | return 51 | } 52 | 53 | // if passwords do not match 54 | if !match { 55 | app.invalidCredentialResponse(w, r) 56 | return 57 | } 58 | 59 | // Creates a new auth token and saves it 60 | token, err := app.models.DB.NewToken(user.ID, 24*time.Hour, models.ScopeAuthentication) 61 | if err != nil { 62 | app.serverErrorResponse(w, r, err) 63 | return 64 | } 65 | 66 | err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token":token}, nil) 67 | if err != nil { 68 | app.serverErrorResponse(w, r, err) 69 | } 70 | } 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /backend/cmd/utilities.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "fmt" 6 | "log" 7 | "time" 8 | "context" 9 | "strings" 10 | "errors" 11 | "net/url" 12 | "net/http" 13 | "strconv" 14 | "encoding/json" 15 | "database/sql" 16 | "backend/types" 17 | "backend/validator" 18 | "github.com/julienschmidt/httprouter" 19 | ) 20 | 21 | //TODO: Add to the Readme 22 | // This will just hold useful functions that server a specific purpose for app handling 23 | 24 | type envelope map[string]interface{} 25 | 26 | func connectDB(cfg types.Config) (*sql.DB, error) { 27 | db, err := sql.Open("postgres", cfg.Db.Dsn) 28 | if err != nil { 29 | log.Fatal("unable to connect to the database") 30 | return nil, err 31 | } 32 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 33 | defer cancel() 34 | 35 | err = db.PingContext(ctx) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return db, nil 40 | } 41 | 42 | 43 | func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error { 44 | js, err := json.MarshalIndent(data, "", "\t") 45 | if err != nil { 46 | return err 47 | } 48 | 49 | js = append(js, '\n') 50 | 51 | for k, v := range headers { 52 | w.Header()[k] = v 53 | } 54 | 55 | w.Header().Set("Content-Type", "application/json") 56 | w.WriteHeader(status) 57 | w.Write(js) 58 | return nil 59 | } 60 | 61 | func (app *application) readIDParam(r *http.Request) (int64, error) { 62 | params := httprouter.ParamsFromContext(r.Context()) 63 | 64 | id, err := strconv.ParseInt(params.ByName("id"),10,64) 65 | if err != nil { 66 | return 0, errors.New("invalid id parameter") 67 | } 68 | return id, nil 69 | } 70 | 71 | func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { 72 | 73 | // Adds a maximum byte size to the load request 74 | maxBytes := 1_048_576 75 | r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) 76 | 77 | // Decoder for our json payload 78 | dec := json.NewDecoder(r.Body) 79 | dec.DisallowUnknownFields() 80 | err := dec.Decode(dst) 81 | 82 | if err != nil { 83 | var syntaxError *json.SyntaxError 84 | var unmarshallTypeError *json.UnmarshalTypeError 85 | var invalidMarshallError *json.InvalidUnmarshalError 86 | 87 | switch { 88 | case errors.As(err, &syntaxError): 89 | return fmt.Errorf("body contains badly formed JSON characters %d", syntaxError.Offset) 90 | 91 | case errors.Is(err, io.ErrUnexpectedEOF): 92 | return errors.New("Badly formatted JSON in body request") 93 | 94 | case errors.As(err, &unmarshallTypeError): 95 | if unmarshallTypeError.Field != "" { 96 | return fmt.Errorf("body contains incorrect JSON type for field %d", unmarshallTypeError.Field) 97 | } 98 | return fmt.Errorf("Body contains incorrect JSON") 99 | 100 | // if there is something in our body thats empty 101 | case errors.Is(err, io.EOF): 102 | return errors.New("body must not be empty") 103 | 104 | case strings.HasPrefix(err.Error(), "json: unknown field"): 105 | // this handles when the body has an incorrect key 106 | fieldName := strings.TrimPrefix(err.Error(), "json: unknown field") 107 | return fmt.Errorf("Body contains unknown key %s", fieldName) 108 | 109 | case err.Error() == "http: request body too large": 110 | return fmt.Errorf("body must not be larger than max size") 111 | 112 | case errors.As(err, &invalidMarshallError): 113 | panic(err) 114 | 115 | default: 116 | return err 117 | } 118 | } 119 | 120 | err = dec.Decode(&struct{}{}) 121 | if err != io.EOF { 122 | return errors.New("body must contan only valid JSON values") 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func (app *application) readString(qs url.Values, key string, defaultValue string) string { 129 | s := qs.Get(key) 130 | 131 | if s == "" { 132 | return defaultValue 133 | } 134 | 135 | return s 136 | } 137 | 138 | func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string{ 139 | csv := qs.Get(key) 140 | 141 | if csv == "" { 142 | return defaultValue 143 | } 144 | 145 | return strings.Split(csv, ",") 146 | } 147 | 148 | func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validator.Validator) int { 149 | s := qs.Get(key) 150 | 151 | if s == "" { 152 | return defaultValue 153 | } 154 | 155 | i, err := strconv.Atoi(s) 156 | if err != nil { 157 | v.AddError(key, "must be an integer value") 158 | } 159 | 160 | return i 161 | } 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | db: 5 | container_name: "YOUR_CONTAINER_NAME" 6 | image: postgres:12.4-alpine 7 | volumes: 8 | - "./database/postgres-data:/var/lib/postgresql/data:rw" 9 | ports: 10 | - "5432:5432" 11 | environment: 12 | POSTGRES_DB: "YOUR_DB_NAME" 13 | POSTGRES_USER: "postgres" 14 | POSTGRES_PASSWORD: "postgres" 15 | restart: unless-stopped 16 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module backend 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/go-mail/mail/v2 v2.3.0 // indirect 7 | github.com/julienschmidt/httprouter v1.3.0 // indirect 8 | github.com/lib/pq v1.10.3 // indirect 9 | github.com/pascaldekloe/jwt v1.10.0 // indirect 10 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect 11 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 12 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw= 2 | github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go= 3 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 4 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 5 | github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= 6 | github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 7 | github.com/pascaldekloe/jwt v1.10.0 h1:ktcIUV4TPvh404R5dIBEnPCsSwj0sqi3/0+XafE5gJs= 8 | github.com/pascaldekloe/jwt v1.10.0/go.mod h1:TKhllgThT7TOP5rGr2zMLKEDZRAgJfBbtKyVeRsNB9A= 9 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= 10 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 11 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= 12 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 13 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 14 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 15 | -------------------------------------------------------------------------------- /backend/jsonlog/jsonlog.go: -------------------------------------------------------------------------------- 1 | package jsonlog 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "runtime/debug" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type Level int8 13 | 14 | // Declare our log levels 15 | const ( 16 | LevelInfo Level = iota 17 | LevelError 18 | LevelFatal 19 | LevelOff 20 | ) 21 | 22 | func (l Level) String() string { 23 | switch l { 24 | case LevelInfo: 25 | return "INFO" 26 | case LevelError: 27 | return "ERROR" 28 | case LevelFatal: 29 | return "FATAL" 30 | default: 31 | return "" 32 | } 33 | } 34 | 35 | type Logger struct { 36 | out io.Writer 37 | minLevel Level 38 | mu sync.Mutex 39 | } 40 | 41 | func New(out io.Writer, minLevel Level) *Logger { 42 | return &Logger{ 43 | out: out, 44 | minLevel: minLevel, 45 | } 46 | } 47 | 48 | func (l *Logger) print(level Level, message string, properties map[string]string) (int, error) { 49 | if level < l.minLevel { 50 | return 0, nil 51 | } 52 | 53 | aux := struct { 54 | Level string `json:"level"` 55 | Time string `json:"time"` 56 | Message string `json:"message"` 57 | Properties map[string]string `json:"properties,omitempty"` 58 | Trace string `json:"trace,omitempty"` 59 | }{ 60 | Level: level.String(), 61 | Time: time.Now().UTC().Format(time.RFC3339), 62 | Message: message, 63 | Properties: properties, 64 | } 65 | 66 | // include a strack trace for error and fatal levels 67 | if level >= LevelError { 68 | aux.Trace = string(debug.Stack()) 69 | } 70 | 71 | var line []byte 72 | line, err := json.Marshal(aux) 73 | if err != nil { 74 | line = []byte(LevelError.String() + ": unable to marshal log message:" + err.Error()) 75 | } 76 | 77 | l.mu.Lock() 78 | defer l.mu.Unlock() 79 | 80 | return l.out.Write(append(line, '\n')) 81 | } 82 | 83 | func (l *Logger) Write(message []byte) (n int, err error) { 84 | return l.print(LevelError, string(message), nil) 85 | } 86 | 87 | func (l *Logger) PrintInfo(message string, properties map[string]string) { 88 | l.print(LevelInfo, message, nil) 89 | } 90 | 91 | func (l *Logger) PrintError(err error, properties map[string]string) { 92 | l.print(LevelError, err.Error(), properties) 93 | } 94 | 95 | func (l *Logger) PrintFatal(err error, properties map[string]string) { 96 | l.print(LevelFatal, err.Error(), properties) 97 | os.Exit(1) 98 | } 99 | 100 | 101 | -------------------------------------------------------------------------------- /backend/mailer/mailer.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "embed" 5 | "bytes" 6 | "github.com/go-mail/mail/v2" 7 | "html/template" 8 | "time" 9 | ) 10 | 11 | //go:embed "templates" 12 | var templateFS embed.FS 13 | 14 | // mailer struct is going to contain our connection to the SMTP 15 | type Mailer struct { 16 | dialer *mail.Dialer 17 | sender string 18 | } 19 | 20 | func New(host string, port int, username, password, sender string) Mailer { 21 | // this is where we connect to our SMTP server (third party provider) 22 | dialer := mail.NewDialer(host, port, username, password) 23 | dialer.Timeout = 5 * time.Second 24 | 25 | return Mailer{ 26 | dialer: dialer, 27 | sender: sender, 28 | } 29 | } 30 | 31 | func (m Mailer)Send(recipient, templateFile string, data interface{}) error { 32 | // parse the templateFS 33 | tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | subject := new(bytes.Buffer) 39 | err = tmpl.ExecuteTemplate(subject, "subject", data) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | plainBody := new(bytes.Buffer) 45 | err = tmpl.ExecuteTemplate(plainBody, "plainBody", data) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | htmlBody := new(bytes.Buffer) 51 | err = tmpl.ExecuteTemplate(htmlBody, "htmlBody", data) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | msg := mail.NewMessage() 57 | msg.SetHeader("To", recipient) 58 | msg.SetHeader("From", m.sender) 59 | msg.SetHeader("Subject", subject.String()) 60 | msg.SetBody("text/plain", plainBody.String()) 61 | msg.AddAlternative("text/html", htmlBody.String()) 62 | 63 | err = m.dialer.DialAndSend(msg) 64 | 65 | if err != nil { 66 | return err 67 | } 68 | 69 | return nil 70 | } 71 | 72 | -------------------------------------------------------------------------------- /backend/mailer/templates/user_welcome.tmpl: -------------------------------------------------------------------------------- 1 | {{define "subject"}}Welcome to Go-React-Boiler!{{end}} 2 | 3 | {{define "plainBody"}} 4 | Hi, 5 | Thanks for signing up for a Go-react-boiler account. We're excited to have you on board! 6 | 7 | For future reference, your user ID number is {{.userID}}. 8 | 9 | Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON 10 | body to activate your account: 11 | 12 | {"token": "{{.activationToken}}"} 13 | Please note that this is a one-time use token and it will expire in 3 days. 14 | Thanks, 15 | The MelkeyDev Team 16 | {{end}} 17 | 18 | {{define "htmlBody"}} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |

Hi,

27 |

Thanks for signing up for a Greenlight account. We're excited to have you on board!

28 |

For future reference, your user ID number is {{.userID}}.

29 |

Please send a request to the PUT /v1/users/activated endpoint with the 30 | following JSON body to activate your account:

31 |

32 |   {"token": "{{.activationToken}}"}
33 |   
34 |

Please note that this is a one-time use token and it will expire in 3 days.

35 |

Thanks,

36 |

The Greenlight Team

37 | 38 | 39 | {{end}} 40 | -------------------------------------------------------------------------------- /backend/migrations/000002_create_users_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | -------------------------------------------------------------------------------- /backend/migrations/000002_create_users_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | id bigserial PRIMARY KEY, 3 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), 4 | name text NOT NULL, 5 | email text UNIQUE NOT NULL, 6 | password_Hash bytea NOT NULL, 7 | activated bool NOT NULL, 8 | version integer NOT NULL DEFAULT 1 9 | ); 10 | 11 | -------------------------------------------------------------------------------- /backend/migrations/000003_create_data_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS dataload; 2 | -------------------------------------------------------------------------------- /backend/migrations/000003_create_data_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS dataload ( 2 | ID BIGSERIAL PRIMARY KEY NOT NULL, 3 | DBDATAONE TEXT NOT NULL, 4 | DBDATATWO TEXT NOT NULL, 5 | DBDATATHREE TEXT NOT NULL, 6 | VERSION INTEGER NOT NULL DEFAULT 1 7 | ); 8 | 9 | -------------------------------------------------------------------------------- /backend/migrations/000004_create_tokens_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS tokens; 2 | -------------------------------------------------------------------------------- /backend/migrations/000004_create_tokens_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS tokens ( 2 | hash bytea PRIMARY KEY, 3 | user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, 4 | expiry timestamp(0) with time zone NOT NULL, 5 | scope text NOT NULL 6 | ) 7 | -------------------------------------------------------------------------------- /backend/migrations/000005_add_permissions.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS permissions; 2 | DROP TABLE IF EXISTS users_permissions; 3 | -------------------------------------------------------------------------------- /backend/migrations/000005_add_permissions.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS permissions ( 2 | id bigserial PRIMARY KEY, 3 | code text NOT NULL 4 | ); 5 | 6 | CREATE TABLE IF NOT EXISTS users_permissions ( 7 | user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, 8 | permissions_id bigint NOT NULL REFERENCES permissions ON DELETE CASCADE, 9 | PRIMARY KEY (user_id, permissions_id) 10 | ); 11 | 12 | INSERT INTO permissions (code) 13 | VALUES 14 | ('dataload:read'), 15 | ('dataload:write'); 16 | -------------------------------------------------------------------------------- /backend/models/filters.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // This file is responsible for all of our smart filtering for searches 4 | 5 | import ( 6 | "math" 7 | "strings" 8 | "backend/validator" 9 | ) 10 | 11 | type Filters struct { 12 | Page int 13 | PageSize int 14 | Sort string 15 | SortSafeList []string 16 | } 17 | 18 | type Metadata struct { 19 | CurrentPage int `json:"curent_page,omitempty"` 20 | PageSize int `json:"page_size,omitempty"` 21 | FirstPage int `json:"first_page,omitempty"` 22 | LastPage int `json:"last_page,omitempty"` 23 | TotalRecords int `json:"total_records,omitempty"` 24 | } 25 | 26 | func (f Filters) sortColumn() string { 27 | for _, safeValue := range f.SortSafeList { 28 | if f.Sort == safeValue { 29 | return strings.TrimPrefix(f.Sort, "-") 30 | } 31 | } 32 | panic("unfase sort parameter" + f.Sort) 33 | } 34 | 35 | func (f Filters) sortDirection() string { 36 | if strings.HasPrefix(f.Sort, "-") { 37 | return "DESC" 38 | } 39 | return "ASC" 40 | } 41 | 42 | func ValidateFilters(v *validator.Validator, f Filters) { 43 | // Filters for filters 44 | v.Check(f.Page > 0, "page", "page must be greater than 0") 45 | v.Check(f.Page < 100, "page", "page must be less than 100") 46 | v.Check(f.PageSize > 0, "page_size", "must be greater than 0") 47 | v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100") 48 | v.Check(validator.In(f.Sort, f.SortSafeList...), "sort", "invalid sort value") 49 | } 50 | 51 | func (f Filters) limit() int { 52 | return f.PageSize 53 | } 54 | 55 | func (f Filters) offset() int { 56 | return (f.Page - 1) * f.PageSize 57 | } 58 | 59 | func createMetadata(totalRecords, page, pagesize int) Metadata { 60 | if totalRecords == 0 { 61 | return Metadata{} 62 | } 63 | 64 | return Metadata{ 65 | CurrentPage: page, 66 | PageSize: pagesize, 67 | FirstPage: 1, 68 | LastPage: int(math.Ceil(float64(totalRecords)/ float64(pagesize))), 69 | TotalRecords: totalRecords, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /backend/models/handlers.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "time" 9 | "crypto/sha256" 10 | ) 11 | 12 | func (m *DBModel) Insert(user *User) error { 13 | query := `INSERT INTO users (name, email, password_hash, activated) VALUES ($1, $2, $3, $4) RETURNING id, created_at, version` 14 | 15 | args := []interface{}{user.Name, user.Email, user.Password.hash, user.Activated} 16 | 17 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 18 | defer cancel() 19 | 20 | err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version) 21 | if err != nil { 22 | switch { 23 | case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: 24 | return ErrDuplicateEmail 25 | default: 26 | return err 27 | } 28 | } 29 | return nil 30 | } 31 | 32 | 33 | func (m *DBModel) GetData(id int64) (*DBLoad, error) { 34 | if id < 1 { 35 | return nil, ErrRecordNotFound 36 | } 37 | 38 | query := `SELECT dbdataone, dbdatatwo, dbdatathree, version from dataload where id = $1` 39 | 40 | var load DBLoad 41 | 42 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 43 | defer cancel() 44 | 45 | err := m.DB.QueryRowContext(ctx, query, id).Scan( 46 | &load.DBDataOne, 47 | &load.DBDataTwo, 48 | &load.DBDataThree, 49 | &load.Version, 50 | ) 51 | 52 | if err != nil { 53 | switch { 54 | case errors.Is(err, sql.ErrNoRows): 55 | return nil, ErrRecordNotFound 56 | default: 57 | return nil, err 58 | } 59 | } 60 | 61 | return &load, nil 62 | } 63 | 64 | func (m *DBModel) InsertDBLoad(load *DBLoad) error { 65 | query := `insert into dataload(dbdataone, dbdatatwo, dbdatathree) VALUES($1, $2, $3) returning version` 66 | 67 | args := []interface{}{load.DBDataOne, load.DBDataTwo, load.DBDataThree} 68 | 69 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 70 | defer cancel() 71 | 72 | return m.DB.QueryRowContext(ctx, query, args...).Scan(&load.DBDataOne) 73 | } 74 | 75 | func (m *DBModel) Delete(id int64) error { 76 | if id < 1 { 77 | return ErrRecordNotFound 78 | } 79 | 80 | query := `DELETE FROM dataload where id = $1` 81 | 82 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 83 | defer cancel() 84 | 85 | results, err := m.DB.ExecContext(ctx, query, id) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | rowsAffected, err := results.RowsAffected() 91 | if err != nil { 92 | return err 93 | } 94 | 95 | if rowsAffected == 0 { 96 | return ErrRecordNotFound 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func (m *DBModel) UpdateUser(user *User) error { 103 | query := `UPDATE users SET name = $1, email = $2, password_hash = $3, activated = $4, version = version + 1 WHERE id = $5 AND version = $6 RETURNING version` 104 | 105 | args := []interface{}{ 106 | user.Name, 107 | user.Email, 108 | user.Password.hash, 109 | user.Activated, 110 | user.ID, 111 | user.Version, 112 | } 113 | 114 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 115 | defer cancel() 116 | 117 | err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.Version) 118 | if err != nil { 119 | switch { 120 | case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: 121 | return ErrDuplicateEmail 122 | case errors.Is(err, sql.ErrNoRows): 123 | return ErrEditConflict 124 | default: 125 | return err 126 | } 127 | } 128 | return nil 129 | } 130 | 131 | func (m *DBModel) GetUserByEmail(email string) (*User, error) { 132 | query := `SELECT id, created_at, name, email, password_hash, activated, version FROM users WHERE email = $1` 133 | 134 | var user User 135 | 136 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 137 | defer cancel() 138 | 139 | err := m.DB.QueryRowContext(ctx, query, email).Scan( 140 | &user.ID, 141 | &user.CreatedAt, 142 | &user.Name, 143 | &user.Email, 144 | &user.Password.hash, 145 | &user.Activated, 146 | &user.Version, 147 | ) 148 | 149 | if err != nil { 150 | switch { 151 | case errors.Is(err, sql.ErrNoRows): 152 | return nil, ErrRecordNotFound 153 | default: 154 | return nil, err 155 | } 156 | } 157 | 158 | return &user, nil 159 | } 160 | 161 | // This updates the database info - not the user 162 | func (m *DBModel) Update(load *DBLoad) error { 163 | // This will handle DB update race condition 164 | query := `UPDATE dbload SET dbdataone = $1, dbdatatwo = $2, dbdatathree = $3, version = version + 1 where id = $4 and VERSION = $5 RETURNING version` 165 | 166 | args := []interface{}{ 167 | load.DBDataOne, 168 | load.DBDataTwo, 169 | load.DBDataThree, 170 | load.ID, 171 | load.Version, 172 | } 173 | 174 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 175 | defer cancel() 176 | 177 | err := m.DB.QueryRowContext(ctx, query, args...).Scan(&load.Version) 178 | if err != nil { 179 | switch { 180 | case errors.Is(err, sql.ErrNoRows): 181 | return ErrEditConflict 182 | default: 183 | return err 184 | } 185 | } 186 | return nil 187 | } 188 | 189 | func (m *DBModel) GetAll(DBDataOne string, filters Filters) ([]*DBLoad, Metadata, error) { 190 | query := fmt.Sprintf(`SELECT count(*) OVER(), dbdataone, dbdatatwo, dbdatathree, id, version FROM dbload WHERE(to_tsvector('simple', dbdataone) @@ plainto_tsquery('simple', $1) OR $1='') ORDER BY %s %s, id ASC LIMIT $2 OFFSET $3`, filters.sortColumn(), filters.sortDirection()) 191 | 192 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 193 | defer cancel() 194 | 195 | args := []interface{}{DBDataOne, filters.limit(), filters.offset()} 196 | rows, err := m.DB.QueryContext(ctx, query, args...) 197 | if err != nil { 198 | return nil, Metadata{}, err 199 | } 200 | 201 | defer rows.Close() 202 | 203 | totalRecords := 0 204 | DBdata := []*DBLoad{} 205 | 206 | for rows.Next() { 207 | var data DBLoad 208 | 209 | err := rows.Scan( 210 | &totalRecords, 211 | &data.DBDataOne, 212 | &data.DBDataTwo, 213 | &data.DBDataThree, 214 | &data.ID, 215 | &data.Version, 216 | ) 217 | 218 | if err != nil { 219 | return nil, Metadata{}, err 220 | } 221 | 222 | DBdata = append(DBdata, &data) 223 | } 224 | 225 | if err = rows.Err(); err != nil { 226 | return nil, Metadata{}, err 227 | } 228 | 229 | metadata := createMetadata(totalRecords, filters.Page, filters.PageSize) 230 | 231 | return DBdata, metadata, nil 232 | } 233 | 234 | func (m *DBModel) GetForToken(tokenScope, TokenPlaintext string) (*User, error) { 235 | tokenHash := sha256.Sum256([]byte(TokenPlaintext)) 236 | 237 | query := `SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.version FROM users INNER JOIN tokens ON users.id = tokens.user_id WHERE tokens.hash = $1 AND tokens.scope = $2 AND tokens.expiry > $3` 238 | 239 | args := []interface{}{tokenHash[:], tokenScope, time.Now()} 240 | 241 | var user User 242 | 243 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 244 | defer cancel() 245 | 246 | err := m.DB.QueryRowContext(ctx, query, args...).Scan( 247 | &user.ID, 248 | &user.CreatedAt, 249 | &user.Name, 250 | &user.Email, 251 | &user.Password.hash, 252 | &user.Activated, 253 | &user.Version, 254 | ) 255 | 256 | if err != nil { 257 | switch { 258 | case errors.Is(err, sql.ErrNoRows): 259 | return nil, ErrRecordNotFound 260 | default: 261 | return nil, err 262 | } 263 | } 264 | 265 | return &user, nil 266 | } 267 | -------------------------------------------------------------------------------- /backend/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "backend/validator" 5 | "database/sql" 6 | "errors" 7 | "time" 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | type Models struct { 12 | DB DBModel 13 | } 14 | 15 | type DBModel struct { 16 | DB *sql.DB 17 | } 18 | 19 | var ( 20 | ErrRecordNotFound = errors.New("record not found") 21 | ErrEditConflict = errors.New("edit conflict") 22 | ErrDuplicateEmail = errors.New("duplicate email") 23 | ) 24 | 25 | func NewModels(db *sql.DB) Models { 26 | return Models{ 27 | DB: DBModel{DB: db}, 28 | } 29 | } 30 | 31 | // A generic user structure 32 | type User struct { 33 | ID int64 `json:"id"` 34 | CreatedAt time.Time `json:"created_at"` 35 | Name string `json:"name"` 36 | Email string `json:"email"` 37 | Password password `json:"-"` 38 | Activated bool `json:"activated"` 39 | Version int `json:"-"` 40 | } 41 | 42 | type password struct { 43 | plaintext *string 44 | hash []byte 45 | } 46 | 47 | var AnonymousUser = &User{} 48 | 49 | func (u *User) IsAnonymous() bool { 50 | return u == AnonymousUser 51 | } 52 | 53 | func (p *password) Set(plaintextPassword string) error { 54 | hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | p.plaintext = &plaintextPassword 60 | p.hash = hash 61 | 62 | return nil 63 | } 64 | 65 | func (p *password) Matches(plaintextPassword string) (bool, error) { 66 | err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) 67 | if err != nil { 68 | switch { 69 | case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): 70 | return false, nil 71 | default: 72 | return false, err 73 | } 74 | } 75 | return true, nil 76 | } 77 | 78 | func ValidateEmail(v *validator.Validator, email string) { 79 | v.Check(email != "", "email", "must be provided") 80 | v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address") 81 | } 82 | 83 | func ValidatePasswordPlaintext(v *validator.Validator, password string) { 84 | v.Check(password != "", "password", "must be provided") 85 | v.Check(len(password) >= 8, "password", "password must be atleast 8 chars long") 86 | v.Check(len(password) <= 72, "password", "password must not be more than 72 chars long") 87 | } 88 | 89 | func ValidateUser(v *validator.Validator, user *User) { 90 | v.Check(user.Name != "", "name", "must be provided") 91 | v.Check(len(user.Name) <= 72, "name", "must not be longer than 72") 92 | 93 | ValidateEmail(v, user.Email) 94 | 95 | if user.Password.plaintext != nil { 96 | ValidatePasswordPlaintext(v, *user.Password.plaintext) 97 | } 98 | 99 | if user.Password.hash == nil { 100 | panic("missing password for hash use") 101 | } 102 | } 103 | 104 | // A generic payload structure got API calls 105 | type Payload struct { 106 | SampleOne string `json:"sample_one"` 107 | SampleTwo string `json:"sample_two"` 108 | SampleThree string `json:"sample_three"` 109 | } 110 | 111 | type DBLoad struct { 112 | DBDataOne string `json:db_data_one` 113 | DBDataTwo string `json:db_data_two` 114 | DBDataThree string `json:db_data_three` 115 | ID int64 `json:id` 116 | Version int32 `json:version` 117 | } 118 | 119 | func ValidateDBLoad(v *validator.Validator, dbload *DBLoad) { 120 | v.Check(dbload.DBDataOne != "", "dbdataone", "data for field one must be provided") 121 | v.Check(len(dbload.DBDataOne) <= 500, "dbdataone", "data must be less than 500 chars") 122 | v.Check(dbload.DBDataTwo != "", "dbdatatwo", "data for field two must be provided") 123 | v.Check(len(dbload.DBDataTwo) <= 500, "dbdatatwo", "data must be less than 500 chars") 124 | v.Check(dbload.DBDataThree != "", "dbdatathree", "data for field three must be provided") 125 | v.Check(len(dbload.DBDataThree) <= 500, "dbdatathree", "data must be less than 500 chars") 126 | } 127 | -------------------------------------------------------------------------------- /backend/models/permissions.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Permissions []string 9 | 10 | func (p Permissions) Include(code string) bool { 11 | for i := range p { 12 | if code == p[i] { 13 | return true 14 | } 15 | } 16 | return false 17 | } 18 | 19 | func (m *DBModel) GetAllForUser(userID int64) (Permissions, error) { 20 | query := ` 21 | SELECT permissions.code 22 | FROM permissions 23 | INNER JOIN users_permissions ON users_permissions.permissions_id = permissions_id 24 | INNER JOIN users ON users_permissions.user_id = users.id 25 | WHERE users.id = $1 26 | ` 27 | 28 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 29 | defer cancel() 30 | 31 | rows, err := m.DB.QueryContext(ctx, query, userID) 32 | if err != nil { 33 | return nil, err 34 | } 35 | defer rows.Close() 36 | 37 | var permissions Permissions 38 | 39 | // every permission needs to be appended to permissions 40 | for rows.Next() { 41 | var permission string 42 | 43 | err := rows.Scan(&permission) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | permissions = append(permissions, permission) 49 | } 50 | 51 | if err = rows.Err(); err != nil { 52 | return nil, err 53 | } 54 | 55 | return permissions, nil 56 | } 57 | -------------------------------------------------------------------------------- /backend/models/tokens.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "encoding/base32" 8 | "time" 9 | //"database/sql" 10 | "backend/validator" 11 | ) 12 | 13 | const ( 14 | ScopeActivation = "activation" 15 | ScopeAuthentication = "authentication" 16 | ) 17 | 18 | type Token struct { 19 | Plaintext string `json:"token"` 20 | Hash []byte `json:"-"` 21 | UserID int64 `json:"-"` 22 | Expiry time.Time `json:"expiry"` 23 | Scope string `json:"-"` 24 | } 25 | 26 | // Token validation check 27 | func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) { 28 | v.Check(tokenPlaintext != "", "token", "must be provided") 29 | v.Check(len(tokenPlaintext) == 26, "token", "must be 26 characters long") 30 | } 31 | 32 | func (m *DBModel) NewToken(userID int64, ttl time.Duration, scope string) (*Token, error) { 33 | token, err := generateToken(userID, ttl, scope) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | err = m.InsertToken(token) 39 | return token, err 40 | } 41 | 42 | func (m *DBModel) InsertToken(token *Token) error { 43 | query := `INSERT INTO tokens (hash, user_id, expiry, scope) VALUES ($1, $2, $3, $4)` 44 | 45 | args := []interface{}{token.Hash, token.UserID, token.Expiry, token.Scope} 46 | 47 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 48 | defer cancel() 49 | 50 | _, err := m.DB.ExecContext(ctx, query, args...) 51 | return err 52 | } 53 | 54 | func (m *DBModel) DeleteAlForUser(scope string, userID int64) error { 55 | query := `DELETE FROM tokens WHERE scope = $1 AND user_id = $2` 56 | 57 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 58 | defer cancel() 59 | 60 | _, err := m.DB.ExecContext(ctx, query, scope, userID) 61 | return err 62 | } 63 | 64 | func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) { 65 | token := &Token{ 66 | UserID: userID, 67 | Expiry: time.Now().Add(ttl), 68 | Scope: scope, 69 | } 70 | 71 | randomBytes := make([]byte, 16) 72 | 73 | _, err := rand.Read(randomBytes) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | // Encode the byte slice to a 32 bit encoded string 79 | token.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) 80 | 81 | // Generate a 256 hash of the plaintext string 82 | hash := sha256.Sum256([]byte(token.Plaintext)) 83 | token.Hash = hash[:] 84 | 85 | return token, nil 86 | } 87 | -------------------------------------------------------------------------------- /backend/models/users.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | -------------------------------------------------------------------------------- /backend/types/structures.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Config struct { 4 | Port int 5 | Env string 6 | Db struct { 7 | Dsn string 8 | } 9 | Jwt struct { 10 | Secret string 11 | } 12 | Limiter struct { 13 | Rps float64 14 | Burst int 15 | Enabled bool 16 | } 17 | SMTP struct { 18 | Host string 19 | Port int 20 | Username string 21 | Password string 22 | Sender string 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/validator/validator.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | // Declare a regex for checking the format of email addresses 8 | var ( 9 | EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 10 | ) 11 | 12 | type Validator struct { 13 | Errors map[string]string 14 | } 15 | 16 | // Helper which creates a new Validator instance 17 | func New() *Validator { 18 | return &Validator{Errors: make(map[string]string)} 19 | } 20 | 21 | // Valid returns true if the errors map doesn't contain any entries. 22 | func (v *Validator) Valid() bool { 23 | return len(v.Errors) == 0 24 | } 25 | 26 | // AddError adds an error message to the map (so long as no entry already exists for the given key). 27 | func (v *Validator) AddError(key, message string) { 28 | if _, exists := v.Errors[key]; !exists { 29 | v.Errors[key] = message 30 | } 31 | } 32 | 33 | // Check adds an error message to the map only if a validation check is not 'ok'. 34 | func (v *Validator) Check(ok bool, key, message string) { 35 | if !ok { 36 | v.AddError(key, message) 37 | } 38 | } 39 | 40 | // In returns true if a specific value is in a list of strings. 41 | func In(value string, list ...string) bool { 42 | for i := range list { 43 | if value == list[i] { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | 50 | // Matches returns true if a string value matches a specific regexp pattern. 51 | func Matches(value string, rx *regexp.Regexp) bool { 52 | return rx.MatchString(value) 53 | } 54 | 55 | 56 | // Unique returns true if all string values in a slice are unique. 57 | func Unique(values []string) bool { 58 | uniqueValues := make(map[string]bool) 59 | for _, value := range values { 60 | uniqueValues[value] = true 61 | } 62 | return len(values) == len(uniqueValues) 63 | } 64 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@types/react-router-dom": "^5.3.1", 11 | "axios": "^0.23.0", 12 | "react": "^17.0.0", 13 | "react-dom": "^17.0.0", 14 | "react-hook-form": "^7.17.4", 15 | "react-router-dom": "^5.3.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^17.0.0", 19 | "@types/react-dom": "^17.0.0", 20 | "@vitejs/plugin-react": "^1.0.0", 21 | "autoprefixer": "^10.3.7", 22 | "postcss": "^8.3.9", 23 | "tailwindcss": "^2.2.16", 24 | "typescript": "^4.3.2", 25 | "vite": "^2.6.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Register } from "./components/Register"; 3 | import { Login } from "./components/Login"; 4 | 5 | import { 6 | BrowserRouter as Router, 7 | Switch, 8 | Route, 9 | Link, 10 | useParams, 11 | } from "react-router-dom"; 12 | 13 | function App() { 14 | const [jwt, setJwt] = useState(""); 15 | 16 | const handleJWTChange = (jwtToken: string) => { 17 | setJwt(jwtToken); 18 | }; 19 | 20 | const logout = () => { 21 | setJwt(""); 22 | window.localStorage.removeItem("jwt"); 23 | }; 24 | 25 | useEffect(() => { 26 | let t = window.localStorage.getItem("jwt"); 27 | 28 | if (t) { 29 | if (jwt === "") { 30 | setJwt(JSON.parse(t)); 31 | } 32 | } 33 | }, []); 34 | 35 | let loginLink; 36 | 37 | if (jwt === "") { 38 | loginLink = Login; 39 | } else { 40 | loginLink = ( 41 | 42 | Logout 43 | 44 | ); 45 | } 46 | 47 | return ( 48 | 49 |
50 | 83 |
84 | 85 | 86 | } 89 | > 90 | 91 |
92 | ); 93 | } 94 | 95 | export default App; 96 | -------------------------------------------------------------------------------- /frontend/src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import axios from "axios"; 4 | 5 | export interface iLogin { 6 | username: string; 7 | password: string; 8 | } 9 | 10 | export const Login = ({ jwtProps }: { jwtProps: any }) => { 11 | const { 12 | register, 13 | handleSubmit, 14 | formState: { errors }, 15 | reset, 16 | getValues, 17 | } = useForm(); 18 | const [loginState, setLoginState] = useState(); 19 | 20 | const onSubmit = async (data: any) => { 21 | const { username, password } = data; 22 | 23 | const body = JSON.stringify({ 24 | username, 25 | password, 26 | }); 27 | 28 | const response = await axios.post("http://localhost:4000/v1/login", body); 29 | //TODO: Handle if nothing returns 30 | console.log(response.data.message); 31 | jwtProps(response.data.message); 32 | window.localStorage.setItem("jwt", JSON.stringify(response.data.response)); 33 | }; 34 | 35 | const handleChange = useCallback((e) => { 36 | const { id, value } = e.target; 37 | 38 | setLoginState((state: any) => ({ 39 | ...state, 40 | [id]: value, 41 | })); 42 | }, []); 43 | 44 | return ( 45 |
46 |
47 |
48 |
49 |
50 | 53 | 54 | 63 |
64 | 65 |
66 | 69 | 70 | 83 |
84 | 85 | 88 |
89 |
90 |
91 |
92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /frontend/src/components/Register.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import axios from "axios"; 4 | 5 | export interface iRegister { 6 | username: string; 7 | password: string; 8 | } 9 | 10 | export const Register = () => { 11 | const { 12 | register, 13 | handleSubmit, 14 | formState: { errors }, 15 | reset, 16 | getValues, 17 | } = useForm(); 18 | const [registerState, setRegisterState] = useState(); 19 | 20 | const onSubmit = async (data: any) => { 21 | console.log(data); 22 | 23 | const { username, password } = data; 24 | 25 | const body = JSON.stringify({ 26 | username, 27 | password, 28 | }); 29 | 30 | const response = await axios.post( 31 | "http://localhost:4000/v1/register", 32 | body 33 | ); 34 | 35 | console.log(response.data); 36 | }; 37 | 38 | const handleChange = useCallback((e) => { 39 | const { id, value } = e.target; 40 | 41 | setRegisterState((state: any) => ({ 42 | ...state, 43 | [id]: value, 44 | })); 45 | }, []); 46 | 47 | return ( 48 |
49 |
50 |
51 |
52 |
53 | 56 | 57 | 66 |
67 | 68 |
69 | 72 | 73 | 86 |
87 | 90 | 91 | 98 | value === getValues("password") || 99 | "the passwords do not match", 100 | })} 101 | onChange={handleChange} 102 | /> 103 | {errors.confirm_password && ( 104 |
{errors.confirm_password?.message}
105 | )} 106 | 107 | 110 |
111 |
112 |
113 |
114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /frontend/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | /* ./src/index.css */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") 11 | ); 12 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | --------------------------------------------------------------------------------