diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..bdc5f94 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,35 @@ +# ng-blog Backend + +This is the backend service for the ng-blog project. It's written in Go and provides the API endpoints for the frontend application. + +## Getting Started + +### Prerequisites + +- Go (version 1.24 or higher) + +### Running the Backend + +The backend provides two main commands: `serve` and `help`. + +- **`serve`**: Starts the backend server. It loads configuration from environment variables and listens for incoming requests. + + ```bash + go run ./cmd/main.go serve + ``` + +- **`help`**: Displays usage information about the configuration options. + + ```bash + go run ./cmd/main.go help + ``` + +### Configuration + +The backend is configured using environment variables. The `help` command will display the available configuration options. The configuration is loaded using the `go-simpler.org/env` library. Environment variable names are expected to be in the format `CONFIG_OPTION_NAME`. + +## Directory Structure + +- `cmd/main.go`: The main entry point for the application. Handles command-line arguments and starts the server. +- `internal/config`: Defines the configuration struct and default values. +- `internal/startup`: Responsible for initializing the HTTP multiplexer and other startup tasks. diff --git a/backend/auth/login.go b/backend/auth/login.go deleted file mode 100644 index 46f641c..0000000 --- a/backend/auth/login.go +++ /dev/null @@ -1,102 +0,0 @@ -package auth - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "git.schreifuchs.ch/schreifuchs/ng-blog/backend/model" - "github.com/golang-jwt/jwt/v5" -) - -func Login(username, password string, secret []byte) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - login := model.Login{} - if err := json.NewDecoder(r.Body).Decode(&login); err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - - if login.Name == username && login.Password == password { - token, err := createJWT(secret) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - - err = json.NewEncoder(w).Encode(&model.LoginResponse{ - Token: token, - }) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - w.WriteHeader(http.StatusOK) - return - } - } -} - -func Authenticated(secret []byte) func(http.HandlerFunc) http.Handler { - return func(next http.HandlerFunc) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Our middleware logic goes here... - token, err := extractToken(r) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - - err = validateJWT(token, secret) - if err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - - next(w, r) - }) - } -} - -func createJWT(secret []byte) (token string, err error) { - return jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{ - "exp": time.Now().Add(time.Hour * 24).Unix(), - }).SignedString(secret) -} - -func validateJWT(tokenString string, secret []byte) (err error) { - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { - // Don't forget to validate the alg is what you expect: - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - - return secret, nil - }) - if err != nil { - return - } - if date, err := token.Claims.GetExpirationTime(); err == nil && date.After(time.Now()) { - return nil - } - return errors.New("JWT not valid") -} - -func extractToken(r *http.Request) (token string, err error) { - tokenHeader := r.Header.Get("Authorization") // Grab the token from the header - - if tokenHeader == "" { - err = errors.New("missing token") - return - } - - token = strings.TrimPrefix(tokenHeader, "Bearer ") - - if token == "" { - err = errors.New("malformed token") - } - return -} diff --git a/backend/blog.db b/backend/blog.db index 0f85c74..409e56d 100644 Binary files a/backend/blog.db and b/backend/blog.db differ diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..2171db9 --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/config" + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/initialize" + + "go-simpler.org/env" +) + +const HELP = "expected 'serve' or 'help' subcommands" + +var ENV_OPTS = &env.Options{NameSep: "_"} + +func main() { + if len(os.Args) < 2 { + fmt.Println(HELP) + return + } + switch os.Args[1] { + case "serve": + serve() + case "help": + help() + default: + fmt.Println(HELP) + os.Exit(1) + } +} + +func serve() { + cfg := config.Default() + + if err := env.Load(cfg, ENV_OPTS); err != nil { + log.Fatal(err) + } + log.Println("config loaded") + + mux := initialize.CreateMux(cfg) + if err := http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), mux); err != nil { + log.Fatal(err) + } +} + +func help() { + env.Usage(config.Default(), os.Stdout, ENV_OPTS) + os.Exit(0) +} diff --git a/backend/go.mod b/backend/go.mod index 9b3fe15..85ab8af 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,5 +14,6 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + go-simpler.org/env v0.12.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 429c99a..6fbdc29 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -10,6 +10,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +go-simpler.org/env v0.12.0 h1:kt/lBts0J1kjWJAnB740goNdvwNxt5emhYngL0Fzufs= +go-simpler.org/env v0.12.0/go.mod h1:cc/5Md9JCUM7LVLtN0HYjPTDcI3Q8TDaPlNTAlDU+WI= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= diff --git a/backend/internal/auth/controller.go b/backend/internal/auth/controller.go new file mode 100644 index 0000000..2c28ba5 --- /dev/null +++ b/backend/internal/auth/controller.go @@ -0,0 +1,53 @@ +package auth + +import ( + "encoding/json" + "net/http" + + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model" +) + +func (s *Service) Login(w http.ResponseWriter, r *http.Request) { + login := model.Login{} + if err := json.NewDecoder(r.Body).Decode(&login); err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if login.Name == s.cfg.AdminName && login.Password == s.cfg.AdminPassword { + token, err := createJWT([]byte(s.cfg.Secret)) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + err = json.NewEncoder(w).Encode(&model.LoginResponse{ + Token: token, + }) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + return + } +} + +func (s *Service) Authenticated(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Our middleware logic goes here... + token, err := extractToken(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + err = validateJWT(token, []byte(s.cfg.Secret)) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + next(w, r) + }) +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..daf55f1 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,51 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func createJWT(secret []byte) (token string, err error) { + return jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{ + "exp": time.Now().Add(time.Hour * 24).Unix(), + }).SignedString(secret) +} + +func validateJWT(tokenString string, secret []byte) (err error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return secret, nil + }) + if err != nil { + return + } + if date, err := token.Claims.GetExpirationTime(); err == nil && date.After(time.Now()) { + return nil + } + return errors.New("JWT not valid") +} + +func extractToken(r *http.Request) (token string, err error) { + tokenHeader := r.Header.Get("Authorization") // Grab the token from the header + + if tokenHeader == "" { + err = errors.New("missing token") + return + } + + token = strings.TrimPrefix(tokenHeader, "Bearer ") + + if token == "" { + err = errors.New("malformed token") + } + return +} diff --git a/backend/internal/auth/resource.go b/backend/internal/auth/resource.go new file mode 100644 index 0000000..0788183 --- /dev/null +++ b/backend/internal/auth/resource.go @@ -0,0 +1,26 @@ +package auth + +import ( + "time" + + "gorm.io/gorm" +) + +type Config struct { + Secret string `env:"SECRET"` + ValidDuration time.Duration `env:"VALID_DURATION"` + AdminName string `env:"ADMIN_NAME"` + AdminPassword string `env:"ADMIN_PASSWORD"` +} + +type Service struct { + cfg *Config + db *gorm.DB +} + +func New(cfg *Config, db *gorm.DB) *Service { + return &Service{ + cfg, + db, + } +} diff --git a/backend/blog/controller.go b/backend/internal/blog/controller.go similarity index 95% rename from backend/blog/controller.go rename to backend/internal/blog/controller.go index adceb86..9332f8c 100644 --- a/backend/blog/controller.go +++ b/backend/internal/blog/controller.go @@ -6,7 +6,7 @@ import ( "net/http" "strconv" - "git.schreifuchs.ch/schreifuchs/ng-blog/backend/model" + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model" "github.com/gorilla/mux" ) diff --git a/backend/blog/resource.go b/backend/internal/blog/resource.go similarity index 100% rename from backend/blog/resource.go rename to backend/internal/blog/resource.go diff --git a/backend/internal/config/resource.go b/backend/internal/config/resource.go new file mode 100644 index 0000000..a305253 --- /dev/null +++ b/backend/internal/config/resource.go @@ -0,0 +1,28 @@ +package config + +import ( + "time" + + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/auth" +) + +type Config struct { + Port int `env:"PORT"` + Host string `env:"HOST"` + DBPath string `env:"DB_PATH"` + Auth auth.Config `env:"AUTH"` +} + +func Default() *Config { + return &Config{ + Port: 8080, + Host: "localhost", + DBPath: "./blog.db", + Auth: auth.Config{ + Secret: "secret", + ValidDuration: time.Hour * 1, + AdminName: "admin", + AdminPassword: "admin", + }, + } +} diff --git a/backend/internal/initialize/inject.go b/backend/internal/initialize/inject.go new file mode 100644 index 0000000..7b901c5 --- /dev/null +++ b/backend/internal/initialize/inject.go @@ -0,0 +1,32 @@ +package initialize + +import ( + "net/http" + + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/auth" + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/blog" + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/config" + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model" + "git.schreifuchs.ch/schreifuchs/ng-blog/backend/pkg/cors" + "github.com/gorilla/mux" +) + +func CreateMux(cfg *config.Config) (r *mux.Router) { + db := model.Init() + blg := blog.New(db) + auth := auth.New(&cfg.Auth, db) + + r = mux.NewRouter() + r.Use(cors.HandlerForOrigin("*")) + r.HandleFunc("/login", auth.Login).Methods("POST") + r.Handle("/posts", auth.Authenticated(blg.SavePost)).Methods("POST") + r.Handle("/posts", auth.Authenticated(blg.SavePost)).Methods("PUT") + r.Handle("/posts/{postID}", auth.Authenticated(blg.DeletePost)).Methods("DELETE") + r.Handle("/posts", http.HandlerFunc(blg.GetAllPosts)).Methods("GET") + r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The CORS middleware should set up the headers for you + w.WriteHeader(http.StatusNoContent) + }) + + return +} diff --git a/backend/model/auth.go b/backend/internal/model/auth.go similarity index 100% rename from backend/model/auth.go rename to backend/internal/model/auth.go diff --git a/backend/model/blog.go b/backend/internal/model/blog.go similarity index 100% rename from backend/model/blog.go rename to backend/internal/model/blog.go diff --git a/backend/internal/model/init.go b/backend/internal/model/init.go new file mode 100644 index 0000000..c25463f --- /dev/null +++ b/backend/internal/model/init.go @@ -0,0 +1,36 @@ +package model + +import ( + "log" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func Init() *gorm.DB { + db, err := gorm.Open(sqlite.Open("./blog.db")) + if err != nil { + log.Panic(err) + } + db.AutoMigrate(&Post{}, &Comment{}) + + db.Save(&Post{ + ID: 1, + Title: "Hello World", + TLDR: "introduction to ng-blog", + Content: ` +## Welcome + +This is ng-blog, your simple blog written in Angular and Golang. + +### Login + +The default login is: +> Username: admin +> +> Password: admin +`, + }) + + return db +} diff --git a/backend/main.go b/backend/main.go deleted file mode 100644 index c6bd50b..0000000 --- a/backend/main.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "net/http" - "os" - - "git.schreifuchs.ch/schreifuchs/ng-blog/backend/auth" - "git.schreifuchs.ch/schreifuchs/ng-blog/backend/blog" - "git.schreifuchs.ch/schreifuchs/ng-blog/backend/cors" - "git.schreifuchs.ch/schreifuchs/ng-blog/backend/model" - "github.com/gorilla/mux" -) - -func main() { - user, ok := os.LookupEnv("USERNAME") - if !ok { - user = "admin" - } - password, ok := os.LookupEnv("PASSWORD") - if !ok { - password = "admin" - } - secret, ok := os.LookupEnv("SECRET") - if !ok { - secret = "Foo" - } - - db := model.Init() - blg := blog.New(db) - r := mux.NewRouter() - r.Use(cors.HandlerForOrigin("*")) - r.Handle("/login", auth.Login(user, password, []byte(secret))).Methods("POST") - r.Handle("/posts", auth.Authenticated([]byte(secret))(blg.SavePost)).Methods("POST") - r.Handle("/posts", auth.Authenticated([]byte(secret))(blg.SavePost)).Methods("PUT") - r.Handle("/posts/{postID}", auth.Authenticated([]byte(secret))(blg.DeletePost)).Methods("DELETE") - r.Handle("/posts", http.HandlerFunc(blg.GetAllPosts)).Methods("GET") - r.Methods("OPTIONS").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // The CORS middleware should set up the headers for you - w.WriteHeader(http.StatusNoContent) - }) - http.ListenAndServe(":8080", r) -} diff --git a/backend/model/init.go b/backend/model/init.go deleted file mode 100644 index 2054405..0000000 --- a/backend/model/init.go +++ /dev/null @@ -1,40 +0,0 @@ -package model - -import ( - "log" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func Init() *gorm.DB { - db, err := gorm.Open(sqlite.Open("./blog.db")) - if err != nil { - log.Panic(err) - } - db.AutoMigrate(&Post{}, &Comment{}) - - db.Save(&Post{ - ID: 1, - Title: "Foo", - TLDR: "Just some Foo Bar", - Content: "fkdj kjfk adjflkjdlö jdslj alsödj fla", - }) - db.Save(&Post{ - ID: 2, - Title: "Bar", - TLDR: "Just some Bar Baz", - Content: ` -# Hello Worls - -- alsödj -- adf adf - -| adsf | asdf | -|------|------| -| adf | adsf | - `, - }) - - return db -} diff --git a/backend/cors/cors.go b/backend/pkg/cors/cors.go similarity index 100% rename from backend/cors/cors.go rename to backend/pkg/cors/cors.go