serve frontend from go
This commit is contained in:
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@ -1 +0,0 @@
|
||||
blog.db
|
@ -1,35 +0,0 @@
|
||||
# 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.
|
BIN
backend/blog.db
BIN
backend/blog.db
Binary file not shown.
@ -1,52 +0,0 @@
|
||||
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)
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
module git.schreifuchs.ch/schreifuchs/ng-blog/backend
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
go-simpler.org/env v0.12.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
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
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
@ -1,22 +0,0 @@
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
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/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
@ -1,113 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
)
|
||||
|
||||
// Signup handles user signup by decoding request body, hashing the password, and saving user data to the database.
|
||||
func (s *Service) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var login Login
|
||||
user := model.NewUser()
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&login); err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len([]byte(login.Password)) > 72 {
|
||||
fmt.Fprint(w, "Password to long, max 72 bytes")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if user.Password, err = bcrypt.GenerateFromPassword([]byte(login.Password), 6); err != nil {
|
||||
log.Println("Error: ", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.Name = login.Name
|
||||
user.Role = s.cfg.DefaultRole
|
||||
|
||||
err = s.db.Save(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrCheckConstraintViolated) {
|
||||
fmt.Fprint(w, "Username is already in use")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Error: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Login handles user login by decoding request body, verifying credentials, and returning a JWT token.
|
||||
func (s *Service) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var login Login
|
||||
var user model.User
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&login); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.First(&user).Error; err != nil {
|
||||
fmt.Fprint(w, "user not found")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(login.Password)); err != nil {
|
||||
fmt.Fprint(w, "Invalid Password")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
token, err := s.createJWT(&user)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&LoginResponse{
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Error: ", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(res)
|
||||
}
|
||||
|
||||
// Logout handles user logout by invalidating the JWT and saving it to the database.
|
||||
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := extractToken(r)
|
||||
if err != nil {
|
||||
log.Printf("Error while extracting token: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(token)
|
||||
if err != nil {
|
||||
fmt.Fprint(w, "Invalid token")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.db.Save(&model.InvalidJWT{JWT: token, ValidUntil: claims.ExpiresAt.Time}).Error; err != nil {
|
||||
log.Printf("Error while saving logout token: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type authkey int
|
||||
|
||||
const keyClaims = iota
|
||||
|
||||
func writeToContext(r *http.Request, claims *Claims) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), keyClaims, claims)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// ExtractClaims extracts user claims from given context. If no claims in context ok = false.
|
||||
func ExtractClaims(ctx context.Context) (claims *Claims, ok bool) {
|
||||
val := ctx.Value(keyClaims)
|
||||
claims, ok = val.(*Claims)
|
||||
return
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var ErrJWTInvalid = errors.New("JWT not valid")
|
||||
|
||||
func (s *Service) createJWT(user *model.User) (token string, err error) {
|
||||
claims := &Claims{
|
||||
Role: user.Role,
|
||||
UserID: user.ID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user.UUID.String(),
|
||||
ExpiresAt: &jwt.NumericDate{
|
||||
Time: time.Now().Add(s.cfg.ValidDuration),
|
||||
},
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte(s.cfg.Secret))
|
||||
}
|
||||
|
||||
// validateJWT returns the token Claims and if token ist invalid ErrJWTInvalid
|
||||
func (s *Service) validateJWT(tokenString string) (claims Claims, err error) {
|
||||
_, err = jwt.ParseWithClaims(tokenString, &claims, 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 []byte(s.cfg.Secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Before(time.Now()) {
|
||||
err = ErrJWTInvalid
|
||||
return
|
||||
}
|
||||
|
||||
// check if user has logged out this token
|
||||
var invalidated bool
|
||||
err = s.db.Model(&model.InvalidJWT{}).
|
||||
Select("count(*) > 0").
|
||||
Where("jwt = ?", tokenString).
|
||||
Find(&invalidated).
|
||||
Error
|
||||
|
||||
if invalidated || err != nil {
|
||||
err = ErrJWTInvalid
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractToken extracts the Bearer token from the request
|
||||
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
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB() (db *gorm.DB) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"))
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(&model.User{}, &model.InvalidJWT{})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestService_JWT(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
user model.User
|
||||
}{
|
||||
{
|
||||
user: model.User{
|
||||
ID: 0,
|
||||
Name: "Hans de Admin",
|
||||
Role: model.RoleAdmin,
|
||||
UUID: uuid.MustParse("9d8973b7-2005-4ca6-a4bf-7bae5aad2916"),
|
||||
},
|
||||
},
|
||||
{
|
||||
user: model.User{
|
||||
ID: 1,
|
||||
Name: "Ueli de User",
|
||||
Role: model.RoleUser,
|
||||
UUID: uuid.MustParse("e1b7099f-a3be-4d77-b33f-389e27123187"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.user.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := New(&Config{
|
||||
Secret: "asdf",
|
||||
ValidDuration: time.Hour,
|
||||
AdminName: "adsf",
|
||||
AdminPassword: "adsf",
|
||||
}, testDB())
|
||||
|
||||
jwt, err := s.createJWT(&tt.user)
|
||||
if err != nil {
|
||||
t.Errorf("Error while creating JWT: %v", err)
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(jwt)
|
||||
if err != nil {
|
||||
t.Errorf("Error while creating JWT: %v", err)
|
||||
}
|
||||
|
||||
if claims.Subject != tt.user.UUID.String() {
|
||||
t.Error("Subject does not match")
|
||||
}
|
||||
if claims.Role != tt.user.Role {
|
||||
t.Error("Roles did not match")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
)
|
||||
|
||||
// Authenticated: This function is a middleware that authenticates incoming HTTP requests using JWT tokens and role-based access control.
|
||||
func (s *Service) Authenticated(next http.HandlerFunc, roles ...model.Role) 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
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(token)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// if roles specified check if satisfied
|
||||
if len(roles) > 0 && !slices.Contains(roles, claims.Role) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
r = writeToContext(r, &claims)
|
||||
next(w, r)
|
||||
})
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Config defines a struct for configuration settings, often loaded from environment variables.
|
||||
type Config struct {
|
||||
Secret string `env:"SECRET"`
|
||||
ValidDuration time.Duration `env:"VALID_DURATION"`
|
||||
AdminName string `env:"ADMIN_NAME"`
|
||||
AdminPassword string `env:"ADMIN_PASSWORD"`
|
||||
DefaultRole model.Role `env:"DEFAULT_ROLE"`
|
||||
}
|
||||
|
||||
// Service Represents a service with configuration and database connection.
|
||||
type Service struct {
|
||||
cfg *Config
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// New creates a new Service instance, initializing a default admin user and saving it to the database.
|
||||
func New(cfg *Config, db *gorm.DB) *Service {
|
||||
user := model.NewUser()
|
||||
var err error
|
||||
if user.Password, err = bcrypt.GenerateFromPassword([]byte(cfg.AdminName), 6); err != nil {
|
||||
log.Fatalf("Error while creating default user: %v", err)
|
||||
}
|
||||
user.Name = cfg.AdminName
|
||||
user.Role = model.RoleAdmin
|
||||
|
||||
// add default user
|
||||
_ = db.Clauses(clause.OnConflict{DoNothing: true}).Save(&user).Error
|
||||
|
||||
return &Service{
|
||||
cfg,
|
||||
db,
|
||||
}
|
||||
}
|
||||
|
||||
// Claims struct represents JWT claims, including role and user ID, extending the standard jwt.RegisteredClaims.
|
||||
type Claims struct {
|
||||
Role model.Role `json:"rl"`
|
||||
UserID uint `json:"uid"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Login struct represents user login credentials with a name and password.
|
||||
type Login struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"Password"`
|
||||
}
|
||||
|
||||
// LoginResponse Represents the response from a login endpoint, containing a JWT token.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/auth"
|
||||
)
|
||||
|
||||
// Config holds configuration settings for the application, loaded from environment variables.
|
||||
type Config struct {
|
||||
Port int `env:"PORT"`
|
||||
Host string `env:"HOST"`
|
||||
DBPath string `env:"DB_PATH"`
|
||||
Auth auth.Config `env:"AUTH"`
|
||||
}
|
||||
|
||||
// Default returns a default configuration with pre-defined values.
|
||||
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",
|
||||
},
|
||||
}
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/auth"
|
||||
"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/internal/posts"
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/pkg/cors"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// CreateMux creates and configures a mux router with authentication and post-related routes.
|
||||
func CreateMux(cfg *config.Config) (r *mux.Router) {
|
||||
db := model.Init()
|
||||
blg := posts.New(db)
|
||||
auth := auth.New(&cfg.Auth, db)
|
||||
|
||||
r = mux.NewRouter()
|
||||
r.Use(cors.HandlerForOrigin("*"))
|
||||
|
||||
// auth
|
||||
r.HandleFunc("/login", auth.Login).Methods("POST")
|
||||
r.HandleFunc("/signup", auth.Signup).Methods("POST")
|
||||
r.Handle("/logout", auth.Authenticated(auth.Logout)).Methods("DELETE")
|
||||
|
||||
// Posts
|
||||
r.Handle("/posts", auth.Authenticated(blg.SavePost, model.RoleUser, model.RoleAdmin)).Methods("POST")
|
||||
r.Handle("/posts", auth.Authenticated(blg.SavePost, model.RoleUser, model.RoleAdmin)).Methods("PUT")
|
||||
r.Handle("/posts/{postID}", auth.Authenticated(blg.DeletePost, model.RoleUser, model.RoleAdmin)).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
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = " admin"
|
||||
RoleUser Role = "user"
|
||||
RoleGuest Role = "guest"
|
||||
)
|
||||
|
||||
// InvalidJWT Represents a JWT that has expired or is otherwise invalid.
|
||||
type InvalidJWT struct {
|
||||
JWT string `gorm:"primarykey"`
|
||||
ValidUntil time.Time
|
||||
}
|
||||
|
||||
// User represents a user with an ID, UUID, name, role, and password.
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey" json:"-"`
|
||||
UUID uuid.UUID `gorm:"type:uuid" json:"uuid"`
|
||||
Name string `json:"name" gorm:"unique"`
|
||||
Role Role `json:"role"`
|
||||
Password []byte `json:"-"`
|
||||
}
|
||||
|
||||
// NewUser creates a new User struct with a generated UUID.
|
||||
func NewUser() User {
|
||||
return User{
|
||||
UUID: uuid.New(),
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Post represents a blog post with associated comments and user ID.
|
||||
type Post struct {
|
||||
gorm.Model
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Title string `json:"title"`
|
||||
TLDR string `json:"tldr"`
|
||||
Content string `json:"content"`
|
||||
Comments []Comment
|
||||
UserID uint `gorm:"->;<-:create"`
|
||||
}
|
||||
|
||||
// Comment represents a comment on a post, including its ID, post association, content, and creator.
|
||||
type Comment struct {
|
||||
ID uint
|
||||
PostID uint
|
||||
Content string `json:"content"`
|
||||
UserID uint `gorm:"->;<-:create"`
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Init initializes the database connection, auto-migrates models, and seeds a default post.
|
||||
func Init() *gorm.DB {
|
||||
_, filerr := os.Open("./blog.db")
|
||||
db, err := gorm.Open(sqlite.Open("./blog.db"))
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
db.AutoMigrate(&Post{}, &Comment{}, &User{}, &InvalidJWT{})
|
||||
|
||||
if filerr != nil {
|
||||
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
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
package posts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/auth"
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// SavePost handles saving a new post to the database after extracting user claims and decoding the request body.
|
||||
func (s Service) SavePost(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.ExtractClaims(r.Context())
|
||||
if !ok {
|
||||
log.Println("Err could not ExtractClaims")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var post model.Post
|
||||
if err := json.NewDecoder(r.Body).Decode(&post); err != nil {
|
||||
fmt.Fprint(w, err.Error())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
post.UserID = claims.UserID
|
||||
|
||||
if err := s.db.Save(&post).Error; err != nil {
|
||||
fmt.Fprint(w, err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&post)
|
||||
if err != nil {
|
||||
fmt.Fprint(w, err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(res)
|
||||
}
|
||||
|
||||
// GetAllPosts retrieves all posts from the database, eager-loads comments, orders them by creation time, and returns them as JSON.
|
||||
func (s Service) GetAllPosts(w http.ResponseWriter, r *http.Request) {
|
||||
var posts []model.Post
|
||||
|
||||
if err := s.db.Preload("Comments").Order("created_at DESC").Find(&posts).Error; err != nil {
|
||||
fmt.Fprint(w, err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&posts)
|
||||
if err != nil {
|
||||
fmt.Fprint(w, err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(res)
|
||||
}
|
||||
|
||||
// DeletePost handles deleting a post from the database based on its ID and user authentication.
|
||||
func (s Service) DeletePost(w http.ResponseWriter, r *http.Request) {
|
||||
idStr, ok := mux.Vars(r)["postID"]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
claims, ok := auth.ExtractClaims(r.Context())
|
||||
if !ok {
|
||||
log.Println("Err could not ExtractClaims")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = s.db.Where("user_id = ?", claims.UserID).Delete(&model.Post{}, id).Error
|
||||
if err != nil {
|
||||
fmt.Fprint(w, err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
package posts
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// Service Represents a service with a database connection.
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// Service New creates a new Service instance, initializing it with a GORM database connection.
|
||||
func New(db *gorm.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/auth"
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ChangePassword handles changing a user's password by decoding a request, validating input, hashing the password, and updating the database.
|
||||
func (s Service) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var req Password
|
||||
user := model.NewUser()
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := auth.ExtractClaims(r.Context()); !ok {
|
||||
log.Println("Error: was not able to extract Claims")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
} else {
|
||||
user.ID = claims.UserID
|
||||
}
|
||||
|
||||
if len([]byte(req.Password)) > 72 {
|
||||
fmt.Fprint(w, "Password to long, max 72 bytes")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if user.Password, err = bcrypt.GenerateFromPassword([]byte(req.Password), 6); err != nil {
|
||||
log.Println("Error: ", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = s.db.Model(&user).
|
||||
Where("id = ?", user.ID).
|
||||
Update("password", user.Password).
|
||||
Error
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service Represents a service with a database connection.
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// Service - Creates a new Service instance, initializing it with a GORM database connection.
|
||||
func New(db *gorm.DB) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Password struct represents a user's password with a JSON tag.
|
||||
type Password struct {
|
||||
Password string `json:"password"`
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/auth"
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/backend/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetUsers retrieves all users from the database and returns them as a JSON response.
|
||||
func (s *Service) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
var users []model.User
|
||||
|
||||
err := s.db.Find(&users).Error
|
||||
if err != nil {
|
||||
log.Printf("Error while getting users: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&users)
|
||||
if err != nil {
|
||||
log.Printf("Error while marshaling users: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Write(res)
|
||||
}
|
||||
|
||||
// SetUserRole handles updating a user's role based on a UUID from the request.
|
||||
func (s *Service) SetUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
var role model.Role
|
||||
userUUIDstr, ok := mux.Vars(r)["userUUID"]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
userUUID, err := uuid.Parse(userUUIDstr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&role); err != nil {
|
||||
fmt.Fprint(w, err.Error())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = s.db.Model(&model.User{}).
|
||||
Where("uuid = ?", userUUID).
|
||||
Update("role", role).
|
||||
Error
|
||||
if err != nil {
|
||||
log.Printf("Error while update user role: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteUser handles the deletion of a user from the database, enforcing authorization checks.
|
||||
func (s *Service) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.ExtractClaims(r.Context())
|
||||
if !ok {
|
||||
log.Println("Error while extracting claims")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
userUUIDstr, ok := mux.Vars(r)["userUUID"]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
userUUID, err := uuid.Parse(userUUIDstr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Role != model.RoleAdmin && userUUIDstr != claims.Subject {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.db.Where("uuid = ?", userUUID).Delete(&model.User{}).Error; err != nil {
|
||||
|
||||
if errors.Is(err, gorm.ErrCheckConstraintViolated) {
|
||||
fmt.Fprint(w, "Username is already in use")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Error: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
package cors
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HandlerForOrigin returns a CORS middleware function that sets headers based on the requested origin.
|
||||
func HandlerForOrigin(origin string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeafaultHandler: Returns a handler that allows requests from any origin by delegating to a handler that allows all origins.
|
||||
func DeafaultHandler(next http.Handler) http.Handler {
|
||||
return HandlerForOrigin("*")(next)
|
||||
}
|
Reference in New Issue
Block a user