initital commit
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.tecamino.local/paadi/licenseServer/internal/handlers/middleware"
|
||||
"gitea.tecamino.local/paadi/licenseServer/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Handler holds shared dependencies for all HTTP handlers.
|
||||
type Handler struct {
|
||||
DB *models.DB
|
||||
Sessions *middleware.SessionStore
|
||||
Tmpl *template.Template
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
func (h *Handler) ShowLogin(w http.ResponseWriter, r *http.Request) {
|
||||
h.Tmpl.ExecuteTemplate(w, "login.html", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
email := r.FormValue("email")
|
||||
pass := r.FormValue("password")
|
||||
|
||||
id, hash, err := h.DB.GetUserByEmail(email)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil {
|
||||
h.Tmpl.ExecuteTemplate(w, "login.html", map[string]string{"Error": "Invalid email or password"})
|
||||
return
|
||||
}
|
||||
token := h.Sessions.Create(id)
|
||||
middleware.SetSession(w, token)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie("session"); err == nil {
|
||||
h.Sessions.Delete(cookie.Value)
|
||||
}
|
||||
middleware.ClearSession(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) ShowSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.DB.UserCount() > 0 {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h.Tmpl.ExecuteTemplate(w, "setup.html", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) PostSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.DB.UserCount() > 0 {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
email := r.FormValue("email")
|
||||
pass := r.FormValue("password")
|
||||
if email == "" || len(pass) < 8 {
|
||||
h.Tmpl.ExecuteTemplate(w, "setup.html", map[string]string{"Error": "Email required, password min 8 chars"})
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||
h.DB.CreateUser(uuid.New().String(), email, string(hash))
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
licenses, err := h.DB.ListLicenses()
|
||||
if err != nil {
|
||||
http.Error(w, "DB error", 500)
|
||||
return
|
||||
}
|
||||
|
||||
active, revoked := 0, 0
|
||||
for _, l := range licenses {
|
||||
if l.Active {
|
||||
active++
|
||||
} else {
|
||||
revoked++
|
||||
}
|
||||
}
|
||||
h.Tmpl.ExecuteTemplate(w, "dashboard.html", map[string]any{
|
||||
"Page": "dashboard",
|
||||
"Licenses": licenses,
|
||||
"Total": len(licenses),
|
||||
"ActiveCount": active,
|
||||
"RevokedCount": revoked,
|
||||
})
|
||||
}
|
||||
|
||||
// --- License management ---
|
||||
|
||||
func (h *Handler) ShowNewLicense(w http.ResponseWriter, r *http.Request) {
|
||||
h.Tmpl.ExecuteTemplate(w, "new_license.html", map[string]string{
|
||||
"Page": "new",
|
||||
|
||||
"GeneratedKey": GenerateKey(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) PostNewLicense(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
|
||||
licType := r.FormValue("type")
|
||||
l := &models.License{
|
||||
ID: uuid.New().String(),
|
||||
Key: r.FormValue("key"),
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
CustomerEmail: r.FormValue("customer_email"),
|
||||
ProductName: r.FormValue("product_name"),
|
||||
MachineID: r.FormValue("machine_id"),
|
||||
Type: licType,
|
||||
Active: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if licType == "expiry" {
|
||||
t, err := time.Parse("2006-01-02", r.FormValue("expires_at"))
|
||||
if err == nil {
|
||||
l.ExpiresAt = &t
|
||||
}
|
||||
} else {
|
||||
n := 0
|
||||
|
||||
fmt.Sscanf(r.FormValue("max_uses"), "%d", &n)
|
||||
l.MaxUses = &n
|
||||
}
|
||||
|
||||
if err := h.DB.CreateLicense(l); err != nil {
|
||||
fmt.Printf("CreateLicense error: %v\n", err)
|
||||
h.Tmpl.ExecuteTemplate(w, "new_license.html", map[string]any{
|
||||
"Error": err.Error(),
|
||||
"GeneratedKey": l.Key,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) RevokeLicense(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
h.DB.RevokeLicense(id)
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func (h *Handler) ReinstateLicense(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
h.DB.ActivateLicense(id)
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
// --- REST API for client apps ---
|
||||
|
||||
type validateRequest struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
MachineID string `json:"machine_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
type validateResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) APIValidate(w http.ResponseWriter, r *http.Request) {
|
||||
var req validateRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
license, err := h.DB.GetLicenseByKey(req.LicenseKey)
|
||||
if err != nil || license == nil {
|
||||
json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "license not found"})
|
||||
return
|
||||
}
|
||||
if !license.Active {
|
||||
json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "license revoked"})
|
||||
return
|
||||
}
|
||||
|
||||
// Machine binding check
|
||||
if license.MachineID != "" && license.MachineID != req.MachineID {
|
||||
json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "machine mismatch"})
|
||||
return
|
||||
}
|
||||
|
||||
// Expiry check
|
||||
if license.Type == "expiry" && license.ExpiresAt != nil {
|
||||
if time.Now().After(*license.ExpiresAt) {
|
||||
json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "license expired"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Usage check
|
||||
if license.Type == "usage" && license.MaxUses != nil {
|
||||
if license.UsedCount >= *license.MaxUses {
|
||||
json.NewEncoder(w).Encode(validateResponse{Valid: false, Message: "usage limit reached"})
|
||||
return
|
||||
}
|
||||
h.DB.IncrementUsage(license.ID)
|
||||
}
|
||||
|
||||
// Record activation
|
||||
h.DB.RecordActivation(&models.Activation{
|
||||
ID: uuid.New().String(),
|
||||
LicenseID: license.ID,
|
||||
MachineID: req.MachineID,
|
||||
Hostname: req.Hostname,
|
||||
OS: req.OS,
|
||||
ActivatedAt: time.Now(),
|
||||
})
|
||||
|
||||
json.NewEncoder(w).Encode(validateResponse{Valid: true, Type: license.Type, Expires: license.ExpiresAt})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateKey produces a license key in the format XXXX-XXXX-XXXX-XXXX
|
||||
func GenerateKey() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
hex := fmt.Sprintf("%X", b)
|
||||
parts := []string{
|
||||
hex[0:4], hex[4:8], hex[8:12], hex[12:16],
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const sessionKey contextKey = "userID"
|
||||
|
||||
// SessionStore is a simple in-memory session store.
|
||||
// Swap for Redis in production.
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]sessionEntry
|
||||
}
|
||||
|
||||
type sessionEntry struct {
|
||||
userID string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
s := &SessionStore{sessions: make(map[string]sessionEntry)}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SessionStore) Create(userID string) string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
s.mu.Lock()
|
||||
s.sessions[token] = sessionEntry{userID: userID, expiresAt: time.Now().Add(24 * time.Hour)}
|
||||
s.mu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(token string) (string, bool) {
|
||||
s.mu.RLock()
|
||||
entry, ok := s.sessions[token]
|
||||
s.mu.RUnlock()
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
return entry.userID, true
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, token)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *SessionStore) cleanup() {
|
||||
for range time.Tick(time.Hour) {
|
||||
s.mu.Lock()
|
||||
for k, v := range s.sessions {
|
||||
if time.Now().After(v.expiresAt) {
|
||||
delete(s.sessions, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth middleware redirects unauthenticated requests to /login.
|
||||
func (s *SessionStore) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
userID, ok := s.Get(cookie.Value)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), sessionKey, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// SetSession writes the session cookie.
|
||||
func SetSession(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearSession removes the session cookie.
|
||||
func ClearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user