diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..75d130f --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,24 @@ +version: "2" +linters: + default: all + disable: + - wsl + - wsl_v5 + - depguard + - embeddedstructfieldcheck + - exhaustruct + - funcorder + - godot + - musttag + - nlreturn + settings: + varnamelen: + ignore-names: + - err + - w + - r + - db + revive: + rules: + - name: exported + disabled: true diff --git a/TODO.md b/TODO.md index 0b5c0c5..44b39de 100644 --- a/TODO.md +++ b/TODO.md @@ -17,6 +17,7 @@ - [ ] make fatcher only return data, and error - [ ] maybe sending forms as json to simplify code? - [ ] move basket_element_template to be together with script +- [ ] make fetchers only return data, and error, and deduce the http response based on that ## new features - [ ] Backup System diff --git a/auth/auth.go b/auth/auth.go index 938e5f4..b711692 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -1,45 +1,54 @@ package auth import ( - _ "embed" "errors" "log" "net/http" + "time" "github.com/Bronku/iroon/crypto" "github.com/Bronku/iroon/models" "gorm.io/gorm" ) -type Authenticator struct { - db *gorm.DB +type Config struct { + SessionExpiration time.Duration } -func New(db *gorm.DB) Authenticator { +type Authenticator struct { + db *gorm.DB + config Config +} + +func New(db *gorm.DB, config Config) Authenticator { err := db.AutoMigrate(&models.User{}, &models.Token{}) if err != nil { log.Fatal("failed to initialize authenticator", err) } - return Authenticator{db} + return Authenticator{db, config} } -// #todo only used once, maybe remove it entierly later +var ErrUserNotFound = errors.New("user with this login doesn't exist") +var ErrWrongCredentials = errors.New("wrong credentials") + +// #todo only used once, maybe remove it entirely later func (a *Authenticator) verifyCredentials(login, password string) error { var user models.User result := a.db.First(&user, "login = ?", login) if result.Error != nil { - return errors.New("user with this login doesn't exist") + return ErrUserNotFound } hash := crypto.PasswordHash(password, user.Salt) if hash == user.Password { return nil } - return errors.New("wrong credentials") + + return ErrWrongCredentials } -func (a *Authenticator) Middleware(in http.Handler) http.Handler { +func (a *Authenticator) Middleware(inner http.Handler) http.Handler { handler := http.NewServeMux() handler.HandleFunc("GET /login", getLogin) handler.HandleFunc("POST /login", a.login) @@ -50,31 +59,20 @@ func (a *Authenticator) Middleware(in http.Handler) http.Handler { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - in.ServeHTTP(w, r) + inner.ServeHTTP(w, r) }) + return handler } -// func (s *Store) AddUser(login, password string) error { -// _, exists := s.GetUser(login) -// if exists { -// return errors.New("the user already exists") -// } -// query := "insert into user (login, password, salt) values(?, ?, ?)" -// salt := crypto.GenerateKey() -// hash := crypto.PasswordHash(password, salt) -// _, err := s.db.Exec(query, login, hash, salt) -// if err == nil { -// s.users[login] = models.User{Password: hash, Salt: salt} -// } -// return err -// } +var ErrUsernameTaken = errors.New("username taken") + func (a *Authenticator) AddUser(login, password string) error { var user models.User result := a.db.First(&user, "login = ?", login) - if result.Error != gorm.ErrRecordNotFound { - return errors.New("user already exists") + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return ErrUsernameTaken } salt := crypto.GenerateKey() diff --git a/auth/get.go b/auth/get.go index 21b9ae2..07621a4 100644 --- a/auth/get.go +++ b/auth/get.go @@ -13,6 +13,5 @@ var loginHTML string // returns a simple login page func getLogin(w http.ResponseWriter, _ *http.Request) { w.Header().Set("content-type", "text/html") - fmt.Fprint(w, loginHTML) - return + _, _ = fmt.Fprint(w, loginHTML) } diff --git a/auth/session.go b/auth/session.go index c4caac1..175afa3 100644 --- a/auth/session.go +++ b/auth/session.go @@ -9,25 +9,29 @@ import ( "github.com/Bronku/iroon/models" ) +var ErrNoCookie = errors.New("session cookie not found") +var ErrSessionNotFound = errors.New("session not found") +var ErrSessionExpired = errors.New("session has expired") + func (a *Authenticator) getSession(r *http.Request) (models.Token, error) { - c, err := r.Cookie("token") + cookie, err := r.Cookie("token") if err != nil { - return models.Token{}, err + return models.Token{}, ErrNoCookie } var session models.Token - result := a.db.First(&session, "token = ?", c.Value) + result := a.db.First(&session, "token = ?", cookie.Value) if result.Error != nil { - return models.Token{}, errors.New("session not found") + return models.Token{}, ErrSessionNotFound } if time.Since(session.Expiration) > 0 { a.db.Delete(&session) - return models.Token{}, errors.New("session expired") + return models.Token{}, ErrSessionExpired } return session, nil } func (a *Authenticator) login(w http.ResponseWriter, r *http.Request) { - r.ParseForm() + _ = r.ParseForm() login := r.PostFormValue("login") password := r.PostFormValue("password") if a.verifyCredentials(login, password) != nil { @@ -60,7 +64,7 @@ func (a *Authenticator) newSession(user string) http.Cookie { key := crypto.GenerateKey() session := models.Token{ User: user, - Expiration: time.Now().Add(time.Hour * 24), + Expiration: time.Now().Add(a.config.SessionExpiration), Token: key, } a.db.Create(&session) diff --git a/config.default.toml b/config.default.toml new file mode 100644 index 0000000..712f569 --- /dev/null +++ b/config.default.toml @@ -0,0 +1,11 @@ +[Database] +File = "foo.db" + +[Authentication] +DefaultLogin = "admin" +DefaultPassword = "secret" +SessionExpiration = "24h" + +[Server] +Addr = ":8080" +ReadHeaderTimeout = "3s" diff --git a/crypto/crypto.go b/crypto/crypto.go index 8863c67..7af21d0 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -3,19 +3,23 @@ package crypto import ( "crypto/rand" "encoding/base64" - "log" "golang.org/x/crypto/argon2" ) func PasswordHash(password, salt string) string { - return string(argon2.Key([]byte(password), []byte(salt), 3, 32*1024, 4, 32)) + var time uint32 = 3 + var memory uint32 = 32 * 1024 + var threads uint8 = 4 + var length uint32 = 32 + + return string(argon2.Key([]byte(password), []byte(salt), time, memory, threads, length)) } func GenerateKey() string { key := [32]byte{} - if _, err := rand.Read(key[:]); err != nil { - log.Fatal("can't generate a valid key", err) - } + // rand.Read always panics when encountering an error, so checking it is pointless, as the program has already panicked + _, _ = rand.Read(key[:]) + return base64.StdEncoding.EncodeToString(key[:]) } diff --git a/go.mod b/go.mod index 99d910e..3a14f02 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,12 @@ module github.com/Bronku/iroon go 1.23.5 -require github.com/mattn/go-sqlite3 v1.14.24 - -require github.com/knaka/go-sqlite3-fts5 v0.0.0-20240729040425-e53b86878d0d +require github.com/BurntSushi/toml v1.5.0 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.23.0 // indirect ) diff --git a/go.sum b/go.sum index fde8d58..c88890c 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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/knaka/go-sqlite3-fts5 v0.0.0-20240729040425-e53b86878d0d h1:I3lRivq7Zx0fqlKhCJG1KaL2tLG6aiHDj3bvJJqppKw= -github.com/knaka/go-sqlite3-fts5 v0.0.0-20240729040425-e53b86878d0d/go.mod h1:kDHCqub/PNhQnqg8ur7OYO49jpZ+pM0mqBQ768DE3UU= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= diff --git a/logging/error.go b/logging/error.go index 48799e7..a633018 100644 --- a/logging/error.go +++ b/logging/error.go @@ -8,6 +8,6 @@ import ( func ErrorPage(err error, status int) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) - fmt.Fprint(w, err.Error()) + _, _ = fmt.Fprint(w, err.Error()) } } diff --git a/main.go b/main.go index b42556e..9b11130 100644 --- a/main.go +++ b/main.go @@ -1,44 +1,94 @@ package main import ( + _ "embed" + "errors" "log" "net/http" "os" + "time" "github.com/Bronku/iroon/auth" "github.com/Bronku/iroon/logging" "github.com/Bronku/iroon/models" "github.com/Bronku/iroon/server" + "github.com/BurntSushi/toml" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) +type config struct { + Database struct { + File string + } + + Authentication struct { + auth.Config + DefaultLogin string + DefaultPassword string + } + + Server struct { + Addr string + ReadHeaderTimeout time.Duration + } +} + +//go:embed config.default.toml +var defaultConfig string + func main() { + // load config + var conf config + _, err := toml.Decode(defaultConfig, &conf) + if err != nil { + log.Fatal(err) + } + _, err = toml.DecodeFile("config.toml", &conf) + if err != nil { + log.Println("using default config") + } + + // load database newLogger := logger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{ IgnoreRecordNotFoundError: true}, ) - db, err := gorm.Open(sqlite.Open("foo.db"), &gorm.Config{ + db, err := gorm.Open(sqlite.Open(conf.Database.File), &gorm.Config{ Logger: newLogger, }) if err != nil { log.Fatal("failed to connect database") } - db.AutoMigrate(&models.Order{}, &models.OrderItem{}, &models.Product{}) - - a := auth.New(db) - err = a.AddUser("admin", "secret") + err = db.AutoMigrate(&models.Order{}, &models.OrderItem{}, &models.Product{}) if err != nil { + log.Fatal("couldn't migrate database") + } + + // load auth + authenticator := auth.New(db, conf.Authentication.Config) + err = authenticator.AddUser(conf.Authentication.DefaultLogin, conf.Authentication.DefaultPassword) + if err != nil && !errors.Is(err, auth.ErrUsernameTaken) { log.Println("add user:", err) } + // load server h := server.New(db) - var handler http.Handler = h - handler = a.Middleware(handler) + handler = authenticator.Middleware(handler) handler = logging.Middleware(handler) log.Println("starting server") - log.Fatal(http.ListenAndServe(":8080", handler)) + + // start server + server := &http.Server{ + Handler: handler, + Addr: conf.Server.Addr, + ReadHeaderTimeout: conf.Server.ReadHeaderTimeout, + } + err = server.ListenAndServe() + if err != nil { + log.Fatal(err) + } } diff --git a/models/order.go b/models/order.go index 6d6adb9..4164968 100644 --- a/models/order.go +++ b/models/order.go @@ -37,5 +37,6 @@ func (o *Order) Total() uint { out += e.Total() } out -= o.Prepaid + return out } diff --git a/server/errors.go b/server/errors.go new file mode 100644 index 0000000..3c7aefe --- /dev/null +++ b/server/errors.go @@ -0,0 +1,11 @@ +// errors are in a dedicated file, because they are used in more than one place +package server + +import "errors" + +var ErrInvalidForm = errors.New("the form has invalid syntax") +var ErrSavingToDatabase = errors.New("error saving to the database") +var ErrWrongValue = errors.New("error converting or getting value") + +// should not occur during runtime +var ErrCatalogueNotFound = errors.New("cake catalogue not found on the server") diff --git a/server/get.go b/server/get.go index 2e7a1f6..32b65a2 100644 --- a/server/get.go +++ b/server/get.go @@ -2,19 +2,19 @@ package server import ( "fmt" - "log" "net/http" "strconv" "strings" "time" "github.com/Bronku/iroon/models" + "gorm.io/gorm" "gorm.io/gorm/clause" ) -func monthInterval(y int, m time.Month) (firstDay, lastDay time.Time) { - firstDay = time.Date(y, m, 1, 0, 0, 0, 0, time.UTC) - lastDay = time.Date(y, m+1, 1, 0, 0, 0, -1, time.UTC) +func monthInterval(y int, m time.Month) (time.Time, time.Time) { + firstDay := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC) + lastDay := time.Date(y, m+1, 1, 0, 0, 0, -1, time.UTC) return firstDay, lastDay } @@ -25,11 +25,13 @@ func (h *Server) orders(_ *http.Request) (any, int, error) { ) y, m, _ = time.Now().Date() first, last := monthInterval(y, m) - fmt.Println(first, last) var orders []models.Order - result := h.db.Preload("OrderItems.Product").Preload(clause.Associations).Where("date between ? and ?", first, last).Find(&orders) + result := h.db. + Preload("OrderItems.Product"). + Preload(clause.Associations). + Where("date between ? and ?", first, last). + Find(&orders) if result.Error != nil { - log.Println("eroor getting orders") return nil, http.StatusInternalServerError, result.Error } data := struct { @@ -41,16 +43,20 @@ func (h *Server) orders(_ *http.Request) (any, int, error) { } func (h *Server) ordersSearch(r *http.Request) (any, int, error) { - from, err := time.Parse("2006-01-02", r.URL.Query().Get("from")) + startTime, err := time.Parse("2006-01-02", r.URL.Query().Get("from")) if err != nil { - from = time.Time{} + startTime = time.Time{} } - to, err := time.Parse("2006-01-02", r.URL.Query().Get("to")) + endTime, err := time.Parse("2006-01-02", r.URL.Query().Get("to")) if err != nil { - to = time.Time{} + endTime = time.Time{} } var orders []models.Order - result := h.db.Preload("OrderItems.Product").Preload(clause.Associations).Where("date between ? and ?", from, to).Find(&orders) + result := h.db. + Preload("OrderItems.Product"). + Preload(clause.Associations). + Where("date between ? and ?", startTime, endTime). + Find(&orders) return orders, http.StatusOK, result.Error } @@ -66,14 +72,17 @@ func (h *Server) cake(r *http.Request) (any, int, error) { return models.Product{}, http.StatusOK, nil } - id, err := strconv.Atoi(url[2]) + cakeID, err := strconv.Atoi(url[2]) if err != nil { - return nil, http.StatusBadRequest, err + return nil, http.StatusBadRequest, fmt.Errorf("%w cakeID", ErrWrongValue) } var cake models.Product - result := h.db.First(&cake, id) - return cake, http.StatusOK, result.Error + result := h.db.First(&cake, cakeID) + if result.Error != nil { + return models.Product{}, http.StatusNotFound, gorm.ErrRecordNotFound + } + return cake, http.StatusOK, nil } func (h *Server) order(r *http.Request) (any, int, error) { @@ -85,7 +94,7 @@ func (h *Server) order(r *http.Request) (any, int, error) { result := h.db.Find(&data.Catalogue) if result.Error != nil { - return nil, http.StatusInternalServerError, result.Error + return nil, http.StatusInternalServerError, ErrCatalogueNotFound } url := strings.Split(r.URL.String(), "/") @@ -93,15 +102,14 @@ func (h *Server) order(r *http.Request) (any, int, error) { return data, http.StatusOK, nil } - id, err := strconv.Atoi(url[2]) + orderID, err := strconv.Atoi(url[2]) if err != nil { - return nil, http.StatusBadRequest, err + return nil, http.StatusBadRequest, fmt.Errorf("%w orderID", ErrWrongValue) } - result = h.db.Preload("OrderItems.Product").Preload(clause.Associations).Find(&data.Order, id) - //result = h.db.Find(&data.Order, id) + result = h.db.Preload("OrderItems.Product").Preload(clause.Associations).Find(&data.Order, orderID) if result.Error != nil { - return nil, http.StatusNotFound, result.Error + return nil, http.StatusNotFound, gorm.ErrRecordNotFound } return data, http.StatusOK, nil diff --git a/server/post.go b/server/post.go index aff8bec..3c33d88 100644 --- a/server/post.go +++ b/server/post.go @@ -1,14 +1,15 @@ package server import ( + "errors" "fmt" - "log" "net/http" "strconv" "strings" "time" "github.com/Bronku/iroon/models" + "gorm.io/gorm" ) func atoui(input string) (uint, error) { @@ -19,73 +20,87 @@ func atoui(input string) (uint, error) { func (h *Server) postCake(r *http.Request) (any, int, error) { err := r.ParseForm() if err != nil { - return nil, http.StatusInternalServerError, err + return nil, http.StatusBadRequest, ErrInvalidForm } - var n models.Product - n.ID, err = atoui(r.FormValue("id")) + var postedCake models.Product + postedCake.ID, err = atoui(r.FormValue("id")) if err != nil { - return nil, http.StatusBadRequest, err + return nil, http.StatusBadRequest, fmt.Errorf("%w cakeID", ErrWrongValue) } - n.Name = r.FormValue("name") - n.Price, err = atoui(r.FormValue("price")) + postedCake.Name = r.FormValue("name") + postedCake.Price, err = atoui(r.FormValue("price")) if err != nil { - return nil, http.StatusBadRequest, err + return nil, http.StatusBadRequest, fmt.Errorf("%w price", ErrWrongValue) } - result := h.db.Save(&n) - return n, http.StatusAccepted, result.Error + result := h.db.Save(&postedCake) + return postedCake, http.StatusAccepted, errors.Join(ErrSavingToDatabase, result.Error) } -func (h *Server) postOrder(r *http.Request) (any, int, error) { +func (h *Server) parseOrder(r *http.Request) (models.Order, error) { var cakes []models.Product result := h.db.Find(&cakes) if result.Error != nil { - return nil, http.StatusInternalServerError, result.Error + return models.Order{}, ErrCatalogueNotFound } err := r.ParseForm() - log.Println("received form:", r.PostForm) if err != nil { - return nil, http.StatusBadRequest, err + return models.Order{}, ErrInvalidForm } - var n models.Order - n.ID, err = atoui(r.FormValue("id")) + var postedOrder models.Order + postedOrder.ID, err = atoui(r.FormValue("id")) if err != nil { - return nil, http.StatusBadRequest, err + return models.Order{}, fmt.Errorf("%w orderID", ErrWrongValue) } - n.Prepaid, err = atoui(r.FormValue("paid")) + postedOrder.Prepaid, err = atoui(r.FormValue("paid")) if err != nil { - return nil, http.StatusBadRequest, err + return models.Order{}, fmt.Errorf("%w prepaid", ErrWrongValue) } - n.Date, err = time.Parse("2006-01-02", r.FormValue("date")) + postedOrder.Date, err = time.Parse("2006-01-02", r.FormValue("date")) if err != nil { - return nil, http.StatusBadRequest, err + return models.Order{}, fmt.Errorf("%w date", ErrWrongValue) } - n.OrderItems = make([]models.OrderItem, 0) + postedOrder.OrderItems = make([]models.OrderItem, 0) for _, e := range cakes { count, err := atoui(r.FormValue(fmt.Sprintf("cake[%d]", e.ID))) if err != nil { continue } - n.OrderItems = append(n.OrderItems, models.OrderItem{Amount: count, Product: e}) + postedOrder.OrderItems = append(postedOrder.OrderItems, models.OrderItem{Amount: count, Product: e}) } - n.Name = strings.TrimSpace(r.FormValue("name")) - n.Surname = strings.TrimSpace(r.FormValue("surname")) - n.Phone = strings.TrimSpace(r.FormValue("phone")) - n.Location = strings.TrimSpace(r.FormValue("location")) - n.Status = strings.TrimSpace(r.FormValue("status")) - - log.Println("parsed order:", n) - err = h.db.Save(&n).Error - if err != nil { - log.Println("save: ", err) - } - err = h.db.Model(&n).Association("OrderItems").Replace(n.OrderItems) - if err != nil { - log.Println("replace: ", err) - } - return n, http.StatusAccepted, nil + postedOrder.Name = strings.TrimSpace(r.FormValue("name")) + postedOrder.Surname = strings.TrimSpace(r.FormValue("surname")) + postedOrder.Phone = strings.TrimSpace(r.FormValue("phone")) + postedOrder.Location = strings.TrimSpace(r.FormValue("location")) + postedOrder.Status = strings.TrimSpace(r.FormValue("status")) + return postedOrder, nil +} + +func (h *Server) postOrder(r *http.Request) (any, int, error) { + postedOrder, err := h.parseOrder(r) + if err != nil { + return nil, http.StatusBadRequest, err + } + + // i have no idea why this works, and other methods don't + err = h.db.Transaction(func(tx *gorm.DB) error { + err = tx.Session(&gorm.Session{FullSaveAssociations: true}).Updates(&postedOrder).Error + if err != nil { + return fmt.Errorf("didn't save the order, %w", err) + } + err = tx.Model(&postedOrder).Association("OrderItems").Replace(postedOrder.OrderItems) + if err != nil { + return fmt.Errorf("didn't save the order items %w", err) + } + return nil + }) + if err != nil { + return nil, http.StatusInternalServerError, errors.Join(ErrSavingToDatabase, err) + } + + return postedOrder, http.StatusAccepted, nil }