Ispravka QR koda za 2FA — generisanje na serveru kao base64 PNG
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image/png"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
// HashujLozinku kreira bcrypt heš lozinke
|
||||
func HashujLozinku(lozinka string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(lozinka), bcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: auth.HashujLozinku: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// ProveriLozinku proverava da li lozinka odgovara heš vrednosti
|
||||
func ProveriLozinku(hash, lozinka string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(lozinka)) == nil
|
||||
}
|
||||
|
||||
// GenerisiToken generiše nasumičan UUID token za sesiju
|
||||
func GenerisiToken() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// TotpPodaci sadrži tajnu, URI i base64-enkodiran QR kod za prikaz
|
||||
type TotpPodaci struct {
|
||||
Tajna string
|
||||
URI string
|
||||
QRBase64 string
|
||||
}
|
||||
|
||||
// GenerisuTotpTajnu generiše novu TOTP tajnu za korisnika
|
||||
func GenerisuTotpTajnu(korisnickoIme, nazivFirme string) (*TotpPodaci, error) {
|
||||
if nazivFirme == "" {
|
||||
nazivFirme = "NTech"
|
||||
}
|
||||
kljuc, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: nazivFirme,
|
||||
AccountName: korisnickoIme,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: %w", err)
|
||||
}
|
||||
img, err := kljuc.Image(200, 200)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: qr: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: png: %w", err)
|
||||
}
|
||||
return &TotpPodaci{
|
||||
Tajna: kljuc.Secret(),
|
||||
URI: kljuc.URL(),
|
||||
QRBase64: base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegenerisiTotpQR vraća URI i base64 QR sliku za već postojeću TOTP tajnu
|
||||
func RegenerisiTotpQR(tajna, korisnickoIme, nazivFirme string) (uri, qrBase64 string) {
|
||||
kljuc, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: nazivFirme,
|
||||
AccountName: korisnickoIme,
|
||||
Secret: []byte(tajna),
|
||||
})
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
img, err := kljuc.Image(200, 200)
|
||||
if err != nil {
|
||||
return kljuc.URL(), ""
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return kljuc.URL(), ""
|
||||
}
|
||||
return kljuc.URL(), base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
|
||||
// VerifikujTotpKod proverava TOTP kod korisnika
|
||||
func VerifikujTotpKod(kod, tajna string) bool {
|
||||
return totp.Validate(kod, tajna)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
@@ -74,3 +75,25 @@ type ProdajaRepository interface {
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
SledeciBroj(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// KorisniciRepository definiše operacije nad korisnicima
|
||||
type KorisniciRepository interface {
|
||||
Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error)
|
||||
DohvatiPoImenu(ctx context.Context, korisnickoIme string) (*model.Korisnik, error)
|
||||
DohvatiPoID(ctx context.Context, id int64) (*model.Korisnik, error)
|
||||
Lista(ctx context.Context) ([]model.Korisnik, error)
|
||||
AzurirajUlogu(ctx context.Context, id int64, uloga string) error
|
||||
AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error
|
||||
PromeniLozinku(ctx context.Context, id int64, hash string) error
|
||||
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
|
||||
PostojiIjedan(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
// SesijeRepository definiše operacije nad sesijama
|
||||
type SesijeRepository interface {
|
||||
Kreiraj(ctx context.Context, korisnikID int64, token string, istice time.Time, totpPotvrdjeno bool) error
|
||||
DohvatiPoTokenu(ctx context.Context, token string) (*model.Sesija, error)
|
||||
PotvrdiTotp(ctx context.Context, token string, novoIstice time.Time) error
|
||||
Obrisi(ctx context.Context, token string) error
|
||||
ObrisiIstekle(ctx context.Context) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
type sqliteKorisniciRepo struct{ db *sql.DB }
|
||||
|
||||
// NoviKorisniciRepo kreira SQLite implementaciju KorisniciRepository
|
||||
func NoviKorisniciRepo(db *sql.DB) *sqliteKorisniciRepo {
|
||||
return &sqliteKorisniciRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error) {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO korisnici (korisnicko_ime, lozinka_hash, uloga) VALUES (?, ?, ?)`,
|
||||
korisnickoIme, lozinkaHash, uloga)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.Kreiraj: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return r.DohvatiPoID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme string) (*model.Korisnik, error) {
|
||||
k := &model.Korisnik{}
|
||||
var aktivan int
|
||||
var totpTajna sql.NullString
|
||||
var datumKreiranja time.Time
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja
|
||||
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme).
|
||||
Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &totpTajna, &datumKreiranja)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.DohvatiPoImenu: %w", err)
|
||||
}
|
||||
k.Aktivan = aktivan == 1
|
||||
k.TotpTajna = totpTajna.String
|
||||
k.DatumKreiranja = datumKreiranja
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model.Korisnik, error) {
|
||||
k := &model.Korisnik{}
|
||||
var aktivan int
|
||||
var datumKreiranja time.Time
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja
|
||||
FROM korisnici WHERE id = ?`, id).
|
||||
Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.DohvatiPoID: %w", err)
|
||||
}
|
||||
k.Aktivan = aktivan == 1
|
||||
k.DatumKreiranja = datumKreiranja
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja
|
||||
FROM korisnici ORDER BY datum_kreiranja ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var lista []model.Korisnik
|
||||
for rows.Next() {
|
||||
var k model.Korisnik
|
||||
var aktivan int
|
||||
var datumKreiranja time.Time
|
||||
if err := rows.Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja); err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||
}
|
||||
k.Aktivan = aktivan == 1
|
||||
k.DatumKreiranja = datumKreiranja
|
||||
lista = append(lista, k)
|
||||
}
|
||||
return lista, nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) AzurirajUlogu(ctx context.Context, id int64, uloga string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.AzurirajUlogu: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error {
|
||||
val := 0
|
||||
if aktivan {
|
||||
val = 1
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET aktivan = ? WHERE id = ?`, val, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.AzurirajAktivan: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) PromeniLozinku(ctx context.Context, id int64, hash string) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET lozinka_hash = ? WHERE id = ?`, hash, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.PromeniLozinku: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error {
|
||||
var err error
|
||||
if tajna == "" {
|
||||
_, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = NULL WHERE id = ?`, id)
|
||||
} else {
|
||||
_, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = ? WHERE id = ?`, tajna, id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.SacuvajTotpTajnu: %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)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ntech: korisnici.PostojiIjedan: %w", err)
|
||||
}
|
||||
return broj > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
type sqliteSesijeRepo struct{ db *sql.DB }
|
||||
|
||||
// NoviSesijeRepo kreira SQLite implementaciju SesijeRepository
|
||||
func NoviSesijeRepo(db *sql.DB) *sqliteSesijeRepo {
|
||||
return &sqliteSesijeRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *sqliteSesijeRepo) Kreiraj(ctx context.Context, korisnikID int64, token string, istice time.Time, totpPotvrdjeno bool) error {
|
||||
potvrdjen := 1
|
||||
if !totpPotvrdjeno {
|
||||
potvrdjen = 0
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO sesije (korisnik_id, token, totp_potvrdjeno, datum_isteka) VALUES (?, ?, ?, ?)`,
|
||||
korisnikID, token, potvrdjen, istice)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: sesije.Kreiraj: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteSesijeRepo) DohvatiPoTokenu(ctx context.Context, token string) (*model.Sesija, error) {
|
||||
s := &model.Sesija{}
|
||||
var totpPotvrdjeno int
|
||||
var datumIsteka, datumKreiranja time.Time
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, korisnik_id, token, totp_potvrdjeno, datum_isteka, datum_kreiranja
|
||||
FROM sesije WHERE token = ?`, token).
|
||||
Scan(&s.ID, &s.KorisnikID, &s.Token, &totpPotvrdjeno, &datumIsteka, &datumKreiranja)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: sesije.DohvatiPoTokenu: %w", err)
|
||||
}
|
||||
s.TotpPotvrdjeno = totpPotvrdjeno == 1
|
||||
s.DatumIsteka = datumIsteka
|
||||
s.DatumKreiranja = datumKreiranja
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *sqliteSesijeRepo) PotvrdiTotp(ctx context.Context, token string, novoIstice time.Time) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE sesije SET totp_potvrdjeno = 1, datum_isteka = ? WHERE token = ?`,
|
||||
novoIstice, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: sesije.PotvrdiTotp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteSesijeRepo) Obrisi(ctx context.Context, token string) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM sesije WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: sesije.Obrisi: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteSesijeRepo) ObrisiIstekle(ctx context.Context) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM sesije WHERE datum_isteka < ?`, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: sesije.ObrisiIstekle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
type kontekstKljuc string
|
||||
|
||||
// KljucKorisnika je ključ za korisnika u request contextu
|
||||
const KljucKorisnika kontekstKljuc = "korisnik"
|
||||
|
||||
// RequireAuth je chi middleware koji proverava sesiju i injektuje korisnika u context
|
||||
func RequireAuth(db *sql.DB) func(http.Handler) http.Handler {
|
||||
sesijeRepo := sqlite.NoviSesijeRepo(db)
|
||||
korisRepo := sqlite.NoviKorisniciRepo(db)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
kolacic, err := r.Cookie("ntech_sesija")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
sesija, err := sesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
|
||||
if err != nil {
|
||||
// nevažeći token — briši kolačić i preusmeri
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "ntech_sesija",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// proveri da li je sesija istekla
|
||||
if time.Now().After(sesija.DatumIsteka) {
|
||||
_ = sesijeRepo.Obrisi(r.Context(), kolacic.Value)
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// ako čeka TOTP verifikaciju, preusmeri na TOTP stranicu
|
||||
if !sesija.TotpPotvrdjeno {
|
||||
http.Redirect(w, r, "/prijava/totp", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
korisnik, err := korisRepo.DohvatiPoID(r.Context(), sesija.KorisnikID)
|
||||
if err != nil || !korisnik.Aktivan {
|
||||
_ = sesijeRepo.Obrisi(r.Context(), kolacic.Value)
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), KljucKorisnika, korisnik)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// KorisnikIzKonteksta vraća trenutno prijavljenog korisnika iz konteksta
|
||||
func KorisnikIzKonteksta(ctx context.Context) *model.Korisnik {
|
||||
k, _ := ctx.Value(KljucKorisnika).(*model.Korisnik)
|
||||
return k
|
||||
}
|
||||
|
||||
// JeAdmin proverava da li korisnik ima admin ili superadmin ulogu
|
||||
func JeAdmin(k *model.Korisnik) bool {
|
||||
if k == nil {
|
||||
return false
|
||||
}
|
||||
return k.Uloga == "admin" || k.Uloga == "superadmin"
|
||||
}
|
||||
|
||||
// ErrNijePrijavljen se vraća kada korisnik nije u contextu
|
||||
var ErrNijePrijavljen = errors.New("korisnik nije prijavljen")
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Korisnik predstavlja nalog korisnika u sistemu
|
||||
type Korisnik struct {
|
||||
ID int64
|
||||
KorisnickoIme string
|
||||
LozinkaHash string
|
||||
Uloga string // "superadmin" | "admin" | "radnik"
|
||||
Aktivan bool
|
||||
TotpTajna string
|
||||
DatumKreiranja time.Time
|
||||
}
|
||||
|
||||
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika
|
||||
type Sesija struct {
|
||||
ID int64
|
||||
KorisnikID int64
|
||||
Token string
|
||||
TotpPotvrdjeno bool
|
||||
DatumIsteka time.Time
|
||||
DatumKreiranja time.Time
|
||||
}
|
||||
@@ -33,6 +33,8 @@ type PodaciStranice struct {
|
||||
LogoTip string // "ikonica", "tekst", "slika"
|
||||
LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
|
||||
Korisnik string
|
||||
KorisnikIme string // korisničko ime prijavljenog korisnika
|
||||
KorisnikUloga string // uloga: "superadmin", "admin", "radnik"
|
||||
}
|
||||
|
||||
// PodaciDashboarda su podaci specifični za dashboard stranicu
|
||||
|
||||
Reference in New Issue
Block a user