initital commit

This commit is contained in:
2026-05-05 08:44:55 +00:00
commit a1922f5be1
22 changed files with 2922 additions and 0 deletions
+227
View File
@@ -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})
}
+18
View File
@@ -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, "-")
}
+110
View File
@@ -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,
})
}
+203
View File
@@ -0,0 +1,203 @@
package models
import (
"database/sql"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
// License represents a license record in the database.
type License struct {
ID string
Key string
CustomerName string
CustomerEmail string
ProductName string
Type string // expiry | usage
ExpiresAt *time.Time
MaxUses *int
UsedCount int
MachineID string
Active bool
CreatedAt time.Time
SearchText string // pre-built lowercase search string for the UI
}
// Activation records each machine that activated a license.
type Activation struct {
ID string
LicenseID string
MachineID string
Hostname string
OS string
ActivatedAt time.Time
}
// DB wraps the SQLite connection with helper methods.
type DB struct {
conn *sql.DB
}
// Open opens (or creates) the SQLite database at path.
func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
if err != nil {
return nil, err
}
db := &DB{conn: conn}
return db, db.migrate()
}
func (db *DB) migrate() error {
_, err := db.conn.Exec(`
CREATE TABLE IF NOT EXISTS licenses (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
product_name TEXT NOT NULL,
type TEXT NOT NULL,
expires_at DATETIME,
max_uses INTEGER,
used_count INTEGER NOT NULL DEFAULT 0,
machine_id TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS activations (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL REFERENCES licenses(id),
machine_id TEXT NOT NULL,
hostname TEXT,
os TEXT,
activated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL
);
`)
return err
}
// --- License CRUD ---
func (db *DB) CreateLicense(l *License) error {
_, err := db.conn.Exec(`
INSERT INTO licenses (id, key, customer_name, customer_email, product_name,
type, expires_at, max_uses, machine_id, active, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
l.ID, l.Key, l.CustomerName, l.CustomerEmail, l.ProductName,
l.Type, l.ExpiresAt, l.MaxUses, l.MachineID, l.Active, l.CreatedAt,
)
return err
}
func (db *DB) ListLicenses() ([]License, error) {
rows, err := db.conn.Query(`
SELECT id, key, customer_name, customer_email, product_name,
type, expires_at, max_uses, used_count, machine_id, active, created_at
FROM licenses ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var licenses []License
for rows.Next() {
var l License
if err := rows.Scan(&l.ID, &l.Key, &l.CustomerName, &l.CustomerEmail,
&l.ProductName, &l.Type, &l.ExpiresAt, &l.MaxUses,
&l.UsedCount, &l.MachineID, &l.Active, &l.CreatedAt); err != nil {
return nil, err
}
l.SearchText = strings.ToLower(fmt.Sprintf("%s %s %s %s %s",
l.Key, l.CustomerName, l.CustomerEmail, l.ProductName, l.MachineID))
licenses = append(licenses, l)
}
return licenses, nil
}
func (db *DB) GetLicenseByKey(key string) (*License, error) {
var l License
err := db.conn.QueryRow(`
SELECT id, key, customer_name, customer_email, product_name,
type, expires_at, max_uses, used_count, machine_id, active, created_at
FROM licenses WHERE key = ?`, key).
Scan(&l.ID, &l.Key, &l.CustomerName, &l.CustomerEmail,
&l.ProductName, &l.Type, &l.ExpiresAt, &l.MaxUses,
&l.UsedCount, &l.MachineID, &l.Active, &l.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &l, err
}
func (db *DB) RevokeLicense(id string) error {
_, err := db.conn.Exec(`UPDATE licenses SET active = 0 WHERE id = ?`, id)
return err
}
func (db *DB) ActivateLicense(id string) error {
_, err := db.conn.Exec(`UPDATE licenses SET active = 1 WHERE id = ?`, id)
return err
}
func (db *DB) IncrementUsage(id string) error {
_, err := db.conn.Exec(`UPDATE licenses SET used_count = used_count + 1 WHERE id = ?`, id)
return err
}
// --- Activations ---
func (db *DB) RecordActivation(a *Activation) error {
_, err := db.conn.Exec(`
INSERT OR REPLACE INTO activations (id, license_id, machine_id, hostname, os, activated_at)
VALUES (?,?,?,?,?,?)`,
a.ID, a.LicenseID, a.MachineID, a.Hostname, a.OS, a.ActivatedAt,
)
return err
}
func (db *DB) ListActivations(licenseID string) ([]Activation, error) {
rows, err := db.conn.Query(`
SELECT id, license_id, machine_id, hostname, os, activated_at
FROM activations WHERE license_id = ? ORDER BY activated_at DESC`, licenseID)
if err != nil {
return nil, err
}
defer rows.Close()
var acts []Activation
for rows.Next() {
var a Activation
rows.Scan(&a.ID, &a.LicenseID, &a.MachineID, &a.Hostname, &a.OS, &a.ActivatedAt)
acts = append(acts, a)
}
return acts, nil
}
// --- Users ---
func (db *DB) CreateUser(id, email, hash string) error {
_, err := db.conn.Exec(`
INSERT INTO users (id, email, password_hash, created_at) VALUES (?,?,?,?)`,
id, email, hash, time.Now())
return err
}
func (db *DB) GetUserByEmail(email string) (id, hash string, err error) {
err = db.conn.QueryRow(
`SELECT id, password_hash FROM users WHERE email = ?`, email).
Scan(&id, &hash)
return
}
func (db *DB) UserCount() int {
var n int
db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}