Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__debug_bin
|
||||||
|
__debug_bin.exe
|
||||||
|
.vscode
|
||||||
|
**/.DS_Store
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
13
.idea/addrss.io.iml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
9
config/config.json
Normal 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
4
config/config.prod.json
Normal 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
9
deploy.sh
Normal 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
12
dockerfile
Normal 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
12
go.mod
Normal 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
10
go.sum
Normal 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=
|
||||||
47
internal/controllers/api.go
Normal file
47
internal/controllers/api.go
Normal 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)
|
||||||
|
}
|
||||||
100
internal/controllers/auth.go
Normal file
100
internal/controllers/auth.go
Normal 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()
|
||||||
|
}
|
||||||
21
internal/controllers/guestbook.go
Normal file
21
internal/controllers/guestbook.go
Normal 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()
|
||||||
|
}
|
||||||
15
internal/controllers/health.go
Normal file
15
internal/controllers/health.go
Normal 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
31
main.go
Normal 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
76
pkg/auth/auth.go
Normal 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
17
pkg/auth/errors.go
Normal 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
74
pkg/auth/password.go
Normal 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
268
pkg/auth/tokens.go
Normal 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
90
pkg/config/config.go
Normal 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
29
pkg/repo/guestbook.go
Normal 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
30
pkg/repo/pageview.go
Normal 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
35
pkg/repo/repo.go
Normal 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
127
pkg/repo/user.go
Normal 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
12
pkg/router/context.go
Normal 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
11
pkg/router/controller.go
Normal 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
76
pkg/router/request.go
Normal 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
99
pkg/router/response.go
Normal 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
33
pkg/router/route.go
Normal 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
192
pkg/router/router.go
Normal 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
0
sql/tommycom-up.sql
Normal file
Reference in New Issue
Block a user