Uvoz/vraćanje baze: čuvanje lokalnih podešavanja i naloga, novi korisnik ime/prezime, custom potvrda umesto confirm(), ispravka prefiksa šifre artikla po kategoriji

This commit is contained in:
2026-07-05 11:16:22 +02:00
parent ffbcbd6fd7
commit 1ff572dd77
15 changed files with 460 additions and 74 deletions
+320
View File
@@ -1,7 +1,9 @@
package handler
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"html"
@@ -20,11 +22,219 @@ import (
"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.
@@ -215,6 +425,9 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
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
@@ -242,7 +455,15 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
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)
}()
@@ -267,6 +488,105 @@ func kopiraFajl(izvor, odrediste string) error {
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.