From ccc08aee082d02a9e4ad21b494523ddfcc25a163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 4 Jun 2026 20:14:03 +0200 Subject: [PATCH] =?UTF-8?q?Dodato=20go:embed=20-=20disk-first=20logika=20z?= =?UTF-8?q?a=20stati=C4=8Dke=20fajlove=20i=20=C5=A1ablone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ntech/main.go | 53 +++++-- internal/db/repository.go | 1 + internal/db/sqlite/korisnici.go | 8 + internal/db/sqlite/login_istorija.go | 5 +- internal/handler/admin.go | 70 ++++++++- internal/handler/handler.go | 20 ++- internal/handler/kes.go | 6 +- internal/handler/podesavanja.go | 147 +++++++++++++++++- internal/handler/prijava.go | 20 ++- internal/middleware/auth.go | 12 ++ web/static/css/main.css | 2 +- web/templates/komponente/sidebar.html | 2 +- web/templates/stranice/admin_korisnici.html | 23 ++- .../stranice/admin_login_istorija.html | 2 +- web/templates/stranice/podesavanja.html | 136 ++++++++++++++-- 15 files changed, 443 insertions(+), 64 deletions(-) diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 5ff6896..1c5f2e4 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -31,8 +31,27 @@ func main() { godotenv.Load("ntech.env") auth.InitAuthLog() + // disk-first logika: ako folder postoji pored binarnog fajla — koristi disk, inače embed. + // Napomena: templFS i migrFS su rootovani na "." tako da putanje ostaju iste kao u embed-u + // (npr. "web/templates/stranice/dashboard.html", "migrations/001_kategorije.sql"). + var templFS fs.FS = assets.TemplatesFS + if _, err := os.Stat("web/templates"); err == nil { + templFS = os.DirFS(".") + } + var migrFS fs.FS = assets.MigracijeFS + if _, err := os.Stat("migrations"); err == nil { + migrFS = os.DirFS(".") + } + // staticFS je rootovan na "web/static" (isti kao fs.Sub embed-a) — uploads ostaju van embed-a + var staticFS fs.FS + if _, err := os.Stat("web/static"); err == nil { + staticFS = os.DirFS("web/static") + } else { + staticFS, _ = fs.Sub(assets.StaticFS, "web/static") + } + if config.JelPrvoPokretanje() { - config.PokreniSetup(assets.TemplatesFS) + config.PokreniSetup(templFS) return } @@ -52,7 +71,7 @@ func main() { } defer db.Close() - if err := sqlite.PokreniMigracije(db, assets.MigracijeFS); err != nil { + if err := sqlite.PokreniMigracije(db, migrFS); err != nil { log.Fatalf("Greška pri migracijama: %v", err) } log.Println("Migracije uspešno izvršene") @@ -75,9 +94,12 @@ func main() { h := handler.Novi(db) h.Verzija = Verzija + h.PutanjaBaze = putanjaBaze + // čuva odabrani FS (disk ili embed) za hot-reload u razvoju i za keš u produkciji + h.TemplatesFS = templFS if os.Getenv("NTECH_ENV") == "production" { - kes, err := handler.KreirajKes(assets.TemplatesFS) + kes, err := handler.KreirajKes(templFS) if err != nil { log.Fatalf("Greška pri kreiranju keša šablona: %v", err) } @@ -90,11 +112,10 @@ func main() { r.Use(ntechmw.CsrfMiddleware) r.Use(middleware.Compress(5)) - // uploads se servira sa diska (runtime fajlovi) + // uploads su uvek na disku — korisnički fajlovi, ne ugrađuju se r.Handle("/static/uploads/*", http.StripPrefix("/static/uploads/", http.FileServer(http.Dir("web/static/uploads")))) - // ostali statični fajlovi (CSS) iz embed FS-a - staticSub, _ := fs.Sub(assets.StaticFS, "web/static") - r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) + // ostali statični fajlovi: disk ako postoji web/static, inače embed + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) // javne rute (bez autentifikacije) r.Get("/prijava", h.PrikazPrijave) @@ -117,6 +138,7 @@ func main() { r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) r.Post("/podesavanja/logo", h.OtpremiLogo) r.Get("/podesavanja/backup", h.BackupBaze) + r.Post("/podesavanja/backup/vrati", h.VratiBackup) r.Get("/tema/{tema}", h.PromeniTemu) r.Get("/magacin", h.Magacin) r.Get("/magacin/novi", h.NoviArtikal) @@ -168,12 +190,16 @@ func main() { r.Post("/podsetnici/zavrseno/{id}", h.OznaciPodsetnik) r.Post("/podsetnici/obrisi/{id}", h.ObrisiPodsetnik) - // admin rute - r.Get("/admin/korisnici", h.AdminKorisnici) - r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija) - r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika) - r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan) - r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu) + // rute dostupne samo superadminu + r.Group(func(r chi.Router) { + r.Use(ntechmw.RequireSuperAdmin) + r.Get("/admin/korisnici", h.AdminKorisnici) + r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija) + r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika) + r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan) + r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu) + r.Post("/admin/korisnici/{id}/obrisi", h.AdminObrisiKorisnika) + }) r.Get("/admin/profil", h.AdminProfil) r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku) r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni) @@ -237,3 +263,4 @@ func ocistiStareBackupe(folder string, max int) { _ = os.Remove(f) } } + diff --git a/internal/db/repository.go b/internal/db/repository.go index 35bb962..3ea4e3c 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -87,6 +87,7 @@ type KorisniciRepository interface { PromeniLozinku(ctx context.Context, id int64, hash string) error SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error PostojiIjedan(ctx context.Context) (bool, error) + Obrisi(ctx context.Context, id int64) error } // SesijeRepository definiše operacije nad sesijama diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go index 12be4ac..861fe4a 100644 --- a/internal/db/sqlite/korisnici.go +++ b/internal/db/sqlite/korisnici.go @@ -125,6 +125,14 @@ func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, ta return nil } +func (r *sqliteKorisniciRepo) Obrisi(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM korisnici WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.Obrisi: %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) diff --git a/internal/db/sqlite/login_istorija.go b/internal/db/sqlite/login_istorija.go index d6e9b69..827de16 100644 --- a/internal/db/sqlite/login_istorija.go +++ b/internal/db/sqlite/login_istorija.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "fmt" - "time" "ntech/internal/model" ) @@ -59,15 +58,13 @@ func (r *LoginIstorijsaRepo) ListaZaKorisnika(ctx context.Context, korisnikID in p := &model.LoginPokusaj{} var kid sql.NullInt64 var u int - var s string - if err := rows.Scan(&p.ID, &kid, &p.IP, &p.UserAgent, &u, &p.Razlog, &s); err != nil { + if err := rows.Scan(&p.ID, &kid, &p.IP, &p.UserAgent, &u, &p.Razlog, &p.Vreme); err != nil { return nil, fmt.Errorf("ntech: LoginIstorijsaRepo.ListaZaKorisnika: scan: %w", err) } if kid.Valid { p.KorisnikID = &kid.Int64 } p.Uspeh = u == 1 - p.Vreme, _ = time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC) lista = append(lista, p) } return lista, rows.Err() diff --git a/internal/handler/admin.go b/internal/handler/admin.go index aa27cb1..c87e161 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -82,18 +82,13 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request) lozinka := r.FormValue("lozinka") uloga := r.FormValue("uloga") - validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true} + // superadmin uloga se ne može kreirati kroz interfejs — jedini superadmin postoji od setup-a + validneUloge := map[string]bool{"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) @@ -161,13 +156,33 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) { return } + // superadmin ne može menjati svoju vlastitu ulogu + if id == k.ID { + http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther) + return + } + uloga := r.FormValue("uloga") - validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true} + // dozvoljena je samo promena između admin i radnik; superadmin uloga se ne može dodeliti ni ukloniti + validneUloge := map[string]bool{"admin": true, "radnik": true} if !validneUloge[uloga] { http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther) return } + // dohvati korisnika da proverimo njegovu trenutnu ulogu + ciljniKorisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id) + if err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther) + return + } + + // superadmin uloga se ne može menjati + if ciljniKorisnik.Uloga == "superadmin" { + http.Redirect(w, r, "/admin/korisnici?greska=3", 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 @@ -176,6 +191,45 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther) } +// AdminObrisiKorisnika briše korisnika sa ulogom radnik +func (h *Handler) AdminObrisiKorisnika(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 id == k.ID { + http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther) + return + } + + ciljni, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id) + if err != nil { + http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther) + return + } + + // dozvoljeno je brisanje samo radnika + if ciljni.Uloga != "radnik" { + http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther) + return + } + + if err := h.KorisniciRepo.Obrisi(r.Context(), id); 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()) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index bacbb46..ea23c67 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -14,7 +14,8 @@ import ( // Handler drži zavisnosti koje su potrebne svim handlerima type Handler struct { - DB *sql.DB + DB *sql.DB + PutanjaBaze string Artikli db.ArtikalRepository KategorijeRepo db.KategorijaRepository DobavljaciRepo db.DobavljacRepository @@ -51,6 +52,23 @@ func Novi(baza *sql.DB) *Handler { } } +// reinicijalzijRepozitorijume zamenjuje sve repozitorijume posle obnove baze +func (h *Handler) reinicijalzijRepozitorijume(novaDB *sql.DB) { + h.DB = novaDB + h.Artikli = sqlite.NoviArtikalRepo(novaDB) + h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB) + h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB) + h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB) + h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB) + h.ServisRepo = sqlite.NoviServisRepo(novaDB) + h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB) + h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB) + h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB) + h.PodsetniciFRepo = sqlite.NoviPodsetnikRepo(novaDB) + h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB) + h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB) +} + // 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{ diff --git a/internal/handler/kes.go b/internal/handler/kes.go index ea52ee1..faf38a4 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -16,7 +16,7 @@ var bazniSabloni = []string{ // saSidebar su šabloni koji koriste base layout (sidebar + topbar) var saSidebar = []string{ - "admin_korisnici", "admin_profil", + "admin_korisnici", "admin_profil", "admin_login_istorija", "dashboard", "dobavljaci", "dobavljac_forma", "izvestaji", @@ -79,7 +79,7 @@ func (h *Handler) renderujTemplate(w http.ResponseWriter, ime string, podaci any copy(fajlovi, bazniSabloni) fajlovi = append(fajlovi, "web/templates/stranice/"+ime+".html") var err error - if tmpl, err = template.ParseFiles(fajlovi...); err != nil { + if tmpl, err = template.ParseFS(h.TemplatesFS, fajlovi...); err != nil { log.Printf("greška pri parsiranju šablona %s: %v", ime, err) http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) return @@ -106,7 +106,7 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a tmpl = t } else { var err error - if tmpl, err = template.ParseFiles("web/templates/stranice/" + ime + ".html"); err != nil { + if tmpl, err = template.ParseFS(h.TemplatesFS, "web/templates/stranice/"+ime+".html"); err != nil { log.Printf("greška pri parsiranju šablona %s: %v", ime, err) http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) return diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 4ac334f..9915806 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -7,10 +7,12 @@ import ( "net/http" "os" "path/filepath" + "regexp" + "sort" "strings" "time" - "ntech/internal/db/sqlite" + ntechsqlite "ntech/internal/db/sqlite" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -29,12 +31,24 @@ type PodaciPodesavanja struct { Tema string Sacuvano bool Verzija string - LogoGreska string + LogoGreska string + BackupVracen bool + Backupi []BackupInfo } +// 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) { - podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return @@ -54,13 +68,123 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { LogoPutanja: podesavanja["logo_putanja"], Tema: podesavanja["tema"], 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(), } 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 +} + +// VratiBackup zamenjuje trenutnu bazu sa izabranim backup fajlom +func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) { + 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 { + log.Printf("vrati backup: greška pri kreiranju sigurnosne kopije: %v", 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 { + log.Printf("vrati backup: wal_checkpoint greška: %v", err) + } + + // zatvori sve konekcije + if err := h.DB.Close(); err != nil { + log.Printf("vrati backup: greška pri zatvaranju baze: %v", err) + } + + // kopiraj backup fajl na mesto trenutne baze + if err := kopiraFajl(putanjaBackupa, h.PutanjaBaze); err != nil { + log.Printf("vrati backup: greška pri kopiranju: %v", err) + http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+obnovi+baze", http.StatusSeeOther) + return + } + + // ukloni WAL i SHM fajlove stare baze + os.Remove(h.PutanjaBaze + "-wal") + os.Remove(h.PutanjaBaze + "-shm") + + // otvori novu konekciju + novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) + if err != nil { + log.Printf("vrati backup: greška pri otvaranju nove baze: %v", err) + http.Redirect(w, r, "/podesavanja?backup_greska=Baza+obnovljena+ali+je+potreban+restart", http.StatusSeeOther) + return + } + + h.reinicijalzijRepozitorijume(novaDB) + log.Printf("Baza uspešno obnovljena iz: %s", 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 +} + // SacuvajPodesavanja prima POST i čuva podešavanja u bazu func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { @@ -82,7 +206,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { if vrednost == "" { continue } - if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { + if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) return } @@ -109,6 +233,8 @@ func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) { // OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) { + // 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, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther) return @@ -121,6 +247,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) { } defer fajl.Close() + // eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera) + if zaglavlje.Size > 2<<20 { + http.Redirect(w, r, "/podesavanja?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{ @@ -172,8 +304,9 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) { return } - putanja := "/static/uploads/logo" + ext - if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil { + // 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 { log.Printf("upload loga: greška pri čuvanju putanje: %v", err) http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther) return @@ -195,7 +328,7 @@ func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) { return } - if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema", tema); err != nil { + if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema", tema); err != nil { http.Error(w, "Greška pri promeni teme", http.StatusInternalServerError) return } diff --git a/internal/handler/prijava.go b/internal/handler/prijava.go index 7c79e7e..0d29e2d 100644 --- a/internal/handler/prijava.go +++ b/internal/handler/prijava.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "strings" "time" "ntech/internal/auth" @@ -41,7 +42,7 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) { return } - ip := izvuciIP(r.RemoteAddr) + ip := izvuciIP(r) korisnickoIme := r.FormValue("korisnicko_ime") lozinka := r.FormValue("lozinka") @@ -249,11 +250,20 @@ func (h *Handler) preostaloBruteforce(ctx context.Context, ip string, od time.Ti return fmt.Sprintf("%d sek", sek), true } -// izvuciIP izdvaja IP adresu iz RemoteAddr formata "ip:port" -func izvuciIP(addr string) string { - host, _, err := net.SplitHostPort(addr) +// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja), +// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr +func izvuciIP(r *http.Request) string { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + // uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent + parts := strings.Split(fwd, ",") + return strings.TrimSpace(parts[len(parts)-1]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { - return addr + return r.RemoteAddr } return host } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 8991910..f02d2f1 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -83,5 +83,17 @@ func JeAdmin(k *model.Korisnik) bool { return k.Uloga == "admin" || k.Uloga == "superadmin" } +// RequireSuperAdmin je middleware koji propušta samo superadmin korisnike +func RequireSuperAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + k := KorisnikIzKonteksta(r.Context()) + if k == nil || k.Uloga != "superadmin" { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + // ErrNijePrijavljen se vraća kada korisnik nije u contextu var ErrNijePrijavljen = errors.New("korisnik nije prijavljen") diff --git a/web/static/css/main.css b/web/static/css/main.css index 450e158..8ebb6cb 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -534,7 +534,7 @@ select { } .animiraj { - animation: fadeInUp 0.4s ease both; + animation: fadeInUp 0.2s ease both; } /* gornja traka magacina — responsive */ diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index 63cac8b..5885f48 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -90,7 +90,7 @@ Moj profil - {{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}} + {{if eq .KorisnikUloga "superadmin"}} Korisnici diff --git a/web/templates/stranice/admin_korisnici.html b/web/templates/stranice/admin_korisnici.html index a7e708e..48122e5 100644 --- a/web/templates/stranice/admin_korisnici.html +++ b/web/templates/stranice/admin_korisnici.html @@ -38,6 +38,8 @@
Proverite unete podatke.
{{else if eq .Greska "2"}}
Greška pri čuvanju. Pokušajte ponovo.
+ {{else if eq .Greska "3"}} +
Ova radnja nije dozvoljena.
{{end}} @@ -62,18 +64,19 @@ {{.KorisnickoIme}} - {{if eq $.KorisnikUloga "superadmin"}} + {{if eq .Uloga "superadmin"}} + Superadmin + {{else if eq $.KorisnikUloga "superadmin"}}
{{else}} - {{if eq .Uloga "superadmin"}}Superadmin{{else if eq .Uloga "admin"}}Admin{{else}}Radnik{{end}} + {{if eq .Uloga "admin"}}Admin{{else}}Radnik{{end}} {{end}} @@ -106,6 +109,15 @@ {{if .Aktivan}}Deaktiviraj{{else}}Aktiviraj{{end}} + {{if eq .Uloga "radnik"}} +
+ +
+ {{end}} {{end}} @@ -124,12 +136,12 @@
-
-
@@ -138,7 +150,6 @@ style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;"> - {{if eq .KorisnikUloga "superadmin"}}{{end}}
diff --git a/web/templates/stranice/admin_login_istorija.html b/web/templates/stranice/admin_login_istorija.html index a3e45d7..b2a64be 100644 --- a/web/templates/stranice/admin_login_istorija.html +++ b/web/templates/stranice/admin_login_istorija.html @@ -27,7 +27,7 @@
Poslednjih 50 pokušaja - Vreme prikazano u UTC + Vreme prikazano po lokalnom vremenu servera
{{if .Istorija}} diff --git a/web/templates/stranice/podesavanja.html b/web/templates/stranice/podesavanja.html index b0756ac..44520f6 100644 --- a/web/templates/stranice/podesavanja.html +++ b/web/templates/stranice/podesavanja.html @@ -8,6 +8,44 @@ .animiraj:nth-child(2) { animation-delay: 0.16s; } .animiraj:nth-child(3) { animation-delay: 0.22s; } .animiraj:nth-child(4) { animation-delay: 0.28s; } + + .toast { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 9999; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 18px; + border-radius: 10px; + font-size: 13px; + font-weight: 500; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); + animation: toastIn 0.3s ease forwards; + max-width: 340px; + } + .toast-greska { + background: #fef2f2; + color: #dc2626; + border: 0.5px solid #fca5a5; + } + .toast-uspeh { + background: #f0fdf4; + color: #16a34a; + border: 0.5px solid #86efac; + } + @keyframes toastIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes toastOut { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(12px); } + } + .toast.nestaje { + animation: toastOut 0.3s ease forwards; + } {{end}} @@ -15,9 +53,10 @@
{{if .Sacuvano}} -
- Podešavanja su uspešno sačuvana. -
+
Podešavanja su uspešno sačuvana.
+ {{end}} + {{if .BackupVracen}} +
Baza je uspešno obnovljena iz rezervne kopije.
{{end}} @@ -27,11 +66,6 @@ Logo firme
- {{if .LogoGreska}} -
- {{.LogoGreska}} -
- {{end}} {{if .LogoPutanja}}
Trenutni logo
Verzija programa: {{.Verzija}}
-
- - Preuzmi backup baze - +
+ + + Preuzmi backup baze + + +
+
+ + +
@@ -170,4 +254,28 @@
+ + + +{{if .LogoGreska}} + + +{{end}} {{end}}