diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index ddf11ce..4cc06eb 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -1,13 +1,19 @@ package main import ( + "fmt" + "io" "log" "net/http" "os" + "path/filepath" + "sort" + "time" "ntech/internal/config" "ntech/internal/db/sqlite" "ntech/internal/handler" + ntechmw "ntech/internal/middleware" "github.com/go-chi/chi/v5" "github.com/joho/godotenv" @@ -42,6 +48,18 @@ func main() { } log.Println("Migracije uspešno izvršene") + napraviStartupBackup(putanjaBaze) + + // periodično brisanje isteklih sesija + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + sesijeRepo := sqlite.NoviSesijeRepo(db) + for range ticker.C { + _ = sesijeRepo.ObrisiIstekle(nil) + } + }() + h := handler.Novi(db) r := chi.NewRouter() @@ -49,53 +67,79 @@ func main() { // statični fajlovi r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) - // rute - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/dashboard", http.StatusFound) + // javne rute (bez autentifikacije) + r.Get("/prijava", h.PrikazPrijave) + r.Post("/prijava", h.Prijava) + r.Get("/prijava/totp", h.PrikazTotp) + r.Post("/prijava/totp", h.VerifikujTotp) + r.Get("/setup", h.PrikazSetupa) + r.Post("/setup", h.SacuvajSetup) + r.Get("/odjava", h.Odjava) + + // zaštićene rute — zahtevaju prijavljenog korisnika + r.Group(func(r chi.Router) { + r.Use(ntechmw.RequireAuth(db)) + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/dashboard", http.StatusFound) + }) + r.Get("/dashboard", h.Dashboard) + r.Get("/podesavanja", h.Podesavanja) + r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) + r.Get("/podesavanja/backup", h.BackupBaze) + r.Get("/tema/{tema}", h.PromeniTemu) + r.Get("/magacin", h.Magacin) + r.Get("/magacin/novi", h.NoviArtikal) + r.Post("/magacin/novi", h.SacuvajArtikal) + r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) + r.Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) + r.Get("/magacin/obrisi/{id}", h.ObrisiArtikal) + r.Get("/magacin/kategorije", h.Kategorije) + r.Post("/magacin/kategorije/dodaj", h.DodajKategoriju) + r.Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) + r.Get("/nabavke", h.Nabavke) + r.Get("/nabavke/nova", h.NovaNabavka) + r.Post("/nabavke/nova", h.SacuvajNabavku) + r.Get("/nabavke/{id}", h.DetaljiNabavke) + r.Post("/nabavke/obrisi/{id}", h.ObrisiNabavku) + r.Get("/dobavljaci", h.Dobavljaci) + r.Get("/dobavljaci/novi", h.NoviDobavljac) + r.Post("/dobavljaci/novi", h.SacuvajDobavljaca) + r.Get("/dobavljaci/izmeni/{id}", h.IzmeniDobavljaca) + r.Post("/dobavljaci/izmeni/{id}", h.SacuvajIzmeneDobavljaca) + r.Post("/dobavljaci/obrisi/{id}", h.ObrisiDobavljaca) + r.Get("/klijenti", h.Klijenti) + r.Get("/klijenti/novi", h.NoviKlijent) + r.Post("/klijenti/novi", h.SacuvajKlijenta) + r.Get("/klijenti/izmeni/{id}", h.IzmeniKlijenta) + r.Post("/klijenti/izmeni/{id}", h.SacuvajIzmenuKlijenta) + r.Post("/klijenti/obrisi/{id}", h.ObrisiKlijenta) + r.Get("/servis", h.Servis) + r.Get("/servis/novi", h.NoviNalog) + r.Post("/servis/novi", h.SacuvajNalog) + r.Get("/servis/izmeni/{id}", h.IzmeniNalog) + r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) + r.Post("/servis/obrisi/{id}", h.ObrisiNalog) + r.Get("/servis/{id}", h.DetaljiNaloga) + r.Get("/izvestaji", h.Izvestaji) + r.Get("/prodaja", h.Prodaja) + r.Get("/prodaja/nova", h.NovaProdaja) + r.Post("/prodaja/nova", h.SacuvajProdaju) + r.Post("/prodaja/obrisi/{id}", h.ObrisiProdaju) + r.Get("/prodaja/{id}/stampa", h.StampaProdaje) + r.Get("/prodaja/{id}", h.DetaljiProdaje) + + // admin rute + r.Get("/admin/korisnici", h.AdminKorisnici) + r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika) + r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan) + r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu) + r.Get("/admin/profil", h.AdminProfil) + r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku) + r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni) + r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija) + r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija) }) - r.Get("/dashboard", h.Dashboard) - r.Get("/podesavanja", h.Podesavanja) - r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) - r.Get("/tema/{tema}", h.PromeniTemu) - r.Get("/magacin", h.Magacin) - r.Get("/magacin/novi", h.NoviArtikal) - r.Post("/magacin/novi", h.SacuvajArtikal) - r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) - r.Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) - r.Get("/magacin/obrisi/{id}", h.ObrisiArtikal) - r.Get("/magacin/kategorije", h.Kategorije) - r.Post("/magacin/kategorije/dodaj", h.DodajKategoriju) - r.Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) - r.Get("/nabavke", h.Nabavke) - r.Get("/nabavke/nova", h.NovaNabavka) - r.Post("/nabavke/nova", h.SacuvajNabavku) - r.Get("/nabavke/{id}", h.DetaljiNabavke) - r.Post("/nabavke/obrisi/{id}", h.ObrisiNabavku) - r.Get("/dobavljaci", h.Dobavljaci) - r.Get("/dobavljaci/novi", h.NoviDobavljac) - r.Post("/dobavljaci/novi", h.SacuvajDobavljaca) - r.Get("/dobavljaci/izmeni/{id}", h.IzmeniDobavljaca) - r.Post("/dobavljaci/izmeni/{id}", h.SacuvajIzmeneDobavljaca) - r.Post("/dobavljaci/obrisi/{id}", h.ObrisiDobavljaca) - r.Get("/klijenti", h.Klijenti) - r.Get("/klijenti/novi", h.NoviKlijent) - r.Post("/klijenti/novi", h.SacuvajKlijenta) - r.Get("/klijenti/izmeni/{id}", h.IzmeniKlijenta) - r.Post("/klijenti/izmeni/{id}", h.SacuvajIzmenuKlijenta) - r.Post("/klijenti/obrisi/{id}", h.ObrisiKlijenta) - r.Get("/servis", h.Servis) - r.Get("/servis/novi", h.NoviNalog) - r.Post("/servis/novi", h.SacuvajNalog) - r.Get("/servis/izmeni/{id}", h.IzmeniNalog) - r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) - r.Post("/servis/obrisi/{id}", h.ObrisiNalog) - r.Get("/servis/{id}", h.DetaljiNaloga) - r.Get("/prodaja", h.Prodaja) - r.Get("/prodaja/nova", h.NovaProdaja) - r.Post("/prodaja/nova", h.SacuvajProdaju) - r.Post("/prodaja/obrisi/{id}", h.ObrisiProdaju) - r.Get("/prodaja/{id}/stampa", h.StampaProdaje) - r.Get("/prodaja/{id}", h.DetaljiProdaje) log.Printf("NTech pokrenut na portu %s", port) err = http.ListenAndServe(":"+port, r) @@ -103,3 +147,53 @@ func main() { log.Fatalf("Greška: port %s je zauzet ili nije dostupan", port) } } + +// napraviStartupBackup kreira kopiju baze pri pokretanju i čuva poslednjih 7 +func napraviStartupBackup(putanjaBaze string) { + if _, err := os.Stat(putanjaBaze); os.IsNotExist(err) { + return + } + + folder := "backups" + if err := os.MkdirAll(folder, 0755); err != nil { + log.Printf("backup: ne mogu kreirati folder: %v", err) + return + } + + ime := fmt.Sprintf("ntech_%s.db", time.Now().Format("20060102_150405")) + odrediste := filepath.Join(folder, ime) + + src, err := os.Open(putanjaBaze) + if err != nil { + log.Printf("backup: ne mogu otvoriti bazu: %v", err) + return + } + defer src.Close() + + dst, err := os.Create(odrediste) + if err != nil { + log.Printf("backup: ne mogu kreirati backup fajl: %v", err) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + log.Printf("backup: greška pri kopiranju: %v", err) + return + } + + log.Printf("Backup kreiran: %s", odrediste) + ocistiStareBackupe(folder, 7) +} + +// ocistiStareBackupe briše najstarije backup fajlove ako ih ima više od max +func ocistiStareBackupe(folder string, max int) { + fajlovi, err := filepath.Glob(filepath.Join(folder, "ntech_*.db")) + if err != nil || len(fajlovi) <= max { + return + } + sort.Strings(fajlovi) + for _, f := range fajlovi[:len(fajlovi)-max] { + _ = os.Remove(f) + } +} diff --git a/go.mod b/go.mod index e4bcfe9..427fcd7 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,18 @@ module ntech go 1.26.3 require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-chi/chi/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 5f0cd83..ba54458 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -10,13 +13,22 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0= github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..2606862 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,93 @@ +package auth + +import ( + "bytes" + "encoding/base64" + "fmt" + "image/png" + + "github.com/google/uuid" + "github.com/pquerna/otp/totp" + "golang.org/x/crypto/bcrypt" +) + +const bcryptCost = 12 + +// HashujLozinku kreira bcrypt heš lozinke +func HashujLozinku(lozinka string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(lozinka), bcryptCost) + if err != nil { + return "", fmt.Errorf("ntech: auth.HashujLozinku: %w", err) + } + return string(hash), nil +} + +// ProveriLozinku proverava da li lozinka odgovara heš vrednosti +func ProveriLozinku(hash, lozinka string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(lozinka)) == nil +} + +// GenerisiToken generiše nasumičan UUID token za sesiju +func GenerisiToken() string { + return uuid.New().String() +} + +// TotpPodaci sadrži tajnu, URI i base64-enkodiran QR kod za prikaz +type TotpPodaci struct { + Tajna string + URI string + QRBase64 string +} + +// GenerisuTotpTajnu generiše novu TOTP tajnu za korisnika +func GenerisuTotpTajnu(korisnickoIme, nazivFirme string) (*TotpPodaci, error) { + if nazivFirme == "" { + nazivFirme = "NTech" + } + kljuc, err := totp.Generate(totp.GenerateOpts{ + Issuer: nazivFirme, + AccountName: korisnickoIme, + }) + if err != nil { + return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: %w", err) + } + img, err := kljuc.Image(200, 200) + if err != nil { + return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: qr: %w", err) + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: png: %w", err) + } + return &TotpPodaci{ + Tajna: kljuc.Secret(), + URI: kljuc.URL(), + QRBase64: base64.StdEncoding.EncodeToString(buf.Bytes()), + }, nil +} + +// RegenerisiTotpQR vraća URI i base64 QR sliku za već postojeću TOTP tajnu +func RegenerisiTotpQR(tajna, korisnickoIme, nazivFirme string) (uri, qrBase64 string) { + kljuc, err := totp.Generate(totp.GenerateOpts{ + Issuer: nazivFirme, + AccountName: korisnickoIme, + Secret: []byte(tajna), + }) + if err != nil { + return "", "" + } + img, err := kljuc.Image(200, 200) + if err != nil { + return kljuc.URL(), "" + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return kljuc.URL(), "" + } + return kljuc.URL(), base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// VerifikujTotpKod proverava TOTP kod korisnika +func VerifikujTotpKod(kod, tajna string) bool { + return totp.Validate(kod, tajna) +} diff --git a/internal/db/repository.go b/internal/db/repository.go index 26b2217..8074350 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -2,6 +2,7 @@ package db import ( "context" + "time" "ntech/internal/model" ) @@ -74,3 +75,25 @@ type ProdajaRepository interface { Obrisi(ctx context.Context, id int64) error SledeciBroj(ctx context.Context) (string, error) } + +// KorisniciRepository definiše operacije nad korisnicima +type KorisniciRepository interface { + Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error) + DohvatiPoImenu(ctx context.Context, korisnickoIme string) (*model.Korisnik, error) + DohvatiPoID(ctx context.Context, id int64) (*model.Korisnik, error) + Lista(ctx context.Context) ([]model.Korisnik, error) + AzurirajUlogu(ctx context.Context, id int64, uloga string) error + AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error + PromeniLozinku(ctx context.Context, id int64, hash string) error + SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error + PostojiIjedan(ctx context.Context) (bool, error) +} + +// SesijeRepository definiše operacije nad sesijama +type SesijeRepository interface { + Kreiraj(ctx context.Context, korisnikID int64, token string, istice time.Time, totpPotvrdjeno bool) error + DohvatiPoTokenu(ctx context.Context, token string) (*model.Sesija, error) + PotvrdiTotp(ctx context.Context, token string, novoIstice time.Time) error + Obrisi(ctx context.Context, token string) error + ObrisiIstekle(ctx context.Context) error +} diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go new file mode 100644 index 0000000..12be4ac --- /dev/null +++ b/internal/db/sqlite/korisnici.go @@ -0,0 +1,135 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ntech/internal/model" +) + +type sqliteKorisniciRepo struct{ db *sql.DB } + +// NoviKorisniciRepo kreira SQLite implementaciju KorisniciRepository +func NoviKorisniciRepo(db *sql.DB) *sqliteKorisniciRepo { + return &sqliteKorisniciRepo{db: db} +} + +func (r *sqliteKorisniciRepo) Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error) { + res, err := r.db.ExecContext(ctx, + `INSERT INTO korisnici (korisnicko_ime, lozinka_hash, uloga) VALUES (?, ?, ?)`, + korisnickoIme, lozinkaHash, uloga) + if err != nil { + return nil, fmt.Errorf("ntech: korisnici.Kreiraj: %w", err) + } + id, _ := res.LastInsertId() + return r.DohvatiPoID(ctx, id) +} + +func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme string) (*model.Korisnik, error) { + k := &model.Korisnik{} + var aktivan int + var totpTajna sql.NullString + var datumKreiranja time.Time + err := r.db.QueryRowContext(ctx, + `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja + FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme). + Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &totpTajna, &datumKreiranja) + if err != nil { + return nil, fmt.Errorf("ntech: korisnici.DohvatiPoImenu: %w", err) + } + k.Aktivan = aktivan == 1 + k.TotpTajna = totpTajna.String + k.DatumKreiranja = datumKreiranja + return k, nil +} + +func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model.Korisnik, error) { + k := &model.Korisnik{} + var aktivan int + var datumKreiranja time.Time + err := r.db.QueryRowContext(ctx, + `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja + FROM korisnici WHERE id = ?`, id). + Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja) + if err != nil { + return nil, fmt.Errorf("ntech: korisnici.DohvatiPoID: %w", err) + } + k.Aktivan = aktivan == 1 + k.DatumKreiranja = datumKreiranja + return k, nil +} + +func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja + FROM korisnici ORDER BY datum_kreiranja ASC`) + if err != nil { + return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) + } + defer rows.Close() + var lista []model.Korisnik + for rows.Next() { + var k model.Korisnik + var aktivan int + var datumKreiranja time.Time + if err := rows.Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja); err != nil { + return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) + } + k.Aktivan = aktivan == 1 + k.DatumKreiranja = datumKreiranja + lista = append(lista, k) + } + return lista, nil +} + +func (r *sqliteKorisniciRepo) AzurirajUlogu(ctx context.Context, id int64, uloga string) error { + _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.AzurirajUlogu: %w", err) + } + return nil +} + +func (r *sqliteKorisniciRepo) AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error { + val := 0 + if aktivan { + val = 1 + } + _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET aktivan = ? WHERE id = ?`, val, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.AzurirajAktivan: %w", err) + } + return nil +} + +func (r *sqliteKorisniciRepo) PromeniLozinku(ctx context.Context, id int64, hash string) error { + _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET lozinka_hash = ? WHERE id = ?`, hash, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.PromeniLozinku: %w", err) + } + return nil +} + +func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error { + var err error + if tajna == "" { + _, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = NULL WHERE id = ?`, id) + } else { + _, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = ? WHERE id = ?`, tajna, id) + } + if err != nil { + return fmt.Errorf("ntech: korisnici.SacuvajTotpTajnu: %w", err) + } + return nil +} + +func (r *sqliteKorisniciRepo) PostojiIjedan(ctx context.Context) (bool, error) { + var broj int + err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM korisnici`).Scan(&broj) + if err != nil { + return false, fmt.Errorf("ntech: korisnici.PostojiIjedan: %w", err) + } + return broj > 0, nil +} diff --git a/internal/db/sqlite/sesije.go b/internal/db/sqlite/sesije.go new file mode 100644 index 0000000..afb8f64 --- /dev/null +++ b/internal/db/sqlite/sesije.go @@ -0,0 +1,74 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ntech/internal/model" +) + +type sqliteSesijeRepo struct{ db *sql.DB } + +// NoviSesijeRepo kreira SQLite implementaciju SesijeRepository +func NoviSesijeRepo(db *sql.DB) *sqliteSesijeRepo { + return &sqliteSesijeRepo{db: db} +} + +func (r *sqliteSesijeRepo) Kreiraj(ctx context.Context, korisnikID int64, token string, istice time.Time, totpPotvrdjeno bool) error { + potvrdjen := 1 + if !totpPotvrdjeno { + potvrdjen = 0 + } + _, err := r.db.ExecContext(ctx, + `INSERT INTO sesije (korisnik_id, token, totp_potvrdjeno, datum_isteka) VALUES (?, ?, ?, ?)`, + korisnikID, token, potvrdjen, istice) + if err != nil { + return fmt.Errorf("ntech: sesije.Kreiraj: %w", err) + } + return nil +} + +func (r *sqliteSesijeRepo) DohvatiPoTokenu(ctx context.Context, token string) (*model.Sesija, error) { + s := &model.Sesija{} + var totpPotvrdjeno int + var datumIsteka, datumKreiranja time.Time + err := r.db.QueryRowContext(ctx, + `SELECT id, korisnik_id, token, totp_potvrdjeno, datum_isteka, datum_kreiranja + FROM sesije WHERE token = ?`, token). + Scan(&s.ID, &s.KorisnikID, &s.Token, &totpPotvrdjeno, &datumIsteka, &datumKreiranja) + if err != nil { + return nil, fmt.Errorf("ntech: sesije.DohvatiPoTokenu: %w", err) + } + s.TotpPotvrdjeno = totpPotvrdjeno == 1 + s.DatumIsteka = datumIsteka + s.DatumKreiranja = datumKreiranja + return s, nil +} + +func (r *sqliteSesijeRepo) PotvrdiTotp(ctx context.Context, token string, novoIstice time.Time) error { + _, err := r.db.ExecContext(ctx, + `UPDATE sesije SET totp_potvrdjeno = 1, datum_isteka = ? WHERE token = ?`, + novoIstice, token) + if err != nil { + return fmt.Errorf("ntech: sesije.PotvrdiTotp: %w", err) + } + return nil +} + +func (r *sqliteSesijeRepo) Obrisi(ctx context.Context, token string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM sesije WHERE token = ?`, token) + if err != nil { + return fmt.Errorf("ntech: sesije.Obrisi: %w", err) + } + return nil +} + +func (r *sqliteSesijeRepo) ObrisiIstekle(ctx context.Context) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM sesije WHERE datum_isteka < ?`, time.Now()) + if err != nil { + return fmt.Errorf("ntech: sesije.ObrisiIstekle: %w", err) + } + return nil +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..33db7ff --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,356 @@ +package handler + +import ( + "html/template" + "net/http" + "strconv" + + "ntech/internal/auth" + "ntech/internal/db/sqlite" + "ntech/internal/middleware" + "ntech/internal/model" + + "github.com/go-chi/chi/v5" +) + +type podaciAdminKorisnici struct { + model.PodaciStranice + Korisnici []model.Korisnik + Greska string + Sacuvano bool +} + +type podaciAdminProfil struct { + model.PodaciStranice + Greska string + Sacuvano string + TotpURI string + TotpTajna string + TotpQR template.URL + TotpAktivan bool +} + +// AdminKorisnici prikazuje listu korisnika +func (h *Handler) AdminKorisnici(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !middleware.JeAdmin(k) { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + lista, err := h.KorisniciRepo.Lista(r.Context()) + if err != nil { + http.Error(w, "Greška pri učitavanju korisnika", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "admin" + ps.NaslovStranice = "Korisnici" + + podaci := podaciAdminKorisnici{ + PodaciStranice: ps, + Korisnici: lista, + Greska: r.URL.Query().Get("greska"), + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + } + + renderujAdminTemplate(w, "web/templates/stranice/admin_korisnici.html", podaci) +} + +// AdminSacuvajKorisnika kreira novog korisnika +func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !middleware.JeAdmin(k) { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + ime := r.FormValue("korisnicko_ime") + lozinka := r.FormValue("lozinka") + uloga := r.FormValue("uloga") + + validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true} + if len(ime) < 3 || len(lozinka) < 8 || !validneUloge[uloga] { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + // superadmin može kreirati samo admin i radnik (ne drugog superadmina) osim ako je jedini superadmin + if uloga == "superadmin" && k.Uloga != "superadmin" { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + hash, err := auth.HashujLozinku(lozinka) + if err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther) + return + } + + if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, uloga); err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther) +} + +// AdminToggleAktivan menja aktivan status korisnika +func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !middleware.JeAdmin(k) { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + // ne sme deaktivirati sam sebe + if id == k.ID { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id) + if err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + if err := h.KorisniciRepo.AzurirajAktivan(r.Context(), id, !korisnik.Aktivan); err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther) +} + +// AdminPromeniUlogu menja ulogu korisnika +func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || k.Uloga != "superadmin" { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + uloga := r.FormValue("uloga") + validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true} + if !validneUloge[uloga] { + http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) + return + } + + if err := h.KorisniciRepo.AzurirajUlogu(r.Context(), id, uloga); err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther) +} + +// AdminProfil prikazuje stranicu profila +func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + // osvežavamo korisnika iz baze da bismo imali aktuelni totp_tajna + svezi, err := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID) + if err != nil { + http.Error(w, "Greška pri učitavanju profila", http.StatusInternalServerError) + return + } + + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "admin" + ps.NaslovStranice = "Moj profil" + + podaci := podaciAdminProfil{ + PodaciStranice: ps, + Greska: r.URL.Query().Get("greska"), + Sacuvano: r.URL.Query().Get("sacuvano"), + TotpAktivan: svezi.TotpTajna != "", + } + + renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci) +} + +// AdminPromeniLozinku menja lozinku prijavljenog korisnika +func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/profil?greska=1", http.StatusSeeOther) + return + } + + stara := r.FormValue("stara_lozinka") + nova := r.FormValue("nova_lozinka") + potvrda := r.FormValue("nova_lozinka_potvrda") + + if !auth.ProveriLozinku(k.LozinkaHash, stara) { + http.Redirect(w, r, "/admin/profil?greska=lozinka", http.StatusSeeOther) + return + } + if len(nova) < 8 || nova != potvrda { + http.Redirect(w, r, "/admin/profil?greska=lozinka2", http.StatusSeeOther) + return + } + + hash, err := auth.HashujLozinku(nova) + if err != nil { + http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther) + return + } + + if err := h.KorisniciRepo.PromeniLozinku(r.Context(), k.ID, hash); err != nil { + http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin/profil?sacuvano=lozinka", http.StatusSeeOther) +} + +// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod +func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + totp, err := auth.GenerisuTotpTajnu(k.KorisnickoIme, podesavanja["naziv_firme"]) + if err != nil { + http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "admin" + ps.NaslovStranice = "Podesi 2FA" + + podaci := podaciAdminProfil{ + PodaciStranice: ps, + TotpURI: totp.URI, + TotpTajna: totp.Tajna, + TotpQR: template.URL("data:image/png;base64," + totp.QRBase64), + } + + renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci) +} + +// AdminTotpAktivacija verifikuje TOTP kod i čuva tajnu +func (h *Handler) AdminTotpAktivacija(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/profil?greska=1", http.StatusSeeOther) + return + } + + tajna := r.FormValue("totp_tajna") + kod := r.FormValue("kod") + + if !auth.VerifikujTotpKod(kod, tajna) { + // ponovni prikaz sa greškom — regenerišemo isti tajnu + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "admin" + ps.NaslovStranice = "Podesi 2FA" + + // regenerišemo QR za već generisanu tajnu (korisnik je video ovaj QR) + nazivFirme := podesavanja["naziv_firme"] + if nazivFirme == "" { + nazivFirme = "NTech" + } + uri, qr := auth.RegenerisiTotpQR(tajna, k.KorisnickoIme, nazivFirme) + + podaci := podaciAdminProfil{ + PodaciStranice: ps, + TotpURI: uri, + TotpTajna: tajna, + TotpQR: template.URL("data:image/png;base64," + qr), + Greska: "totp", + } + renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci) + return + } + + if err := h.KorisniciRepo.SacuvajTotpTajnu(r.Context(), k.ID, tajna); err != nil { + http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin/profil?sacuvano=totp", http.StatusSeeOther) +} + +// AdminTotpDeaktivacija uklanja TOTP tajnu +func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + if err := h.KorisniciRepo.SacuvajTotpTajnu(r.Context(), k.ID, ""); err != nil { + http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin/profil?sacuvano=totp_off", http.StatusSeeOther) +} + +func renderujAdminTemplate(w http.ResponseWriter, stranica string, podaci any) { + tmpl, err := template.ParseFiles( + "web/templates/teme/podrazumevana/base.html", + "web/templates/komponente/sidebar.html", + "web/templates/komponente/topbar.html", + stranica, + ) + if err != nil { + http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) + return + } + if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil { + http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError) + } +} + +// parseBoolForm čita boolean vrednost iz forme +func parseBoolForm(s string) bool { + b, _ := strconv.ParseBool(s) + return b +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index fcc967e..ffa4d04 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -5,6 +5,9 @@ import ( "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/middleware" + "ntech/internal/model" + "net/http" ) // Handler drži zavisnosti koje su potrebne svim handlerima @@ -17,6 +20,8 @@ type Handler struct { KlijentiRepo db.KlijentRepository ServisRepo db.ServisRepository ProdajaRepo db.ProdajaRepository + KorisniciRepo db.KorisniciRepository + SesijeRepo db.SesijeRepository } // Novi kreira novi Handler sa datom bazom @@ -30,5 +35,25 @@ func Novi(baza *sql.DB) *Handler { KlijentiRepo: sqlite.NoviKlijentRepo(baza), ServisRepo: sqlite.NoviServisRepo(baza), ProdajaRepo: sqlite.NoviProdajaRepo(baza), + KorisniciRepo: sqlite.NoviKorisniciRepo(baza), + SesijeRepo: sqlite.NoviSesijeRepo(baza), } } + +// popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika +func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice { + ps := model.PodaciStranice{ + Tema: podesavanja["tema"], + NazivFirme: podesavanja["naziv_firme"], + Podnazlov: podesavanja["podnazlov"], + LogoTip: podesavanja["logo_tip"], + LogoPutanja: podesavanja["logo_putanja"], + Korisnik: "Admin", + } + if k := middleware.KorisnikIzKonteksta(r.Context()); k != nil { + ps.Korisnik = k.KorisnickoIme + ps.KorisnikIme = k.KorisnickoIme + ps.KorisnikUloga = k.Uloga + } + return ps +} diff --git a/internal/handler/izvestaji.go b/internal/handler/izvestaji.go new file mode 100644 index 0000000..e254760 --- /dev/null +++ b/internal/handler/izvestaji.go @@ -0,0 +1,275 @@ +package handler + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "time" + + "ntech/internal/db/sqlite" + "ntech/internal/model" +) + +var srpskaImenaMeseci = []string{ + "", "Januar", "Februar", "Mart", "April", "Maj", "Jun", + "Jul", "Avgust", "Septembar", "Oktobar", "Novembar", "Decembar", +} + +func formatujMesec(yyyymm string) string { + var god, mes int + if _, err := fmt.Sscanf(yyyymm, "%d-%d", &god, &mes); err != nil || mes < 1 || mes > 12 { + return yyyymm + } + return fmt.Sprintf("%s %d", srpskaImenaMeseci[mes], god) +} + +// PodaciIzvestaja su podaci za stranicu izveštaja +type PodaciIzvestaja struct { + model.PodaciStranice + MesecniPrihodi []MesecniPrihod + GrafikonJSON template.JS + StariNalozi []StariNalog + TopArtikli []TopArtikal + TopKlijenti []TopKlijent +} + +// MesecniPrihod drži prihod od prodaje i servisa za jedan mesec +type MesecniPrihod struct { + MesecPrikaz string + Prodaja float64 + Servis float64 + Ukupno float64 +} + +// StariNalog je servisni nalog bez datuma završetka stariji od 14 dana +type StariNalog struct { + ID int64 + BrojNaloga string + Uredjaj string + KlijentNaziv string + Status string + DatumPrijema string + DanaProslo int +} + +// TopArtikal je artikal rangiran po prodatoj količini +type TopArtikal struct { + Rang int + Naziv string + Kategorija string + UkupnoKolicina int + UkupnoPrihod float64 +} + +// TopKlijent je klijent rangiran po ukupnoj vrednosti naloga +type TopKlijent struct { + Rang int + Naziv string + BrojNaloga int + UkupnoVrednost float64 +} + +// Izvestaji renderuje stranicu sa izveštajima +func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB) + if err != nil { + http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) + return + } + + // --- mesečni prihod: prodaja --- + prodajaPoMesecu := map[string]float64{} + prodajaRed, err := h.DB.QueryContext(ctx, ` + SELECT substr(datum, 1, 7), SUM(ukupno) + FROM prodajni_nalozi + WHERE substr(datum, 1, 10) >= date('now', '-11 months', 'start of month') + GROUP BY substr(datum, 1, 7)`) + if err != nil { + log.Printf("izvestaji: prihod prodaja: %v", err) + } else { + defer prodajaRed.Close() + for prodajaRed.Next() { + var mesec string + var iznos float64 + if err := prodajaRed.Scan(&mesec, &iznos); err == nil { + prodajaPoMesecu[mesec] = iznos + } + } + } + + // --- mesečni prihod: servis --- + servisPoMesecu := map[string]float64{} + servisRed, err := h.DB.QueryContext(ctx, ` + SELECT substr(datum_zavrsetka, 1, 7), SUM(cena_konacna) + FROM servisni_nalozi + WHERE datum_zavrsetka IS NOT NULL + AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month') + GROUP BY substr(datum_zavrsetka, 1, 7)`) + if err != nil { + log.Printf("izvestaji: prihod servis: %v", err) + } else { + defer servisRed.Close() + for servisRed.Next() { + var mesec string + var iznos float64 + if err := servisRed.Scan(&mesec, &iznos); err == nil { + servisPoMesecu[mesec] = iznos + } + } + } + + // gradimo niz za poslednjih 12 meseci (hronološki) + sada := time.Now() + var mesecniPrihodi []MesecniPrihod + var grafikonLabele []string + var grafikonProdaja []float64 + var grafikonServis []float64 + + for i := 11; i >= 0; i-- { + t := sada.AddDate(0, -i, 0) + kljuc := t.Format("2006-01") + prod := prodajaPoMesecu[kljuc] + serv := servisPoMesecu[kljuc] + mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{ + MesecPrikaz: formatujMesec(kljuc), + Prodaja: prod, + Servis: serv, + Ukupno: prod + serv, + }) + grafikonLabele = append(grafikonLabele, formatujMesec(kljuc)) + grafikonProdaja = append(grafikonProdaja, prod) + grafikonServis = append(grafikonServis, serv) + } + + type grafikonPodaci struct { + Labele []string `json:"labele"` + Prodaja []float64 `json:"prodaja"` + Servis []float64 `json:"servis"` + } + jsonBytes, _ := json.Marshal(grafikonPodaci{ + Labele: grafikonLabele, + Prodaja: grafikonProdaja, + Servis: grafikonServis, + }) + + // --- stari otvoreni nalozi (>14 dana bez završetka) --- + stariRed, err := h.DB.QueryContext(ctx, ` + SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema, + COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '—') AS klijent_naziv + FROM servisni_nalozi sn + LEFT JOIN klijenti k ON k.id = sn.klijent_id + WHERE sn.datum_zavrsetka IS NULL + AND substr(sn.datum_prijema, 1, 10) <= date('now', '-14 days') + ORDER BY sn.datum_prijema ASC`) + + var stariNalozi []StariNalog + if err != nil { + log.Printf("izvestaji: stari nalozi: %v", err) + } else { + defer stariRed.Close() + for stariRed.Next() { + var sn StariNalog + var datumVreme time.Time + if err := stariRed.Scan(&sn.ID, &sn.BrojNaloga, &sn.Uredjaj, &sn.Status, &datumVreme, &sn.KlijentNaziv); err == nil { + sn.DatumPrijema = datumVreme.Format("02.01.2006.") + sn.DanaProslo = int(time.Since(datumVreme).Hours() / 24) + stariNalozi = append(stariNalozi, sn) + } + } + } + + // --- top 10 najprodavanijih artikala --- + artRed, err := h.DB.QueryContext(ctx, ` + SELECT a.naziv, COALESCE(k.naziv, '—'), SUM(sp.kolicina), SUM(sp.ukupno) + FROM stavke_prodaje sp + JOIN artikli a ON a.id = sp.artikal_id + LEFT JOIN kategorije k ON k.id = a.kategorija_id + GROUP BY sp.artikal_id + ORDER BY SUM(sp.kolicina) DESC + LIMIT 10`) + + var topArtikli []TopArtikal + if err != nil { + log.Printf("izvestaji: top artikli: %v", err) + } else { + defer artRed.Close() + for artRed.Next() { + var a TopArtikal + if err := artRed.Scan(&a.Naziv, &a.Kategorija, &a.UkupnoKolicina, &a.UkupnoPrihod); err == nil { + a.Rang = len(topArtikli) + 1 + topArtikli = append(topArtikli, a) + } + } + } + + // --- top 10 klijenata po ukupnoj vrednosti --- + klijRed, err := h.DB.QueryContext(ctx, ` + SELECT + COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, ''))) AS naziv, + COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) AS ukupno_vrednost, + COALESCE(p.broj_prodaja, 0) + COALESCE(s.broj_servisa, 0) AS broj_naloga + FROM klijenti k + LEFT JOIN ( + SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja + FROM prodajni_nalozi GROUP BY klijent_id + ) p ON p.klijent_id = k.id + LEFT JOIN ( + SELECT klijent_id, SUM(cena_konacna) AS ukupno_servis, COUNT(*) AS broj_servisa + FROM servisni_nalozi WHERE cena_konacna IS NOT NULL GROUP BY klijent_id + ) s ON s.klijent_id = k.id + WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0 + ORDER BY ukupno_vrednost DESC + LIMIT 10`) + + var topKlijenti []TopKlijent + if err != nil { + log.Printf("izvestaji: top klijenti: %v", err) + } else { + defer klijRed.Close() + for klijRed.Next() { + var k TopKlijent + if err := klijRed.Scan(&k.Naziv, &k.UkupnoVrednost, &k.BrojNaloga); err == nil { + k.Rang = len(topKlijenti) + 1 + topKlijenti = append(topKlijenti, k) + } + } + } + + podaci := PodaciIzvestaja{ + PodaciStranice: model.PodaciStranice{ + Stranica: "izvestaji", + NaslovStranice: "Izveštaji", + Tema: podesavanja["tema"], + NazivFirme: podesavanja["naziv_firme"], + Podnazlov: podesavanja["podnazlov"], + LogoTip: podesavanja["logo_tip"], + LogoPutanja: podesavanja["logo_putanja"], + Korisnik: "Admin", + }, + MesecniPrihodi: mesecniPrihodi, + GrafikonJSON: template.JS(jsonBytes), + StariNalozi: stariNalozi, + TopArtikli: topArtikli, + TopKlijenti: topKlijenti, + } + + tmpl, err := template.ParseFiles( + "web/templates/teme/podrazumevana/base.html", + "web/templates/komponente/sidebar.html", + "web/templates/komponente/topbar.html", + "web/templates/stranice/izvestaji.html", + ) + if err != nil { + http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) + return + } + + if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil { + http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError) + return + } +} diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 9bcc78f..c24d6d1 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -1,8 +1,11 @@ package handler import ( + "fmt" "html/template" "net/http" + "os" + "time" "ntech/internal/db/sqlite" "ntech/internal/model" @@ -101,6 +104,22 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther) } +// BackupBaze kreira konzistentnu kopiju baze i šalje je kao attachment +func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) { + 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) +} + // PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) { tema := chi.URLParam(r, "tema") diff --git a/internal/handler/prijava.go b/internal/handler/prijava.go new file mode 100644 index 0000000..58bd9ec --- /dev/null +++ b/internal/handler/prijava.go @@ -0,0 +1,212 @@ +package handler + +import ( + "html/template" + "net/http" + "time" + + "ntech/internal/auth" +) + +const imeKolacica = "ntech_sesija" +const trajanjeSeije = 7 * 24 * time.Hour +const trajanjePredSeije = 5 * time.Minute + +// PrikazPrijave renderuje formu za prijavu +func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) { + // ako nema korisnika, preusmeri na setup wizard + postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context()) + if err == nil && !postoji { + http.Redirect(w, r, "/setup", http.StatusSeeOther) + return + } + + greska := r.URL.Query().Get("greska") + renderujStandaloneTemplate(w, "web/templates/stranice/prijava.html", map[string]any{ + "Greska": greska, + }) +} + +// Prijava obrađuje POST formu za prijavu +func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther) + return + } + + korisnickoIme := r.FormValue("korisnicko_ime") + lozinka := r.FormValue("lozinka") + + korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme) + if err != nil || !korisnik.Aktivan { + http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther) + return + } + + if !auth.ProveriLozinku(korisnik.LozinkaHash, lozinka) { + http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther) + return + } + + token := auth.GenerisiToken() + + if korisnik.TotpTajna != "" { + // kreira privremenu sesiju koja čeka TOTP verifikaciju + if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjePredSeije), false); err != nil { + http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther) + return + } + http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjePredSeije))) + http.Redirect(w, r, "/prijava/totp", http.StatusSeeOther) + return + } + + // direktna sesija bez TOTP + if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjeSeije), true); err != nil { + http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther) + return + } + http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjeSeije))) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +// PrikazTotp renderuje formu za unos TOTP koda +func (h *Handler) PrikazTotp(w http.ResponseWriter, r *http.Request) { + kolacic, err := r.Cookie(imeKolacica) + if err != nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value) + if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + greska := r.URL.Query().Get("greska") + renderujStandaloneTemplate(w, "web/templates/stranice/totp_provera.html", map[string]any{ + "Greska": greska, + }) +} + +// VerifikujTotp obrađuje POST formu za TOTP verifikaciju +func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) { + kolacic, err := r.Cookie(imeKolacica) + if err != nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value) + if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), sesija.KorisnikID) + if err != nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + kod := r.FormValue("kod") + if !auth.VerifikujTotpKod(kod, korisnik.TotpTajna) { + http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther) + return + } + + 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) + return + } + + http.SetCookie(w, napraviKolacic(kolacic.Value, novoIstice)) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) +} + +// PrikazSetupa renderuje setup wizard za prvog korisnika +func (h *Handler) PrikazSetupa(w http.ResponseWriter, r *http.Request) { + postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context()) + if err == nil && postoji { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + greska := r.URL.Query().Get("greska") + renderujStandaloneTemplate(w, "web/templates/stranice/setup.html", map[string]any{ + "Greska": greska, + }) +} + +// SacuvajSetup kreira prvog superadmin korisnika +func (h *Handler) SacuvajSetup(w http.ResponseWriter, r *http.Request) { + postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context()) + if err == nil && postoji { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther) + return + } + + ime := r.FormValue("korisnicko_ime") + lozinka := r.FormValue("lozinka") + potvrda := r.FormValue("lozinka_potvrda") + + if len(ime) < 3 || len(lozinka) < 8 || lozinka != potvrda { + http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther) + return + } + + hash, err := auth.HashujLozinku(lozinka) + if err != nil { + http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther) + return + } + + if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, "superadmin"); err != nil { + http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/prijava?sacuvano=1", http.StatusSeeOther) +} + +// Odjava briše sesiju i kolačić +func (h *Handler) Odjava(w http.ResponseWriter, r *http.Request) { + if kolacic, err := r.Cookie(imeKolacica); err == nil { + _ = h.SesijeRepo.Obrisi(r.Context(), kolacic.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: imeKolacica, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) + http.Redirect(w, r, "/prijava", http.StatusSeeOther) +} + +func napraviKolacic(token string, istice time.Time) *http.Cookie { + return &http.Cookie{ + Name: imeKolacica, + Value: token, + Path: "/", + Expires: istice, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + } +} + +func renderujStandaloneTemplate(w http.ResponseWriter, putanja string, podaci any) { + tmpl, err := template.ParseFiles(putanja) + if err != nil { + http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) + return + } + if err := tmpl.Execute(w, podaci); err != nil { + http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..8991910 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,87 @@ +package middleware + +import ( + "context" + "database/sql" + "errors" + "net/http" + "time" + + "ntech/internal/db/sqlite" + "ntech/internal/model" +) + +type kontekstKljuc string + +// KljucKorisnika je ključ za korisnika u request contextu +const KljucKorisnika kontekstKljuc = "korisnik" + +// RequireAuth je chi middleware koji proverava sesiju i injektuje korisnika u context +func RequireAuth(db *sql.DB) func(http.Handler) http.Handler { + sesijeRepo := sqlite.NoviSesijeRepo(db) + korisRepo := sqlite.NoviKorisniciRepo(db) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + kolacic, err := r.Cookie("ntech_sesija") + if err != nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + sesija, err := sesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value) + if err != nil { + // nevažeći token — briši kolačić i preusmeri + http.SetCookie(w, &http.Cookie{ + Name: "ntech_sesija", + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + // proveri da li je sesija istekla + if time.Now().After(sesija.DatumIsteka) { + _ = sesijeRepo.Obrisi(r.Context(), kolacic.Value) + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + // ako čeka TOTP verifikaciju, preusmeri na TOTP stranicu + if !sesija.TotpPotvrdjeno { + http.Redirect(w, r, "/prijava/totp", http.StatusSeeOther) + return + } + + korisnik, err := korisRepo.DohvatiPoID(r.Context(), sesija.KorisnikID) + if err != nil || !korisnik.Aktivan { + _ = sesijeRepo.Obrisi(r.Context(), kolacic.Value) + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + + ctx := context.WithValue(r.Context(), KljucKorisnika, korisnik) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// KorisnikIzKonteksta vraća trenutno prijavljenog korisnika iz konteksta +func KorisnikIzKonteksta(ctx context.Context) *model.Korisnik { + k, _ := ctx.Value(KljucKorisnika).(*model.Korisnik) + return k +} + +// JeAdmin proverava da li korisnik ima admin ili superadmin ulogu +func JeAdmin(k *model.Korisnik) bool { + if k == nil { + return false + } + return k.Uloga == "admin" || k.Uloga == "superadmin" +} + +// ErrNijePrijavljen se vraća kada korisnik nije u contextu +var ErrNijePrijavljen = errors.New("korisnik nije prijavljen") diff --git a/internal/model/korisnik.go b/internal/model/korisnik.go new file mode 100644 index 0000000..4580c6e --- /dev/null +++ b/internal/model/korisnik.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// Korisnik predstavlja nalog korisnika u sistemu +type Korisnik struct { + ID int64 + KorisnickoIme string + LozinkaHash string + Uloga string // "superadmin" | "admin" | "radnik" + Aktivan bool + TotpTajna string + DatumKreiranja time.Time +} + +// Sesija predstavlja aktivnu sesiju prijavljenog korisnika +type Sesija struct { + ID int64 + KorisnikID int64 + Token string + TotpPotvrdjeno bool + DatumIsteka time.Time + DatumKreiranja time.Time +} diff --git a/internal/model/stranica.go b/internal/model/stranica.go index ce612fd..e0456bb 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -33,6 +33,8 @@ type PodaciStranice struct { LogoTip string // "ikonica", "tekst", "slika" LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika" Korisnik string + KorisnikIme string // korisničko ime prijavljenog korisnika + KorisnikUloga string // uloga: "superadmin", "admin", "radnik" } // PodaciDashboarda su podaci specifični za dashboard stranicu diff --git a/migrations/016_korisnici.sql b/migrations/016_korisnici.sql new file mode 100644 index 0000000..087c707 --- /dev/null +++ b/migrations/016_korisnici.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS korisnici ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + korisnicko_ime TEXT NOT NULL UNIQUE, + lozinka_hash TEXT NOT NULL, + uloga TEXT NOT NULL DEFAULT 'radnik', + aktivan INTEGER NOT NULL DEFAULT 1, + totp_tajna TEXT, + datum_kreiranja DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/017_sesije.sql b/migrations/017_sesije.sql new file mode 100644 index 0000000..0534f3d --- /dev/null +++ b/migrations/017_sesije.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS sesije ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + korisnik_id INTEGER NOT NULL REFERENCES korisnici(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + totp_potvrdjeno INTEGER NOT NULL DEFAULT 1, + datum_isteka DATETIME NOT NULL, + datum_kreiranja DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index d42f244..b564f38 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -81,6 +81,23 @@ +
+ + + {{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}} + + + Korisnici + + + {{end}} + + + + Moj profil + + + diff --git a/web/templates/stranice/admin_korisnici.html b/web/templates/stranice/admin_korisnici.html new file mode 100644 index 0000000..c5b0cd8 --- /dev/null +++ b/web/templates/stranice/admin_korisnici.html @@ -0,0 +1,124 @@ +{{template "base" .}} + +{{define "naslov"}}Korisnici — NTech{{end}} + +{{define "sadrzaj"}} ++ Skenirajte QR kod u aplikaciji (Google Authenticator, Authy...), pa unesite generisani kod da potvrdite podešavanje. +
+ +{{.TotpTajna}}
+ | Mesec | +Prodaja | +Servis | +Ukupno | +
|---|---|---|---|
| {{.MesecPrikaz}} | ++ {{if gt .Prodaja 0.0}}{{printf "%.0f" .Prodaja}} din{{else}}—{{end}} + | ++ {{if gt .Servis 0.0}}{{printf "%.0f" .Servis}} din{{else}}—{{end}} + | ++ {{if gt .Ukupno 0.0}}{{printf "%.0f" .Ukupno}} din{{else}}—{{end}} + | +
| Nalog | +Uređaj | +Klijent | +Status | +Primljeno | +Dana | +
|---|---|---|---|---|---|
| + {{.BrojNaloga}} + | +{{.Uredjaj}} | +{{.KlijentNaziv}} | +{{.Status}} | +{{.DatumPrijema}} | ++ {{if gt .DanaProslo 30}} + {{.DanaProslo}} dana + {{else}} + {{.DanaProslo}} dana + {{end}} + | +
| # | +Artikal | +Kom. | +Prihod | +
|---|---|---|---|
| + {{.Rang}} + | +
+ {{.Naziv}}
+ {{.Kategorija}}
+ |
+ {{.UkupnoKolicina}} | +{{printf "%.0f" .UkupnoPrihod}} din | +
| # | +Klijent | +Naloga | +Vrednost | +
|---|---|---|---|
| + {{.Rang}} + | +{{.Naziv}} | +{{.BrojNaloga}} | +{{printf "%.0f" .UkupnoVrednost}} din | +
Dobrodošli. Kreirajte nalog superadmin korisnika koji će upravljati sistemom.
+ + {{if eq .Greska "1"}} +Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju.
+ + {{if eq .Greska "1"}} +