commit b02525e28aab398f2d4ec27c654cc701d7c75b27 Author: tommy Date: Sat Sep 6 21:35:45 2025 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50182ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__debug_bin +__debug_bin.exe +.vscode +**/.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..1c2fda5 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/addrss.io.iml b/.idea/addrss.io.iml new file mode 100644 index 0000000..2986543 --- /dev/null +++ b/.idea/addrss.io.iml @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..1d21d4a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..e93e53b --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..88d9dba --- /dev/null +++ b/config/config.json @@ -0,0 +1,9 @@ +{ + "jwtAudience": "addrss.io", + "jwtIssuer": "tomraterman.com", + "jwtLifetime": 3600, + "jwtKey": "zomgjwtkey", + "dsn": "root:zomgpassword@tcp(ratermania.net:3306)/addrss", + "port": 1337, + "cors.origins": "*" +} \ No newline at end of file diff --git a/config/config.prod.json b/config/config.prod.json new file mode 100644 index 0000000..5d3efe3 --- /dev/null +++ b/config/config.prod.json @@ -0,0 +1,4 @@ +{ + "jwtKey": "{{addrss_JWT_KEY}}", + "dsn": "addrss:{{addrss_MYSQL_PASSWORD}}@tcp(172.17.0.1:3306)/addrss" +} \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..ee651df --- /dev/null +++ b/deploy.sh @@ -0,0 +1,9 @@ +#!/bin/bash +git reset --hard +git checkout master +git pull + +docker stop addrss +docker rm addrss +docker build --tag addrss . +docker run --name addrss -d -p 1337:1337 --restart always --env-file /home/tommy/addrss.env addrss \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..c15dbfa --- /dev/null +++ b/dockerfile @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.-alpine +WORKDIR /app +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download +COPY . ./ +WORKDIR cmd/addrss +RUN go build +EXPOSE 1337 +CMD [ "./addrss" ] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a9a540d --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module addrss + +go 1.23.0 + +require ( + github.com/go-sql-driver/mysql v1.9.3 + github.com/google/uuid v1.6.0 + github.com/openvenues/gopostal v0.0.0-20240426055609-4fe3a773f519 + golang.org/x/crypto v0.41.0 +) + +require filippo.io/edwards25519 v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd09c3e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/openvenues/gopostal v0.0.0-20240426055609-4fe3a773f519 h1:xZ0ZhxCnrs2zaBBvGIHQqzoeXjzctJP61r+aX3QjXhQ= +github.com/openvenues/gopostal v0.0.0-20240426055609-4fe3a773f519/go.mod h1:Ycrd7XnwQdumHzpB/6WEa85B4WNdbLC6Wz4FAQNkaV0= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= diff --git a/internal/controllers/api.go b/internal/controllers/api.go new file mode 100644 index 0000000..6a15b58 --- /dev/null +++ b/internal/controllers/api.go @@ -0,0 +1,47 @@ +package controllers + +import ( + "addrss/pkg/router" + "fmt" + + expand "github.com/openvenues/gopostal/expand" + parser "github.com/openvenues/gopostal/parser" +) + +type Api struct{} + +type ParseRequest struct { + Address string `json:"address"` +} + +func (a Api) AddRoutes() { + router.AddPost("/v1/expand", expandAddress).Anonymous() + router.AddPost("/v1/parse", parseAddress).Anonymous() +} + +func expandAddress(ctx *router.Context) { + expansions := expand.ExpandAddress("1080 Brayden Ct. Hebron KY 41048") + for i := 0; i < len(expansions); i++ { + fmt.Println(expansions[i]) + } + + ctx.Response.NoContent() +} + +func parseAddress(ctx *router.Context) { + pr := ParseRequest{} + if err := ctx.Request.Bind(&pr); err != nil { + ctx.Response.BadRequest(err) + } + + options := parser.ParserOptions{} + + pa := parser.ParseAddressOptions(pr.Address, options) + addr := map[string]any{} + + for i := 0; i < len(pa); i++ { + addr[pa[i].Label] = pa[i].Value + } + + ctx.Response.OK(addr) +} diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go new file mode 100644 index 0000000..32f12a9 --- /dev/null +++ b/internal/controllers/auth.go @@ -0,0 +1,100 @@ +package controllers + +import ( + "addrss/pkg/auth" + "addrss/pkg/router" + "fmt" + "strings" +) + +type Auth struct{} + +func (a Auth) AddRoutes() { + router.AddPost(`/auth/guest`, guest).Anonymous() + router.AddPost(`/auth/login`, login).Anonymous() + router.AddPost(`/auth/logout`, logout).Anonymous() + router.AddPost(`/auth/refresh`, refresh).Anonymous() + router.AddPut(`/auth/password`, changePassword) +} +func guest(ctx *router.Context) { + tokens, err := auth.AuthenticateGuest() + if err != nil { + ctx.Response.Unauthorized(err) + return + } + + ctx.Response.OK(tokens) +} + +func login(ctx *router.Context) { + user := auth.UserLogin{} + if err := ctx.Request.Bind(user); err != nil { + ctx.Response.BadRequest(err) + } + + tokens, err := auth.AuthenticateUserLogin(user) + if err != nil { + switch err.(type) { + case *auth.ErrorUnauthorized: + ctx.Response.Unauthorized(err) + case *auth.ErrorForbidden: + ctx.Response.Forbidden(err) + } + + return + } + + ctx.Response.OK(tokens) +} + +func logout(ctx *router.Context) { + // Logout uses the refresh token + authSegments := strings.Split(ctx.Request.Header.Get("Authorization"), " ") + + if len(authSegments) != 2 || authSegments[0] != "Bearer" { + ctx.Response.BadRequest(fmt.Errorf("invalid token")) + } + + claims := auth.RefreshClaims{} + if err := auth.ValidateJwtToken(authSegments[1], &claims); err != nil { + ctx.Response.Unauthorized(fmt.Errorf("invalid token signature")) + } + + if err := auth.DestroySession(claims.Sub); err != nil { + ctx.Response.BadRequest(fmt.Errorf("failed to destroy session")) + } + + ctx.Response.NoContent() +} + +func refresh(ctx *router.Context) { + tokens := auth.Tokens{} + if err := ctx.Request.Bind(&tokens); err != nil { + ctx.Response.BadRequest(err) + } + + tokens, err := auth.AuthenticateUserRefresh(tokens.RefreshToken) + if err != nil { + ctx.Response.Unauthorized(err) + } + + ctx.Response.OK(tokens) +} + +func changePassword(ctx *router.Context) { + pc := auth.PasswordChange{} + if err := ctx.Request.Bind(&pc); err != nil { + ctx.Response.BadRequest(err) + } + + if pc.UserId != ctx.Claims.Sub { + err := fmt.Errorf("user id/sub claim mismatch") + ctx.Response.Forbidden(err) + } + + if err := auth.ChangePassword(pc); err != nil { + ctx.Response.BadRequest(err) + } + + ctx.Response.NoContent() +} diff --git a/internal/controllers/guestbook.go b/internal/controllers/guestbook.go new file mode 100644 index 0000000..6f8feb2 --- /dev/null +++ b/internal/controllers/guestbook.go @@ -0,0 +1,21 @@ +package controllers + +import ( + "addrss/pkg/router" +) + +type Guestbook struct{} + +type guestbookError struct { + Fields []string `json:"fields"` +} + +func (g Guestbook) AddRoutes() { + router.AddPost("/guestbook", signGuestbook).Anonymous() +} + +func signGuestbook(ctx *router.Context) { + //gb := ctx.Request.Model.(repo.Guestbook) + + ctx.Response.NoContent() +} diff --git a/internal/controllers/health.go b/internal/controllers/health.go new file mode 100644 index 0000000..49c26d1 --- /dev/null +++ b/internal/controllers/health.go @@ -0,0 +1,15 @@ +package controllers + +import ( + "addrss/pkg/router" +) + +type Health struct{} + +func (h Health) AddRoutes() { + router.AddGet("/health", healthCheck).Anonymous() +} + +func healthCheck(ctx *router.Context) { + ctx.Response.NoContent() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..405f43f --- /dev/null +++ b/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "addrss/internal/controllers" + "addrss/pkg/config" + "addrss/pkg/router" + "fmt" + "log" +) + +func main() { + if err := config.Load("config/config.json"); err != nil { + log.Fatal(err) + } + + port, err := config.GetInt64("port") + if err != nil { + log.Fatal(err) + } + + router.AddControllers( + controllers.Auth{}, + controllers.Health{}, + controllers.Api{}, + ) + + log.Printf("addrss.io starting on port %d", port) + if err := router.Serve(fmt.Sprintf(":%d", port)); err != nil { + log.Fatal(err) + } +} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 0000000..6e781bf --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,76 @@ +package auth + +import ( + "addrss/pkg/repo" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +type UserLogin struct { + EmailAddress string `json:"emailAddress"` + Password string `json:"password"` +} + +func AuthenticateGuest() (Tokens, error) { + gt, err := getGuestToken() + if err != nil { + return Tokens{}, err + } + return Tokens{AccessToken: gt}, nil +} + +func AuthenticateUserLogin(userLogin UserLogin) (Tokens, error) { + user, err := repo.GetUserByEmail(userLogin.EmailAddress) + if err != nil { + return Tokens{}, &ErrorUnauthorized{err} + } + + if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(userLogin.Password)); err != nil { + return Tokens{}, &ErrorUnauthorized{err} + } + + tokens, err := AcquireTokens(user) + if err != nil { + return Tokens{}, &ErrorForbidden{err} + } + + return tokens, nil +} + +func AuthenticateUserRefresh(refreshToken string) (Tokens, error) { + claims := RefreshClaims{} + if err := ValidateJwtToken(refreshToken, &claims); err != nil { + return Tokens{}, &ErrorUnauthorized{err} + } + + us, err := repo.GetUserSessionById(claims.Sub) + if err != nil { + return Tokens{}, &ErrorUnauthorized{err} + } + + if us.TokenId != claims.Jti { + _ = repo.DeleteUserSession(claims.Sub) + return Tokens{}, &ErrorUnauthorized{fmt.Errorf("token id mismatch")} + } + + user, err := repo.GetUserById(claims.Sub) + if err != nil { + return Tokens{}, &ErrorUnauthorized{err} + } + + tokens, err := AcquireTokens(user) + if err != nil { + return Tokens{}, &ErrorForbidden{err} + } + + return tokens, nil +} + +func DestroySession(userId int64) error { + if err := repo.DeleteUserSession(userId); err != nil { + return err + } + + return nil +} diff --git a/pkg/auth/errors.go b/pkg/auth/errors.go new file mode 100644 index 0000000..d1e3ecb --- /dev/null +++ b/pkg/auth/errors.go @@ -0,0 +1,17 @@ +package auth + +type authError struct { + InnerError error +} + +type ErrorUnauthorized authError + +func (eu *ErrorUnauthorized) Error() string { + return "Invalid username or password" +} + +type ErrorForbidden authError + +func (ef *ErrorForbidden) Error() string { + return "Access is denied" +} diff --git a/pkg/auth/password.go b/pkg/auth/password.go new file mode 100644 index 0000000..99ae66d --- /dev/null +++ b/pkg/auth/password.go @@ -0,0 +1,74 @@ +package auth + +import ( + "addrss/pkg/repo" + "fmt" + "math/rand" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type NewPassword struct { + Password string `json:"-"` + Hash string `json:"-"` +} + +type PasswordChange struct { + UserId int64 `json:"userId"` + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` + ConfirmPassword string `json:"confirmPassword"` +} + +func ChangePassword(pc PasswordChange) error { + u, err := repo.GetUserById(pc.UserId) + if err != nil { + return fmt.Errorf("could not get user for user id %d: %v", pc.UserId, err) + } + + if err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(pc.OldPassword)); err != nil { + return fmt.Errorf("incorrect password for user id %d", pc.UserId) + } + + h, err := bcrypt.GenerateFromPassword([]byte(pc.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("bcrypt error: %v", err) + } + + u.Password = string(h) + if err = repo.UpdateUserPassword(u); err != nil { + return fmt.Errorf("failed to update password for user id %d: %v", u.Id, err) + } + + return nil +} + +func GetRandomPassword(length int) (NewPassword, error) { + p := GetRandomString(length) + + h, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) + if err != nil { + return NewPassword{}, err + } + + np := NewPassword{ + Password: p, + Hash: string(h), + } + + return np, nil +} + +// GetRandomString Keep as a separate function in case this becomes useful for some other purpose +func GetRandomString(length int) string { + const chars = "!@#$%?abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + + b := make([]byte, length) + for i := range b { + b[i] = chars[rnd.Intn(len(chars))] + } + + return string(b) +} diff --git a/pkg/auth/tokens.go b/pkg/auth/tokens.go new file mode 100644 index 0000000..f9a9e38 --- /dev/null +++ b/pkg/auth/tokens.go @@ -0,0 +1,268 @@ +package auth + +import ( + "addrss/pkg/config" + "addrss/pkg/repo" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +type header struct { + Alg string `json:"alg"` + Typ string `json:"typ"` +} + +type baseClaims struct { + Aud string `json:"aud"` + Iss string `json:"iss"` + Exp int64 `json:"exp"` + Sub int64 `json:"sub"` +} + +type Payload interface { + getBaseClaims() baseClaims + *AccessClaims | *IdClaims | *RefreshClaims +} + +type AccessClaims struct { + baseClaims + Scopes string `json:"scope"` +} + +func (ac *AccessClaims) getBaseClaims() baseClaims { + return ac.baseClaims +} + +type IdClaims struct { + baseClaims + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` +} + +func (ic *IdClaims) getBaseClaims() baseClaims { + return ic.baseClaims +} + +type RefreshClaims struct { + baseClaims + Jti string `json:"jti"` +} + +func (rc *RefreshClaims) getBaseClaims() baseClaims { + return rc.baseClaims +} + +type Tokens struct { + AccessToken string `json:"accessToken,omitempty"` + IdToken string `json:"idToken,omitempty"` + RefreshToken string `json:"refreshToken,omitempty"` +} + +func AcquireTokens(user repo.User) (Tokens, error) { + at, err := getAccessToken(user) + if err != nil { + return Tokens{}, err + } + + it, err := getIdToken(user) + if err != nil { + return Tokens{}, err + } + + rt, err := getRefreshToken(user) + if err != nil { + return Tokens{}, err + } + + t := Tokens{ + AccessToken: at, + IdToken: it, + RefreshToken: rt, + } + + return t, nil +} + +func ValidateJwtToken[T Payload](token string, claims T) error { + segments := strings.Split(token, ".") + if len(segments) != 3 { + return fmt.Errorf("invalid segment count") + } + + k, err := config.GetString("jwtKey") + if err != nil { + return err + } + + mac := hmac.New(sha256.New, []byte(k)) + mac.Write([]byte(segments[0] + "." + segments[1])) + if strings.ReplaceAll(base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=", "") != segments[2] { + return fmt.Errorf("invalid sigature") + } + + for len(segments[1])%4 != 0 { + segments[1] += "=" + } + + bytes, _ := base64.URLEncoding.DecodeString(segments[1]) + if err := json.Unmarshal(bytes, &claims); err != nil { + return fmt.Errorf("failed to unmarshal token payload") + } + + c := claims.getBaseClaims() + aud, err := config.GetString("jwtAudience") + if err != nil { + return err + } + + iss, err := config.GetString("jwtIssuer") + if err != nil { + return err + } + + if c.Aud == aud && c.Iss == iss && c.Exp >= time.Now().Unix() { + return nil + } + + return fmt.Errorf("invalid payload parameters") +} + +func getGuestToken() (string, error) { + bc, err := newBaseClaims(0) + if err != nil { + return "", err + } + + ap := AccessClaims{ + bc, + "", + } + return encodeAndSignJwt(ap) +} + +func getAccessToken(user repo.User) (string, error) { + bc, err := newBaseClaims(user.Id) + if err != nil { + return "", err + } + + ac := AccessClaims{ + bc, + "", + } + + return encodeAndSignJwt(ac) +} + +func getIdToken(user repo.User) (string, error) { + bc, err := newBaseClaims(user.Id) + if err != nil { + return "", err + } + + ic := IdClaims{ + bc, + user.FirstName, + user.LastName, + user.EmailAddress, + user.PhoneNumber, + } + + return encodeAndSignJwt(ic) +} + +func getRefreshToken(user repo.User) (string, error) { + session := repo.UserSession{ + UserId: user.Id, + TokenId: uuid.New().String(), + Expiration: time.Now().AddDate(0, 1, 0), + } + + if err := repo.AddUserSession(session); err != nil { + return "", err + } + + bc, err := newBaseClaims(user.Id) + if err != nil { + return "", err + } + + rc := RefreshClaims{ + bc, + session.TokenId, + } + rc.Exp = session.Expiration.Unix() + + return encodeAndSignJwt(rc) +} + +func encodeAndSignJwt(payload any) (string, error) { + h, err := json.Marshal(newHeader()) + if err != nil { + return "", err + } + + p, err := json.Marshal(payload) + if err != nil { + return "", err + } + + token := base64.URLEncoding.EncodeToString(h) + "." + base64.URLEncoding.EncodeToString(p) + token = strings.ReplaceAll(token, "=", "") + + key, err := config.GetString("jwtKey") + if err != nil { + return "", err + } + + mac := hmac.New(sha256.New, []byte(key)) + _, err = mac.Write([]byte(token)) + if err != nil { + return "", err + } + + signed := strings.ReplaceAll(token+"."+base64.URLEncoding.EncodeToString(mac.Sum(nil)), "=", "") + return signed, nil +} + +func newHeader() header { + return header{ + Alg: "HS256", + Typ: "JWT", + } +} + +func newBaseClaims(userId int64) (baseClaims, error) { + aud, err := config.GetString("jwtAudience") + if err != nil { + return baseClaims{}, err + } + + iss, err := config.GetString("jwtIssuer") + if err != nil { + return baseClaims{}, err + } + + lt, err := config.GetInt64("jwtLifetime") + if err != nil { + return baseClaims{}, err + } + + bc := baseClaims{ + Aud: aud, + Iss: iss, + Exp: time.Now().Add(time.Duration(lt * int64(time.Second))).Unix(), + Sub: userId, + } + + return bc, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..8f2d680 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,90 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +var config map[string]interface{} + +func Load(path string) error { + err := loadConfigFile(path) + if err != nil { + return err + } + + env := os.Getenv("addrss_ENVIRONMENT") + if env != "" { + path = strings.ReplaceAll(path, ".json", "."+env+".json") + err = loadConfigFile(path) + if err != nil { + return err + } + } + + return nil +} + +func GetString(key string) (string, error) { + v, err := Get(key) + if err != nil { + return "", err + } + + return v.(string), nil +} + +func GetInt64(key string) (int64, error) { + v, err := Get(key) + if err != nil { + return 0, err + } + + return int64(v.(float64)), nil +} + +func GetBool(key string) (bool, error) { + v, err := Get(key) + if err != nil { + return false, err + } + + return v.(bool), nil +} + +func Get(key string) (interface{}, error) { + value, ok := config[key] + if !ok { + return nil, fmt.Errorf("configuration value not found for key %s", key) + } + + switch v := value.(type) { + case string: + if strings.Contains(v, "{{") && strings.Contains(v, "}}") { + _, a, _ := strings.Cut(v, "{{") + envVar, _, _ := strings.Cut(a, "}}") + + value = strings.ReplaceAll(v, "{{"+envVar+"}}", os.Getenv(envVar)) + } + return value, nil + + default: + return value, nil + } +} + +func loadConfigFile(path string) error { + contents, err := os.ReadFile(path) + if err != nil { + return err + } + + err = json.Unmarshal(contents, &config) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/repo/guestbook.go b/pkg/repo/guestbook.go new file mode 100644 index 0000000..1b14f6b --- /dev/null +++ b/pkg/repo/guestbook.go @@ -0,0 +1,29 @@ +package repo + +import ( + _ "github.com/go-sql-driver/mysql" +) + +type Guestbook struct { + PageviewId int64 `json:"pageviewId"` + FirstName string `json:"firstName" validate:"required"` + LastName string `json:"lastName" validate:"required"` + EmailAddress string `json:"emailAddress" validate:"required"` + Message string `json:"message"` +} + +func AddGuestbook(entry Guestbook) (int64, error) { + db := getDB() + + result, err := db.Exec("INSERT INTO guestbook (first_name, last_name, email_address, message, pageview_id) VALUES (?, ?, ?, ?, ?)", entry.FirstName, entry.LastName, entry.EmailAddress, entry.Message, entry.PageviewId) + if err != nil { + return 0, err + } + + id, err := result.LastInsertId() + if err != nil { + return 0, err + } + + return id, nil +} diff --git a/pkg/repo/pageview.go b/pkg/repo/pageview.go new file mode 100644 index 0000000..291722e --- /dev/null +++ b/pkg/repo/pageview.go @@ -0,0 +1,30 @@ +package repo + +import ( + "time" + + _ "github.com/go-sql-driver/mysql" +) + +type Pageview struct { + Id int64 + IPAddress string + UserAgent string + Timestamp time.Time +} + +func AddPageview(pageview Pageview) (int64, error) { + db := getDB() + + result, err := db.Exec("INSERT INTO pageview (ip_address, user_agent) VALUES (?, ?)", pageview.IPAddress, pageview.UserAgent) + if err != nil { + return 0, err + } + + id, err := result.LastInsertId() + if err != nil { + return 0, err + } + + return id, nil +} diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go new file mode 100644 index 0000000..37178cc --- /dev/null +++ b/pkg/repo/repo.go @@ -0,0 +1,35 @@ +package repo + +import ( + "addrss/pkg/config" + "database/sql" + "fmt" + + _ "github.com/go-sql-driver/mysql" +) + +func getDB() *sql.DB { + dsn, err := config.GetString("dsn") + if err != nil { + panic(err) + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + panic(err) + } + + return db +} + +func dbQueryError(err error) error { + return fmt.Errorf("could not execute query: %w", err) +} + +func dbScanError(err error) error { + return fmt.Errorf("could not scan into struct: %w", err) +} + +func dbRowsError(err error) error { + return fmt.Errorf("could not iterate over rows: %w", err) +} diff --git a/pkg/repo/user.go b/pkg/repo/user.go new file mode 100644 index 0000000..d7bc2df --- /dev/null +++ b/pkg/repo/user.go @@ -0,0 +1,127 @@ +package repo + +import ( + "time" + + _ "github.com/go-sql-driver/mysql" +) + +type User struct { + Id int64 `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + EmailAddress string `json:"emailAddress"` + PhoneNumber string `json:"phoneNumber"` + Password string `json:"-"` + CreatedAt time.Time `json:"createdAt"` +} + +func AddUser(user User) (int64, error) { + //TODO: Creating the password needs to be split out so that the temporary password can be emailed to the user as part of their validation email + // password, err := // auth.GetTempPassword(8) + // if err != nil { + // return 0, err + // } + + password := "abc123" + + db := getDB() + + result, err := db.Exec("INSERT INTO `user` (first_name, last_name, email_address, phone_number, password) VALUES (?, ?, ?, ?, ?)", user.FirstName, user.LastName, user.EmailAddress, user.PhoneNumber, password) + if err != nil { + return 0, err + } + + return result.LastInsertId() +} + +func GetUserByEmail(email string) (User, error) { + db := getDB() + row := db.QueryRow("SELECT `id`, `first_name`, `last_name`, `email_address`, `phone_number`, `password`, `created_at`, `image_id` FROM `user` WHERE email_address = ?", email) + u := User{} + + if err := row.Scan(&u.Id, &u.FirstName, &u.LastName, &u.EmailAddress, &u.PhoneNumber, &u.Password, &u.CreatedAt); err != nil { + return User{}, err + } + + return u, nil +} + +func GetUserById(id int64) (User, error) { + db := getDB() + row := db.QueryRow("SELECT `id`, `first_name`, `last_name`, `email_address`, `phone_number`, `password`, `created_at`, `image_id` FROM `user` WHERE `id` = ?", id) + u := User{} + + if err := row.Scan(&u.Id, &u.FirstName, &u.LastName, &u.EmailAddress, &u.PhoneNumber, &u.Password, &u.CreatedAt); err != nil { + return User{}, err + } + + return u, nil +} + +func UpdateUser(u User) error { + db := getDB() + + _, err := db.Exec("UPDATE `user` SET `first_name` = ?, `last_name` = ?, `email_address` = ?, `phone_number` = ? where `id` = ?", u.FirstName, u.LastName, u.EmailAddress, u.PhoneNumber, u.Id) + if err != nil { + return err + } + + return nil +} + +func UpdateUserPassword(u User) error { + db := getDB() + + _, err := db.Exec("UPDATE `user` SET `password` = ? where `id` = ?", u.Password, u.Id) + if err != nil { + return err + } + + return nil +} + +type UserSession struct { + UserId int64 + TokenId string + Expiration time.Time +} + +func GetUserSessionById(userId int64) (UserSession, error) { + db := getDB() + row := db.QueryRow("SELECT user_id, token_id, expiration FROM `user_session` WHERE user_id = ?", userId) + + us := UserSession{} + if err := row.Scan(&us.UserId, &us.TokenId, &us.Expiration); err != nil { + return UserSession{}, err + } + + return us, nil +} + +func AddUserSession(session UserSession) error { + err := DeleteUserSession(session.UserId) + if err != nil { + return err + } + + db := getDB() + + _, err = db.Exec("INSERT INTO `user_session` VALUES (?, ?, ?)", session.UserId, session.TokenId, session.Expiration) + if err != nil { + return err + } + + return nil +} + +func DeleteUserSession(userId int64) error { + db := getDB() + + _, err := db.Exec("DELETE FROM `user_session` WHERE user_id = ?", userId) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/router/context.go b/pkg/router/context.go new file mode 100644 index 0000000..5002f74 --- /dev/null +++ b/pkg/router/context.go @@ -0,0 +1,12 @@ +package router + +import ( + "addrss/pkg/auth" +) + +type Context struct { + Response Response + Claims auth.AccessClaims + Request Request + route Route +} diff --git a/pkg/router/controller.go b/pkg/router/controller.go new file mode 100644 index 0000000..9b2ea5b --- /dev/null +++ b/pkg/router/controller.go @@ -0,0 +1,11 @@ +package router + +type Controller interface { + AddRoutes() +} + +func AddControllers(controllers ...Controller) { + for _, controller := range controllers { + controller.AddRoutes() + } +} diff --git a/pkg/router/request.go b/pkg/router/request.go new file mode 100644 index 0000000..9b858d8 --- /dev/null +++ b/pkg/router/request.go @@ -0,0 +1,76 @@ +package router + +import ( + "addrss/pkg/config" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "strconv" + "strings" +) + +type Request struct { + *http.Request + params map[string]any +} + +func (req Request) Param(key string) (any, error) { + value, ok := req.params[key] + if ok { + return value, nil + } + + return nil, fmt.Errorf("param %s not found", key) +} + +func (req Request) ParamInt64(key string) (int64, error) { + param, err := req.Param(key) + if err != nil { + return 0, err + } + + i, err := strconv.Atoi(param.(string)) + if err != nil { + return 0, err + } + + return int64(i), nil +} + +// Bind Binds the request body to the struct pointer v using the Content-Type header to select a binder. This function panics if v cannot be bound. +func (req Request) Bind(v any) error { + mime := req.Header.Get("Content-Type") + mime = strings.Split(mime, ";")[0] + + switch mime { + case "application/json": + err := json.NewDecoder(req.Body).Decode(&v) + if err != nil { + return fmt.Errorf("error while decoding http request body as json: %w", err) + } + + default: + return fmt.Errorf("no binder exists for type %s", mime) + } + + return nil +} + +func (req Request) FormFile(key string) (multipart.File, *multipart.FileHeader, error) { + maxMegs, err := config.GetInt64("uploads.maxMegabytes") + if err != nil { + maxMegs = 8000000 + } + + if err := req.ParseMultipartForm(maxMegs << 20); err != nil { + return nil, nil, err + } + + file, header, err := req.Request.FormFile(key) + if err != nil { + return nil, nil, err + } + + return file, header, nil +} diff --git a/pkg/router/response.go b/pkg/router/response.go new file mode 100644 index 0000000..44c73d9 --- /dev/null +++ b/pkg/router/response.go @@ -0,0 +1,99 @@ +package router + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "net/http" + "path/filepath" +) + +type Response struct { + http.ResponseWriter +} + +func (resp Response) View(name string, data any) { + fp := filepath.Join("internal/views", fmt.Sprintf("%s.html", name)) + tmpl, _ := template.ParseFiles(fp) + + buf := new(bytes.Buffer) + if err := tmpl.ExecuteTemplate(buf, "index", data); err != nil { + resp.statusCode(http.StatusInternalServerError) + return + } + + resp.html(buf.Bytes(), http.StatusOK) +} + +func (resp Response) OK(data any) { + resp.json(data, http.StatusOK) +} + +func (resp Response) NoContent() { + resp.statusCode(http.StatusNoContent) +} + +func (resp Response) BadRequest(err error) { + resp.error(err, http.StatusBadRequest) +} + +func (resp Response) Unauthorized(err error) { + resp.error(err, http.StatusUnauthorized) +} + +func (resp Response) Forbidden(err error) { + resp.error(err, http.StatusForbidden) +} + +func (resp Response) NotFound(err error) { + resp.error(err, http.StatusNotFound) +} + +func (resp Response) Conflict(err error) { + resp.error(err, http.StatusConflict) +} + +func (resp Response) UnsupportedMediaType(err error) { + resp.error(err, http.StatusUnsupportedMediaType) +} + +func (resp Response) InternalServerError(err error) { + resp.error(err, http.StatusInternalServerError) +} + +func (resp Response) error(err error, status int) { + er := struct { + Message string `json:"message"` + }{ + Message: err.Error(), + } + resp.json(er, status) +} + +func (resp Response) html(content []byte, status int) { + resp.Header().Add("Content-Type", "text/html; charset=utf-8") + resp.WriteHeader(status) + + if _, err := resp.Write(content); err != nil { + panic("Failed to write HTTP response: " + err.Error()) + } +} + +func (resp Response) json(data any, statusCode int) { + content, err := json.Marshal(data) + if err != nil { + panic("Failed to marshal JSON: " + err.Error()) + } + + resp.Header().Add("Content-Type", "application/json; charset=utf-8") + resp.WriteHeader(statusCode) + + if _, err := resp.Write(content); err != nil { + panic("Failed to write HTTP response: " + err.Error()) + } +} + +func (resp Response) statusCode(code int) { + resp.WriteHeader(code) +} diff --git a/pkg/router/route.go b/pkg/router/route.go new file mode 100644 index 0000000..99ccfbf --- /dev/null +++ b/pkg/router/route.go @@ -0,0 +1,33 @@ +package router + +type Route struct { + handler func(*Context) + method string + anonymous bool + scope string + params map[string]any + path string + bindModel any +} + +func (r Route) Anonymous() Route { + for i, route := range routeMap[r.method] { + if route.path == r.path { + routeMap[r.method][i].scope = "" + routeMap[r.method][i].anonymous = true + } + } + + return r +} + +func (r Route) RequireScope(scope string) Route { + for i, route := range routeMap[r.method] { + if route.path == r.path { + routeMap[r.method][i].scope = scope + routeMap[r.method][i].anonymous = false + } + } + + return r +} diff --git a/pkg/router/router.go b/pkg/router/router.go new file mode 100644 index 0000000..1077398 --- /dev/null +++ b/pkg/router/router.go @@ -0,0 +1,192 @@ +package router + +import ( + "addrss/pkg/auth" + "addrss/pkg/config" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +var routeMap = map[string][]Route{} + +type router struct{} + +func Serve(addr string) error { + if len(routeMap) == 0 { + return errors.New("no registered routes") + } + + return http.ListenAndServe(addr, router{}) +} + +func (r router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + route, ok := getRoute(req) + if !ok { + // See if the request is for a file that exists in the content directory + file := filepath.Join("content", req.URL.Path) + if info, err := os.Stat(file); err == nil && !info.IsDir() { + http.ServeFile(w, req, file) + return + } + + w.WriteHeader(http.StatusNotFound) + return + } + + ctx := Context{ + Request: Request{req, map[string]any{}}, + Response: Response{w}, + Claims: auth.AccessClaims{}, + route: route, + } + + defer func() { + if rec := recover(); rec != nil { + err := fmt.Errorf("recovered from panic: %v", rec) + ctx.Response.InternalServerError(err) + } + }() + + originHeader := req.Header.Get("Origin") + if originHeader != "" { + o, err := config.Get("cors.origins") + if err != nil { + panic(err) + } + + origins := o.([]any) + for _, origin := range origins { + if origin.(string) == originHeader { + w.Header().Add("Access-Control-Allow-Origin", origin.(string)) + } + } + } + + if req.Method == http.MethodOptions { + acrh := req.Header.Get("Access-Control-Request-Headers") + acrm := req.Header.Get("Access-Control-Request-Method") + if route.method != acrm { + w.WriteHeader(http.StatusForbidden) + return + } + + // Congrats, you get whatever headers you want + w.Header().Add("Access-Control-Allow-Headers", acrh) + w.Header().Add("Access-Control-Allow-Methods", acrm) + w.WriteHeader(http.StatusNoContent) + + return + } + + if !route.anonymous { + authSegments := strings.Split(req.Header.Get("Authorization"), " ") + + if len(authSegments) != 2 || authSegments[0] != "Bearer" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err := auth.ValidateJwtToken(authSegments[1], &ctx.Claims); err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + } + + if route.scope != "" { + if !strings.Contains(ctx.Claims.Scopes, route.scope) { + w.WriteHeader(http.StatusForbidden) + return + } + } + + route.handler(&ctx) +} + +func AddGet(pattern string, handler func(*Context)) Route { + return addRoute(http.MethodGet, pattern, handler) +} + +func AddPost(pattern string, handler func(*Context)) Route { + return addRoute(http.MethodPost, pattern, handler) +} + +func AddPut(pattern string, handler func(*Context)) Route { + return addRoute(http.MethodPut, pattern, handler) +} + +func AddPatch(pattern string, handler func(*Context)) Route { + return addRoute(http.MethodPatch, pattern, handler) +} + +func AddDelete(pattern string, handler func(*Context)) Route { + return addRoute(http.MethodDelete, pattern, handler) +} + +func addRoute(method string, path string, handler func(*Context)) Route { + route := Route{ + handler: handler, + method: method, + path: path, + } + + if routeMap[method] == nil { + routeMap[method] = []Route{} + } + + routeMap[method] = append(routeMap[method], route) + + return route +} + +func getRoute(req *http.Request) (Route, bool) { + method := req.Method + if method == http.MethodOptions { + method = req.Header.Get("Access-Control-Request-Method") + } + + req.URL.Path = strings.TrimSuffix(req.URL.Path, "/") + + for _, route := range routeMap[method] { + //if strings.Contains(route.path, ":") { + // rte, ok := regexMatchRoute(route, req.URL.Path) + // if ok { + // return rte, true + // } + //} else { + if route.path == req.URL.Path { + return route, true + } + //} + } + + return Route{}, false +} + +//func regexMatchRoute(route Route, path string) (Route, bool) { +// regex := regexp.MustCompile(`:[a-zA-Z]+`) +// matchPath := regex.ReplaceAllString(route.path, `([A-Za-z0-9\-_.]+)`) +// matchPath += "$" +// regex = regexp.MustCompile(matchPath) +// +// // matchPath := route.path +// if matches := regex.FindStringSubmatch(path); len(matches) > 0 { +// if len(matches) > 1 { +// route.params = map[string]any{} +// +// values := strings.Split(path, "/") +// for i, param := range strings.Split(route.path, "/") { +// if strings.Contains(param, ":") { +// route.params[param[1:]] = values[i] +// } +// } +// } +// +// return route, true +// } +// +// return route, false +//} diff --git a/sql/tommycom-up.sql b/sql/tommycom-up.sql new file mode 100644 index 0000000..e69de29