Dodato go:embed - disk-first logika za statičke fajlove i šablone

This commit is contained in:
2026-06-04 20:14:03 +02:00
parent 4f54ca3470
commit ccc08aee08
15 changed files with 443 additions and 64 deletions
+35 -8
View File
@@ -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.Group(func(r chi.Router) {
r.Use(ntechmw.RequireSuperAdmin)
r.Get("/admin/korisnici", h.AdminKorisnici) r.Get("/admin/korisnici", h.AdminKorisnici)
r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija) r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija)
r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika) r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika)
r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan) r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan)
r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu) 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)
} }
} }
+1
View File
@@ -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
+8
View File
@@ -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)
+1 -4
View File
@@ -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()
+62 -8
View File
@@ -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())
+18
View File
@@ -15,6 +15,7 @@ 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{
+3 -3
View File
@@ -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
+139 -6
View File
@@ -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"
@@ -30,11 +32,23 @@ type PodaciPodesavanja struct {
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
} }
+15 -5
View File
@@ -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
} }
+12
View File
@@ -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")
+1 -1
View File
@@ -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 */
+1 -1
View File
@@ -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>
+17 -6
View File
@@ -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}}
+116 -8
View File
@@ -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>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="/podesavanja/backup" <a href="/podesavanja/backup"
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;" 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;"
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'"> 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"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <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>
Preuzmi backup baze Preuzmi backup baze
</a> </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}}