Dodato go:embed - disk-first logika za statičke fajlove i šablone
This commit is contained in:
+35
-8
@@ -31,8 +31,27 @@ func main() {
|
||||
godotenv.Load("ntech.env")
|
||||
auth.InitAuthLog()
|
||||
|
||||
// disk-first logika: ako folder postoji pored binarnog fajla — koristi disk, inače embed.
|
||||
// Napomena: templFS i migrFS su rootovani na "." tako da putanje ostaju iste kao u embed-u
|
||||
// (npr. "web/templates/stranice/dashboard.html", "migrations/001_kategorije.sql").
|
||||
var templFS fs.FS = assets.TemplatesFS
|
||||
if _, err := os.Stat("web/templates"); err == nil {
|
||||
templFS = os.DirFS(".")
|
||||
}
|
||||
var migrFS fs.FS = assets.MigracijeFS
|
||||
if _, err := os.Stat("migrations"); err == nil {
|
||||
migrFS = os.DirFS(".")
|
||||
}
|
||||
// staticFS je rootovan na "web/static" (isti kao fs.Sub embed-a) — uploads ostaju van embed-a
|
||||
var staticFS fs.FS
|
||||
if _, err := os.Stat("web/static"); err == nil {
|
||||
staticFS = os.DirFS("web/static")
|
||||
} else {
|
||||
staticFS, _ = fs.Sub(assets.StaticFS, "web/static")
|
||||
}
|
||||
|
||||
if config.JelPrvoPokretanje() {
|
||||
config.PokreniSetup(assets.TemplatesFS)
|
||||
config.PokreniSetup(templFS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,7 +71,7 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := sqlite.PokreniMigracije(db, assets.MigracijeFS); err != nil {
|
||||
if err := sqlite.PokreniMigracije(db, migrFS); err != nil {
|
||||
log.Fatalf("Greška pri migracijama: %v", err)
|
||||
}
|
||||
log.Println("Migracije uspešno izvršene")
|
||||
@@ -75,9 +94,12 @@ func main() {
|
||||
|
||||
h := handler.Novi(db)
|
||||
h.Verzija = Verzija
|
||||
h.PutanjaBaze = putanjaBaze
|
||||
// čuva odabrani FS (disk ili embed) za hot-reload u razvoju i za keš u produkciji
|
||||
h.TemplatesFS = templFS
|
||||
|
||||
if os.Getenv("NTECH_ENV") == "production" {
|
||||
kes, err := handler.KreirajKes(assets.TemplatesFS)
|
||||
kes, err := handler.KreirajKes(templFS)
|
||||
if err != nil {
|
||||
log.Fatalf("Greška pri kreiranju keša šablona: %v", err)
|
||||
}
|
||||
@@ -90,11 +112,10 @@ func main() {
|
||||
r.Use(ntechmw.CsrfMiddleware)
|
||||
r.Use(middleware.Compress(5))
|
||||
|
||||
// uploads se servira sa diska (runtime fajlovi)
|
||||
// uploads su uvek na disku — korisnički fajlovi, ne ugrađuju se
|
||||
r.Handle("/static/uploads/*", http.StripPrefix("/static/uploads/", http.FileServer(http.Dir("web/static/uploads"))))
|
||||
// ostali statični fajlovi (CSS) iz embed FS-a
|
||||
staticSub, _ := fs.Sub(assets.StaticFS, "web/static")
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
// ostali statični fajlovi: disk ako postoji web/static, inače embed
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// javne rute (bez autentifikacije)
|
||||
r.Get("/prijava", h.PrikazPrijave)
|
||||
@@ -117,6 +138,7 @@ func main() {
|
||||
r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
|
||||
r.Post("/podesavanja/logo", h.OtpremiLogo)
|
||||
r.Get("/podesavanja/backup", h.BackupBaze)
|
||||
r.Post("/podesavanja/backup/vrati", h.VratiBackup)
|
||||
r.Get("/tema/{tema}", h.PromeniTemu)
|
||||
r.Get("/magacin", h.Magacin)
|
||||
r.Get("/magacin/novi", h.NoviArtikal)
|
||||
@@ -168,12 +190,16 @@ func main() {
|
||||
r.Post("/podsetnici/zavrseno/{id}", h.OznaciPodsetnik)
|
||||
r.Post("/podsetnici/obrisi/{id}", h.ObrisiPodsetnik)
|
||||
|
||||
// admin rute
|
||||
// rute dostupne samo superadminu
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(ntechmw.RequireSuperAdmin)
|
||||
r.Get("/admin/korisnici", h.AdminKorisnici)
|
||||
r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija)
|
||||
r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika)
|
||||
r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan)
|
||||
r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu)
|
||||
r.Post("/admin/korisnici/{id}/obrisi", h.AdminObrisiKorisnika)
|
||||
})
|
||||
r.Get("/admin/profil", h.AdminProfil)
|
||||
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
|
||||
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
|
||||
@@ -237,3 +263,4 @@ func ocistiStareBackupe(folder string, max int) {
|
||||
_ = os.Remove(f)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ type KorisniciRepository interface {
|
||||
PromeniLozinku(ctx context.Context, id int64, hash string) error
|
||||
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
|
||||
PostojiIjedan(ctx context.Context) (bool, error)
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// SesijeRepository definiše operacije nad sesijama
|
||||
|
||||
@@ -125,6 +125,14 @@ func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, ta
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM korisnici WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.Obrisi: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) PostojiIjedan(ctx context.Context) (bool, error) {
|
||||
var broj int
|
||||
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM korisnici`).Scan(&broj)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
@@ -59,15 +58,13 @@ func (r *LoginIstorijsaRepo) ListaZaKorisnika(ctx context.Context, korisnikID in
|
||||
p := &model.LoginPokusaj{}
|
||||
var kid sql.NullInt64
|
||||
var u int
|
||||
var s string
|
||||
if err := rows.Scan(&p.ID, &kid, &p.IP, &p.UserAgent, &u, &p.Razlog, &s); err != nil {
|
||||
if err := rows.Scan(&p.ID, &kid, &p.IP, &p.UserAgent, &u, &p.Razlog, &p.Vreme); err != nil {
|
||||
return nil, fmt.Errorf("ntech: LoginIstorijsaRepo.ListaZaKorisnika: scan: %w", err)
|
||||
}
|
||||
if kid.Valid {
|
||||
p.KorisnikID = &kid.Int64
|
||||
}
|
||||
p.Uspeh = u == 1
|
||||
p.Vreme, _ = time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC)
|
||||
lista = append(lista, p)
|
||||
}
|
||||
return lista, rows.Err()
|
||||
|
||||
@@ -82,18 +82,13 @@ func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request)
|
||||
lozinka := r.FormValue("lozinka")
|
||||
uloga := r.FormValue("uloga")
|
||||
|
||||
validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true}
|
||||
// superadmin uloga se ne može kreirati kroz interfejs — jedini superadmin postoji od setup-a
|
||||
validneUloge := map[string]bool{"admin": true, "radnik": true}
|
||||
if len(ime) < 3 || len(lozinka) < 8 || !validneUloge[uloga] {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// superadmin može kreirati samo admin i radnik (ne drugog superadmina) osim ako je jedini superadmin
|
||||
if uloga == "superadmin" && k.Uloga != "superadmin" {
|
||||
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashujLozinku(lozinka)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||
@@ -161,13 +156,33 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// superadmin ne može menjati svoju vlastitu ulogu
|
||||
if id == k.ID {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
uloga := r.FormValue("uloga")
|
||||
validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true}
|
||||
// dozvoljena je samo promena između admin i radnik; superadmin uloga se ne može dodeliti ni ukloniti
|
||||
validneUloge := map[string]bool{"admin": true, "radnik": true}
|
||||
if !validneUloge[uloga] {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// dohvati korisnika da proverimo njegovu trenutnu ulogu
|
||||
ciljniKorisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// superadmin uloga se ne može menjati
|
||||
if ciljniKorisnik.Uloga == "superadmin" {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.KorisniciRepo.AzurirajUlogu(r.Context(), id, uloga); err != nil {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||
return
|
||||
@@ -176,6 +191,45 @@ func (h *Handler) AdminPromeniUlogu(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminObrisiKorisnika briše korisnika sa ulogom radnik
|
||||
func (h *Handler) AdminObrisiKorisnika(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
if k == nil || k.Uloga != "superadmin" {
|
||||
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if id == k.ID {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ciljni, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// dozvoljeno je brisanje samo radnika
|
||||
if ciljni.Uloga != "radnik" {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.KorisniciRepo.Obrisi(r.Context(), id); err != nil {
|
||||
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminProfil prikazuje stranicu profila
|
||||
func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// Handler drži zavisnosti koje su potrebne svim handlerima
|
||||
type Handler struct {
|
||||
DB *sql.DB
|
||||
PutanjaBaze string
|
||||
Artikli db.ArtikalRepository
|
||||
KategorijeRepo db.KategorijaRepository
|
||||
DobavljaciRepo db.DobavljacRepository
|
||||
@@ -51,6 +52,23 @@ func Novi(baza *sql.DB) *Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// reinicijalzijRepozitorijume zamenjuje sve repozitorijume posle obnove baze
|
||||
func (h *Handler) reinicijalzijRepozitorijume(novaDB *sql.DB) {
|
||||
h.DB = novaDB
|
||||
h.Artikli = sqlite.NoviArtikalRepo(novaDB)
|
||||
h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB)
|
||||
h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB)
|
||||
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
|
||||
h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB)
|
||||
h.ServisRepo = sqlite.NoviServisRepo(novaDB)
|
||||
h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB)
|
||||
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB)
|
||||
h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
|
||||
h.PodsetniciFRepo = sqlite.NoviPodsetnikRepo(novaDB)
|
||||
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
|
||||
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
|
||||
}
|
||||
|
||||
// popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika
|
||||
func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice {
|
||||
ps := model.PodaciStranice{
|
||||
|
||||
@@ -16,7 +16,7 @@ var bazniSabloni = []string{
|
||||
|
||||
// saSidebar su šabloni koji koriste base layout (sidebar + topbar)
|
||||
var saSidebar = []string{
|
||||
"admin_korisnici", "admin_profil",
|
||||
"admin_korisnici", "admin_profil", "admin_login_istorija",
|
||||
"dashboard",
|
||||
"dobavljaci", "dobavljac_forma",
|
||||
"izvestaji",
|
||||
@@ -79,7 +79,7 @@ func (h *Handler) renderujTemplate(w http.ResponseWriter, ime string, podaci any
|
||||
copy(fajlovi, bazniSabloni)
|
||||
fajlovi = append(fajlovi, "web/templates/stranice/"+ime+".html")
|
||||
var err error
|
||||
if tmpl, err = template.ParseFiles(fajlovi...); err != nil {
|
||||
if tmpl, err = template.ParseFS(h.TemplatesFS, fajlovi...); err != nil {
|
||||
log.Printf("greška pri parsiranju šablona %s: %v", ime, err)
|
||||
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -106,7 +106,7 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a
|
||||
tmpl = t
|
||||
} else {
|
||||
var err error
|
||||
if tmpl, err = template.ParseFiles("web/templates/stranice/" + ime + ".html"); err != nil {
|
||||
if tmpl, err = template.ParseFS(h.TemplatesFS, "web/templates/stranice/"+ime+".html"); err != nil {
|
||||
log.Printf("greška pri parsiranju šablona %s: %v", ime, err)
|
||||
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db/sqlite"
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/model"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -30,11 +32,23 @@ type PodaciPodesavanja struct {
|
||||
Sacuvano bool
|
||||
Verzija string
|
||||
LogoGreska string
|
||||
BackupVracen bool
|
||||
Backupi []BackupInfo
|
||||
}
|
||||
|
||||
// BackupInfo opisuje jedan backup fajl
|
||||
type BackupInfo struct {
|
||||
Ime string
|
||||
Datum string
|
||||
Velicina string
|
||||
}
|
||||
|
||||
// validnoImeBackupa proverava da li je ime backup fajla bezbedno (bez path traversala)
|
||||
var validnoImeBackupa = regexp.MustCompile(`^ntech_\d{8}_\d{6}\.db$`)
|
||||
|
||||
// Podesavanja renderuje stranicu podešavanja
|
||||
func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -54,13 +68,123 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
LogoPutanja: podesavanja["logo_putanja"],
|
||||
Tema: podesavanja["tema"],
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno",
|
||||
Verzija: h.Verzija,
|
||||
LogoGreska: r.URL.Query().Get("logo_greska"),
|
||||
Backupi: ucitajListuBackupa(),
|
||||
}
|
||||
|
||||
h.renderujTemplate(w, "podesavanja", podaci)
|
||||
}
|
||||
|
||||
// ucitajListuBackupa vraća sortiranu listu fajlova iz backups/ foldera
|
||||
func ucitajListuBackupa() []BackupInfo {
|
||||
fajlovi, _ := filepath.Glob(filepath.Join("backups", "ntech_*.db"))
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(fajlovi)))
|
||||
var lista []BackupInfo
|
||||
for _, f := range fajlovi {
|
||||
info, err := os.Stat(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vel := info.Size()
|
||||
var velStr string
|
||||
switch {
|
||||
case vel >= 1024*1024:
|
||||
velStr = fmt.Sprintf("%.1f MB", float64(vel)/(1024*1024))
|
||||
default:
|
||||
velStr = fmt.Sprintf("%d KB", vel/1024)
|
||||
}
|
||||
datum := info.ModTime().Format("02.01.2006. 15:04:05")
|
||||
lista = append(lista, BackupInfo{
|
||||
Ime: filepath.Base(f),
|
||||
Datum: datum,
|
||||
Velicina: velStr,
|
||||
})
|
||||
}
|
||||
return lista
|
||||
}
|
||||
|
||||
// VratiBackup zamenjuje trenutnu bazu sa izabranim backup fajlom
|
||||
func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čitanju+zahteva", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ime := r.FormValue("ime")
|
||||
if !validnoImeBackupa.MatchString(ime) {
|
||||
http.Redirect(w, r, "/podesavanja?backup_greska=Neispravan+naziv+fajla", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
putanjaBackupa := filepath.Join("backups", ime)
|
||||
if _, err := os.Stat(putanjaBackupa); err != nil {
|
||||
http.Redirect(w, r, "/podesavanja?backup_greska=Backup+fajl+nije+pronađen", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// pre obnove, sačuvaj trenutno stanje baze
|
||||
sigurnosni := filepath.Join("backups", fmt.Sprintf("ntech_%s_pred_vracanjem.db", time.Now().Format("20060102_150405")))
|
||||
if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", sigurnosni); err != nil {
|
||||
log.Printf("vrati backup: greška pri kreiranju sigurnosne kopije: %v", err)
|
||||
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+kreiranju+sigurnosne+kopije", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// isprazni WAL u glavni fajl
|
||||
if _, err := h.DB.ExecContext(r.Context(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
log.Printf("vrati backup: wal_checkpoint greška: %v", err)
|
||||
}
|
||||
|
||||
// zatvori sve konekcije
|
||||
if err := h.DB.Close(); err != nil {
|
||||
log.Printf("vrati backup: greška pri zatvaranju baze: %v", err)
|
||||
}
|
||||
|
||||
// kopiraj backup fajl na mesto trenutne baze
|
||||
if err := kopiraFajl(putanjaBackupa, h.PutanjaBaze); err != nil {
|
||||
log.Printf("vrati backup: greška pri kopiranju: %v", err)
|
||||
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+obnovi+baze", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// ukloni WAL i SHM fajlove stare baze
|
||||
os.Remove(h.PutanjaBaze + "-wal")
|
||||
os.Remove(h.PutanjaBaze + "-shm")
|
||||
|
||||
// otvori novu konekciju
|
||||
novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze)
|
||||
if err != nil {
|
||||
log.Printf("vrati backup: greška pri otvaranju nove baze: %v", err)
|
||||
http.Redirect(w, r, "/podesavanja?backup_greska=Baza+obnovljena+ali+je+potreban+restart", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
h.reinicijalzijRepozitorijume(novaDB)
|
||||
log.Printf("Baza uspešno obnovljena iz: %s", ime)
|
||||
|
||||
http.Redirect(w, r, "/podesavanja?sacuvano=vraceno", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// kopiraFajl kopira fajl sa izvora na odredište
|
||||
func kopiraFajl(izvor, odrediste string) error {
|
||||
src, err := os.Open(izvor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create(odrediste)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
return err
|
||||
}
|
||||
|
||||
// SacuvajPodesavanja prima POST i čuva podešavanja u bazu
|
||||
func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -82,7 +206,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
if vrednost == "" {
|
||||
continue
|
||||
}
|
||||
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil {
|
||||
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil {
|
||||
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -109,6 +233,8 @@ func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/
|
||||
func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
// ograničavamo telo zahteva na 2MB + malo za zaglavlja forme
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096)
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
||||
return
|
||||
@@ -121,6 +247,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer fajl.Close()
|
||||
|
||||
// eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera)
|
||||
if zaglavlje.Size > 2<<20 {
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// proveravamo ekstenziju
|
||||
ext := strings.ToLower(filepath.Ext(zaglavlje.Filename))
|
||||
dozvoljenoExt := map[string]string{
|
||||
@@ -172,8 +304,9 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
putanja := "/static/uploads/logo" + ext
|
||||
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
|
||||
// timestamp u URL-u sprečava browser da koristi staru keširanu sliku
|
||||
putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix())
|
||||
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
|
||||
log.Printf("upload loga: greška pri čuvanju putanje: %v", err)
|
||||
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
|
||||
return
|
||||
@@ -195,7 +328,7 @@ func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema", tema); err != nil {
|
||||
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema", tema); err != nil {
|
||||
http.Error(w, "Greška pri promeni teme", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ntech/internal/auth"
|
||||
@@ -41,7 +42,7 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := izvuciIP(r.RemoteAddr)
|
||||
ip := izvuciIP(r)
|
||||
korisnickoIme := r.FormValue("korisnicko_ime")
|
||||
lozinka := r.FormValue("lozinka")
|
||||
|
||||
@@ -249,11 +250,20 @@ func (h *Handler) preostaloBruteforce(ctx context.Context, ip string, od time.Ti
|
||||
return fmt.Sprintf("%d sek", sek), true
|
||||
}
|
||||
|
||||
// izvuciIP izdvaja IP adresu iz RemoteAddr formata "ip:port"
|
||||
func izvuciIP(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja),
|
||||
// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr
|
||||
func izvuciIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
// uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent
|
||||
parts := strings.Split(fwd, ",")
|
||||
return strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return addr
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
@@ -83,5 +83,17 @@ func JeAdmin(k *model.Korisnik) bool {
|
||||
return k.Uloga == "admin" || k.Uloga == "superadmin"
|
||||
}
|
||||
|
||||
// RequireSuperAdmin je middleware koji propušta samo superadmin korisnike
|
||||
func RequireSuperAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
k := KorisnikIzKonteksta(r.Context())
|
||||
if k == nil || k.Uloga != "superadmin" {
|
||||
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ErrNijePrijavljen se vraća kada korisnik nije u contextu
|
||||
var ErrNijePrijavljen = errors.New("korisnik nije prijavljen")
|
||||
|
||||
@@ -534,7 +534,7 @@ select {
|
||||
}
|
||||
|
||||
.animiraj {
|
||||
animation: fadeInUp 0.4s ease both;
|
||||
animation: fadeInUp 0.2s ease both;
|
||||
}
|
||||
|
||||
/* gornja traka magacina — responsive */
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<span class="nav-tooltip">Moj profil</span>
|
||||
</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}}">
|
||||
<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>
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
<div class="poruka-greska">Proverite unete podatke.</div>
|
||||
{{else if eq .Greska "2"}}
|
||||
<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}}
|
||||
|
||||
<!-- lista korisnika -->
|
||||
@@ -62,18 +64,19 @@
|
||||
<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;">
|
||||
{{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;">
|
||||
<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;">
|
||||
<option value="superadmin" {{if eq .Uloga "superadmin"}}selected{{end}}>Superadmin</option>
|
||||
<option value="admin" {{if eq .Uloga "admin"}}selected{{end}}>Admin</option>
|
||||
<option value="radnik" {{if eq .Uloga "radnik"}}selected{{end}}>Radnik</option>
|
||||
</select>
|
||||
</form>
|
||||
{{else}}
|
||||
<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>
|
||||
{{end}}
|
||||
</td>
|
||||
@@ -106,6 +109,15 @@
|
||||
{{if .Aktivan}}Deaktiviraj{{else}}Aktiviraj{{end}}
|
||||
</button>
|
||||
</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}}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -124,12 +136,12 @@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:14px;margin-bottom:16px;">
|
||||
<div>
|
||||
<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;">
|
||||
</div>
|
||||
<div>
|
||||
<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;">
|
||||
</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;">
|
||||
<option value="radnik">Radnik</option>
|
||||
<option value="admin">Admin</option>
|
||||
{{if eq .KorisnikUloga "superadmin"}}<option value="superadmin">Superadmin</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<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;">
|
||||
<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>
|
||||
|
||||
{{if .Istorija}}
|
||||
|
||||
@@ -8,6 +8,44 @@
|
||||
.animiraj:nth-child(2) { animation-delay: 0.16s; }
|
||||
.animiraj:nth-child(3) { animation-delay: 0.22s; }
|
||||
.animiraj:nth-child(4) { animation-delay: 0.28s; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
animation: toastIn 0.3s ease forwards;
|
||||
max-width: 340px;
|
||||
}
|
||||
.toast-greska {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 0.5px solid #fca5a5;
|
||||
}
|
||||
.toast-uspeh {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border: 0.5px solid #86efac;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(12px); }
|
||||
}
|
||||
.toast.nestaje {
|
||||
animation: toastOut 0.3s ease forwards;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
@@ -15,9 +53,10 @@
|
||||
<div style="width:100%;max-width:100%;">
|
||||
|
||||
{{if .Sacuvano}}
|
||||
<div class="poruka-uspeh">
|
||||
Podešavanja su uspešno sačuvana.
|
||||
</div>
|
||||
<div class="poruka-uspeh">Podešavanja su uspešno sačuvana.</div>
|
||||
{{end}}
|
||||
{{if .BackupVracen}}
|
||||
<div class="poruka-uspeh">Baza je uspešno obnovljena iz rezervne kopije.</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 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>
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Logo firme</span>
|
||||
</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}}
|
||||
<div style="margin-bottom:12px;">
|
||||
<img src="{{.LogoPutanja}}" alt="Trenutni logo"
|
||||
@@ -150,12 +184,62 @@
|
||||
</div>
|
||||
<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="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<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;"
|
||||
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>
|
||||
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>
|
||||
|
||||
@@ -170,4 +254,28 @@
|
||||
|
||||
</form>
|
||||
</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}}
|
||||
|
||||
Reference in New Issue
Block a user