Dodato go:embed - disk-first logika za statičke fajlove i šablone
This commit is contained in:
+40
-13
@@ -31,8 +31,27 @@ func main() {
|
|||||||
godotenv.Load("ntech.env")
|
godotenv.Load("ntech.env")
|
||||||
auth.InitAuthLog()
|
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() {
|
if config.JelPrvoPokretanje() {
|
||||||
config.PokreniSetup(assets.TemplatesFS)
|
config.PokreniSetup(templFS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +71,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
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.Fatalf("Greška pri migracijama: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("Migracije uspešno izvršene")
|
log.Println("Migracije uspešno izvršene")
|
||||||
@@ -75,9 +94,12 @@ func main() {
|
|||||||
|
|
||||||
h := handler.Novi(db)
|
h := handler.Novi(db)
|
||||||
h.Verzija = Verzija
|
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" {
|
if os.Getenv("NTECH_ENV") == "production" {
|
||||||
kes, err := handler.KreirajKes(assets.TemplatesFS)
|
kes, err := handler.KreirajKes(templFS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Greška pri kreiranju keša šablona: %v", err)
|
log.Fatalf("Greška pri kreiranju keša šablona: %v", err)
|
||||||
}
|
}
|
||||||
@@ -90,11 +112,10 @@ func main() {
|
|||||||
r.Use(ntechmw.CsrfMiddleware)
|
r.Use(ntechmw.CsrfMiddleware)
|
||||||
r.Use(middleware.Compress(5))
|
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"))))
|
r.Handle("/static/uploads/*", http.StripPrefix("/static/uploads/", http.FileServer(http.Dir("web/static/uploads"))))
|
||||||
// ostali statični fajlovi (CSS) iz embed FS-a
|
// ostali statični fajlovi: disk ako postoji web/static, inače embed
|
||||||
staticSub, _ := fs.Sub(assets.StaticFS, "web/static")
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
|
||||||
|
|
||||||
// javne rute (bez autentifikacije)
|
// javne rute (bez autentifikacije)
|
||||||
r.Get("/prijava", h.PrikazPrijave)
|
r.Get("/prijava", h.PrikazPrijave)
|
||||||
@@ -117,6 +138,7 @@ func main() {
|
|||||||
r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
|
r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
|
||||||
r.Post("/podesavanja/logo", h.OtpremiLogo)
|
r.Post("/podesavanja/logo", h.OtpremiLogo)
|
||||||
r.Get("/podesavanja/backup", h.BackupBaze)
|
r.Get("/podesavanja/backup", h.BackupBaze)
|
||||||
|
r.Post("/podesavanja/backup/vrati", h.VratiBackup)
|
||||||
r.Get("/tema/{tema}", h.PromeniTemu)
|
r.Get("/tema/{tema}", h.PromeniTemu)
|
||||||
r.Get("/magacin", h.Magacin)
|
r.Get("/magacin", h.Magacin)
|
||||||
r.Get("/magacin/novi", h.NoviArtikal)
|
r.Get("/magacin/novi", h.NoviArtikal)
|
||||||
@@ -168,12 +190,16 @@ func main() {
|
|||||||
r.Post("/podsetnici/zavrseno/{id}", h.OznaciPodsetnik)
|
r.Post("/podsetnici/zavrseno/{id}", h.OznaciPodsetnik)
|
||||||
r.Post("/podsetnici/obrisi/{id}", h.ObrisiPodsetnik)
|
r.Post("/podsetnici/obrisi/{id}", h.ObrisiPodsetnik)
|
||||||
|
|
||||||
// admin rute
|
// rute dostupne samo superadminu
|
||||||
r.Get("/admin/korisnici", h.AdminKorisnici)
|
r.Group(func(r chi.Router) {
|
||||||
r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija)
|
r.Use(ntechmw.RequireSuperAdmin)
|
||||||
r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika)
|
r.Get("/admin/korisnici", h.AdminKorisnici)
|
||||||
r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan)
|
r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija)
|
||||||
r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu)
|
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.Get("/admin/profil", h.AdminProfil)
|
||||||
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
|
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
|
||||||
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
|
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
|
||||||
@@ -237,3 +263,4 @@ func ocistiStareBackupe(folder string, max int) {
|
|||||||
_ = os.Remove(f)
|
_ = os.Remove(f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type KorisniciRepository interface {
|
|||||||
PromeniLozinku(ctx context.Context, id int64, hash string) error
|
PromeniLozinku(ctx context.Context, id int64, hash string) error
|
||||||
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
|
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
|
||||||
PostojiIjedan(ctx context.Context) (bool, error)
|
PostojiIjedan(ctx context.Context) (bool, error)
|
||||||
|
Obrisi(ctx context.Context, id int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SesijeRepository definiše operacije nad sesijama
|
// SesijeRepository definiše operacije nad sesijama
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, ta
|
|||||||
return nil
|
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) {
|
func (r *sqliteKorisniciRepo) PostojiIjedan(ctx context.Context) (bool, error) {
|
||||||
var broj int
|
var broj int
|
||||||
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM korisnici`).Scan(&broj)
|
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM korisnici`).Scan(&broj)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
)
|
)
|
||||||
@@ -59,15 +58,13 @@ func (r *LoginIstorijsaRepo) ListaZaKorisnika(ctx context.Context, korisnikID in
|
|||||||
p := &model.LoginPokusaj{}
|
p := &model.LoginPokusaj{}
|
||||||
var kid sql.NullInt64
|
var kid sql.NullInt64
|
||||||
var u int
|
var u int
|
||||||
var s string
|
if err := rows.Scan(&p.ID, &kid, &p.IP, &p.UserAgent, &u, &p.Razlog, &p.Vreme); err != nil {
|
||||||
if err := rows.Scan(&p.ID, &kid, &p.IP, &p.UserAgent, &u, &p.Razlog, &s); err != nil {
|
|
||||||
return nil, fmt.Errorf("ntech: LoginIstorijsaRepo.ListaZaKorisnika: scan: %w", err)
|
return nil, fmt.Errorf("ntech: LoginIstorijsaRepo.ListaZaKorisnika: scan: %w", err)
|
||||||
}
|
}
|
||||||
if kid.Valid {
|
if kid.Valid {
|
||||||
p.KorisnikID = &kid.Int64
|
p.KorisnikID = &kid.Int64
|
||||||
}
|
}
|
||||||
p.Uspeh = u == 1
|
p.Uspeh = u == 1
|
||||||
p.Vreme, _ = time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC)
|
|
||||||
lista = append(lista, p)
|
lista = append(lista, p)
|
||||||
}
|
}
|
||||||
return lista, rows.Err()
|
return lista, rows.Err()
|
||||||
|
|||||||
@@ -82,18 +82,13 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
|
|||||||
lozinka := r.FormValue("lozinka")
|
lozinka := r.FormValue("lozinka")
|
||||||
uloga := r.FormValue("uloga")
|
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] {
|
if len(ime) < 3 || len(lozinka) < 8 || !validneUloge[uloga] {
|
||||||
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||||
return
|
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)
|
hash, err := auth.HashujLozinku(lozinka)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
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
|
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")
|
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] {
|
if !validneUloge[uloga] {
|
||||||
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||||
return
|
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 {
|
if err := h.KorisniciRepo.AzurirajUlogu(r.Context(), id, uloga); err != nil {
|
||||||
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||||
return
|
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)
|
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
|
// AdminProfil prikazuje stranicu profila
|
||||||
func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
|
||||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import (
|
|||||||
|
|
||||||
// Handler drži zavisnosti koje su potrebne svim handlerima
|
// Handler drži zavisnosti koje su potrebne svim handlerima
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
|
PutanjaBaze string
|
||||||
Artikli db.ArtikalRepository
|
Artikli db.ArtikalRepository
|
||||||
KategorijeRepo db.KategorijaRepository
|
KategorijeRepo db.KategorijaRepository
|
||||||
DobavljaciRepo db.DobavljacRepository
|
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
|
// popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika
|
||||||
func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice {
|
func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice {
|
||||||
ps := model.PodaciStranice{
|
ps := model.PodaciStranice{
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ var bazniSabloni = []string{
|
|||||||
|
|
||||||
// saSidebar su šabloni koji koriste base layout (sidebar + topbar)
|
// saSidebar su šabloni koji koriste base layout (sidebar + topbar)
|
||||||
var saSidebar = []string{
|
var saSidebar = []string{
|
||||||
"admin_korisnici", "admin_profil",
|
"admin_korisnici", "admin_profil", "admin_login_istorija",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"dobavljaci", "dobavljac_forma",
|
"dobavljaci", "dobavljac_forma",
|
||||||
"izvestaji",
|
"izvestaji",
|
||||||
@@ -79,7 +79,7 @@ func (h *Handler) renderujTemplate(w http.ResponseWriter, ime string, podaci any
|
|||||||
copy(fajlovi, bazniSabloni)
|
copy(fajlovi, bazniSabloni)
|
||||||
fajlovi = append(fajlovi, "web/templates/stranice/"+ime+".html")
|
fajlovi = append(fajlovi, "web/templates/stranice/"+ime+".html")
|
||||||
var err error
|
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)
|
log.Printf("greška pri parsiranju šablona %s: %v", ime, err)
|
||||||
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -106,7 +106,7 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a
|
|||||||
tmpl = t
|
tmpl = t
|
||||||
} else {
|
} else {
|
||||||
var err error
|
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)
|
log.Printf("greška pri parsiranju šablona %s: %v", ime, err)
|
||||||
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ntech/internal/db/sqlite"
|
ntechsqlite "ntech/internal/db/sqlite"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -29,12 +31,24 @@ type PodaciPodesavanja struct {
|
|||||||
Tema string
|
Tema string
|
||||||
Sacuvano bool
|
Sacuvano bool
|
||||||
Verzija string
|
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
|
// Podesavanja renderuje stranicu podešavanja
|
||||||
func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -54,13 +68,123 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
|
|||||||
LogoPutanja: podesavanja["logo_putanja"],
|
LogoPutanja: podesavanja["logo_putanja"],
|
||||||
Tema: podesavanja["tema"],
|
Tema: podesavanja["tema"],
|
||||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||||
|
BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno",
|
||||||
Verzija: h.Verzija,
|
Verzija: h.Verzija,
|
||||||
LogoGreska: r.URL.Query().Get("logo_greska"),
|
LogoGreska: r.URL.Query().Get("logo_greska"),
|
||||||
|
Backupi: ucitajListuBackupa(),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderujTemplate(w, "podesavanja", podaci)
|
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
|
// SacuvajPodesavanja prima POST i čuva podešavanja u bazu
|
||||||
func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
@@ -82,7 +206,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
|||||||
if vrednost == "" {
|
if vrednost == "" {
|
||||||
continue
|
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)
|
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
|
||||||
return
|
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/
|
// OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/
|
||||||
func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||||
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -121,6 +247,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer fajl.Close()
|
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
|
// proveravamo ekstenziju
|
||||||
ext := strings.ToLower(filepath.Ext(zaglavlje.Filename))
|
ext := strings.ToLower(filepath.Ext(zaglavlje.Filename))
|
||||||
dozvoljenoExt := map[string]string{
|
dozvoljenoExt := map[string]string{
|
||||||
@@ -172,8 +304,9 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
putanja := "/static/uploads/logo" + ext
|
// timestamp u URL-u sprečava browser da koristi staru keširanu sliku
|
||||||
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
|
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)
|
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)
|
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -195,7 +328,7 @@ func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
http.Error(w, "Greška pri promeni teme", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ntech/internal/auth"
|
"ntech/internal/auth"
|
||||||
@@ -41,7 +42,7 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := izvuciIP(r.RemoteAddr)
|
ip := izvuciIP(r)
|
||||||
korisnickoIme := r.FormValue("korisnicko_ime")
|
korisnickoIme := r.FormValue("korisnicko_ime")
|
||||||
lozinka := r.FormValue("lozinka")
|
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
|
return fmt.Sprintf("%d sek", sek), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// izvuciIP izdvaja IP adresu iz RemoteAddr formata "ip:port"
|
// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja),
|
||||||
func izvuciIP(addr string) string {
|
// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr
|
||||||
host, _, err := net.SplitHostPort(addr)
|
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 {
|
if err != nil {
|
||||||
return addr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,5 +83,17 @@ func JeAdmin(k *model.Korisnik) bool {
|
|||||||
return k.Uloga == "admin" || k.Uloga == "superadmin"
|
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
|
// ErrNijePrijavljen se vraća kada korisnik nije u contextu
|
||||||
var ErrNijePrijavljen = errors.New("korisnik nije prijavljen")
|
var ErrNijePrijavljen = errors.New("korisnik nije prijavljen")
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animiraj {
|
.animiraj {
|
||||||
animation: fadeInUp 0.4s ease both;
|
animation: fadeInUp 0.2s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* gornja traka magacina — responsive */
|
/* gornja traka magacina — responsive */
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
<span class="nav-tooltip">Moj profil</span>
|
<span class="nav-tooltip">Moj profil</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}}
|
{{if eq .KorisnikUloga "superadmin"}}
|
||||||
<a href="/admin/korisnici" class="nav-stavka {{if eq .Stranica "admin"}}aktivan{{end}}">
|
<a href="/admin/korisnici" class="nav-stavka {{if eq .Stranica "admin"}}aktivan{{end}}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
<span>Korisnici</span>
|
<span>Korisnici</span>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
<div class="poruka-greska">Proverite unete podatke.</div>
|
<div class="poruka-greska">Proverite unete podatke.</div>
|
||||||
{{else if eq .Greska "2"}}
|
{{else if eq .Greska "2"}}
|
||||||
<div class="poruka-greska">Greška pri čuvanju. Pokušajte ponovo.</div>
|
<div class="poruka-greska">Greška pri čuvanju. Pokušajte ponovo.</div>
|
||||||
|
{{else if eq .Greska "3"}}
|
||||||
|
<div class="poruka-greska">Ova radnja nije dozvoljena.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- lista korisnika -->
|
<!-- lista korisnika -->
|
||||||
@@ -62,18 +64,19 @@
|
|||||||
<tr class="animiraj" style="border-bottom:0.5px solid var(--ivica);">
|
<tr class="animiraj" style="border-bottom:0.5px solid var(--ivica);">
|
||||||
<td style="padding:10px 20px;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.KorisnickoIme}}</td>
|
<td style="padding:10px 20px;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.KorisnickoIme}}</td>
|
||||||
<td style="padding:10px 20px;">
|
<td style="padding:10px 20px;">
|
||||||
{{if eq $.KorisnikUloga "superadmin"}}
|
{{if eq .Uloga "superadmin"}}
|
||||||
|
<span style="font-size:13px;color:var(--tekst-sporedni);">Superadmin</span>
|
||||||
|
{{else if eq $.KorisnikUloga "superadmin"}}
|
||||||
<form method="POST" action="/admin/korisnici/{{.ID}}/uloga" style="display:inline;">
|
<form method="POST" action="/admin/korisnici/{{.ID}}/uloga" style="display:inline;">
|
||||||
<select name="uloga" onchange="this.form.submit()"
|
<select name="uloga" onchange="this.form.submit()"
|
||||||
style="padding:4px 8px;border:0.5px solid var(--ivica);border-radius:6px;background:var(--pozadina);color:var(--tekst-glavni);font-size:12px;">
|
style="padding:4px 8px;border:0.5px solid var(--ivica);border-radius:6px;background:var(--pozadina);color:var(--tekst-glavni);font-size:12px;">
|
||||||
<option value="superadmin" {{if eq .Uloga "superadmin"}}selected{{end}}>Superadmin</option>
|
|
||||||
<option value="admin" {{if eq .Uloga "admin"}}selected{{end}}>Admin</option>
|
<option value="admin" {{if eq .Uloga "admin"}}selected{{end}}>Admin</option>
|
||||||
<option value="radnik" {{if eq .Uloga "radnik"}}selected{{end}}>Radnik</option>
|
<option value="radnik" {{if eq .Uloga "radnik"}}selected{{end}}>Radnik</option>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span style="font-size:13px;color:var(--tekst-sporedni);">
|
<span style="font-size:13px;color:var(--tekst-sporedni);">
|
||||||
{{if eq .Uloga "superadmin"}}Superadmin{{else if eq .Uloga "admin"}}Admin{{else}}Radnik{{end}}
|
{{if eq .Uloga "admin"}}Admin{{else}}Radnik{{end}}
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
@@ -106,6 +109,15 @@
|
|||||||
{{if .Aktivan}}Deaktiviraj{{else}}Aktiviraj{{end}}
|
{{if .Aktivan}}Deaktiviraj{{else}}Aktiviraj{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{if eq .Uloga "radnik"}}
|
||||||
|
<form method="POST" action="/admin/korisnici/{{.ID}}/obrisi" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Obrisati korisnika {{.KorisnickoIme}}? Ova radnja je trajna.')">
|
||||||
|
<button type="submit"
|
||||||
|
style="padding:5px 12px;border:0.5px solid #fca5a5;border-radius:6px;background:transparent;color:#dc2626;font-size:12px;cursor:pointer;">
|
||||||
|
Obriši
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -124,12 +136,12 @@
|
|||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:14px;margin-bottom:16px;">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:14px;margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Korisničko ime</label>
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Korisničko ime</label>
|
||||||
<input type="text" name="korisnicko_ime" required minlength="3"
|
<input type="text" name="korisnicko_ime" required minlength="3" autocomplete="off"
|
||||||
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;">
|
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;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Lozinka</label>
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Lozinka</label>
|
||||||
<input type="password" name="lozinka" required minlength="8"
|
<input type="password" name="lozinka" required minlength="8" autocomplete="new-password"
|
||||||
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;">
|
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;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -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;">
|
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;">
|
||||||
<option value="radnik">Radnik</option>
|
<option value="radnik">Radnik</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
{{if eq .KorisnikUloga "superadmin"}}<option value="superadmin">Superadmin</option>{{end}}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||||
<div style="padding:16px 20px;border-bottom:0.5px solid var(--ivica);display:flex;align-items:center;justify-content:space-between;">
|
<div style="padding:16px 20px;border-bottom:0.5px solid var(--ivica);display:flex;align-items:center;justify-content:space-between;">
|
||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Poslednjih 50 pokušaja</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Poslednjih 50 pokušaja</span>
|
||||||
<span style="font-size:12px;color:var(--tekst-sporedni);">Vreme prikazano u UTC</span>
|
<span style="font-size:12px;color:var(--tekst-sporedni);">Vreme prikazano po lokalnom vremenu servera</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Istorija}}
|
{{if .Istorija}}
|
||||||
|
|||||||
@@ -8,6 +8,44 @@
|
|||||||
.animiraj:nth-child(2) { animation-delay: 0.16s; }
|
.animiraj:nth-child(2) { animation-delay: 0.16s; }
|
||||||
.animiraj:nth-child(3) { animation-delay: 0.22s; }
|
.animiraj:nth-child(3) { animation-delay: 0.22s; }
|
||||||
.animiraj:nth-child(4) { animation-delay: 0.28s; }
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -15,9 +53,10 @@
|
|||||||
<div style="width:100%;max-width:100%;">
|
<div style="width:100%;max-width:100%;">
|
||||||
|
|
||||||
{{if .Sacuvano}}
|
{{if .Sacuvano}}
|
||||||
<div class="poruka-uspeh">
|
<div class="poruka-uspeh">Podešavanja su uspešno sačuvana.</div>
|
||||||
Podešavanja su uspešno sačuvana.
|
{{end}}
|
||||||
</div>
|
{{if .BackupVracen}}
|
||||||
|
<div class="poruka-uspeh">Baza je uspešno obnovljena iz rezervne kopije.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- upload loga — posebna forma jer je multipart, mora biti van glavne forme -->
|
<!-- upload loga — posebna forma jer je multipart, mora biti van glavne forme -->
|
||||||
@@ -27,11 +66,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Logo firme</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Logo firme</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .LogoGreska}}
|
|
||||||
<div style="background:rgba(207,87,87,0.1);border:0.5px solid var(--greska);border-radius:8px;padding:8px 12px;margin-bottom:10px;font-size:13px;color:var(--greska);">
|
|
||||||
{{.LogoGreska}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{if .LogoPutanja}}
|
{{if .LogoPutanja}}
|
||||||
<div style="margin-bottom:12px;">
|
<div style="margin-bottom:12px;">
|
||||||
<img src="{{.LogoPutanja}}" alt="Trenutni logo"
|
<img src="{{.LogoPutanja}}" alt="Trenutni logo"
|
||||||
@@ -150,12 +184,62 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
||||||
<div style="font-size:13px;color:var(--tekst-sporedni);">Verzija programa: <span style="color:var(--tekst-glavni);font-weight:500;">{{.Verzija}}</span></div>
|
<div style="font-size:13px;color:var(--tekst-sporedni);">Verzija programa: <span style="color:var(--tekst-glavni);font-weight:500;">{{.Verzija}}</span></div>
|
||||||
<a href="/podesavanja/backup"
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:var(--kartica);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);text-decoration:none;transition:background 0.2s;"
|
<a href="/podesavanja/backup"
|
||||||
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
|
style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:var(--kartica);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);text-decoration:none;transition:background 0.2s;"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
|
||||||
Preuzmi backup baze
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
</a>
|
Preuzmi backup baze
|
||||||
|
</a>
|
||||||
|
<button type="button" onclick="toggleBackupPanel()"
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:var(--kartica);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);cursor:pointer;transition:background 0.2s;"
|
||||||
|
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><polyline points="7 5 12 10 17 5"/></svg>
|
||||||
|
Vrati backup baze
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- panel sa listom backupa -->
|
||||||
|
<div id="backup-panel" style="display:none;margin-top:16px;border-top:0.5px solid var(--ivica);padding-top:16px;">
|
||||||
|
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:10px;">Dostupne rezervne kopije</div>
|
||||||
|
{{if .Backupi}}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-weight:500;color:var(--tekst-sporedni);">Naziv fajla</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-weight:500;color:var(--tekst-sporedni);">Datum</th>
|
||||||
|
<th style="padding:8px 12px;text-align:right;font-weight:500;color:var(--tekst-sporedni);">Veličina</th>
|
||||||
|
<th style="padding:8px 12px;text-align:right;font-weight:500;color:var(--tekst-sporedni);"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Backupi}}
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<td style="padding:8px 12px;color:var(--tekst-glavni);font-family:monospace;font-size:12px;">{{.Ime}}</td>
|
||||||
|
<td style="padding:8px 12px;color:var(--tekst-sporedni);">{{.Datum}}</td>
|
||||||
|
<td style="padding:8px 12px;text-align:right;color:var(--tekst-sporedni);">{{.Velicina}}</td>
|
||||||
|
<td style="padding:8px 12px;text-align:right;">
|
||||||
|
<form method="POST" action="/podesavanja/backup/vrati"
|
||||||
|
onsubmit="return confirm('Vratiti bazu na stanje od: {{.Datum}}?\n\nTrenutna baza će biti automatski sačuvana pre obnove.')">
|
||||||
|
<input type="hidden" name="ime" value="{{.Ime}}">
|
||||||
|
<button type="submit"
|
||||||
|
style="padding:4px 12px;background:transparent;border:0.5px solid #fca5a5;border-radius:6px;color:#dc2626;font-size:12px;cursor:pointer;">
|
||||||
|
Vrati
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="padding:20px;text-align:center;color:var(--tekst-sporedni);font-size:13px;">
|
||||||
|
Nema dostupnih rezervnih kopija.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,4 +254,28 @@
|
|||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleBackupPanel() {
|
||||||
|
var p = document.getElementById('backup-panel');
|
||||||
|
p.style.display = p.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{if .LogoGreska}}
|
||||||
|
<div id="toast-logo" class="toast toast-greska">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
{{.LogoGreska}}
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var t = document.getElementById('toast-logo');
|
||||||
|
if (!t) return;
|
||||||
|
setTimeout(function() {
|
||||||
|
t.classList.add('nestaje');
|
||||||
|
setTimeout(function() { t.remove(); }, 300);
|
||||||
|
}, 4000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user