diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index e2b7a1e..2252034 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -58,7 +58,9 @@ func main() { // kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" { if _, err := os.Stat(envFajl); os.IsNotExist(err) { - os.WriteFile(envFajl, []byte(""), 0600) + if err := os.WriteFile(envFajl, []byte(""), 0600); err != nil { + slog.Error("kreiranje praznog ntech.env nije uspelo", "putanja", envFajl, "error", err) + } } } godotenv.Load(envFajl) @@ -141,7 +143,9 @@ func main() { napraviBackup(db, putanjaBaze, max) } - os.MkdirAll("web/static/uploads", 0755) + if err := os.MkdirAll("web/static/uploads", 0755); err != nil { + slog.Error("kreiranje foldera za uploade nije uspelo", "error", err) + } h := handler.Novi(db, totpKljuc) h.Verzija = Verzija @@ -238,6 +242,16 @@ func main() { http.FileServer(http.FS(staticFS)).ServeHTTP(w, req) }))) + // health check — bez autentifikacije, za monitoring/orkestraciju (Docker healthcheck i sl.) + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + if err := db.PingContext(r.Context()); err != nil { + http.Error(w, "baza nedostupna", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + // javne rute (bez autentifikacije), ali i dalje sa CSRF zaštitom — sprečava login-CSRF r.Group(func(r chi.Router) { r.Use(ntechmw.CsrfMiddleware) @@ -254,9 +268,18 @@ func main() { // primenjuje isti pretpostavljeni napadački model (napadač bez tokena ne može // ni da pogodi rutu). r.Get("/status/{token}", h.ServisJavniStatus) - r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) - r.Post("/status/{token}/odbij", h.ServisJavniOdbij) - r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) + r.Group(func(r chi.Router) { + // telo javnih POST-ova (komentar klijenta) ograničeno na 4KB — nema razloga da bude veće + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 4<<10) + next.ServeHTTP(w, r) + }) + }) + r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) + r.Post("/status/{token}/odbij", h.ServisJavniOdbij) + r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) + }) r.Get("/v/", h.FiskalVerifikacija) // zaštićene rute — zahtevaju prijavljenog korisnika diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e1868c3..3d887df 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -29,7 +29,18 @@ func ProveriLozinku(hash, lozinka string) bool { // dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju. // Koristi ga IzjednaciVremeProvere kada korisnik ne postoji. -var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost) +var dummyHash []byte + +func init() { + h, err := bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost) + if err != nil { + // cost je fiksna konstanta i lozinka nije prazna — ovo se praktično ne + // može desiti; ako se ipak desi, tiho propadanje bi obesmislilo + // anti-enumeraciju u IzjednaciVremeProvere, pa je bolje pući na startu. + panic(fmt.Sprintf("ntech: auth: generisanje dummyHash nije uspelo: %v", err)) + } + dummyHash = h +} // IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme // odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom — diff --git a/internal/db/sqlite/izvestaj.go b/internal/db/sqlite/izvestaj.go index 4652e55..6e49473 100644 --- a/internal/db/sqlite/izvestaj.go +++ b/internal/db/sqlite/izvestaj.go @@ -57,7 +57,7 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) { var n int err := r.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min`).Scan(&n) + `SELECT COUNT(*) FROM artikli WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0`).Scan(&n) if err != nil { return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err) } @@ -86,7 +86,7 @@ func (r *sqliteIzvestajRepo) PoslednjiServisi(ctx context.Context, limit int) ([ func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) { rows, err := r.db.QueryContext(ctx, ` SELECT naziv, kolicina, kolicina_min FROM artikli - WHERE kolicina <= kolicina_min + WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0 ORDER BY kolicina ASC LIMIT ?`, limit) if err != nil { return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err) diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index e58d216..e965981 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -259,6 +259,9 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int } stavke = append(stavke, s) } + if err := redovi.Err(); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: rows: %w", err) + } // Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može) for _, s := range stavke { diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index 523e112..9283254 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -3,6 +3,7 @@ package handler import ( "log/slog" "net/http" + "net/url" "strconv" "strings" @@ -82,10 +83,10 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) { queryDelići := "" if pretraga != "" { - queryDelići += "&pretraga=" + pretraga + queryDelići += "&pretraga=" + url.QueryEscape(pretraga) } if tipFilter != "" { - queryDelići += "&tip=" + tipFilter + queryDelići += "&tip=" + url.QueryEscape(tipFilter) } stranicaPrev := stranicaBr - 1 diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index d11f62a..902a109 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -4,6 +4,7 @@ import ( "errors" "log/slog" "net/http" + "net/url" "strconv" "ntech/internal/db" @@ -104,10 +105,10 @@ func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, str // izgradi query string za paginaciju (čuva filtere) queryDelići := "" if v := filter.Pretraga; v != "" { - queryDelići += "&pretraga=" + v + queryDelići += "&pretraga=" + url.QueryEscape(v) } if katIDStr != "" { - queryDelići += "&kategorija=" + katIDStr + queryDelići += "&kategorija=" + url.QueryEscape(katIDStr) } if filter.SamoKriticni { queryDelići += "&kriticni=1" diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 9bcbe80..05a58c7 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -999,16 +999,18 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) { klijent := &http.Client{Timeout: 5 * time.Second} resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese) if err != nil { + slog.Error("test fiskalizacije: konekcija nije uspela", "url", statusURL, "error", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `