serve frontend from go

This commit is contained in:
u80864958
2025-05-05 10:00:50 +02:00
parent a06444c4df
commit 73a62b63ae
88 changed files with 76 additions and 36 deletions

113
internal/auth/controller.go Normal file
View File

@ -0,0 +1,113 @@
package auth
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"git.schreifuchs.ch/schreifuchs/ng-blog/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)
}

22
internal/auth/ctx.go Normal file
View File

@ -0,0 +1,22 @@
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
}

79
internal/auth/jwt.go Normal file
View File

@ -0,0 +1,79 @@
package auth
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.schreifuchs.ch/schreifuchs/ng-blog/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
}

76
internal/auth/jwt_test.go Normal file
View File

@ -0,0 +1,76 @@
package auth
import (
"log"
"testing"
"time"
"git.schreifuchs.ch/schreifuchs/ng-blog/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")
}
})
}
}

View File

@ -0,0 +1,35 @@
package auth
import (
"net/http"
"slices"
"git.schreifuchs.ch/schreifuchs/ng-blog/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)
})
}

64
internal/auth/resource.go Normal file
View File

@ -0,0 +1,64 @@
package auth
import (
"log"
"time"
"git.schreifuchs.ch/schreifuchs/ng-blog/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"`
}

View File

@ -0,0 +1,30 @@
package config
import (
"time"
"git.schreifuchs.ch/schreifuchs/ng-blog/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",
},
}
}

View File

@ -0,0 +1,49 @@
package initialize
import (
"net/http"
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/auth"
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/config"
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/model"
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/posts"
"git.schreifuchs.ch/schreifuchs/ng-blog/pkg/cors"
"git.schreifuchs.ch/schreifuchs/ng-blog/pkg/middlewares"
"git.schreifuchs.ch/schreifuchs/ng-blog/web"
"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) {
r = mux.NewRouter()
r.Use(cors.HandlerForOrigin("*"))
app(r.PathPrefix("/api").Subrouter(), cfg)
frontend := web.Frontend
r.PathPrefix("/").Handler(middlewares.AddPrefix("/dist/frontend/browser", http.FileServerFS(frontend)))
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
}
func app(r *mux.Router, cfg *config.Config) {
db := model.Init()
blg := posts.New(db)
auth := auth.New(&cfg.Auth, db)
// 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")
}

37
internal/model/auth.go Normal file
View File

@ -0,0 +1,37 @@
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(),
}
}

24
internal/model/blog.go Normal file
View File

@ -0,0 +1,24 @@
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"`
}

41
internal/model/init.go Normal file
View File

@ -0,0 +1,41 @@
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
}

View File

@ -0,0 +1,93 @@
package posts
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/auth"
"git.schreifuchs.ch/schreifuchs/ng-blog/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)
}

View File

@ -0,0 +1,13 @@
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}
}

View File

@ -0,0 +1,52 @@
package users
import (
"encoding/json"
"fmt"
"log"
"net/http"
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/auth"
"git.schreifuchs.ch/schreifuchs/ng-blog/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)
}

View File

@ -0,0 +1,22 @@
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"`
}

107
internal/users/roles.go Normal file
View File

@ -0,0 +1,107 @@
package users
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/auth"
"git.schreifuchs.ch/schreifuchs/ng-blog/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)
}