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
+40 -13
View File
@@ -31,8 +31,27 @@ func main() {
godotenv.Load("ntech.env")
auth.InitAuthLog()
// disk-first logika: ako folder postoji pored binarnog fajla — koristi disk, inače embed.
// Napomena: templFS i migrFS su rootovani na "." tako da putanje ostaju iste kao u embed-u
// (npr. "web/templates/stranice/dashboard.html", "migrations/001_kategorije.sql").
var templFS fs.FS = assets.TemplatesFS
if _, err := os.Stat("web/templates"); err == nil {
templFS = os.DirFS(".")
}
var migrFS fs.FS = assets.MigracijeFS
if _, err := os.Stat("migrations"); err == nil {
migrFS = os.DirFS(".")
}
// staticFS je rootovan na "web/static" (isti kao fs.Sub embed-a) — uploads ostaju van embed-a
var staticFS fs.FS
if _, err := os.Stat("web/static"); err == nil {
staticFS = os.DirFS("web/static")
} else {
staticFS, _ = fs.Sub(assets.StaticFS, "web/static")
}
if config.JelPrvoPokretanje() {
config.PokreniSetup(assets.TemplatesFS)
config.PokreniSetup(templFS)
return
}
@@ -52,7 +71,7 @@ func main() {
}
defer db.Close()
if err := sqlite.PokreniMigracije(db, assets.MigracijeFS); err != nil {
if err := sqlite.PokreniMigracije(db, migrFS); err != nil {
log.Fatalf("Greška pri migracijama: %v", err)
}
log.Println("Migracije uspešno izvršene")
@@ -75,9 +94,12 @@ func main() {
h := handler.Novi(db)
h.Verzija = Verzija
h.PutanjaBaze = putanjaBaze
// čuva odabrani FS (disk ili embed) za hot-reload u razvoju i za keš u produkciji
h.TemplatesFS = templFS
if os.Getenv("NTECH_ENV") == "production" {
kes, err := handler.KreirajKes(assets.TemplatesFS)
kes, err := handler.KreirajKes(templFS)
if err != nil {
log.Fatalf("Greška pri kreiranju keša šablona: %v", err)
}
@@ -90,11 +112,10 @@ func main() {
r.Use(ntechmw.CsrfMiddleware)
r.Use(middleware.Compress(5))
// uploads se servira sa diska (runtime fajlovi)
// uploads su uvek na disku — korisnički fajlovi, ne ugrađuju se
r.Handle("/static/uploads/*", http.StripPrefix("/static/uploads/", http.FileServer(http.Dir("web/static/uploads"))))
// ostali statični fajlovi (CSS) iz embed FS-a
staticSub, _ := fs.Sub(assets.StaticFS, "web/static")
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
// ostali statični fajlovi: disk ako postoji web/static, inače embed
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// javne rute (bez autentifikacije)
r.Get("/prijava", h.PrikazPrijave)
@@ -117,6 +138,7 @@ func main() {
r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
r.Post("/podesavanja/logo", h.OtpremiLogo)
r.Get("/podesavanja/backup", h.BackupBaze)
r.Post("/podesavanja/backup/vrati", h.VratiBackup)
r.Get("/tema/{tema}", h.PromeniTemu)
r.Get("/magacin", h.Magacin)
r.Get("/magacin/novi", h.NoviArtikal)
@@ -168,12 +190,16 @@ func main() {
r.Post("/podsetnici/zavrseno/{id}", h.OznaciPodsetnik)
r.Post("/podsetnici/obrisi/{id}", h.ObrisiPodsetnik)
// admin rute
r.Get("/admin/korisnici", h.AdminKorisnici)
r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija)
r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika)
r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan)
r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu)
// rute dostupne samo superadminu
r.Group(func(r chi.Router) {
r.Use(ntechmw.RequireSuperAdmin)
r.Get("/admin/korisnici", h.AdminKorisnici)
r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija)
r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika)
r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan)
r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu)
r.Post("/admin/korisnici/{id}/obrisi", h.AdminObrisiKorisnika)
})
r.Get("/admin/profil", h.AdminProfil)
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
@@ -237,3 +263,4 @@ func ocistiStareBackupe(folder string, max int) {
_ = os.Remove(f)
}
}
+1
View File
@@ -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
+8
View File
@@ -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)
+1 -4
View File
@@ -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()
+62 -8
View File
@@ -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())
+19 -1
View File
@@ -14,7 +14,8 @@ import (
// Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct {
DB *sql.DB
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
@@ -51,6 +52,23 @@ func Novi(baza *sql.DB) *Handler {
}
}
// reinicijalzijRepozitorijume zamenjuje sve repozitorijume posle obnove baze
func (h *Handler) reinicijalzijRepozitorijume(novaDB *sql.DB) {
h.DB = novaDB
h.Artikli = sqlite.NoviArtikalRepo(novaDB)
h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB)
h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB)
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB)
h.ServisRepo = sqlite.NoviServisRepo(novaDB)
h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB)
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB)
h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
h.PodsetniciFRepo = sqlite.NoviPodsetnikRepo(novaDB)
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
}
// popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika
func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice {
ps := model.PodaciStranice{
+3 -3
View File
@@ -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
+140 -7
View File
@@ -7,10 +7,12 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"ntech/internal/db/sqlite"
ntechsqlite "ntech/internal/db/sqlite"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
@@ -29,12 +31,24 @@ type PodaciPodesavanja struct {
Tema string
Sacuvano bool
Verzija string
LogoGreska string
LogoGreska string
BackupVracen bool
Backupi []BackupInfo
}
// BackupInfo opisuje jedan backup fajl
type BackupInfo struct {
Ime string
Datum string
Velicina string
}
// validnoImeBackupa proverava da li je ime backup fajla bezbedno (bez path traversala)
var validnoImeBackupa = regexp.MustCompile(`^ntech_\d{8}_\d{6}\.db$`)
// Podesavanja renderuje stranicu podešavanja
func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
@@ -54,13 +68,123 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
LogoPutanja: podesavanja["logo_putanja"],
Tema: podesavanja["tema"],
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno",
Verzija: h.Verzija,
LogoGreska: r.URL.Query().Get("logo_greska"),
Backupi: ucitajListuBackupa(),
}
h.renderujTemplate(w, "podesavanja", podaci)
}
// ucitajListuBackupa vraća sortiranu listu fajlova iz backups/ foldera
func ucitajListuBackupa() []BackupInfo {
fajlovi, _ := filepath.Glob(filepath.Join("backups", "ntech_*.db"))
sort.Sort(sort.Reverse(sort.StringSlice(fajlovi)))
var lista []BackupInfo
for _, f := range fajlovi {
info, err := os.Stat(f)
if err != nil {
continue
}
vel := info.Size()
var velStr string
switch {
case vel >= 1024*1024:
velStr = fmt.Sprintf("%.1f MB", float64(vel)/(1024*1024))
default:
velStr = fmt.Sprintf("%d KB", vel/1024)
}
datum := info.ModTime().Format("02.01.2006. 15:04:05")
lista = append(lista, BackupInfo{
Ime: filepath.Base(f),
Datum: datum,
Velicina: velStr,
})
}
return lista
}
// VratiBackup zamenjuje trenutnu bazu sa izabranim backup fajlom
func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čitanju+zahteva", http.StatusSeeOther)
return
}
ime := r.FormValue("ime")
if !validnoImeBackupa.MatchString(ime) {
http.Redirect(w, r, "/podesavanja?backup_greska=Neispravan+naziv+fajla", http.StatusSeeOther)
return
}
putanjaBackupa := filepath.Join("backups", ime)
if _, err := os.Stat(putanjaBackupa); err != nil {
http.Redirect(w, r, "/podesavanja?backup_greska=Backup+fajl+nije+pronađen", http.StatusSeeOther)
return
}
// pre obnove, sačuvaj trenutno stanje baze
sigurnosni := filepath.Join("backups", fmt.Sprintf("ntech_%s_pred_vracanjem.db", time.Now().Format("20060102_150405")))
if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", sigurnosni); err != nil {
log.Printf("vrati backup: greška pri kreiranju sigurnosne kopije: %v", err)
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+kreiranju+sigurnosne+kopije", http.StatusSeeOther)
return
}
// isprazni WAL u glavni fajl
if _, err := h.DB.ExecContext(r.Context(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
log.Printf("vrati backup: wal_checkpoint greška: %v", err)
}
// zatvori sve konekcije
if err := h.DB.Close(); err != nil {
log.Printf("vrati backup: greška pri zatvaranju baze: %v", err)
}
// kopiraj backup fajl na mesto trenutne baze
if err := kopiraFajl(putanjaBackupa, h.PutanjaBaze); err != nil {
log.Printf("vrati backup: greška pri kopiranju: %v", err)
http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+obnovi+baze", http.StatusSeeOther)
return
}
// ukloni WAL i SHM fajlove stare baze
os.Remove(h.PutanjaBaze + "-wal")
os.Remove(h.PutanjaBaze + "-shm")
// otvori novu konekciju
novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze)
if err != nil {
log.Printf("vrati backup: greška pri otvaranju nove baze: %v", err)
http.Redirect(w, r, "/podesavanja?backup_greska=Baza+obnovljena+ali+je+potreban+restart", http.StatusSeeOther)
return
}
h.reinicijalzijRepozitorijume(novaDB)
log.Printf("Baza uspešno obnovljena iz: %s", ime)
http.Redirect(w, r, "/podesavanja?sacuvano=vraceno", http.StatusSeeOther)
}
// kopiraFajl kopira fajl sa izvora na odredište
func kopiraFajl(izvor, odrediste string) error {
src, err := os.Open(izvor)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(odrediste)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
// SacuvajPodesavanja prima POST i čuva podešavanja u bazu
func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
@@ -82,7 +206,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
if vrednost == "" {
continue
}
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil {
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return
}
@@ -109,6 +233,8 @@ func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) {
// OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/
func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
// ograničavamo telo zahteva na 2MB + malo za zaglavlja forme
r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096)
if err := r.ParseMultipartForm(2 << 20); err != nil {
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
return
@@ -121,6 +247,12 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
}
defer fajl.Close()
// eksplicitna provera veličine (zaglavlje.Size je postavljeno od strane browsera)
if zaglavlje.Size > 2<<20 {
http.Redirect(w, r, "/podesavanja?logo_greska=Fajl+je+prevelik+%28maksimum+2+MB%29", http.StatusSeeOther)
return
}
// proveravamo ekstenziju
ext := strings.ToLower(filepath.Ext(zaglavlje.Filename))
dozvoljenoExt := map[string]string{
@@ -172,8 +304,9 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
return
}
putanja := "/static/uploads/logo" + ext
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
// timestamp u URL-u sprečava browser da koristi staru keširanu sliku
putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix())
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
log.Printf("upload loga: greška pri čuvanju putanje: %v", err)
http.Redirect(w, r, "/podesavanja?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
return
@@ -195,7 +328,7 @@ func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
return
}
if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema", tema); err != nil {
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "tema", tema); err != nil {
http.Error(w, "Greška pri promeni teme", http.StatusInternalServerError)
return
}
+15 -5
View File
@@ -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
}
+12
View File
@@ -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")
+1 -1
View File
@@ -534,7 +534,7 @@ select {
}
.animiraj {
animation: fadeInUp 0.4s ease both;
animation: fadeInUp 0.2s ease both;
}
/* gornja traka magacina — responsive */
+1 -1
View File
@@ -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>
+17 -6
View File
@@ -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}}
+122 -14
View File
@@ -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>
<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>
<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}}