package handler import ( "context" "crypto/rand" "database/sql" "encoding/hex" "fmt" "html" "io" "log/slog" "net" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "encoding/json" "ntech/internal/auth" "ntech/internal/db" ntechsqlite "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" ) // PostaviDemoKorisnika osigurava da postoji korisnik "Demo" sa ulogom "admin" // i podrazumevanom lozinkom. Poziva se pri startu demo instance, i ponovo // posle uvoza/vraćanja baze (jer taj hot-swap donosi svoje korisnike iz // uvezenog/vraćenog fajla, koji mogu imati drugačiju ili nepoznatu lozinku). func PostaviDemoKorisnika(ctx context.Context, repo db.KorisniciRepository) error { const ( demoIme = "Demo" demoLozinka = "Demo1234" ) hash, err := auth.HashujLozinku(demoLozinka) if err != nil { return fmt.Errorf("ntech: PostaviDemoKorisnika: %w", err) } korisnik, err := repo.DohvatiPoImenu(ctx, demoIme) if err != nil { // korisnik ne postoji — kreiraj ga if _, err := repo.Kreiraj(ctx, demoIme, hash, "admin"); err != nil { return fmt.Errorf("ntech: PostaviDemoKorisnika: kreiranje: %w", err) } slog.Info("demo korisnik kreiran", "korisnik", demoIme) return nil } // korisnik postoji — resetuj lozinku i osiguraj da je aktivan if err := repo.PromeniLozinku(ctx, korisnik.ID, hash); err != nil { return fmt.Errorf("ntech: PostaviDemoKorisnika: lozinka: %w", err) } if !korisnik.Aktivan { if err := repo.AzurirajAktivan(ctx, korisnik.ID, true); err != nil { return fmt.Errorf("ntech: PostaviDemoKorisnika: aktivan: %w", err) } } slog.Info("demo korisnik resetovan", "korisnik", demoIme) return nil } // lokalniKljuceviPodesavanja su ključevi iz tabele podesavanja koji opisuju // OVU instalaciju/server (izgled login ekrana, adresa fiskalnog uređaja, // raspored backupa...), a ne podatke firme. Pri uvozu ili vraćanju baze sa // drugog računara ovi ključevi se NE prepisuju uvezenim vrednostima — server // zadržava svoje. Sve ostalo u podesavanja smatra se podacima firme i putuje // sa uvezenom/vraćenom bazom. var lokalniKljuceviPodesavanja = []string{ "backup_interval_sati", "backup_broj_kopija", "login_pozadina", "login_pozadina_opacity", "login_pozadina_blur_pozadine", "login_pozadina_blur_kartice", "login_pozadina_zatamnjenje_kartice", "topbar_logo_slika", "topbar_logo_tekst", "pfr_url", "pfr_tip", "qr_bazni_url", "verify_host", } // ucitajLokalnaPodesavanja čita trenutne vrednosti lokalnih ključeva iz baze // pre nego što se baza zameni uvozom/vraćanjem backupa. func ucitajLokalnaPodesavanja(ctx context.Context, db *sql.DB) map[string]string { vrednosti := make(map[string]string, len(lokalniKljuceviPodesavanja)) for _, kljuc := range lokalniKljuceviPodesavanja { var vrednost string if err := db.QueryRowContext(ctx, "SELECT vrednost FROM podesavanja WHERE kljuc = ?", kljuc).Scan(&vrednost); err == nil { vrednosti[kljuc] = vrednost } } return vrednosti } // upisiLokalnaPodesavanja upisuje sačuvane lokalne vrednosti nazad u bazu // posle zamene, prepisujući preko onoga što je došlo iz uvezene/vraćene baze. func upisiLokalnaPodesavanja(ctx context.Context, db *sql.DB, vrednosti map[string]string) { for kljuc, vrednost := range vrednosti { if _, err := db.ExecContext(ctx, "INSERT INTO podesavanja (kljuc, vrednost) VALUES (?, ?) ON CONFLICT(kljuc) DO UPDATE SET vrednost = excluded.vrednost", kljuc, vrednost); err != nil { slog.Error("upis lokalnih podešavanja: greška", "kljuc", kljuc, "error", err) } } } // lokalneTabelePodesavanja su tabele koje opisuju podešavanja OVOG servera, a // ne poslovne podatke. Korisnici NAMERNO nisu ovde: poslovni podaci (servisni // nalozi, magacinske promene...) referenciraju konkretne korisnike preko ID-a // (ko je uradio nalog, ko je izvršio promenu), pa moraju putovati zajedno — // razdvajanje bi ili pokidalo FK ili tiho pripisalo tuđe podatke pogrešnoj // osobi. Zato korisnici putuju sa uvezenom/vraćenom bazom kao i ostali // poslovni podaci; samo dozvole (permisije po ulozi, bez FK ka korisnicima) // ostaju lokalne. var lokalneTabelePodesavanja = []string{ "dozvole", } // volatilneTabeleBezbednosti su sesije i srodno runtime/bezbednosno stanje // koje referencira korisnike preko FK. Pošto korisnici putuju sa uvezenom // bazom (vidi lokalneTabelePodesavanja), staro stanje ovih tabela bi posle // zamene moglo da referencira nepostojeće ili pogrešne korisnike — zato se // jednostavno brišu (ne vraćaju iz snimka), što tera sve na ponovnu prijavu. var volatilneTabeleBezbednosti = []string{ "webauthn_kredencijali", "rezervni_kodovi", "login_istorija", "sesije", "pokusaji_prijave", } // ocistiVolatilneTabele briše sesije i srodno bezbednosno stanje posle zamene // baze — vidi volatilneTabeleBezbednosti. func ocistiVolatilneTabele(ctx context.Context, db *sql.DB) { for _, tabela := range volatilneTabeleBezbednosti { if _, err := db.ExecContext(ctx, "DELETE FROM "+tabela); err != nil { slog.Error("čišćenje volatilnih tabela: greška", "tabela", tabela, "error", err) } } } // tabelaSnimak čuva sve redove jedne tabele (generički, bez fiksne šeme) radi // vraćanja posle zamene baze. type tabelaSnimak struct { kolone []string redovi [][]any } // ucitajTabelu učitava sve redove i imena kolona zadate tabele. func ucitajTabelu(ctx context.Context, db *sql.DB, tabela string) ([]string, [][]any, error) { rows, err := db.QueryContext(ctx, "SELECT * FROM "+tabela) if err != nil { return nil, nil, err } defer rows.Close() kolone, err := rows.Columns() if err != nil { return nil, nil, err } var redovi [][]any for rows.Next() { vrednosti := make([]any, len(kolone)) pokazivaci := make([]any, len(kolone)) for i := range vrednosti { pokazivaci[i] = &vrednosti[i] } if err := rows.Scan(pokazivaci...); err != nil { return nil, nil, err } redovi = append(redovi, vrednosti) } return kolone, redovi, rows.Err() } // ucitajLokalneTabele čita trenutni sadržaj naloga/bezbednosnih tabela pre // zamene baze. func ucitajLokalneTabele(ctx context.Context, db *sql.DB) map[string]tabelaSnimak { snimci := make(map[string]tabelaSnimak, len(lokalneTabelePodesavanja)) for _, tabela := range lokalneTabelePodesavanja { kolone, redovi, err := ucitajTabelu(ctx, db, tabela) if err != nil { slog.Error("čuvanje lokalnih tabela: greška pri čitanju", "tabela", tabela, "error", err) continue } snimci[tabela] = tabelaSnimak{kolone: kolone, redovi: redovi} } return snimci } // upisiLokalneTabele vraća sačuvani sadržaj naloga/bezbednosnih tabela posle // zamene baze, brišući ono što je stiglo iz uvezene/vraćene baze. func upisiLokalneTabele(ctx context.Context, db *sql.DB, snimci map[string]tabelaSnimak) { tx, err := db.BeginTx(ctx, nil) if err != nil { slog.Error("upis lokalnih tabela: greška pri otvaranju transakcije", "error", err) return } defer tx.Rollback() for i := len(lokalneTabelePodesavanja) - 1; i >= 0; i-- { tabela := lokalneTabelePodesavanja[i] if _, err := tx.ExecContext(ctx, "DELETE FROM "+tabela); err != nil { slog.Error("upis lokalnih tabela: greška pri brisanju", "tabela", tabela, "error", err) return } } for _, tabela := range lokalneTabelePodesavanja { snimak, ok := snimci[tabela] if !ok || len(snimak.redovi) == 0 { continue } upit := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tabela, strings.Join(snimak.kolone, ", "), strings.TrimSuffix(strings.Repeat("?, ", len(snimak.kolone)), ", ")) for _, red := range snimak.redovi { if _, err := tx.ExecContext(ctx, upit, red...); err != nil { slog.Error("upis lokalnih tabela: greška pri upisu", "tabela", tabela, "error", err) return } } } if err := tx.Commit(); err != nil { slog.Error("upis lokalnih tabela: greška pri komitovanju", "error", err) } } // podrazumevaniUsloviServisa je podrazumevani tekst uslova servisa koji se // štampa na reversu; korisnik ga može izmeniti u Podešavanja → Servis. const podrazumevaniUsloviServisa = `1. Servis ne odgovara za podatke na uređaju. Klijent je dužan da sam napravi rezervnu kopiju podataka pre predaje. 2. Uređaj se preuzima uz ovaj revers i ličnu kartu. Bez reversa uređaj se ne izdaje. 3. Rok za podizanje uređaja je 30 dana od obaveštenja o završetku. Po isteku roka servis ne odgovara za uređaj. 4. Ako klijent odustane od popravke, naplaćuje se dijagnostika prema cenovniku. 5. Garancija na izvršeni servis važi za zamenjene delove i obavljeni rad, ne i za nove kvarove. 6. Potpisom klijent potvrđuje da je saglasan sa navedenim uslovima.` // podrazumevanaKlauzulaPredracuna je podrazumevana napomena koja se štampa na // dnu predračuna; korisnik je može izmeniti u Podešavanja → Servis. const podrazumevanaKlauzulaPredracuna = `Procena je data na osnovu zahteva klijenta i nije fiskalni dokument. Ako se tokom rada utvrdi dodatni kvar, servis kontaktira klijenta radi saglasnosti pre nastavka radova i izmene cene.` // PodaciPodesavanja su podaci za stranicu podešavanja type PodaciPodesavanja struct { model.PodaciStranice NazivFirme string Podnazlov string Adresa string Telefon string PIB string MaticniBroj string // poslovna jedinica — za fiskalizaciju (oznaka PJ se šalje uz svaki račun) NazivPJ string OznakaPJ string Opstina string Grad string LogoPutanja string TopbarLogoSlika bool // profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale FirmaPravniOblik string FirmaPdvObveznik string FirmaFiskalizacija string FirmaRezim string Sacuvano bool Verzija string LogoGreska string BackupVracen bool Backupi []BackupInfo BackupIntervalSati string BackupBrojKopija string KalkulacijaMarza string ServisGarancijaDana string ServisCenaDijagnostike string PredracunRokDana string PredvidjenRokDana string ServisUslovi string ServisKlauzulaPredracuna string QrBazniUrl string PfrUrl string PfrTip string PfrKasir string VerifyHost string BePin string BeLimit string BeEnabled string FiskalPismo string LoginPozadina string LoginPozadinaOpacity string LoginPozadinaBlurPozadine string LoginPozadinaBlurKartice string LoginPozadinaZatamnjenjeKartice string } // BackupInfo opisuje jedan backup fajl type BackupInfo struct { Ime string Datum string Velicina string } // validnoImeBackupa proverava da li je ime backup fajla bezbedno (bez path traversala) var validnoImeBackupa = regexp.MustCompile(`^ntech_\d{8}_\d{6}\.db$`) // Podesavanja renderuje stranicu podešavanja func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "podesavanja" ps.NaslovStranice = "Podešavanja" podaci := PodaciPodesavanja{ PodaciStranice: ps, NazivFirme: podesavanja["naziv_firme"], Podnazlov: podesavanja["podnazlov"], Adresa: podesavanja["adresa"], Telefon: podesavanja["telefon"], PIB: podesavanja["pib"], MaticniBroj: podesavanja["maticni_broj"], NazivPJ: podesavanja["poslovna_jedinica_naziv"], OznakaPJ: podesavanja["poslovna_jedinica_oznaka"], Opstina: podesavanja["opstina"], Grad: podesavanja["grad"], LogoPutanja: podesavanja["logo_putanja"], TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1", Sacuvano: r.URL.Query().Get("sacuvano") == "1", BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", Verzija: h.Verzija, LogoGreska: r.URL.Query().Get("logo_greska"), Backupi: ucitajListuBackupa(), LoginPozadina: podesavanja["login_pozadina"], LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"), LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), LoginPozadinaBlurKartice: vrednostIliDefault(podesavanja, "login_pozadina_blur_kartice", "12"), LoginPozadinaZatamnjenjeKartice: vrednostIliDefault(podesavanja, "login_pozadina_zatamnjenje_kartice", "0"), BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"), BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"), } h.renderujTemplate(w, "podesavanja", podaci) } // ucitajListuBackupa vraća sortiranu listu fajlova iz backups/ foldera func ucitajListuBackupa() []BackupInfo { fajlovi, _ := filepath.Glob(filepath.Join("backups", "ntech_*.db")) sort.Sort(sort.Reverse(sort.StringSlice(fajlovi))) var lista []BackupInfo for _, f := range fajlovi { info, err := os.Stat(f) if err != nil { continue } vel := info.Size() var velStr string switch { case vel >= 1024*1024: velStr = fmt.Sprintf("%.1f MB", float64(vel)/(1024*1024)) default: velStr = fmt.Sprintf("%d KB", vel/1024) } datum := info.ModTime().Format("02.01.2006. 15:04:05") lista = append(lista, BackupInfo{ Ime: filepath.Base(f), Datum: datum, Velicina: velStr, }) } return lista } // vrednostIliDefault vraća vrednost iz mape ako postoji i nije prazan string, inače vraća podrazumevanu vrednost func vrednostIliDefault(m map[string]string, kljuc, podrazumevano string) string { if v := m[kljuc]; v != "" { return v } return podrazumevano } // VratiBackup zamenjuje trenutnu bazu sa izabranim backup fajlom func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "backup.pokreni"); !ok { return } if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čitanju+zahteva", http.StatusSeeOther) return } ime := r.FormValue("ime") if !validnoImeBackupa.MatchString(ime) { http.Redirect(w, r, "/podesavanja?backup_greska=Neispravan+naziv+fajla", http.StatusSeeOther) return } putanjaBackupa := filepath.Join("backups", ime) if _, err := os.Stat(putanjaBackupa); err != nil { http.Redirect(w, r, "/podesavanja?backup_greska=Backup+fajl+nije+pronađen", http.StatusSeeOther) return } // pre obnove, sačuvaj trenutno stanje baze sigurnosni := filepath.Join("backups", fmt.Sprintf("ntech_%s_pred_vracanjem.db", time.Now().Format("20060102_150405"))) if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", sigurnosni); err != nil { slog.Error("vrati backup: greška pri kreiranju sigurnosne kopije", "error", err) http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+kreiranju+sigurnosne+kopije", http.StatusSeeOther) return } // isprazni WAL u glavni fajl if _, err := h.DB.ExecContext(r.Context(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { slog.Error("vrati backup: wal_checkpoint greška", "error", err) } lokalnaPodesavanja := ucitajLokalnaPodesavanja(r.Context(), h.DB) lokalneTabele := ucitajLokalneTabele(r.Context(), h.DB) // Sama zamena baze (zatvaranje stare, kopiranje, otvaranje nove) radi se u // zasebnoj gorutini pod EKSKLUZIVNIM zaključavanjem (h.mu.Lock). Razlog: ovaj // zahtev još drži deljeno zaključavanje (ZakljucajCitanje middleware), pa bi // uzimanje ekskluzivnog u istoj gorutini izazvalo deadlock. Ekskluzivno // zaključavanje sačeka da svi tekući zahtevi (uključujući ovaj, čim vrati // odgovor) završe, pa tek onda menja konekciju — bez data race-a i bez upita // nad zatvorenom bazom. Sledeći zahtev (redirect na /podesavanja) prirodno // sačeka da zamena završi jer čeka na deljeno zaključavanje. go func() { h.mu.Lock() defer h.mu.Unlock() if err := h.DB.Close(); err != nil { slog.Error("vrati backup: greška pri zatvaranju baze", "error", err) } if err := kopiraFajl(putanjaBackupa, h.PutanjaBaze); err != nil { slog.Error("vrati backup: greška pri kopiranju (baza je zatvorena, potreban restart)", "error", err) return } os.Remove(h.PutanjaBaze + "-wal") os.Remove(h.PutanjaBaze + "-shm") novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) if err != nil { slog.Error("vrati backup: greška pri otvaranju nove baze (potreban restart)", "error", err) return } upisiLokalnaPodesavanja(context.Background(), novaDB, lokalnaPodesavanja) upisiLokalneTabele(context.Background(), novaDB, lokalneTabele) ocistiVolatilneTabele(context.Background(), novaDB) h.reinicijalizujRepozitorijume(novaDB) if h.JelDemo { if err := PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil { slog.Error("vrati backup: greška pri resetovanju demo korisnika", "error", err) } } slog.Info("baza uspešno obnovljena", "izvor", ime) }() http.Redirect(w, r, "/podesavanja?sacuvano=vraceno", http.StatusSeeOther) } // kopiraFajl kopira fajl sa izvora na odredište func kopiraFajl(izvor, odrediste string) error { src, err := os.Open(izvor) if err != nil { return err } defer src.Close() dst, err := os.Create(odrediste) if err != nil { return err } defer dst.Close() _, err = io.Copy(dst, src) return err } // UvezizBazu zamenjuje trenutnu bazu fajlom koji korisnik otpremi sa svog računara func (h *Handler) UvezizBazu(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "backup.pokreni"); !ok { return } const maxVelicinaUvoza = 500 << 20 // 500 MB r.Body = http.MaxBytesReader(w, r.Body, maxVelicinaUvoza) if err := r.ParseMultipartForm(32 << 20); err != nil { http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+je+prevelik+ili+zahtev+nije+ispravan", http.StatusSeeOther) return } fajl, _, err := r.FormFile("baza") if err != nil { http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+nije+izabran", http.StatusSeeOther) return } defer fajl.Close() zaglavlje := make([]byte, 16) if _, err := io.ReadFull(fajl, zaglavlje); err != nil || string(zaglavlje) != "SQLite format 3\x00" { http.Redirect(w, r, "/podesavanja?backup_greska=Fajl+nije+ispravna+SQLite+baza", http.StatusSeeOther) return } privremena := filepath.Join("backups", fmt.Sprintf("uvoz_%s.db", time.Now().Format("20060102_150405"))) odrediste, err := os.Create(privremena) if err != nil { slog.Error("uvezi bazu: greška pri kreiranju privremenog fajla", "error", err) http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther) return } if _, err := odrediste.Write(zaglavlje); err != nil { odrediste.Close() http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther) return } if _, err := io.Copy(odrediste, fajl); err != nil { odrediste.Close() slog.Error("uvezi bazu: greška pri čuvanju otpremljenog fajla", "error", err) http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čuvanju+otpremljenog+fajla", http.StatusSeeOther) return } odrediste.Close() // pre uvoza, sačuvaj trenutno stanje baze sigurnosni := filepath.Join("backups", fmt.Sprintf("ntech_%s_pred_uvozom.db", time.Now().Format("20060102_150405"))) if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", sigurnosni); err != nil { slog.Error("uvezi bazu: greška pri kreiranju sigurnosne kopije", "error", err) os.Remove(privremena) http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+kreiranju+sigurnosne+kopije", http.StatusSeeOther) return } // isprazni WAL u glavni fajl if _, err := h.DB.ExecContext(r.Context(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { slog.Error("uvezi bazu: wal_checkpoint greška", "error", err) } lokalnaPodesavanja := ucitajLokalnaPodesavanja(r.Context(), h.DB) lokalneTabele := ucitajLokalneTabele(r.Context(), h.DB) // zamena baze radi se pod ekskluzivnim zaključavanjem — isti obrazac kao VratiBackup go func() { h.mu.Lock() defer h.mu.Unlock() defer os.Remove(privremena) if err := h.DB.Close(); err != nil { slog.Error("uvezi bazu: greška pri zatvaranju baze", "error", err) } if err := kopiraFajl(privremena, h.PutanjaBaze); err != nil { slog.Error("uvezi bazu: greška pri kopiranju (baza je zatvorena, potreban restart)", "error", err) return } os.Remove(h.PutanjaBaze + "-wal") os.Remove(h.PutanjaBaze + "-shm") novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) if err != nil { slog.Error("uvezi bazu: greška pri otvaranju nove baze (potreban restart)", "error", err) return } upisiLokalnaPodesavanja(context.Background(), novaDB, lokalnaPodesavanja) upisiLokalneTabele(context.Background(), novaDB, lokalneTabele) ocistiVolatilneTabele(context.Background(), novaDB) h.reinicijalizujRepozitorijume(novaDB) if h.JelDemo { if err := PostaviDemoKorisnika(context.Background(), h.KorisniciRepo); err != nil { slog.Error("uvezi bazu: greška pri resetovanju demo korisnika", "error", err) } } slog.Info("baza uspešno uvezena") }() http.Redirect(w, r, "/podesavanja?sacuvano=vraceno", http.StatusSeeOther) } // validirajMaticniBroj proverava da li uneti matični broj ima tačno 8 cifara. // Prazan broj je dozvoljen (polje nije obavezno). Vraća srpsku poruku o grešci // ili prazan string ako je broj ispravan. func validirajMaticniBroj(broj string) string { if broj == "" { return "" } if len(broj) != 8 { return "Matični broj mora imati tačno 8 cifara." } for _, c := range broj { if c < '0' || c > '9' { return "Matični broj sme sadržati samo cifre." } } return "" } // validirajProfilFirme proverava da li je kombinacija pravnog/poreskog statusa // pravno moguća — vidi docs/Greške.md §1. „Samo evidencija” gasi ceo zakonski // sloj, pa dok je firma u tom režimu ne postoji osnov da bude PDV obveznik // niti da izdaje fiskalne račune. func validirajProfilFirme(rezim, pdvObveznik, fiskalizacija string) string { if rezim != "samo_evidencija" { return "" } if pdvObveznik == "da" || fiskalizacija == "da" { return "Režim „Samo evidencija” ne dozvoljava PDV obveznika ni izdavanje fiskalnih računa — prebacite firmu na režim „Pun” ako stvarno posluje." } return "" } // SacuvajPodesavanja prima POST i čuva podešavanja u bazu func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { return } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return } // checkbox „topbar_logo_slika" živi u Firma sekciji. Pošto se sekcije auto-snimaju // odvojeno, čitamo ga SAMO kad je poslata Firma sekcija (marker `_sekcija_firma`), // inače ostaje "" pa ga petlja ispod preskoči i ne dira postojeću vrednost. // (Checkbox koji nije čekiran se ne šalje, pa bi inače snimanje druge sekcije // ugasilo logo u topbaru.) topbarLogoSlika := "" if _, jeFirmaSekcija := r.Form["_sekcija_firma"]; jeFirmaSekcija { topbarLogoSlika = "0" if r.FormValue("topbar_logo_slika") == "1" { topbarLogoSlika = "1" } } // matični broj — 8 cifara; prazno je dozvoljeno (polje nije obavezno) maticniBroj := strings.TrimSpace(r.FormValue("maticni_broj")) if greska := validirajMaticniBroj(maticniBroj); greska != "" { middleware.SetFlash(w, r, h.DB, "greska", greska) sledeci := "/podesavanja" if r.FormValue("_next") == "/admin/podesavanja/opste" { sledeci = "/admin/podesavanja/opste" } http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if _, jeFirmaSekcija := r.Form["_sekcija_firma"]; jeFirmaSekcija { greska := validirajProfilFirme(r.FormValue("firma_rezim"), r.FormValue("firma_pdv_obveznik"), r.FormValue("firma_fiskalizacija")) if greska != "" { middleware.SetFlash(w, r, h.DB, "greska", greska) sledeci := "/podesavanja" if r.FormValue("_next") == "/admin/podesavanja/opste" { sledeci = "/admin/podesavanja/opste" } http.Redirect(w, r, sledeci, http.StatusSeeOther) return } } polja := map[string]string{ "naziv_firme": r.FormValue("naziv_firme"), "podnazlov": r.FormValue("podnazlov"), "adresa": r.FormValue("adresa"), "telefon": r.FormValue("telefon"), "pib": r.FormValue("pib"), "maticni_broj": maticniBroj, "poslovna_jedinica_naziv": r.FormValue("poslovna_jedinica_naziv"), "poslovna_jedinica_oznaka": r.FormValue("poslovna_jedinica_oznaka"), "opstina": r.FormValue("opstina"), "grad": r.FormValue("grad"), "topbar_logo_slika": topbarLogoSlika, // profil firme (Faza 0) — radio dugmad uvek šalju vrednost, pa se uredno čuvaju "firma_pravni_oblik": r.FormValue("firma_pravni_oblik"), "firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"), "firma_fiskalizacija": r.FormValue("firma_fiskalizacija"), "firma_rezim": r.FormValue("firma_rezim"), // fiskalizacija — L-PFR i kartica emulator podešavanja "pfr_url": r.FormValue("pfr_url"), "pfr_tip": r.FormValue("pfr_tip"), "pfr_kasir": r.FormValue("pfr_kasir"), "verify_host": r.FormValue("verify_host"), "be_pin": r.FormValue("be_pin"), "be_limit": r.FormValue("be_limit"), "be_enabled": r.FormValue("be_enabled"), "fiskalni_pismo": r.FormValue("fiskalni_pismo"), } for kljuc, vrednost := range polja { if vrednost == "" { continue } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // whitelist dozvoljenih redirekcija — vrednost uvek dolazi iz mape, nikad od korisnika dozvoljeniSledeci := map[string]string{ "/admin/podesavanja/opste": "/admin/podesavanja/opste", "/admin/podesavanja/sistem": "/admin/podesavanja/sistem", "/admin/podesavanja/servis": "/admin/podesavanja/servis", "/admin/podesavanja/kalkulacija-pdv": "/admin/podesavanja/kalkulacija-pdv", "/admin/podesavanja/fiskalizacija": "/admin/podesavanja/fiskalizacija", "/podesavanja": "/podesavanja", } sledeci := "/podesavanja" if v, ok := dozvoljeniSledeci[r.FormValue("_next")]; ok { sledeci = v } // backup podešavanja — pri neispravnom unosu javljamo jasnu grešku // umesto da ga tiho preskočimo a korisniku prikažemo "sačuvano" if v := r.FormValue("backup_interval_sati"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 720 { middleware.SetFlash(w, r, h.DB, "greska", "Razmak između backupa mora biti broj između 1 i 720 sati.") http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_interval_sati", strconv.Itoa(n)); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } if v := r.FormValue("backup_broj_kopija"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 100 { middleware.SetFlash(w, r, h.DB, "greska", "Broj kopija mora biti broj između 1 i 100.") http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_broj_kopija", strconv.Itoa(n)); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // podrazumevana marža za kalkulaciju (procenat, 0–1000) if v := strings.TrimSpace(r.FormValue("kalkulacija_marza")); v != "" { marza, err := strconv.ParseFloat(strings.Replace(v, ",", ".", 1), 64) if err != nil || marza < 0 || marza > 1000 { middleware.SetFlash(w, r, h.DB, "greska", "Marža mora biti broj između 0 i 1000.") http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "kalkulacija_marza", strconv.FormatFloat(marza, 'f', -1, 64)); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // podrazumevana garancija za servis (dani, 0–3650) if v := strings.TrimSpace(r.FormValue("servis_garancija_dana")); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 0 || n > 3650 { middleware.SetFlash(w, r, h.DB, "greska", "Garancija mora biti broj između 0 i 3650 dana.") http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_garancija_dana", strconv.Itoa(n)); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // podrazumevana cena dijagnostike (din, ≥ 0) if v := strings.TrimSpace(r.FormValue("servis_cena_dijagnostike")); v != "" { cena, err := strconv.ParseFloat(strings.ReplaceAll(v, ",", "."), 64) if err != nil || cena < 0 { middleware.SetFlash(w, r, h.DB, "greska", "Cena dijagnostike mora biti broj veći ili jednak 0.") http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_cena_dijagnostike", strconv.FormatFloat(cena, 'f', -1, 64)); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // rok važenja predračuna u danima (1–90) if v := strings.TrimSpace(r.FormValue("predracun_rok_dana")); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 90 { middleware.SetFlash(w, r, h.DB, "greska", "Rok važenja predračuna mora biti broj između 1 i 90 dana.") http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predracun_rok_dana", strconv.Itoa(n)); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } if v := strings.TrimSpace(r.FormValue("predvidjen_rok_dana")); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 365 { middleware.SetFlash(w, r, h.DB, "greska", "Predviđen rok popravke mora biti broj između 1 i 365 dana.") http.Redirect(w, r, sledeci, http.StatusSeeOther) return } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predvidjen_rok_dana", strconv.Itoa(n)); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // uslovi servisa (slobodan tekst, štampa se na reversu); čuva se uvek, // pa korisnik može i da skrati ili isprazni tekst if _, ima := r.Form["servis_uslovi"]; ima { uslovi := strings.TrimSpace(r.FormValue("servis_uslovi")) if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_uslovi", uslovi); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // klauzula na predračunu (slobodan tekst); čuva se uvek if _, ima := r.Form["servis_klauzula_predracuna"]; ima { klauzula := strings.TrimSpace(r.FormValue("servis_klauzula_predracuna")) if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_klauzula_predracuna", klauzula); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } // bazna adresa za QR kod (npr. http://192.168.1.25:3000); prazno → koristi se host iz zahteva if _, ima := r.Form["qr_bazni_url"]; ima { bazni := strings.TrimSpace(r.FormValue("qr_bazni_url")) if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "qr_bazni_url", bazni); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } } http.Redirect(w, r, sledeci+"?sacuvano=1", http.StatusSeeOther) } // BackupBaze kreira konzistentnu kopiju baze i šalje je kao attachment func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "backup.pregled"); !ok { return } privremeni := fmt.Sprintf("%s/ntech_backup_%s.db", os.TempDir(), time.Now().Format("20060102_150405")) if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", privremeni); err != nil { http.Error(w, "Greška pri kreiranju rezervne kopije", http.StatusInternalServerError) return } defer os.Remove(privremeni) ime := fmt.Sprintf("ntech_backup_%s.db", time.Now().Format("20060102")) w.Header().Set("Content-Disposition", "attachment; filename=\""+ime+"\"") w.Header().Set("Content-Type", "application/octet-stream") http.ServeFile(w, r, privremeni) } // OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { return } // ograničavamo telo zahteva na 2MB + malo za zaglavlja forme r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096) if err := r.ParseMultipartForm(2 << 20); err != nil { http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther) return } fajl, zaglavlje, err := r.FormFile("logo") if err != nil { http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Nije+odabran+fajl", http.StatusSeeOther) return } defer fajl.Close() // eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera) if zaglavlje.Size > 2<<20 { http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther) return } // proveravamo ekstenziju ext := strings.ToLower(filepath.Ext(zaglavlje.Filename)) dozvoljenoExt := map[string]string{ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", } ocekivaniMime, ok := dozvoljenoExt[ext] if !ok { http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Dozvoljeni+formati+su+PNG%2C+JPG+i+SVG", http.StatusSeeOther) return } // za binarne formate proveravamo i stvarni tip fajla (SVG je tekstualni, preskačemo) if ext != ".svg" { buf := make([]byte, 512) n, _ := fajl.Read(buf) stvarniMime := http.DetectContentType(buf[:n]) if !strings.HasPrefix(stvarniMime, ocekivaniMime) { http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Sadržaj+fajla+ne+odgovara+odabranoj+ekstenziji", http.StatusSeeOther) return } // vraćamo kursor na početak if _, err := fajl.Seek(0, io.SeekStart); err != nil { http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+obradi+fajla", http.StatusSeeOther) return } } // brišemo stare logo fajlove stari, _ := filepath.Glob("web/static/uploads/logo.*") for _, s := range stari { os.Remove(s) } odrediste := "web/static/uploads/logo" + ext dst, err := os.Create(odrediste) if err != nil { slog.Error("upload loga: ne mogu kreirati fajl", "error", err) http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther) return } defer dst.Close() if _, err := io.Copy(dst, fajl); err != nil { slog.Error("upload loga: greška pri kopiranju", "error", err) http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+fajla", http.StatusSeeOther) return } // timestamp u URL-u sprečava browser da koristi staru keširanu sliku putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix()) if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil { slog.Error("upload loga: greška pri čuvanju putanje", "error", err) http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther) return } http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther) } // UkloniLogo briše logo fajl i čisti putanju iz podešavanja func (h *Handler) UkloniLogo(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { return } stari, _ := filepath.Glob("web/static/uploads/logo.*") for _, s := range stari { os.Remove(s) } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", ""); err != nil { slog.Error("ukloni logo: greška pri čuvanju", "error", err) } http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther) } // PodesavanjaServis renderuje stranicu sa podešavanjima servisnog modula func (h *Handler) PodesavanjaServis(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Servis") if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } podaci.Stranica = "podesavanja-servis" h.renderujTemplate(w, "podesavanja_servis", podaci) } // generisiImeUploada vraća slučajno hex ime (16 bajtova) sa datom ekstenzijom func generisiImeUploada(ext string) (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf) + ext, nil } // OtpremiLoginPozadinu prima multipart upload slike i čuva je kao pozadinsku sliku login stranice func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.login_pozadina"); !ok { return } r.Body = http.MaxBytesReader(w, r.Body, 5<<20+4096) if err := r.ParseMultipartForm(5 << 20); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } fajl, zaglavlje, err := r.FormFile("login_pozadina") if err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Nije odabran fajl.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } defer fajl.Close() if zaglavlje.Size > 5<<20 { middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } ext := strings.ToLower(filepath.Ext(zaglavlje.Filename)) dozvoljenoExt := map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", } ocekivaniMime, ok := dozvoljenoExt[ext] if !ok { middleware.SetFlash(w, r, h.DB, "greska", "Dozvoljeni formati su JPG, PNG i WebP.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } // proveravamo stvarni tip sadržaja (magic bytes) buf := make([]byte, 512) n, _ := fajl.Read(buf) stvarniMime := http.DetectContentType(buf[:n]) if !strings.HasPrefix(stvarniMime, ocekivaniMime) { middleware.SetFlash(w, r, h.DB, "greska", "Sadržaj fajla ne odgovara odabranoj ekstenziji.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } if _, err := fajl.Seek(0, io.SeekStart); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri obradi fajla.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } // briše staru pozadinu sa diska ako postoji staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if stara := staraPodesavanja["login_pozadina"]; stara != "" { // putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla deoBezverzije, _, _ := strings.Cut(stara, "?") staroIme := filepath.Base(deoBezverzije) os.Remove(filepath.Join("web/static/uploads", staroIme)) } novoIme, err := generisiImeUploada(ext) if err != nil { slog.Error("upload login pozadine: greška pri generisanju imena", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } odrediste := filepath.Join("web/static/uploads", novoIme) dst, err := os.Create(odrediste) if err != nil { slog.Error("upload login pozadine: ne mogu kreirati fajl", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } defer dst.Close() if _, err := io.Copy(dst, fajl); err != nil { slog.Error("upload login pozadine: greška pri kopiranju", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } putanja := fmt.Sprintf("/static/uploads/%s?v=%d", novoIme, time.Now().Unix()) if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", putanja); err != nil { slog.Error("upload login pozadine: greška pri čuvanju putanje", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) } // UkloniLoginPozadinu briše pozadinsku sliku login stranice sa diska i iz podešavanja func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.login_pozadina"); !ok { return } podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err == nil { if stara := podesavanja["login_pozadina"]; stara != "" { deoBezverzije, _, _ := strings.Cut(stara, "?") staroIme := filepath.Base(deoBezverzije) os.Remove(filepath.Join("web/static/uploads", staroIme)) } } if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", ""); err != nil { slog.Error("ukloni login pozadinu: greška pri čuvanju", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther) } // SacuvajLoginPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine login stranice func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.login_pozadina"); !ok { return } if err := r.ParseForm(); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čitanju forme.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } blurPozadineStr := r.FormValue("blur_pozadine") blurKarticeStr := r.FormValue("blur_kartice") opacityStr := r.FormValue("opacity") zatamnjenjeKarticeStr := r.FormValue("zatamnjenje_kartice") blurPozadineVal, err := strconv.Atoi(blurPozadineStr) if err != nil || blurPozadineVal < 0 || blurPozadineVal > 20 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zamućenja pozadine.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } blurKarticeVal, err := strconv.Atoi(blurKarticeStr) if err != nil || blurKarticeVal < 0 || blurKarticeVal > 20 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zamućenja kartice.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } opacityVal, err := strconv.Atoi(opacityStr) if err != nil || opacityVal < 0 || opacityVal > 80 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost prozirnosti.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } zatamnjenjeKarticeVal, err := strconv.Atoi(zatamnjenjeKarticeStr) if err != nil || zatamnjenjeKarticeVal < 0 || zatamnjenjeKarticeVal > 80 { middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zatamnjivanja kartice.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } for kljuc, vrednost := range map[string]string{ "login_pozadina_blur_pozadine": blurPozadineStr, "login_pozadina_blur_kartice": blurKarticeStr, "login_pozadina_opacity": opacityStr, "login_pozadina_zatamnjenje_kartice": zatamnjenjeKarticeStr, } { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { slog.Error("greška pri čuvanju stila login pozadine", "kljuc", kljuc, "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) return } } http.Redirect(w, r, "/admin/podesavanja/izgled?sacuvano=1", http.StatusSeeOther) } // napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (PodaciPodesavanja, error) { podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { return PodaciPodesavanja{}, err } ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "podesavanja" ps.NaslovStranice = naslov return PodaciPodesavanja{ PodaciStranice: ps, NazivFirme: podesavanja["naziv_firme"], Podnazlov: podesavanja["podnazlov"], Adresa: podesavanja["adresa"], Telefon: podesavanja["telefon"], PIB: podesavanja["pib"], MaticniBroj: podesavanja["maticni_broj"], NazivPJ: podesavanja["poslovna_jedinica_naziv"], OznakaPJ: podesavanja["poslovna_jedinica_oznaka"], Opstina: podesavanja["opstina"], Grad: podesavanja["grad"], LogoPutanja: podesavanja["logo_putanja"], TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1", FirmaPravniOblik: vrednostIliDefault(podesavanja, "firma_pravni_oblik", "pausalac"), FirmaPdvObveznik: vrednostIliDefault(podesavanja, "firma_pdv_obveznik", "ne"), FirmaFiskalizacija: vrednostIliDefault(podesavanja, "firma_fiskalizacija", "ne"), FirmaRezim: vrednostIliDefault(podesavanja, "firma_rezim", "samo_evidencija"), Sacuvano: r.URL.Query().Get("sacuvano") == "1", BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", Verzija: h.Verzija, LogoGreska: r.URL.Query().Get("logo_greska"), Backupi: ucitajListuBackupa(), LoginPozadina: podesavanja["login_pozadina"], LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"), LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), LoginPozadinaBlurKartice: vrednostIliDefault(podesavanja, "login_pozadina_blur_kartice", "12"), LoginPozadinaZatamnjenjeKartice: vrednostIliDefault(podesavanja, "login_pozadina_zatamnjenje_kartice", "0"), BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"), BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"), KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"), ServisGarancijaDana: vrednostIliDefault(podesavanja, "servis_garancija_dana", "60"), ServisCenaDijagnostike: vrednostIliDefault(podesavanja, "servis_cena_dijagnostike", "0"), PredracunRokDana: vrednostIliDefault(podesavanja, "predracun_rok_dana", "7"), PredvidjenRokDana: vrednostIliDefault(podesavanja, "predvidjen_rok_dana", "15"), ServisUslovi: vrednostIliDefault(podesavanja, "servis_uslovi", podrazumevaniUsloviServisa), ServisKlauzulaPredracuna: vrednostIliDefault(podesavanja, "servis_klauzula_predracuna", podrazumevanaKlauzulaPredracuna), QrBazniUrl: podesavanja["qr_bazni_url"], PfrUrl: vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566"), PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"), PfrKasir: podesavanja["pfr_kasir"], VerifyHost: podesavanja["verify_host"], BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"), BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"), BeEnabled: vrednostIliDefault(podesavanja, "be_enabled", "true"), FiskalPismo: vrednostIliDefault(podesavanja, "fiskalni_pismo", "latin"), }, nil } // PodesavanjaOpste renderuje stranicu sa opštim podešavanjima (firma i logo) func (h *Handler) PodesavanjaOpste(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Opšte") if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } podaci.Stranica = "podesavanja-opste" h.renderujTemplate(w, "podesavanja_opste", podaci) } // PodesavanjaIzgled renderuje stranicu sa podešavanjima izgleda (pozadine i tema) func (h *Handler) PodesavanjaIzgled(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Izgled") if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } podaci.Stranica = "podesavanja-izgled" h.renderujTemplate(w, "podesavanja_izgled", podaci) } // PodesavanjaFiskalizacija renderuje stranicu sa podešavanjima L-PFR veze func (h *Handler) PodesavanjaFiskalizacija(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Fiskalizacija") if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } podaci.Stranica = "podesavanja-fiskalizacija" h.renderujTemplate(w, "podesavanja_fiskalizacija", podaci) } // TestFiskalizacije udara /api/status na konfigurisanom PFR URL-u i vraća HTMX fragment func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } pfrURL := strings.TrimSpace(r.FormValue("pfr_url")) if pfrURL == "" { podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška", http.StatusInternalServerError) return } pfrURL = vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566") } // SSRF zaštita: dozvoljeni su samo localhost i privatne mreže (192.168.x.x, // 10.x.x.x, 172.16-31.x.x, 127.x.x.x). URL rekonstruišemo kroz url.URL{} // iz validiranih komponenti da CodeQL prepozna sanitizaciju. parsedURL, err := url.Parse(pfrURL) if err != nil { http.Error(w, "Nevažeći PFR URL", http.StatusBadRequest) return } host := parsedURL.Hostname() if !jePrivatnaAdresa(host) { http.Error(w, "Nevažeći PFR URL — dozvoljeni su samo lokalni/privatni hostovi (127.x, 192.168.x, 10.x, 172.16-31.x)", http.StatusBadRequest) return } port := 4566 if p := parsedURL.Port(); p != "" { if n, e := strconv.Atoi(p); e == nil && n > 0 && n <= 65535 { port = n } } safeURL := url.URL{ Scheme: "http", Host: net.JoinHostPort(host, strconv.Itoa(port)), Path: "/api/status", } statusURL := safeURL.String() 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.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.Fprint(w, `
✗ Neispravan odgovor servera
`) return } sdcDateTime, _ := status["sdcDateTime"].(string) lastInvoice, _ := status["lastInvoiceNumber"].(string) tin, _ := status["tin"].(string) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
✓ Povezano — %s
TIN: %s Poslednji račun: %s Vreme: %s
`, html.EscapeString(pfrURL), html.EscapeString(tin), html.EscapeString(lastInvoice), html.EscapeString(sdcDateTime)) } // BeStatus se spaja na lokalni kartica emulator (TCP :4567) i vraća HTMX fragment sa statusom. func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } bePort := os.Getenv("BE_PORT") if bePort == "" { bePort = "4567" } addr := "127.0.0.1:" + bePort 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
`, html.EscapeString(addr)) return } defer conn.Close() cmd := `{"command":"status"}` + "\n" conn.Write([]byte(cmd)) conn.SetDeadline(time.Now().Add(2 * time.Second)) buf := make([]byte, 4096) n, _ := conn.Read(buf) conn.Close() var st map[string]any if err := json.Unmarshal(buf[:n], &st); err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
✗ Neispravan odgovor
`) return } total, _ := st["total_counter"].(float64) unread, _ := st["unread_amount"].(float64) limit, _ := st["limit"].(float64) pinReq, _ := st["pin_required"].(bool) pinTekst := "PIN unesen" if pinReq { pinTekst = "Čeka PIN" } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
✓ Kartica emulator aktivan
Ukupno računa: %d Neisčitano: %s / %s din %s
`, int(total), html.EscapeString(formatirajDinare(unread, 0)), html.EscapeString(formatirajDinare(limit, 0)), html.EscapeString(pinTekst), ) } // 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.izmeni"); !ok { return } bePort := os.Getenv("BE_PORT") if bePort == "" { bePort = "4567" } conn, err := net.DialTimeout("tcp", "127.0.0.1:"+bePort, 2*time.Second) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
✗ Emulator nije dostupan
`) return } conn.Write([]byte(`{"command":"reset_audit"}` + "\n")) conn.SetDeadline(time.Now().Add(2 * time.Second)) buf := make([]byte, 512) n, _ := conn.Read(buf) conn.Close() var resp map[string]any json.Unmarshal(buf[:n], &resp) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
✓ Neisčitani iznos resetovan na 0 (Proof of Audit simuliran)
`) } // jePrivatnaAdresa proverava da li je hostname u opsegu privatnih/lokalnih mreža. // Dozvoljeni: 127.x.x.x, localhost, 10.x.x.x, 172.16-31.x.x, 192.168.x.x. func jePrivatnaAdresa(hostname string) bool { if hostname == "localhost" { return true } if ip := net.ParseIP(hostname); ip != nil { return jePrivatnaIP(ip) } // Hostname (npr. Docker Compose service name kao "teron-mock") — razreši // preko DNS-a i dozvoli samo ako SVE dobijene adrese spadaju u privatne // opsege (localhost/127/10/172.16-31/192.168). ips, err := net.LookupIP(hostname) if err != nil || len(ips) == 0 { return false } for _, ip := range ips { if !jePrivatnaIP(ip) { return false } } return true } func jePrivatnaIP(ip net.IP) bool { ip4 := ip.To4() if ip4 == nil { return false } return ip4[0] == 127 || // loopback ip4[0] == 10 || // privatna klasa A (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || // privatna klasa B (ip4[0] == 192 && ip4[1] == 168) // privatna klasa C } // PodesavanjaSistem renderuje stranicu sa sistemskim podešavanjima (backup) func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { return } podaci, err := h.napuniPodaciPodesavanja(r, "Podešavanja — Sistem") if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } podaci.Stranica = "podesavanja-sistem" h.renderujTemplate(w, "podesavanja_sistem", podaci) }