Initial commit

This commit is contained in:
2025-09-06 21:35:45 -04:00
commit b02525e28a
32 changed files with 1478 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__debug_bin
__debug_bin.exe
.vscode
**/.DS_Store

8
.idea/.gitignore generated vendored Normal file
View File

@@ -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

13
.idea/addrss.io.iml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true">
<buildTags>
<option name="os" value="linux" />
</buildTags>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/addrss.io.iml" filepath="$PROJECT_DIR$/.idea/addrss.io.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/libpostal" vcs="Git" />
</component>
</project>

9
config/config.json Normal file
View File

@@ -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": "*"
}

4
config/config.prod.json Normal file
View File

@@ -0,0 +1,4 @@
{
"jwtKey": "{{addrss_JWT_KEY}}",
"dsn": "addrss:{{addrss_MYSQL_PASSWORD}}@tcp(172.17.0.1:3306)/addrss"
}

9
deploy.sh Normal file
View File

@@ -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

12
dockerfile Normal file
View File

@@ -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" ]

12
go.mod Normal file
View File

@@ -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

10
go.sum Normal file
View File

@@ -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=

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

31
main.go Normal file
View File

@@ -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)
}
}

76
pkg/auth/auth.go Normal file
View File

@@ -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
}

17
pkg/auth/errors.go Normal file
View File

@@ -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"
}

74
pkg/auth/password.go Normal file
View File

@@ -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)
}

268
pkg/auth/tokens.go Normal file
View File

@@ -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
}

90
pkg/config/config.go Normal file
View File

@@ -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
}

29
pkg/repo/guestbook.go Normal file
View File

@@ -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
}

30
pkg/repo/pageview.go Normal file
View File

@@ -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
}

35
pkg/repo/repo.go Normal file
View File

@@ -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)
}

127
pkg/repo/user.go Normal file
View File

@@ -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
}

12
pkg/router/context.go Normal file
View File

@@ -0,0 +1,12 @@
package router
import (
"addrss/pkg/auth"
)
type Context struct {
Response Response
Claims auth.AccessClaims
Request Request
route Route
}

11
pkg/router/controller.go Normal file
View File

@@ -0,0 +1,11 @@
package router
type Controller interface {
AddRoutes()
}
func AddControllers(controllers ...Controller) {
for _, controller := range controllers {
controller.AddRoutes()
}
}

76
pkg/router/request.go Normal file
View File

@@ -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
}

99
pkg/router/response.go Normal file
View File

@@ -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)
}

33
pkg/router/route.go Normal file
View File

@@ -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
}

192
pkg/router/router.go Normal file
View File

@@ -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
//}

0
sql/tommycom-up.sql Normal file
View File