Vlasnik izlazi iz DAL price za ovaj projekat - postaje samostalan proizvod. Preimenovanje je mehanicko (import putanje, imena fajlova/baze/UI naslova), NIJEDNA linija poslovne logike u internal/handler ili internal/service nije dirana - go build, go vet i go test ./... prolaze cisto pod novim imenom. - go.mod: module dal-license-server -> licence-server; 28 import linija u 13 .go fajlova usaglaseno - internal/config/config.go (+test): default DB_NAME licence_db - HTML sabloni (7 stranica) + 7 Playwright e2e spec fajlova: "DAL License Server" -> "Licence Server" u title/nav-brand, testovi usaglaseni u istom prolazu (nista ne moze da se razdvoji) - package.json/package-lock.json/.gitignore/.claude/project.json: ime projekta/binarnog fajla/work_dir - Usput: package.json je u repository.url nosio OTVORENU Gitea lozinku u cistom tekstu - uklonjena (URL sad bez kredencijala). Lozinka je i dalje u staroj git istoriji - preporuka: rotirati je posebno. - CLAUDE.md/README/API/TESTING/docs/SPEC/ARCHITECTURE/SETUP: naslovi, ASCII dijagrami, git clone URL, mysqldump primer, systemd predlozak u SETUP.md zamenjen stvarnim (obrazac terminia.service - journal log + graceful shutdown, ne stari minimalni predlozak) - Debrendiranje: "Univerzalni licencni server za sve DAL proizvode" -> "za vise proizvoda i klijenata"; potpis "Nenad Djukic / DAL d.o.o." -> "Nenad Djukic". NE dirano: nazivi ESIR/ARV/LIGHT_TICKET (tudji proizvodi, ne DAL brend), "DAL" kao stvaran naziv org-a u RBAC modelu (docs/loggerservice/README.md) - to je podatak, ne branding ovog servera. - docs/asp-terminia/, docs/loggerservice/: prozni pomeni imena servera Baza (dal_license_db -> licence_db) i deploy na 151 idu u posebnom koraku, posle preimenovanja Gitea repoa - vidi CLAUDE.md istorijsku belesku na dnu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
323 lines
9.1 KiB
Go
323 lines
9.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"licence-server/internal/model"
|
|
"licence-server/internal/repository"
|
|
"licence-server/internal/service"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type SessionData struct {
|
|
UserID int64
|
|
Username string
|
|
FullName string
|
|
Expiry time.Time
|
|
}
|
|
|
|
type DashboardHandler struct {
|
|
licenses *service.LicenseService
|
|
activation *service.ActivationService
|
|
audit *repository.AuditRepo
|
|
userRepo *repository.UserRepo
|
|
templates map[string]*template.Template
|
|
sessions map[string]*SessionData
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func NewDashboardHandler(licenses *service.LicenseService, activation *service.ActivationService, audit *repository.AuditRepo, userRepo *repository.UserRepo, tmplDir string) *DashboardHandler {
|
|
funcMap := template.FuncMap{
|
|
"formatDate": func(t time.Time) string { return t.Format("02.01.2006 15:04") },
|
|
"formatDateShort": func(t time.Time) string { return t.Format("02.01.2006") },
|
|
"json": func(v interface{}) string {
|
|
b, _ := json.MarshalIndent(v, "", " ")
|
|
return string(b)
|
|
},
|
|
"jsonPretty": func(v json.RawMessage) string {
|
|
var out interface{}
|
|
json.Unmarshal(v, &out)
|
|
b, _ := json.MarshalIndent(out, "", " ")
|
|
return string(b)
|
|
},
|
|
"add": func(a, b int) int { return a + b },
|
|
"maskKey": func(key string) string {
|
|
if len(key) < 10 {
|
|
return key
|
|
}
|
|
parts := strings.SplitN(key, "-", 2)
|
|
prefix := parts[0] + "-"
|
|
rest := parts[1]
|
|
rParts := strings.Split(rest, "-")
|
|
if len(rParts) >= 4 {
|
|
return prefix + rParts[0] + "-****-****-" + rParts[3]
|
|
}
|
|
return key
|
|
},
|
|
}
|
|
|
|
h := &DashboardHandler{
|
|
licenses: licenses,
|
|
activation: activation,
|
|
audit: audit,
|
|
userRepo: userRepo,
|
|
templates: make(map[string]*template.Template),
|
|
sessions: make(map[string]*SessionData),
|
|
}
|
|
|
|
layoutFiles, _ := filepath.Glob(filepath.Join(tmplDir, "layout", "*.html"))
|
|
partialFiles, _ := filepath.Glob(filepath.Join(tmplDir, "partials", "*.html"))
|
|
baseFiles := append(layoutFiles, partialFiles...)
|
|
|
|
pageFiles, _ := filepath.Glob(filepath.Join(tmplDir, "pages", "*.html"))
|
|
for _, page := range pageFiles {
|
|
name := filepath.Base(page)
|
|
files := append(baseFiles, page)
|
|
tmpl := template.Must(template.New(name).Funcs(funcMap).ParseFiles(files...))
|
|
h.templates[name] = tmpl
|
|
}
|
|
|
|
return h
|
|
}
|
|
|
|
func (h *DashboardHandler) render(w http.ResponseWriter, name string, data interface{}) {
|
|
tmpl, ok := h.templates[name]
|
|
if !ok {
|
|
log.Printf("Template not found: %s", name)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
log.Printf("Template error (%s): %v", name, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *DashboardHandler) renderWithSession(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
|
|
if sess := h.getSession(r); sess != nil {
|
|
data["CurrentUser"] = sess.FullName
|
|
if data["CurrentUser"] == "" {
|
|
data["CurrentUser"] = sess.Username
|
|
}
|
|
}
|
|
h.render(w, name, data)
|
|
}
|
|
|
|
func (h *DashboardHandler) getSession(r *http.Request) *SessionData {
|
|
c, err := r.Cookie("dash_session")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
sess, ok := h.sessions[c.Value]
|
|
if !ok || time.Now().After(sess.Expiry) {
|
|
return nil
|
|
}
|
|
return sess
|
|
}
|
|
|
|
func (h *DashboardHandler) RequireLogin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if h.getSession(r) == nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (h *DashboardHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
|
|
h.render(w, "login.html", nil)
|
|
}
|
|
|
|
func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
|
|
user, err := h.userRepo.GetByUsername(username)
|
|
if err != nil || !user.Active {
|
|
h.render(w, "login.html", map[string]interface{}{"Error": "Pogresno korisnicko ime ili lozinka"})
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
h.render(w, "login.html", map[string]interface{}{"Error": "Pogresno korisnicko ime ili lozinka"})
|
|
return
|
|
}
|
|
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
sid := hex.EncodeToString(b)
|
|
|
|
h.mu.Lock()
|
|
h.sessions[sid] = &SessionData{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
FullName: user.FullName,
|
|
Expiry: time.Now().Add(8 * time.Hour),
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
h.userRepo.UpdateLastLogin(user.ID)
|
|
h.audit.Log(nil, "LOGIN", clientIP(r), map[string]string{"username": user.Username})
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "dash_session",
|
|
Value: sid,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *DashboardHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie("dash_session"); err == nil {
|
|
h.mu.Lock()
|
|
sess := h.sessions[c.Value]
|
|
delete(h.sessions, c.Value)
|
|
h.mu.Unlock()
|
|
if sess != nil {
|
|
h.audit.Log(nil, "LOGOUT", clientIP(r), map[string]string{"username": sess.Username})
|
|
}
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "dash_session", MaxAge: -1, Path: "/"})
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|
stats, _ := h.licenses.GetStats()
|
|
expiring, _ := h.licenses.ExpiringIn(7)
|
|
recent, _ := h.audit.Recent(10)
|
|
|
|
h.renderWithSession(w, r, "dashboard.html", map[string]interface{}{
|
|
"Stats": stats,
|
|
"Expiring": expiring,
|
|
"Recent": recent,
|
|
"ActivePage": "dashboard",
|
|
})
|
|
}
|
|
|
|
func (h *DashboardHandler) LicenseList(w http.ResponseWriter, r *http.Request) {
|
|
product := r.URL.Query().Get("product")
|
|
status := r.URL.Query().Get("status")
|
|
search := r.URL.Query().Get("search")
|
|
|
|
licenses, _ := h.licenses.List(product, status, search)
|
|
products, _ := h.licenses.GetProducts()
|
|
|
|
h.renderWithSession(w, r, "licenses.html", map[string]interface{}{
|
|
"Licenses": licenses,
|
|
"Products": products,
|
|
"Product": product,
|
|
"Status": status,
|
|
"Search": search,
|
|
"ActivePage": "licenses",
|
|
})
|
|
}
|
|
|
|
func (h *DashboardHandler) LicenseNew(w http.ResponseWriter, r *http.Request) {
|
|
products, _ := h.licenses.GetProducts()
|
|
h.renderWithSession(w, r, "license-new.html", map[string]interface{}{
|
|
"Products": products,
|
|
"ActivePage": "licenses",
|
|
})
|
|
}
|
|
|
|
func (h *DashboardHandler) LicenseCreate(w http.ResponseWriter, r *http.Request) {
|
|
productID, _ := strconv.ParseInt(r.FormValue("product_id"), 10, 64)
|
|
graceDays, _ := strconv.Atoi(r.FormValue("grace_days"))
|
|
if graceDays == 0 {
|
|
graceDays = 30
|
|
}
|
|
|
|
limitsStr := r.FormValue("limits")
|
|
var limits json.RawMessage
|
|
if limitsStr != "" {
|
|
limits = json.RawMessage(limitsStr)
|
|
}
|
|
|
|
featuresStr := r.FormValue("features")
|
|
var features json.RawMessage
|
|
if featuresStr != "" {
|
|
features = json.RawMessage(featuresStr)
|
|
}
|
|
|
|
req := &model.CreateLicenseRequest{
|
|
ProductID: productID,
|
|
LicenseType: r.FormValue("license_type"),
|
|
CustomerName: r.FormValue("customer_name"),
|
|
CustomerPIB: r.FormValue("customer_pib"),
|
|
CustomerEmail: r.FormValue("customer_email"),
|
|
Limits: limits,
|
|
Features: features,
|
|
GraceDays: graceDays,
|
|
Notes: r.FormValue("notes"),
|
|
}
|
|
|
|
license, err := h.licenses.Create(req, clientIP(r))
|
|
if err != nil {
|
|
products, _ := h.licenses.GetProducts()
|
|
h.renderWithSession(w, r, "license-new.html", map[string]interface{}{
|
|
"Products": products,
|
|
"Error": err.Error(),
|
|
"ActivePage": "licenses",
|
|
})
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/licenses/"+strconv.FormatInt(license.ID, 10), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *DashboardHandler) LicenseDetail(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
license, err := h.licenses.GetByID(id)
|
|
if err != nil {
|
|
http.Error(w, "Licenca nije pronadjena", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
activations, _ := h.activation.ListByLicense(id)
|
|
auditEntries, _ := h.audit.List(&id, 20)
|
|
|
|
h.renderWithSession(w, r, "license-detail.html", map[string]interface{}{
|
|
"License": license,
|
|
"Activations": activations,
|
|
"Audit": auditEntries,
|
|
"ActivePage": "licenses",
|
|
})
|
|
}
|
|
|
|
func (h *DashboardHandler) LicenseRevoke(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
reason := r.FormValue("reason")
|
|
h.licenses.Revoke(id, reason, clientIP(r))
|
|
http.Redirect(w, r, "/licenses/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *DashboardHandler) LicenseRelease(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
h.activation.ForceRelease(id, clientIP(r))
|
|
http.Redirect(w, r, "/licenses/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *DashboardHandler) AuditPage(w http.ResponseWriter, r *http.Request) {
|
|
entries, _ := h.audit.Recent(100)
|
|
h.renderWithSession(w, r, "audit.html", map[string]interface{}{
|
|
"Entries": entries,
|
|
"ActivePage": "audit",
|
|
})
|
|
}
|