Ispravka QR koda za 2FA — generisanje na serveru kao base64 PNG

This commit is contained in:
2026-06-02 22:29:53 +02:00
parent f918b76542
commit 2401f6d5ec
25 changed files with 2449 additions and 48 deletions
+356
View File
@@ -0,0 +1,356 @@
package handler
import (
"html/template"
"net/http"
"strconv"
"ntech/internal/auth"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
type podaciAdminKorisnici struct {
model.PodaciStranice
Korisnici []model.Korisnik
Greska string
Sacuvano bool
}
type podaciAdminProfil struct {
model.PodaciStranice
Greska string
Sacuvano string
TotpURI string
TotpTajna string
TotpQR template.URL
TotpAktivan bool
}
// AdminKorisnici prikazuje listu korisnika
func (h *Handler) AdminKorisnici(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !middleware.JeAdmin(k) {
http.Error(w, "Pristup odbijen", http.StatusForbidden)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
lista, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju korisnika", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "admin"
ps.NaslovStranice = "Korisnici"
podaci := podaciAdminKorisnici{
PodaciStranice: ps,
Korisnici: lista,
Greska: r.URL.Query().Get("greska"),
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
renderujAdminTemplate(w, "web/templates/stranice/admin_korisnici.html", podaci)
}
// AdminSacuvajKorisnika kreira novog korisnika
func (h *Handler) AdminSacuvajKorisnika(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !middleware.JeAdmin(k) {
http.Error(w, "Pristup odbijen", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
return
}
ime := r.FormValue("korisnicko_ime")
lozinka := r.FormValue("lozinka")
uloga := r.FormValue("uloga")
validneUloge := map[string]bool{"superadmin": true, "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)
return
}
if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, uloga); err != nil {
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminToggleAktivan menja aktivan status korisnika
func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !middleware.JeAdmin(k) {
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
}
// ne sme deaktivirati sam sebe
if id == k.ID {
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
return
}
korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id)
if err != nil {
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
return
}
if err := h.KorisniciRepo.AzurirajAktivan(r.Context(), id, !korisnik.Aktivan); err != nil {
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
}
// AdminPromeniUlogu menja ulogu korisnika
func (h *Handler) AdminPromeniUlogu(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 err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
return
}
uloga := r.FormValue("uloga")
validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true}
if !validneUloge[uloga] {
http.Redirect(w, r, "/admin/korisnici?greska=1", 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
}
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())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
// osvežavamo korisnika iz baze da bismo imali aktuelni totp_tajna
svezi, err := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID)
if err != nil {
http.Error(w, "Greška pri učitavanju profila", http.StatusInternalServerError)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "admin"
ps.NaslovStranice = "Moj profil"
podaci := podaciAdminProfil{
PodaciStranice: ps,
Greska: r.URL.Query().Get("greska"),
Sacuvano: r.URL.Query().Get("sacuvano"),
TotpAktivan: svezi.TotpTajna != "",
}
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
}
// AdminPromeniLozinku menja lozinku prijavljenog korisnika
func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/profil?greska=1", http.StatusSeeOther)
return
}
stara := r.FormValue("stara_lozinka")
nova := r.FormValue("nova_lozinka")
potvrda := r.FormValue("nova_lozinka_potvrda")
if !auth.ProveriLozinku(k.LozinkaHash, stara) {
http.Redirect(w, r, "/admin/profil?greska=lozinka", http.StatusSeeOther)
return
}
if len(nova) < 8 || nova != potvrda {
http.Redirect(w, r, "/admin/profil?greska=lozinka2", http.StatusSeeOther)
return
}
hash, err := auth.HashujLozinku(nova)
if err != nil {
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
return
}
if err := h.KorisniciRepo.PromeniLozinku(r.Context(), k.ID, hash); err != nil {
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profil?sacuvano=lozinka", http.StatusSeeOther)
}
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod
func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
totp, err := auth.GenerisuTotpTajnu(k.KorisnickoIme, podesavanja["naziv_firme"])
if err != nil {
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "admin"
ps.NaslovStranice = "Podesi 2FA"
podaci := podaciAdminProfil{
PodaciStranice: ps,
TotpURI: totp.URI,
TotpTajna: totp.Tajna,
TotpQR: template.URL("data:image/png;base64," + totp.QRBase64),
}
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
}
// AdminTotpAktivacija verifikuje TOTP kod i čuva tajnu
func (h *Handler) AdminTotpAktivacija(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/profil?greska=1", http.StatusSeeOther)
return
}
tajna := r.FormValue("totp_tajna")
kod := r.FormValue("kod")
if !auth.VerifikujTotpKod(kod, tajna) {
// ponovni prikaz sa greškom — regenerišemo isti tajnu
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "admin"
ps.NaslovStranice = "Podesi 2FA"
// regenerišemo QR za već generisanu tajnu (korisnik je video ovaj QR)
nazivFirme := podesavanja["naziv_firme"]
if nazivFirme == "" {
nazivFirme = "NTech"
}
uri, qr := auth.RegenerisiTotpQR(tajna, k.KorisnickoIme, nazivFirme)
podaci := podaciAdminProfil{
PodaciStranice: ps,
TotpURI: uri,
TotpTajna: tajna,
TotpQR: template.URL("data:image/png;base64," + qr),
Greska: "totp",
}
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
return
}
if err := h.KorisniciRepo.SacuvajTotpTajnu(r.Context(), k.ID, tajna); err != nil {
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profil?sacuvano=totp", http.StatusSeeOther)
}
// AdminTotpDeaktivacija uklanja TOTP tajnu
func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
if err := h.KorisniciRepo.SacuvajTotpTajnu(r.Context(), k.ID, ""); err != nil {
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profil?sacuvano=totp_off", http.StatusSeeOther)
}
func renderujAdminTemplate(w http.ResponseWriter, stranica string, podaci any) {
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
stranica,
)
if err != nil {
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
}
// parseBoolForm čita boolean vrednost iz forme
func parseBoolForm(s string) bool {
b, _ := strconv.ParseBool(s)
return b
}
+25
View File
@@ -5,6 +5,9 @@ import (
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"net/http"
)
// Handler drži zavisnosti koje su potrebne svim handlerima
@@ -17,6 +20,8 @@ type Handler struct {
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
}
// Novi kreira novi Handler sa datom bazom
@@ -30,5 +35,25 @@ func Novi(baza *sql.DB) *Handler {
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
}
}
// 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{
Tema: podesavanja["tema"],
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
}
if k := middleware.KorisnikIzKonteksta(r.Context()); k != nil {
ps.Korisnik = k.KorisnickoIme
ps.KorisnikIme = k.KorisnickoIme
ps.KorisnikUloga = k.Uloga
}
return ps
}
+275
View File
@@ -0,0 +1,275 @@
package handler
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"time"
"ntech/internal/db/sqlite"
"ntech/internal/model"
)
var srpskaImenaMeseci = []string{
"", "Januar", "Februar", "Mart", "April", "Maj", "Jun",
"Jul", "Avgust", "Septembar", "Oktobar", "Novembar", "Decembar",
}
func formatujMesec(yyyymm string) string {
var god, mes int
if _, err := fmt.Sscanf(yyyymm, "%d-%d", &god, &mes); err != nil || mes < 1 || mes > 12 {
return yyyymm
}
return fmt.Sprintf("%s %d", srpskaImenaMeseci[mes], god)
}
// PodaciIzvestaja su podaci za stranicu izveštaja
type PodaciIzvestaja struct {
model.PodaciStranice
MesecniPrihodi []MesecniPrihod
GrafikonJSON template.JS
StariNalozi []StariNalog
TopArtikli []TopArtikal
TopKlijenti []TopKlijent
}
// MesecniPrihod drži prihod od prodaje i servisa za jedan mesec
type MesecniPrihod struct {
MesecPrikaz string
Prodaja float64
Servis float64
Ukupno float64
}
// StariNalog je servisni nalog bez datuma završetka stariji od 14 dana
type StariNalog struct {
ID int64
BrojNaloga string
Uredjaj string
KlijentNaziv string
Status string
DatumPrijema string
DanaProslo int
}
// TopArtikal je artikal rangiran po prodatoj količini
type TopArtikal struct {
Rang int
Naziv string
Kategorija string
UkupnoKolicina int
UkupnoPrihod float64
}
// TopKlijent je klijent rangiran po ukupnoj vrednosti naloga
type TopKlijent struct {
Rang int
Naziv string
BrojNaloga int
UkupnoVrednost float64
}
// Izvestaji renderuje stranicu sa izveštajima
func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
// --- mesečni prihod: prodaja ---
prodajaPoMesecu := map[string]float64{}
prodajaRed, err := h.DB.QueryContext(ctx, `
SELECT substr(datum, 1, 7), SUM(ukupno)
FROM prodajni_nalozi
WHERE substr(datum, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(datum, 1, 7)`)
if err != nil {
log.Printf("izvestaji: prihod prodaja: %v", err)
} else {
defer prodajaRed.Close()
for prodajaRed.Next() {
var mesec string
var iznos float64
if err := prodajaRed.Scan(&mesec, &iznos); err == nil {
prodajaPoMesecu[mesec] = iznos
}
}
}
// --- mesečni prihod: servis ---
servisPoMesecu := map[string]float64{}
servisRed, err := h.DB.QueryContext(ctx, `
SELECT substr(datum_zavrsetka, 1, 7), SUM(cena_konacna)
FROM servisni_nalozi
WHERE datum_zavrsetka IS NOT NULL
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(datum_zavrsetka, 1, 7)`)
if err != nil {
log.Printf("izvestaji: prihod servis: %v", err)
} else {
defer servisRed.Close()
for servisRed.Next() {
var mesec string
var iznos float64
if err := servisRed.Scan(&mesec, &iznos); err == nil {
servisPoMesecu[mesec] = iznos
}
}
}
// gradimo niz za poslednjih 12 meseci (hronološki)
sada := time.Now()
var mesecniPrihodi []MesecniPrihod
var grafikonLabele []string
var grafikonProdaja []float64
var grafikonServis []float64
for i := 11; i >= 0; i-- {
t := sada.AddDate(0, -i, 0)
kljuc := t.Format("2006-01")
prod := prodajaPoMesecu[kljuc]
serv := servisPoMesecu[kljuc]
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
MesecPrikaz: formatujMesec(kljuc),
Prodaja: prod,
Servis: serv,
Ukupno: prod + serv,
})
grafikonLabele = append(grafikonLabele, formatujMesec(kljuc))
grafikonProdaja = append(grafikonProdaja, prod)
grafikonServis = append(grafikonServis, serv)
}
type grafikonPodaci struct {
Labele []string `json:"labele"`
Prodaja []float64 `json:"prodaja"`
Servis []float64 `json:"servis"`
}
jsonBytes, _ := json.Marshal(grafikonPodaci{
Labele: grafikonLabele,
Prodaja: grafikonProdaja,
Servis: grafikonServis,
})
// --- stari otvoreni nalozi (>14 dana bez završetka) ---
stariRed, err := h.DB.QueryContext(ctx, `
SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema,
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '—') AS klijent_naziv
FROM servisni_nalozi sn
LEFT JOIN klijenti k ON k.id = sn.klijent_id
WHERE sn.datum_zavrsetka IS NULL
AND substr(sn.datum_prijema, 1, 10) <= date('now', '-14 days')
ORDER BY sn.datum_prijema ASC`)
var stariNalozi []StariNalog
if err != nil {
log.Printf("izvestaji: stari nalozi: %v", err)
} else {
defer stariRed.Close()
for stariRed.Next() {
var sn StariNalog
var datumVreme time.Time
if err := stariRed.Scan(&sn.ID, &sn.BrojNaloga, &sn.Uredjaj, &sn.Status, &datumVreme, &sn.KlijentNaziv); err == nil {
sn.DatumPrijema = datumVreme.Format("02.01.2006.")
sn.DanaProslo = int(time.Since(datumVreme).Hours() / 24)
stariNalozi = append(stariNalozi, sn)
}
}
}
// --- top 10 najprodavanijih artikala ---
artRed, err := h.DB.QueryContext(ctx, `
SELECT a.naziv, COALESCE(k.naziv, '—'), SUM(sp.kolicina), SUM(sp.ukupno)
FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id
LEFT JOIN kategorije k ON k.id = a.kategorija_id
GROUP BY sp.artikal_id
ORDER BY SUM(sp.kolicina) DESC
LIMIT 10`)
var topArtikli []TopArtikal
if err != nil {
log.Printf("izvestaji: top artikli: %v", err)
} else {
defer artRed.Close()
for artRed.Next() {
var a TopArtikal
if err := artRed.Scan(&a.Naziv, &a.Kategorija, &a.UkupnoKolicina, &a.UkupnoPrihod); err == nil {
a.Rang = len(topArtikli) + 1
topArtikli = append(topArtikli, a)
}
}
}
// --- top 10 klijenata po ukupnoj vrednosti ---
klijRed, err := h.DB.QueryContext(ctx, `
SELECT
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, ''))) AS naziv,
COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) AS ukupno_vrednost,
COALESCE(p.broj_prodaja, 0) + COALESCE(s.broj_servisa, 0) AS broj_naloga
FROM klijenti k
LEFT JOIN (
SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja
FROM prodajni_nalozi GROUP BY klijent_id
) p ON p.klijent_id = k.id
LEFT JOIN (
SELECT klijent_id, SUM(cena_konacna) AS ukupno_servis, COUNT(*) AS broj_servisa
FROM servisni_nalozi WHERE cena_konacna IS NOT NULL GROUP BY klijent_id
) s ON s.klijent_id = k.id
WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0
ORDER BY ukupno_vrednost DESC
LIMIT 10`)
var topKlijenti []TopKlijent
if err != nil {
log.Printf("izvestaji: top klijenti: %v", err)
} else {
defer klijRed.Close()
for klijRed.Next() {
var k TopKlijent
if err := klijRed.Scan(&k.Naziv, &k.UkupnoVrednost, &k.BrojNaloga); err == nil {
k.Rang = len(topKlijenti) + 1
topKlijenti = append(topKlijenti, k)
}
}
}
podaci := PodaciIzvestaja{
PodaciStranice: model.PodaciStranice{
Stranica: "izvestaji",
NaslovStranice: "Izveštaji",
Tema: podesavanja["tema"],
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
},
MesecniPrihodi: mesecniPrihodi,
GrafikonJSON: template.JS(jsonBytes),
StariNalozi: stariNalozi,
TopArtikli: topArtikli,
TopKlijenti: topKlijenti,
}
tmpl, err := template.ParseFiles(
"web/templates/teme/podrazumevana/base.html",
"web/templates/komponente/sidebar.html",
"web/templates/komponente/topbar.html",
"web/templates/stranice/izvestaji.html",
)
if err != nil {
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
return
}
}
+19
View File
@@ -1,8 +1,11 @@
package handler
import (
"fmt"
"html/template"
"net/http"
"os"
"time"
"ntech/internal/db/sqlite"
"ntech/internal/model"
@@ -101,6 +104,22 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
}
// BackupBaze kreira konzistentnu kopiju baze i šalje je kao attachment
func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) {
privremeni := fmt.Sprintf("%s/ntech_backup_%s.db", os.TempDir(), time.Now().Format("20060102_150405"))
if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", privremeni); err != nil {
http.Error(w, "Greška pri kreiranju rezervne kopije", http.StatusInternalServerError)
return
}
defer os.Remove(privremeni)
ime := fmt.Sprintf("ntech_backup_%s.db", time.Now().Format("20060102"))
w.Header().Set("Content-Disposition", "attachment; filename=\""+ime+"\"")
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, privremeni)
}
// PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu
func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
tema := chi.URLParam(r, "tema")
+212
View File
@@ -0,0 +1,212 @@
package handler
import (
"html/template"
"net/http"
"time"
"ntech/internal/auth"
)
const imeKolacica = "ntech_sesija"
const trajanjeSeije = 7 * 24 * time.Hour
const trajanjePredSeije = 5 * time.Minute
// PrikazPrijave renderuje formu za prijavu
func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
// ako nema korisnika, preusmeri na setup wizard
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
if err == nil && !postoji {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
greska := r.URL.Query().Get("greska")
renderujStandaloneTemplate(w, "web/templates/stranice/prijava.html", map[string]any{
"Greska": greska,
})
}
// Prijava obrađuje POST formu za prijavu
func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
return
}
korisnickoIme := r.FormValue("korisnicko_ime")
lozinka := r.FormValue("lozinka")
korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
if err != nil || !korisnik.Aktivan {
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
return
}
if !auth.ProveriLozinku(korisnik.LozinkaHash, lozinka) {
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
return
}
token := auth.GenerisiToken()
if korisnik.TotpTajna != "" {
// kreira privremenu sesiju koja čeka TOTP verifikaciju
if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjePredSeije), false); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
return
}
http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjePredSeije)))
http.Redirect(w, r, "/prijava/totp", http.StatusSeeOther)
return
}
// direktna sesija bez TOTP
if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjeSeije), true); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
return
}
http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjeSeije)))
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
// PrikazTotp renderuje formu za unos TOTP koda
func (h *Handler) PrikazTotp(w http.ResponseWriter, r *http.Request) {
kolacic, err := r.Cookie(imeKolacica)
if err != nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
greska := r.URL.Query().Get("greska")
renderujStandaloneTemplate(w, "web/templates/stranice/totp_provera.html", map[string]any{
"Greska": greska,
})
}
// VerifikujTotp obrađuje POST formu za TOTP verifikaciju
func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
kolacic, err := r.Cookie(imeKolacica)
if err != nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), sesija.KorisnikID)
if err != nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
kod := r.FormValue("kod")
if !auth.VerifikujTotpKod(kod, korisnik.TotpTajna) {
http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
return
}
novoIstice := time.Now().Add(trajanjeSeije)
if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
return
}
http.SetCookie(w, napraviKolacic(kolacic.Value, novoIstice))
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
// PrikazSetupa renderuje setup wizard za prvog korisnika
func (h *Handler) PrikazSetupa(w http.ResponseWriter, r *http.Request) {
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
if err == nil && postoji {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
greska := r.URL.Query().Get("greska")
renderujStandaloneTemplate(w, "web/templates/stranice/setup.html", map[string]any{
"Greska": greska,
})
}
// SacuvajSetup kreira prvog superadmin korisnika
func (h *Handler) SacuvajSetup(w http.ResponseWriter, r *http.Request) {
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
if err == nil && postoji {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther)
return
}
ime := r.FormValue("korisnicko_ime")
lozinka := r.FormValue("lozinka")
potvrda := r.FormValue("lozinka_potvrda")
if len(ime) < 3 || len(lozinka) < 8 || lozinka != potvrda {
http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther)
return
}
hash, err := auth.HashujLozinku(lozinka)
if err != nil {
http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther)
return
}
if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, "superadmin"); err != nil {
http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/prijava?sacuvano=1", http.StatusSeeOther)
}
// Odjava briše sesiju i kolačić
func (h *Handler) Odjava(w http.ResponseWriter, r *http.Request) {
if kolacic, err := r.Cookie(imeKolacica); err == nil {
_ = h.SesijeRepo.Obrisi(r.Context(), kolacic.Value)
}
http.SetCookie(w, &http.Cookie{
Name: imeKolacica,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
})
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
}
func napraviKolacic(token string, istice time.Time) *http.Cookie {
return &http.Cookie{
Name: imeKolacica,
Value: token,
Path: "/",
Expires: istice,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
}
func renderujStandaloneTemplate(w http.ResponseWriter, putanja string, podaci any) {
tmpl, err := template.ParseFiles(putanja)
if err != nil {
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
return
}
if err := tmpl.Execute(w, podaci); err != nil {
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
}
}