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, `
✗ Nije dostupan — %s
`, html.EscapeString(err.Error())) + fmt.Fprint(w, `
✗ Nije dostupan — proverite adresu i mrežu
`) return } defer resp.Body.Close() var status map[string]any if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + slog.Error("test fiskalizacije: neispravan odgovor servera", "url", statusURL, "error", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `
✗ Neispravan odgovor servera — %s
`, html.EscapeString(err.Error())) + fmt.Fprint(w, `
✗ Neispravan odgovor servera
`) return } @@ -1041,9 +1043,10 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) { conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { + slog.Error("BeStatus: emulator nedostupan", "addr", addr, "error", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `
✗ Emulator nije dostupan na %s — %s
`, - html.EscapeString(addr), html.EscapeString(err.Error())) + fmt.Fprintf(w, `
✗ Emulator nije dostupan na %s
`, + html.EscapeString(addr)) return } defer conn.Close() diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index c8b5447..f83f0f0 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -149,7 +149,12 @@ func RequireDozvolaMut(proveri func(ctx context.Context, uloga, akcija string) b } } -// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić +// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić. Namerno +// odvojen mehanizam od SetFlash/GetFlash (flash.go): ove funkcije se pozivaju +// iz middleware-a koji nema pristup *sql.DB, a redirect je uvek na /dashboard +// (jedino mesto koje čita ovaj kolačić) — pa kolačić bez DB upisa dovoljan. +// SetFlash/GetFlash (kolona sesije.flash) koristi se u handlerima koji imaju +// DB i redirect na proizvoljnu stranicu. func postaviFlashGresku(w http.ResponseWriter, poruka string) { http.SetCookie(w, &http.Cookie{ Name: "ntech_flash_greska", diff --git a/internal/middleware/flash.go b/internal/middleware/flash.go index 5421c43..88552ba 100644 --- a/internal/middleware/flash.go +++ b/internal/middleware/flash.go @@ -3,6 +3,7 @@ package middleware import ( "database/sql" "encoding/json" + "log/slog" "net/http" "ntech/internal/model" @@ -18,9 +19,11 @@ func SetFlash(w http.ResponseWriter, r *http.Request, db *sql.DB, tip, poruka st if err != nil { return } - db.ExecContext(r.Context(), + if _, err := db.ExecContext(r.Context(), `UPDATE sesije SET flash = ? WHERE token = ?`, - string(data), kolacic.Value) + string(data), kolacic.Value); err != nil { + slog.Error("SetFlash: upis nije uspeo", "error", err) + } } // GetFlash čita i atomično briše flash poruku iz aktivne sesije diff --git a/migrations/102_indeksi_fk_kolona.sql b/migrations/102_indeksi_fk_kolona.sql new file mode 100644 index 0000000..ab6c512 --- /dev/null +++ b/migrations/102_indeksi_fk_kolona.sql @@ -0,0 +1,19 @@ +-- Indeksi na FK kolonama koje se često JOIN-uju/filtriraju (detalji naloga, izveštaji). +-- Bez indeksa ove tabele rade full table scan koji raste sa prometom. +CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_nalog_id ON stavke_prodaje(nalog_id); +CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_artikal_id ON stavke_prodaje(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_servisni_delovi_nalog_id ON servisni_delovi(nalog_id); +CREATE INDEX IF NOT EXISTS idx_servisni_delovi_artikal_id ON servisni_delovi(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_nalog_id ON servisni_potrazivani_delovi(nalog_id); +CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_artikal_id ON servisni_potrazivani_delovi(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_magacinske_promene_artikal_id ON magacinske_promene(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_stavke_nabavke_nabavka_id ON stavke_nabavke(nabavka_id); + +CREATE INDEX IF NOT EXISTS idx_pdv_kir_izvor ON pdv_kir(izvor, izvor_id); +CREATE INDEX IF NOT EXISTS idx_pdv_kir_broj_dokumenta ON pdv_kir(broj_dokumenta); + +CREATE INDEX IF NOT EXISTS idx_pdv_kpr_izvor ON pdv_kpr(izvor, izvor_id); diff --git a/web/templates/stranice/blokirane_ip.html b/web/templates/stranice/blokirane_ip.html index f2980e0..f938f67 100644 --- a/web/templates/stranice/blokirane_ip.html +++ b/web/templates/stranice/blokirane_ip.html @@ -7,7 +7,7 @@
IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave - Osveži + Osveži
{{if .Lista}} diff --git a/web/templates/stranice/fiskal_pazar.html b/web/templates/stranice/fiskal_pazar.html index e624895..df51c62 100644 --- a/web/templates/stranice/fiskal_pazar.html +++ b/web/templates/stranice/fiskal_pazar.html @@ -90,7 +90,7 @@
- Osveži + Osveži {{if index .Dozvole "fiskal.zakljucenje"}}