Merge grane develop: bezbednosne popravke, refaktor i README ažuriranja

This commit is contained in:
2026-07-04 01:36:02 +02:00
52 changed files with 710 additions and 459 deletions
+9 -1
View File
@@ -1,11 +1,14 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
@@ -159,7 +162,12 @@ func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := h.DB.ExecContext(r.Context(), "DELETE FROM kategorije WHERE id = ?", id); err != nil {
if err := h.KategorijeRepo.Obrisi(r.Context(), id); err != nil {
if errors.Is(err, db.ErrKategorijaUUpotrebi) {
middleware.SetFlash(w, r, h.DB, "greska", "Kategorija je u upotrebi kod artikala i ne može se obrisati.")
http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther)
return
}
http.Error(w, "Greška pri brisanju kategorije", http.StatusInternalServerError)
return
}
+23 -2
View File
@@ -48,6 +48,16 @@ var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "servis_eskalacioni_list", "fiskal_verifikacija", "popis_stampa",
}
// standaloneExtraFajlovi su dodatni fajlovi (partial-i sa {{define}}) koje treba
// parsirati uz standalone šablon — npr. zajedničko zaglavlje servisnih dokumenata,
// koje se deli između revers/predračun/otpremnica/eskalacioni list.
var standaloneExtraFajlovi = map[string][]string{
"servis_revers": {"web/templates/komponente/servis_zaglavlje.html"},
"servis_predracun": {"web/templates/komponente/servis_zaglavlje.html"},
"servis_otpremnica": {"web/templates/komponente/servis_zaglavlje.html"},
"servis_eskalacioni_list": {"web/templates/komponente/servis_zaglavlje.html"},
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
// dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu
// prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}).
@@ -129,6 +139,15 @@ var sablonskeFunkcije = template.FuncMap{
},
// zbirF64 vraća zbir dva float64 — za aritmetiku u šablonima
"zbirF64": func(a, b float64) float64 { return a + b },
// inicijali vraća prva najviše n RUNA stringa (ne bajta) — sigurno za ćirilicu
// i č/ć/š/ž/đ, za razliku od ugrađenog {{slice}} koji seče po bajtovima.
"inicijali": func(s string, n int) string {
r := []rune(s)
if len(r) > n {
r = r[:n]
}
return string(r)
},
}
// KreirajKes parsuje sve šablone iz fsys i vraća ih keširane u mapi
@@ -148,7 +167,8 @@ func KreirajKes(fsys fs.FS) (map[string]*template.Template, error) {
for _, ime := range standaloneIme {
// ime+".html" mora biti ime roota da bi Execute() pronašlo sadržaj fajla
t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, "web/templates/stranice/"+ime+".html")
fajlovi := append([]string{"web/templates/stranice/" + ime + ".html"}, standaloneExtraFajlovi[ime]...)
t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, fajlovi...)
if err != nil {
return nil, fmt.Errorf("kes: %s: %w", ime, err)
}
@@ -210,7 +230,8 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a
} else {
var err error
// kao u kreirajKes: root mora biti ime+".html" i moraju biti registrovane šablonske funkcije
if tmpl, err = template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(h.TemplatesFS, "web/templates/stranice/"+ime+".html"); err != nil {
fajlovi := append([]string{"web/templates/stranice/" + ime + ".html"}, standaloneExtraFajlovi[ime]...)
if tmpl, err = template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(h.TemplatesFS, fajlovi...); err != nil {
slog.Error("greška pri parsiranju šablona", "ime", ime, "error", err)
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
+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"
+2 -2
View File
@@ -374,13 +374,13 @@ func parseFormuArtikla(r *http.Request, podrazumevanaStopa float64) (model.Artik
if err != nil || v < 0 {
return artikal, "Minimalna količina mora biti pozitivan broj."
}
artikal.KolicinMin = v
artikal.KolicinaMin = v
}
// usluge i troškovi nemaju stanje na lageru
if !artikal.PratiLager() {
artikal.Kolicina = 0
artikal.KolicinMin = 0
artikal.KolicinaMin = 0
}
if c := r.FormValue("nabavna_cena"); c != "" {
+17 -2
View File
@@ -196,12 +196,27 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
return
}
// jedan batch-dohvat svih artikala iz stavki — umesto DohvatiID po stavki u svakoj petlji ispod
artikalIDji := make([]int64, 0, len(stavke))
vidjeni := make(map[int64]bool, len(stavke))
for _, s := range stavke {
if !vidjeni[s.ArtikalID] {
vidjeni[s.ArtikalID] = true
artikalIDji = append(artikalIDji, s.ArtikalID)
}
}
artikliMapa, err := h.Artikli.DohvatiVise(r.Context(), artikalIDji)
if err != nil {
slog.Error("dohvat artikala za nabavku nije uspeo", "error", err)
artikliMapa = map[int64]*model.Artikal{}
}
// automatski zavedi u KPR ako je firma PDV obveznik; PDV se izvodi iz stope artikla
if h.modulUkljucen(r.Context(), "pdv") {
var stavkePdv []model.NabavkaStavkaPdv
for _, s := range stavke {
var stopa float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil {
if a, ok := artikliMapa[s.ArtikalID]; ok {
stopa = a.PdvStopa
}
stavkePdv = append(stavkePdv, model.NabavkaStavkaPdv{
@@ -241,7 +256,7 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
}
// stara prodajna i tekuća (ponderisana) nabavna — nabavnu zadržavamo
var staraProdajna, nabavna float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil {
if a, ok := artikliMapa[s.ArtikalID]; ok {
staraProdajna = a.ProdajnaCena
nabavna = a.NabavnaCena
}
+8 -5
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()
@@ -1090,7 +1093,7 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
// BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment.
func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
return
}
bePort := os.Getenv("BE_PORT")
+50 -11
View File
@@ -186,6 +186,23 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
return
}
// isti bruteforce brojač kao za lozinku — deli IP-bazirano zaključavanje
ip := izvuciIP(r)
od := time.Now().Add(-prozorPrijave)
n, _ := h.PokusajiRepo.BrojNeuspeha(r.Context(), ip, od)
if n >= maxNeuspehaPrijave {
if preostalo, zaklj := h.preostaloBruteforce(r.Context(), ip, od); zaklj {
auth.LogZaklucano(ip, korisnik.KorisnickoIme)
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "ip_zaklucano_totp", false)
h.renderujStandalone(w, "totp_provera", map[string]any{
"Greska": "zakljucano",
"Preostalo": preostalo,
"CsrfToken": middleware.CsrfToken(r.Context()),
})
return
}
}
kod := r.FormValue("kod")
validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna)
if !validan {
@@ -196,10 +213,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
}
}
if !validan {
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, false)
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "pogrešan_totp", false)
auth.LogNeuspehPrijave(ip, korisnik.KorisnickoIme, "wrong_totp")
http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
return
}
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, true)
novoIstice := time.Now().Add(trajanjeSeije)
if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
@@ -295,24 +317,41 @@ func (h *Handler) preostaloBruteforce(ctx context.Context, ip string, od time.Ti
return fmt.Sprintf("%d sek", sek), true
}
// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja),
// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr
// izvuciIP čita pravi IP klijenta iz X-Real-IP/X-Forwarded-For, ALI samo ako
// zahtev fizički stiže sa poverljive adrese (Caddy na istom hostu ili Docker
// mreža — loopback/privatni opseg). Ova zaglavlja su inače potpuno pod
// kontrolom klijenta; slepo verovanje u njih zaobilazi bruteforce zaključavanje
// prijave/TOTP-a. Ako RemoteAddr nije poverljiv, header se ignoriše.
func izvuciIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent
parts := strings.Split(fwd, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
host = r.RemoteAddr
}
if remoteAdresaJePoverljiva(host) {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent
parts := strings.Split(fwd, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
}
return host
}
// remoteAdresaJePoverljiva vraća true ako je adresa loopback ili iz privatnog
// (RFC 1918) opsega — tipično za reverse proxy na istom hostu ili u istoj Docker mreži.
func remoteAdresaJePoverljiva(host string) bool {
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate()
}
func napraviKolacic(token string, istice time.Time) *http.Cookie {
return &http.Cookie{
Name: imeKolacica,
+7 -4
View File
@@ -1049,12 +1049,15 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
}
// predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje)
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID)
predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno
slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina)
nalog, err := h.ServisRepo.DohvatiID(r.Context(), nalogID)
if err != nil || nalog == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Nalog nije pronađen.")
http.Redirect(w, r, "/servis", http.StatusSeeOther)
return
}
predlozeno := nalog.Status != model.StatusPrimljeno
// atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane
ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno)
slog.Info("DODAJ_DEO_OUT", "ugradjeno", ugradjeno, "nedostaje", nedostaje, "err", err)
if err != nil {
slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
+4 -2
View File
@@ -13,8 +13,10 @@ func TestIzvuciIP(t *testing.T) {
remoteAddr string
ocek string
}{
{"X-Real-IP ima prioritet", "1.2.3.4", "9.9.9.9", "5.5.5.5:1234", "1.2.3.4"},
{"poslednji X-Forwarded-For", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "5.5.5.5:1234", "3.3.3.3"},
{"X-Real-IP ima prioritet kad je proxy poverljiv (loopback)", "1.2.3.4", "9.9.9.9", "127.0.0.1:1234", "1.2.3.4"},
{"X-Real-IP ima prioritet kad je proxy poverljiv (privatna Docker mreža)", "1.2.3.4", "9.9.9.9", "172.18.0.1:1234", "1.2.3.4"},
{"poslednji X-Forwarded-For kad je proxy poverljiv", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "127.0.0.1:1234", "3.3.3.3"},
{"zaglavlje se IGNORIŠE kad RemoteAddr nije poverljiv (javna adresa — spoofing)", "1.2.3.4", "", "5.5.5.5:1234", "5.5.5.5"},
{"RemoteAddr bez porta kad nema zaglavlja", "", "", "5.5.5.5:1234", "5.5.5.5"},
{"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"},
}