Nizak prioritet iz pregleda koda: indeksi, greške, sitne popravke

- migracija 102: indeksi na FK kolonama (stavke_prodaje, servisni_delovi,
  servisni_potrazivani_delovi, magacinske_promene, stavke_nabavke, pdv_kir,
  pdv_kpr) — potvrđeno kreiranje i idempotentnost
- BrojKriticnihZaliha/KriticneZalihe filtriraju samo proizvode i aktivne
  artikle, usklađeno sa ArtikalRepo.Lista(SamoKriticni)
- url.QueryEscape na query string u magacin.go/klijent.go (paginacija)
- TestFiskalizacije/BeStatus vraćaju generičku poruku, detalj u slog
- SetFlash loguje grešku umesto tihog ignorisanja
- os.WriteFile/os.MkdirAll u main.go proveravaju grešku
- dummyHash generisanje panikuje na startu ako bcrypt ikad zakaže
  (umesto tihog sloma anti-enumeracije)
- PrihvatiPredlozene proverava redovi.Err()
- /healthz endpoint (DB ping)
- hx-boost na "Osveži" linkovima (blokirane_ip, fiskal_pazar)
- MaxBytesReader (4KB) na javne /status/{token}/* POST rute
- komentar u auth.go zašto postoje dva flash mehanizma (namerno, ne spojeno)

Provereno: jePrivatnaAdresa ne radi DNS lookup (nema net.LookupIP u repou),
pa DNS-rebinding nalaz iz pregleda nije primenljiv na trenutni kod.
This commit is contained in:
2026-07-03 23:31:00 +02:00
parent 127cebea3c
commit d9f337617b
12 changed files with 90 additions and 21 deletions
+12 -1
View File
@@ -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 —
+2 -2
View File
@@ -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)
+3
View File
@@ -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 {
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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"
+7 -4
View File
@@ -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, `<div class="fisk-status greska">&#10007; Nije dostupan — %s</div>`, html.EscapeString(err.Error()))
fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Nije dostupan — proverite adresu i mrežu</div>`)
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, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera — %s</div>`, html.EscapeString(err.Error()))
fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera</div>`)
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, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s — %s</div>`,
html.EscapeString(addr), html.EscapeString(err.Error()))
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s</div>`,
html.EscapeString(addr))
return
}
defer conn.Close()
+6 -1
View File
@@ -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",
+5 -2
View File
@@ -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