Inicijalni commit: kompletna implementacija + dokumentacija + testovi
- Kompletna Go implementacija licencnog servera (19 Go fajlova) - Klijentski API: activate, deactivate, validate - Admin API: CRUD licence, stats, audit log - Admin dashboard: htmx + Go templates - RSA-2048 potpisivanje licencnih podataka - Rate limiting i API key autentifikacija - MySQL migracije i seed podaci (ESIR, ARV, LIGHT_TICKET) - Unit testovi: keygen, crypto, model, middleware (24 testa) - Dokumentacija: SPEC.md, ARCHITECTURE.md, SETUP.md, API.md, TESTING.md, README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
dc0114e4b7
3
.claude/feedback/idx_counter.json
Normal file
3
.claude/feedback/idx_counter.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"next": 1
|
||||||
|
}
|
||||||
8
.claude/project.json
Normal file
8
.claude/project.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "dal-license-server",
|
||||||
|
"description": "Univerzalni licencni server za sve DAL proizvode (ESIR, ARV, Light-Ticket).",
|
||||||
|
"binary": "dal-license-server",
|
||||||
|
"start_cmd": "",
|
||||||
|
"work_dir": "/root/projects/dal-license-server",
|
||||||
|
"ports": {}
|
||||||
|
}
|
||||||
19
.env.example
Normal file
19
.env.example
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
APP_PORT=8090
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=dal_license_db
|
||||||
|
DB_USER=license
|
||||||
|
DB_PASS=OBAVEZNO-PROMENITI
|
||||||
|
|
||||||
|
ADMIN_API_KEY=OBAVEZNO-GENERISATI-MIN-32-CHARS
|
||||||
|
ADMIN_PASSWORD=OBAVEZNO-PROMENITI
|
||||||
|
SESSION_SECRET=OBAVEZNO-PROMENITI
|
||||||
|
|
||||||
|
RSA_PRIVATE_KEY_PATH=./crypto/private.pem
|
||||||
|
|
||||||
|
RATE_LIMIT_ACTIVATE=10
|
||||||
|
RATE_LIMIT_VALIDATE=60
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
*.exe
|
||||||
|
dal-license-server
|
||||||
|
crypto/private.pem
|
||||||
|
log/*.log
|
||||||
|
data/
|
||||||
272
API.md
Normal file
272
API.md
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
# DAL License Server — API Dokumentacija
|
||||||
|
|
||||||
|
Bazni URL: `http://localhost:8090`
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
### GET /api/v1/health
|
||||||
|
|
||||||
|
Provera da li server radi.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Klijentski API
|
||||||
|
|
||||||
|
Endpointi za klijentske aplikacije (ESIR, ARV, Light-Ticket). Bez autentifikacije, zastiteni rate limiting-om.
|
||||||
|
|
||||||
|
### POST /api/v1/activate
|
||||||
|
|
||||||
|
Aktivacija licence na konkretnom racunaru.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6...",
|
||||||
|
"app_version": "1.0.0",
|
||||||
|
"os": "windows",
|
||||||
|
"hostname": "FIRMA-PC"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license": {
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"product": "LIGHT_TICKET",
|
||||||
|
"license_type": "MONTHLY",
|
||||||
|
"issued_at": "2026-03-01T00:00:00Z",
|
||||||
|
"expires_at": "2026-04-01T00:00:00Z",
|
||||||
|
"activated_at": "2026-03-03T10:00:00Z",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6...",
|
||||||
|
"grace_days": 30,
|
||||||
|
"limits": {
|
||||||
|
"max_operators": 3
|
||||||
|
},
|
||||||
|
"features": ["TICKET_VALIDATION", "REPORTS", "EXCEL_EXPORT", "LIVE_FEED"],
|
||||||
|
"customer": {
|
||||||
|
"name": "Firma DOO",
|
||||||
|
"email": "admin@firma.rs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signature": "RSA-SHA256:base64encodedSignature..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error kodovi:**
|
||||||
|
|
||||||
|
| Kod | Opis |
|
||||||
|
|-----|------|
|
||||||
|
| INVALID_KEY | Licencni kljuc ne postoji |
|
||||||
|
| ALREADY_ACTIVATED | Licenca vec aktivirana na drugom racunaru |
|
||||||
|
| KEY_EXPIRED | Licenca istekla |
|
||||||
|
| KEY_REVOKED | Licenca opozvana |
|
||||||
|
| PRODUCT_MISMATCH | Proizvod ne odgovara |
|
||||||
|
|
||||||
|
**Error response 400:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "ALREADY_ACTIVATED",
|
||||||
|
"message": "Licenca je već aktivirana na drugom računaru",
|
||||||
|
"details": {
|
||||||
|
"activated_on": "DRUGA-PC",
|
||||||
|
"activated_at": "2026-02-15T08:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/deactivate
|
||||||
|
|
||||||
|
Deaktivacija licence (za transfer na drugi racunar).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Licenca uspešno deaktivirana",
|
||||||
|
"can_reactivate": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/validate
|
||||||
|
|
||||||
|
Opciona online provera validnosti licence.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"expires_at": "2026-04-01T00:00:00Z",
|
||||||
|
"revoked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
Svi admin endpointi zahtevaju `X-API-Key` header.
|
||||||
|
|
||||||
|
```
|
||||||
|
X-API-Key: vas-api-kljuc
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/admin/products
|
||||||
|
|
||||||
|
Lista svih proizvoda.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"code": "ESIR",
|
||||||
|
"name": "ESIR Fiskalizacija",
|
||||||
|
"key_prefix": "ESIR-",
|
||||||
|
"default_limits": {"max_installations": 1},
|
||||||
|
"available_features": ["FISCALIZATION", "REPORTS"],
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/admin/licenses
|
||||||
|
|
||||||
|
Lista licenci. Podrzava filter po proizvodu.
|
||||||
|
|
||||||
|
**Query parametri:**
|
||||||
|
- `product` — Filter po product code (ESIR, ARV, LIGHT_TICKET)
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"product_code": "LIGHT_TICKET",
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"license_type": "MONTHLY",
|
||||||
|
"customer_name": "Firma DOO",
|
||||||
|
"expires_at": "2026-04-01T00:00:00Z",
|
||||||
|
"active": true,
|
||||||
|
"revoked": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/admin/licenses
|
||||||
|
|
||||||
|
Kreiranje nove licence.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product_code": "LIGHT_TICKET",
|
||||||
|
"license_type": "MONTHLY",
|
||||||
|
"customer_name": "Firma DOO",
|
||||||
|
"customer_pib": "123456789",
|
||||||
|
"customer_email": "admin@firma.rs",
|
||||||
|
"limits": {"max_operators": 10},
|
||||||
|
"features": ["TICKET_VALIDATION", "REPORTS", "EXCEL_EXPORT", "LIVE_FEED"],
|
||||||
|
"grace_days": 30,
|
||||||
|
"notes": "Pro paket"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response 201:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"message": "Licenca kreirana"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/admin/licenses/{id}
|
||||||
|
|
||||||
|
Detalji jedne licence.
|
||||||
|
|
||||||
|
### PUT /api/v1/admin/licenses/{id}
|
||||||
|
|
||||||
|
Izmena licence.
|
||||||
|
|
||||||
|
### POST /api/v1/admin/licenses/{id}/revoke
|
||||||
|
|
||||||
|
Opozivanje licence.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reason": "Klijent nije platio"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/admin/licenses/{id}/release
|
||||||
|
|
||||||
|
Force release aktivacije (kada je racunar klijenta nedostupan).
|
||||||
|
|
||||||
|
### GET /api/v1/admin/licenses/{id}/activations
|
||||||
|
|
||||||
|
Lista aktivacija za licencu.
|
||||||
|
|
||||||
|
### GET /api/v1/admin/audit
|
||||||
|
|
||||||
|
Audit log. Lista svih akcija.
|
||||||
|
|
||||||
|
### GET /api/v1/admin/stats
|
||||||
|
|
||||||
|
Statistike po proizvodima.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard rute (htmx)
|
||||||
|
|
||||||
|
| Metoda | Putanja | Opis |
|
||||||
|
|--------|---------|------|
|
||||||
|
| GET | /login | Login stranica |
|
||||||
|
| POST | /login | Login submit |
|
||||||
|
| POST | /logout | Logout |
|
||||||
|
| GET | /dashboard | Pocetna — statistike |
|
||||||
|
| GET | /licenses | Lista licenci |
|
||||||
|
| GET | /licenses/new | Forma za novu licencu |
|
||||||
|
| POST | /licenses | Kreiranje licence |
|
||||||
|
| GET | /licenses/{id} | Detalji licence |
|
||||||
|
| POST | /licenses/{id}/revoke | Opozivanje |
|
||||||
|
| POST | /licenses/{id}/release | Force release |
|
||||||
|
| GET | /audit | Audit log |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
|
||||||
|
| Endpoint | Limit |
|
||||||
|
|----------|-------|
|
||||||
|
| POST /api/v1/activate | 10 req/min per IP |
|
||||||
|
| POST /api/v1/deactivate | 10 req/min per IP |
|
||||||
|
| POST /api/v1/validate | 60 req/min per IP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Mart 2026*
|
||||||
744
CLAUDE.md
Normal file
744
CLAUDE.md
Normal file
@ -0,0 +1,744 @@
|
|||||||
|
# DAL License Server — CLAUDE.md
|
||||||
|
|
||||||
|
## Pregled
|
||||||
|
|
||||||
|
Univerzalni licencni server za **sve DAL proizvode**: ESIR, ARV, Light-Ticket, i buduće aplikacije.
|
||||||
|
|
||||||
|
Zamenjuje stari `esir-license-server` (koji niko ne koristi u produkciji). Arhitektura prema ARV licencnoj dokumentaciji (`/root/projects/arv/TASKS/Licenciranje-Starter.md`), ali univerzalna — podržava N proizvoda.
|
||||||
|
|
||||||
|
**Princip:** License Server je jedini koji ima RSA private key. Klijentske aplikacije imaju samo public key. Server potpisuje licence, klijenti verifikuju potpis. Niko osim servera ne može da kreira validnu licencu.
|
||||||
|
|
||||||
|
**KRITIČNO — Testiranje:** Ova aplikacija je osnova budućeg poslovanja DAL-a. Mora biti **rigorozno testirana** svim poznatim tipovima testova:
|
||||||
|
- **Unit testovi** — svaki servis, model, helper, middleware
|
||||||
|
- **Integration testovi** — kompletni API flow-ovi (activate → validate → deactivate → re-activate)
|
||||||
|
- **Security testovi** — SQL injection, brute force, API key bypass, tampered signatures, invalid inputs
|
||||||
|
- **Edge case testovi** — expired licence, grace period granice, perpetual licence, race conditions
|
||||||
|
- **Regression testovi** — svaki bug fix mora imati test koji potvrdjuje ispravku
|
||||||
|
- **Load testovi** — rate limiting pod pritiskom, konkurentni zahtevi
|
||||||
|
- Nikad ne smanjivati pokrivenost testovima. Svaka nova funkcionalnost MORA imati test pre merge-a.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kako radi
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ DAL LICENSE SERVER │
|
||||||
|
│ port: 8090 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Admin API │ │ Klijent │ │ Admin Dashboard │ │
|
||||||
|
│ │ (CRUD) │ │ API │ │ (htmx) │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────┼───────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────┴────────┐ │
|
||||||
|
│ │ MySQL baza │ │
|
||||||
|
│ │ license_db │ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ RSA-2048 Private Key (samo ovde, nikad ne izlazi) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
│ HTTPS │ HTTPS │ HTTPS
|
||||||
|
│ │ │
|
||||||
|
┌────┴────┐ ┌────┴────┐ ┌────┴────────┐
|
||||||
|
│ ESIR │ │ ARV │ │ Light-Ticket │
|
||||||
|
│ klijent │ │ klijent │ │ klijent │
|
||||||
|
└─────────┘ └─────────┘ └──────────────┘
|
||||||
|
Svaki ima RSA public key ugrađen u binary
|
||||||
|
Svaki čuva license.enc lokalno
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tok — od kupovine do rada
|
||||||
|
|
||||||
|
### 1. Admin kreira licencu
|
||||||
|
```
|
||||||
|
Admin → Dashboard → Nova licenca
|
||||||
|
→ Bira proizvod (ESIR / ARV / LIGHT_TICKET)
|
||||||
|
→ Unosi: firma, email, tip (MONTHLY/ANNUAL/PERPETUAL), limiti
|
||||||
|
→ Server generiše ključ: {PREFIX}-XXXX-XXXX-XXXX-XXXX
|
||||||
|
→ Ključ se šalje klijentu (email ili ručno)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Klijent aktivira
|
||||||
|
```
|
||||||
|
Klijent instalira aplikaciju
|
||||||
|
→ Unese licencni ključ u Settings
|
||||||
|
→ Aplikacija šalje serveru: ključ + machine_fingerprint + app_version + OS
|
||||||
|
→ Server proverava: ključ validan? nije aktiviran drugde? nije istekao?
|
||||||
|
→ Server potpisuje licencne podatke RSA private key-em
|
||||||
|
→ Vraća: licencni JSON + RSA potpis
|
||||||
|
→ Klijent kreira license.enc (AES-256-GCM + RSA potpis)
|
||||||
|
→ Aplikacija radi OFFLINE dok licenca važi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Svakodnevni rad (offline)
|
||||||
|
```
|
||||||
|
Aplikacija se pokrene
|
||||||
|
→ Čita license.enc sa diska
|
||||||
|
→ Dekriptuje (AES sa machine fingerprint)
|
||||||
|
→ Proverava RSA potpis (public key ugrađen u binary)
|
||||||
|
→ Proverava fingerprint (isti računar?)
|
||||||
|
→ Proverava rok (nije istekao?)
|
||||||
|
→ SVE OK → normalan rad
|
||||||
|
→ Internet NIJE potreban za svakodnevni rad
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Opciona online provera
|
||||||
|
```
|
||||||
|
Jednom dnevno (ako ima internet):
|
||||||
|
→ Aplikacija šalje: ključ + fingerprint
|
||||||
|
→ Server proverava: nije revocirana?
|
||||||
|
→ Ako je revocirana na serveru → klijent invalidira lokalnu licencu
|
||||||
|
→ Ako nema interneta → preskače, radi sa lokalnim fajlom
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Komponenta | Tehnologija |
|
||||||
|
|------------|-------------|
|
||||||
|
| Backend | Go + net/http (Go 1.22+ routing) |
|
||||||
|
| Admin UI | htmx + Go html/template |
|
||||||
|
| Baza | MySQL 8.0 |
|
||||||
|
| Kripto | RSA-2048 (potpis), AES-256-GCM (enkripcija na klijentima) |
|
||||||
|
| Auth | API key za admin API, session za dashboard |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go struktura projekta
|
||||||
|
|
||||||
|
```
|
||||||
|
dal-license-server/
|
||||||
|
├── cmd/
|
||||||
|
│ └── server/
|
||||||
|
│ └── main.go
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── config.go # .env konfiguracija
|
||||||
|
│ ├── model/
|
||||||
|
│ │ ├── license.go # License, Product, LicenseType
|
||||||
|
│ │ ├── activation.go # Activation model
|
||||||
|
│ │ ├── audit.go # Audit log model
|
||||||
|
│ │ └── request.go # Request/Response structs
|
||||||
|
│ ├── repository/
|
||||||
|
│ │ ├── license_repo.go # License CRUD
|
||||||
|
│ │ ├── activation_repo.go # Activation CRUD
|
||||||
|
│ │ └── audit_repo.go # Audit log
|
||||||
|
│ ├── service/
|
||||||
|
│ │ ├── license_service.go # Poslovna logika
|
||||||
|
│ │ ├── activation_service.go # Aktivacija/deaktivacija
|
||||||
|
│ │ ├── crypto_service.go # RSA potpisivanje
|
||||||
|
│ │ └── keygen.go # Generisanje ključeva
|
||||||
|
│ ├── handler/
|
||||||
|
│ │ ├── client_handler.go # API za klijentske app-e (activate, deactivate, validate)
|
||||||
|
│ │ ├── admin_handler.go # Admin CRUD API
|
||||||
|
│ │ ├── dashboard_handler.go # Admin dashboard (htmx)
|
||||||
|
│ │ └── helpers.go # JSON/error helpers
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.go # API key + session auth
|
||||||
|
│ │ └── ratelimit.go # Rate limiting
|
||||||
|
│ └── router/
|
||||||
|
│ └── router.go
|
||||||
|
├── templates/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ └── base.html
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ ├── dashboard.html # Statistike po proizvodu
|
||||||
|
│ │ ├── licenses.html # Lista licenci + CRUD
|
||||||
|
│ │ ├── license-detail.html # Detalji licence + aktivacije
|
||||||
|
│ │ └── audit.html # Audit log pregled
|
||||||
|
│ └── partials/
|
||||||
|
│ ├── license-row.html
|
||||||
|
│ └── stats.html
|
||||||
|
├── crypto/
|
||||||
|
│ ├── private.pem # RSA private key (NIKAD u git-u!)
|
||||||
|
│ └── public.pem # RSA public key (deli se sa klijentima)
|
||||||
|
├── migrations/
|
||||||
|
│ ├── 001_create_tables.sql
|
||||||
|
│ └── 002_seed_products.sql
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore
|
||||||
|
├── go.mod
|
||||||
|
├── README.md
|
||||||
|
├── TESTING.md
|
||||||
|
└── CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baza podataka (MySQL)
|
||||||
|
|
||||||
|
### Tabele
|
||||||
|
|
||||||
|
#### `products`
|
||||||
|
| Kolona | Tip | Opis |
|
||||||
|
|--------|-----|------|
|
||||||
|
| id | BIGINT PK AUTO_INCREMENT | |
|
||||||
|
| code | VARCHAR(20) UNIQUE | ESIR, ARV, LIGHT_TICKET |
|
||||||
|
| name | VARCHAR(100) | Puno ime proizvoda |
|
||||||
|
| key_prefix | VARCHAR(10) | ESIR-, ARV-, LT- |
|
||||||
|
| default_limits | JSON | Default limiti za taj proizvod |
|
||||||
|
| available_features | JSON | Sve moguće features za taj proizvod |
|
||||||
|
| active | BOOLEAN DEFAULT TRUE | |
|
||||||
|
| created_at | TIMESTAMP | |
|
||||||
|
|
||||||
|
**Seed podaci:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO products (code, name, key_prefix, default_limits, available_features) VALUES
|
||||||
|
('ESIR', 'ESIR Fiskalizacija', 'ESIR-',
|
||||||
|
'{"max_installations": 1}',
|
||||||
|
'["FISCALIZATION", "REPORTS"]'),
|
||||||
|
('ARV', 'ARV Evidencija RV', 'ARV-',
|
||||||
|
'{"max_employees": 50, "max_readers": 4}',
|
||||||
|
'["TIME_ATTENDANCE", "BASIC_REPORTS", "EMPLOYEE_MANAGEMENT", "SHIFTS", "HR_MODULE", "ACCESS_CONTROL"]'),
|
||||||
|
('LIGHT_TICKET', 'Light-Ticket', 'LT-',
|
||||||
|
'{"max_operators": 3}',
|
||||||
|
'["TICKET_VALIDATION", "REPORTS", "EXCEL_EXPORT", "LIVE_FEED"]');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Light-Ticket paketi licenci (odlučeno 03.03.2026):**
|
||||||
|
- Admin ručno upisuje `max_operators` prilikom kreiranja licence
|
||||||
|
- Nema eksplicitnog `edition` polja — paketi su samo konvencija:
|
||||||
|
|
||||||
|
| Paket | max_operators | Tip licence | Features |
|
||||||
|
|-------|--------------|-------------|----------|
|
||||||
|
| Starter | 3 | MONTHLY ili PERPETUAL | Sve |
|
||||||
|
| Pro | 10 | MONTHLY ili PERPETUAL | Sve |
|
||||||
|
| Enterprise | 0 (neograničeno) | MONTHLY ili PERPETUAL | Sve |
|
||||||
|
|
||||||
|
- Default u seed-u je `max_operators: 3` (Starter)
|
||||||
|
- Sve features su iste za sve pakete — razlikuje se SAMO `max_operators`
|
||||||
|
- Tipovi licence za LT: MONTHLY (mesečna) i PERPETUAL (trajna). TRIAL i ANNUAL nisu predviđeni za V1.
|
||||||
|
|
||||||
|
#### `licenses`
|
||||||
|
| Kolona | Tip | Opis |
|
||||||
|
|--------|-----|------|
|
||||||
|
| id | BIGINT PK AUTO_INCREMENT | |
|
||||||
|
| product_id | BIGINT FK(products.id) | Koji proizvod |
|
||||||
|
| license_key | VARCHAR(25) UNIQUE | {PREFIX}-XXXX-XXXX-XXXX-XXXX |
|
||||||
|
| license_type | VARCHAR(20) NOT NULL | TRIAL / MONTHLY / ANNUAL / PERPETUAL |
|
||||||
|
| customer_name | VARCHAR(255) NOT NULL | Naziv firme |
|
||||||
|
| customer_pib | VARCHAR(20) | PIB (opciono) |
|
||||||
|
| customer_email | VARCHAR(255) | Email |
|
||||||
|
| limits | JSON NOT NULL | {"max_employees": 50, "max_readers": 4} |
|
||||||
|
| features | JSON NOT NULL | ["TIME_ATTENDANCE", "BASIC_REPORTS"] |
|
||||||
|
| issued_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
| expires_at | TIMESTAMP NULL | NULL = neograničena (PERPETUAL) |
|
||||||
|
| grace_days | INT DEFAULT 30 | Koliko dana grace period |
|
||||||
|
| active | BOOLEAN DEFAULT TRUE | |
|
||||||
|
| revoked | BOOLEAN DEFAULT FALSE | |
|
||||||
|
| revoked_at | TIMESTAMP NULL | |
|
||||||
|
| revoked_reason | TEXT | |
|
||||||
|
| notes | TEXT | Interne beleške |
|
||||||
|
| created_at | TIMESTAMP | |
|
||||||
|
| updated_at | TIMESTAMP | |
|
||||||
|
|
||||||
|
**Indeksi:**
|
||||||
|
- `idx_licenses_key` na (license_key) — UNIQUE
|
||||||
|
- `idx_licenses_product` na (product_id)
|
||||||
|
- `idx_licenses_customer` na (customer_name)
|
||||||
|
- `idx_licenses_expires` na (expires_at)
|
||||||
|
|
||||||
|
#### `activations`
|
||||||
|
| Kolona | Tip | Opis |
|
||||||
|
|--------|-----|------|
|
||||||
|
| id | BIGINT PK AUTO_INCREMENT | |
|
||||||
|
| license_id | BIGINT FK(licenses.id) | |
|
||||||
|
| machine_fingerprint | VARCHAR(100) NOT NULL | sha256:... |
|
||||||
|
| hostname | VARCHAR(100) | Ime računara |
|
||||||
|
| os_info | VARCHAR(50) | windows / linux |
|
||||||
|
| app_version | VARCHAR(20) | Verzija aplikacije |
|
||||||
|
| ip_address | VARCHAR(45) | IP pri aktivaciji |
|
||||||
|
| activated_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
| deactivated_at | TIMESTAMP NULL | NULL = aktivna |
|
||||||
|
| is_active | BOOLEAN DEFAULT TRUE | |
|
||||||
|
| last_seen_at | TIMESTAMP | Poslednja online provera |
|
||||||
|
|
||||||
|
**Indeksi:**
|
||||||
|
- `idx_activations_license` na (license_id)
|
||||||
|
- `idx_activations_fingerprint` na (machine_fingerprint)
|
||||||
|
- `idx_activations_active` na (license_id, is_active)
|
||||||
|
|
||||||
|
#### `audit_log`
|
||||||
|
| Kolona | Tip | Opis |
|
||||||
|
|--------|-----|------|
|
||||||
|
| id | BIGINT PK AUTO_INCREMENT | |
|
||||||
|
| license_id | BIGINT FK(licenses.id) NULL | |
|
||||||
|
| action | VARCHAR(30) NOT NULL | ACTIVATE, DEACTIVATE, VALIDATE, REVOKE, FORCE_RELEASE, CREATE, UPDATE |
|
||||||
|
| ip_address | VARCHAR(45) | |
|
||||||
|
| details | JSON | Dodatni podaci (fingerprint, hostname, error, itd.) |
|
||||||
|
| created_at | TIMESTAMP DEFAULT NOW() | |
|
||||||
|
|
||||||
|
**Indeksi:**
|
||||||
|
- `idx_audit_license` na (license_id)
|
||||||
|
- `idx_audit_action` na (action)
|
||||||
|
- `idx_audit_created` na (created_at)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API endpointi
|
||||||
|
|
||||||
|
### Klijentski API (za ESIR, ARV, Light-Ticket aplikacije)
|
||||||
|
|
||||||
|
| Metoda | Putanja | Auth | Opis |
|
||||||
|
|--------|---------|------|------|
|
||||||
|
| POST | `/api/v1/activate` | - | Aktivacija licence |
|
||||||
|
| POST | `/api/v1/deactivate` | - | Deaktivacija (transfer) |
|
||||||
|
| POST | `/api/v1/validate` | - | Opciona online provera |
|
||||||
|
| GET | `/api/v1/check-update` | - | Provera za update aplikacije |
|
||||||
|
|
||||||
|
#### POST `/api/v1/activate`
|
||||||
|
```json
|
||||||
|
// Request (od klijentske aplikacije)
|
||||||
|
{
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6...",
|
||||||
|
"app_version": "1.0.0",
|
||||||
|
"os": "windows",
|
||||||
|
"hostname": "FIRMA-PC"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"license": {
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"product": "LIGHT_TICKET",
|
||||||
|
"license_type": "MONTHLY",
|
||||||
|
"issued_at": "2026-03-01T00:00:00Z",
|
||||||
|
"expires_at": "2026-04-01T00:00:00Z",
|
||||||
|
"activated_at": "2026-03-03T10:00:00Z",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6...",
|
||||||
|
"grace_days": 30,
|
||||||
|
"limits": {
|
||||||
|
"max_operators": 3
|
||||||
|
},
|
||||||
|
"features": ["TICKET_VALIDATION", "REPORTS", "EXCEL_EXPORT", "LIVE_FEED"],
|
||||||
|
"customer": {
|
||||||
|
"name": "Firma DOO",
|
||||||
|
"email": "admin@firma.rs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signature": "RSA-SHA256:base64encodedSignature..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 400
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "ALREADY_ACTIVATED",
|
||||||
|
"message": "Licenca je već aktivirana na drugom računaru",
|
||||||
|
"details": {
|
||||||
|
"activated_on": "DRUGA-PC",
|
||||||
|
"activated_at": "2026-02-15T08:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error kodovi:** INVALID_KEY, ALREADY_ACTIVATED, KEY_EXPIRED, KEY_REVOKED, PRODUCT_MISMATCH
|
||||||
|
|
||||||
|
#### POST `/api/v1/deactivate`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"message": "Licenca uspešno deaktivirana",
|
||||||
|
"can_reactivate": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST `/api/v1/validate`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"license_key": "LT-K7M2-9P4N-R3W8-J6T1",
|
||||||
|
"machine_fingerprint": "sha256:a1b2c3d4e5f6..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"expires_at": "2026-04-01T00:00:00Z",
|
||||||
|
"revoked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
|
||||||
|
| Metoda | Putanja | Auth | Opis |
|
||||||
|
|--------|---------|------|------|
|
||||||
|
| GET | `/api/v1/admin/products` | API key | Lista proizvoda |
|
||||||
|
| GET | `/api/v1/admin/licenses` | API key | Lista licenci (filter po proizvodu) |
|
||||||
|
| POST | `/api/v1/admin/licenses` | API key | Kreiraj licencu |
|
||||||
|
| GET | `/api/v1/admin/licenses/{id}` | API key | Detalji licence |
|
||||||
|
| PUT | `/api/v1/admin/licenses/{id}` | API key | Izmeni licencu |
|
||||||
|
| POST | `/api/v1/admin/licenses/{id}/revoke` | API key | Opozovi licencu |
|
||||||
|
| POST | `/api/v1/admin/licenses/{id}/release` | API key | Force release (računar crkao) |
|
||||||
|
| GET | `/api/v1/admin/licenses/{id}/activations` | API key | Aktivacije za licencu |
|
||||||
|
| GET | `/api/v1/admin/audit` | API key | Audit log |
|
||||||
|
| GET | `/api/v1/admin/stats` | API key | Statistike |
|
||||||
|
|
||||||
|
### Admin Dashboard (htmx)
|
||||||
|
|
||||||
|
| Metoda | Putanja | Auth | Opis |
|
||||||
|
|--------|---------|------|------|
|
||||||
|
| GET | `/login` | - | Login |
|
||||||
|
| POST | `/login` | - | Login submit |
|
||||||
|
| GET | `/dashboard` | Session | Početna — statistike po proizvodu |
|
||||||
|
| GET | `/licenses` | Session | Tabela licenci + filteri |
|
||||||
|
| GET | `/licenses/new` | Session | Forma za novu licencu |
|
||||||
|
| POST | `/licenses` | Session | Kreiraj licencu |
|
||||||
|
| GET | `/licenses/{id}` | Session | Detalji + aktivacije |
|
||||||
|
| POST | `/licenses/{id}/revoke` | Session | Opozovi |
|
||||||
|
| POST | `/licenses/{id}/release` | Session | Force release |
|
||||||
|
| GET | `/audit` | Session | Audit log |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RSA ključevi
|
||||||
|
|
||||||
|
### Generisanje (jednom, čuva se zauvek)
|
||||||
|
```bash
|
||||||
|
# Generiši private key (ČUVAJ TAJNO)
|
||||||
|
openssl genrsa -out crypto/private.pem 2048
|
||||||
|
|
||||||
|
# Izvuci public key (deli se sa klijentima)
|
||||||
|
openssl rsa -in crypto/private.pem -pubout -out crypto/public.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Korišćenje
|
||||||
|
- **License Server:** koristi `private.pem` za potpisivanje licenci pri aktivaciji
|
||||||
|
- **Klijenti (ARV, ESIR, LT):** imaju `public.pem` ugrađen u Go binary (`embed`) za verifikaciju potpisa
|
||||||
|
- **Isti par ključeva** za sve proizvode — jedan server, jedan ključ
|
||||||
|
|
||||||
|
### Šta se potpisuje
|
||||||
|
```
|
||||||
|
Server prima activate request
|
||||||
|
→ Kreira licencni JSON (bez signature polja)
|
||||||
|
→ Potpisuje JSON sa RSA-SHA256 (private key)
|
||||||
|
→ Dodaje potpis u response
|
||||||
|
→ Klijent prima JSON + potpis
|
||||||
|
→ Klijent verifikuje potpis (public key)
|
||||||
|
→ Klijent enkriptuje JSON sa AES-256-GCM (ključ = machine_fingerprint + app_secret)
|
||||||
|
→ Klijent sačuva kao license.enc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licencni ključ — format
|
||||||
|
|
||||||
|
```
|
||||||
|
{PREFIX}-XXXX-XXXX-XXXX-XXXX
|
||||||
|
|
||||||
|
Primeri:
|
||||||
|
ESIR-K7M2-9P4N-R3W8-J6T1
|
||||||
|
ARV-A3B5-C8D2-E7F4-G9H6
|
||||||
|
LT-M4N8-P2Q6-R5S3-T7U9
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prefix po proizvodu: čita se iz `products.key_prefix`
|
||||||
|
- 4 grupe po 4 alfanumerička karaktera
|
||||||
|
- Karakteri: A-H, J-N, P-Y, 2-9 (bez O/0/I/1 konfuzije)
|
||||||
|
- Generisanje: crypto/rand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipovi licenci
|
||||||
|
|
||||||
|
| Tip | Trajanje | Obnova | Primer |
|
||||||
|
|-----|----------|--------|--------|
|
||||||
|
| TRIAL | 30 dana | Nema | Besplatno testiranje |
|
||||||
|
| MONTHLY | 30 dana | Automatski ili ručno | Light-Ticket |
|
||||||
|
| ANNUAL | 365 dana | Ručno | ARV, ESIR |
|
||||||
|
| PERPETUAL | Zauvek | Nema (expires_at = NULL) | Kupljena zauvek |
|
||||||
|
|
||||||
|
### Grace period
|
||||||
|
- Default: 30 dana posle isteka
|
||||||
|
- Konfigurisano per licenca (`grace_days` kolona)
|
||||||
|
- Tokom grace perioda: pun rad + upozorenje na klijentskoj app
|
||||||
|
- Posle grace-a: read-only režim (GET dozvoljen, POST/PUT/DELETE blokiran)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard — šta prikazuje
|
||||||
|
|
||||||
|
### Početna strana
|
||||||
|
- **Po proizvodu:** broj aktivnih / isteklih / u grace-u / trial
|
||||||
|
- **Ukupno:** sve licence, aktivne aktivacije
|
||||||
|
- **Alarm:** licence koje ističu u narednih 7 dana
|
||||||
|
- **Poslednja aktivnost:** zadnjih 10 akcija iz audit loga
|
||||||
|
|
||||||
|
### Lista licenci
|
||||||
|
- **Filter:** proizvod, status (active/expired/revoked/trial), pretraga po firmi
|
||||||
|
- **Kolone:** ključ, firma, proizvod, tip, ističe, aktivacija, status
|
||||||
|
- **Sortiranje:** po datumu isteka (najhitnije prvo)
|
||||||
|
|
||||||
|
### Detalji licence
|
||||||
|
- Sve informacije o licenci
|
||||||
|
- Lista aktivacija (hostname, OS, verzija, IP, poslednji put viđen)
|
||||||
|
- Audit log za tu licencu
|
||||||
|
- Akcije: produži, opozovi, force release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migracija sa ESIR License Server-a
|
||||||
|
|
||||||
|
Pošto stari server niko ne koristi u produkciji, migracija je jednostavna:
|
||||||
|
|
||||||
|
1. Kreiraj novi `dal-license-server` projekat
|
||||||
|
2. Kopiraj korisnu logiku iz `esir-license-server` (keygen, verify flow, helpers)
|
||||||
|
3. Proširi model (product, features, limits, RSA)
|
||||||
|
4. Zameni port 8090 (isti port, drop-in replacement)
|
||||||
|
5. Stari `esir-license-server` → arhiviraj
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementacioni taskovi
|
||||||
|
|
||||||
|
### Faza 0: Priprema
|
||||||
|
- **T0-01:** Go projekat (go mod init, struktura foldera)
|
||||||
|
- **T0-02:** .env.example, .gitignore, README.md
|
||||||
|
- **T0-03:** Git repo na Gitea
|
||||||
|
- **T0-04:** Generisanje RSA ključeva (private.pem, public.pem)
|
||||||
|
|
||||||
|
### Faza 1: Baza
|
||||||
|
- **T1-01:** Config modul (.env)
|
||||||
|
- **T1-02:** MySQL konekcija
|
||||||
|
- **T1-03:** Migracija 001_create_tables.sql (products, licenses, activations, audit_log)
|
||||||
|
- **T1-04:** Migracija 002_seed_products.sql (ESIR, ARV, LIGHT_TICKET)
|
||||||
|
- **T1-05:** Repository sloj (license_repo, activation_repo, audit_repo)
|
||||||
|
|
||||||
|
### Faza 2: Core servis
|
||||||
|
- **T2-01:** Keygen — generisanje ključa sa prefix-om po proizvodu
|
||||||
|
- **T2-02:** Crypto service — RSA potpisivanje licencnog JSON-a
|
||||||
|
- **T2-03:** License service — CRUD, validacija, revoke
|
||||||
|
- **T2-04:** Activation service — activate, deactivate, force release, validate
|
||||||
|
- **T2-05:** Audit logging — svaka akcija se loguje
|
||||||
|
|
||||||
|
### Faza 3: Klijentski API
|
||||||
|
- **T3-01:** POST /api/v1/activate
|
||||||
|
- **T3-02:** POST /api/v1/deactivate
|
||||||
|
- **T3-03:** POST /api/v1/validate
|
||||||
|
- **T3-04:** Rate limiting na klijentske endpointe
|
||||||
|
|
||||||
|
### Faza 4: Admin API
|
||||||
|
- **T4-01:** Auth middleware (API key)
|
||||||
|
- **T4-02:** CRUD endpointi za licence
|
||||||
|
- **T4-03:** Revoke + Force release endpointi
|
||||||
|
- **T4-04:** Aktivacije i audit endpointi
|
||||||
|
- **T4-05:** Statistike endpoint
|
||||||
|
|
||||||
|
### Faza 5: Admin Dashboard (htmx)
|
||||||
|
- **T5-01:** Login stranica + session auth
|
||||||
|
- **T5-02:** Dashboard — statistike po proizvodu
|
||||||
|
- **T5-03:** Lista licenci — tabela, filteri, pretraga
|
||||||
|
- **T5-04:** Nova licenca — forma sa izborom proizvoda
|
||||||
|
- **T5-05:** Detalji licence — info, aktivacije, audit, akcije
|
||||||
|
- **T5-06:** Audit log stranica
|
||||||
|
|
||||||
|
### Faza 6: Testovi
|
||||||
|
- **T6-01:** Unit — keygen (format, prefix, uniqueness)
|
||||||
|
- **T6-02:** Unit — crypto (RSA sign/verify, tampered data)
|
||||||
|
- **T6-03:** Unit — license service (create, expire, grace, revoke)
|
||||||
|
- **T6-04:** Unit — activation service (activate, deactivate, already_activated, force_release)
|
||||||
|
- **T6-05:** Integration — activate flow (full roundtrip)
|
||||||
|
- **T6-06:** Integration — deactivate flow
|
||||||
|
- **T6-07:** Integration — expired license
|
||||||
|
- **T6-08:** Integration — revoked license
|
||||||
|
- **T6-09:** Security — SQL injection na svim inputima
|
||||||
|
- **T6-10:** Security — rate limiting (brute force key)
|
||||||
|
- **T6-11:** Security — API key validation
|
||||||
|
- **T6-12:** TESTING.md
|
||||||
|
|
||||||
|
### Faza 7: DevOps
|
||||||
|
- **T7-01:** Gitea CI workflow
|
||||||
|
- **T7-02:** Systemd service fajl
|
||||||
|
- **T7-03:** Backup skripta za bazu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguracija (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Server
|
||||||
|
APP_PORT=8090
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=dal_license_db
|
||||||
|
DB_USER=license
|
||||||
|
DB_PASS=OBAVEZNO-PROMENITI
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
ADMIN_API_KEY=OBAVEZNO-GENERISATI
|
||||||
|
SESSION_SECRET=OBAVEZNO-PROMENITI
|
||||||
|
|
||||||
|
# RSA
|
||||||
|
RSA_PRIVATE_KEY_PATH=./crypto/private.pem
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT_ACTIVATE=10/min
|
||||||
|
RATE_LIMIT_VALIDATE=60/min
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=./log/server.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konvencije
|
||||||
|
|
||||||
|
- Go net/http (bez framework-a, isto kao ESIR)
|
||||||
|
- Go 1.22+ method routing (`POST /api/v1/activate`)
|
||||||
|
- database/sql + raw SQL (bez ORM-a)
|
||||||
|
- RSA-2048 za potpis, SHA-256 za fingerprint
|
||||||
|
- API key u `X-API-Key` header-u za admin
|
||||||
|
- Sve akcije se loguju u audit_log
|
||||||
|
- Error wrapping: `fmt.Errorf("activate: %w", err)`
|
||||||
|
- Licencni ključ se NIKAD ne loguje ceo — samo prefix + poslednja 4 karaktera
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bezbednost
|
||||||
|
|
||||||
|
- `private.pem` NIKAD u git-u (u .gitignore)
|
||||||
|
- `private.pem` permisije: 600 (samo owner čita)
|
||||||
|
- Admin API key min 32 karaktera
|
||||||
|
- Rate limiting na activate/validate (zaštita od brute force)
|
||||||
|
- Audit log za svaku akciju
|
||||||
|
- HTTPS u produkciji (TLS termination na reverse proxy)
|
||||||
|
- Licencni ključ u logovima maskiran: `LT-K7M2-****-****-J6T1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status implementacije (mart 2026)
|
||||||
|
|
||||||
|
**Kompletno implementiran** — server je funkcionalan, testiran (22/22 testova prošlo).
|
||||||
|
|
||||||
|
### Implementirano
|
||||||
|
|
||||||
|
- ✅ Go projekat inicijalizovan (go.mod, go.sum)
|
||||||
|
- ✅ MySQL baza `dal_license_db` kreirana, user `license`
|
||||||
|
- ✅ Migracije (001_create_tables.sql, 002_seed_products.sql)
|
||||||
|
- ✅ RSA-2048 ključevi generisani (crypto/private.pem, crypto/public.pem)
|
||||||
|
- ✅ Seed podaci: 3 proizvoda (ESIR, ARV, LIGHT_TICKET)
|
||||||
|
- ✅ Client API: activate, deactivate, validate
|
||||||
|
- ✅ Admin API: CRUD licence, stats, audit log
|
||||||
|
- ✅ Dashboard: login, pregled licenci, kreiranje, detalji, audit log
|
||||||
|
- ✅ Rate limiting (in-memory sliding window)
|
||||||
|
- ✅ API key autentifikacija (X-API-Key header)
|
||||||
|
- ✅ RSA-SHA256 potpisivanje licencnih podataka
|
||||||
|
- ✅ Audit log za sve akcije
|
||||||
|
- ✅ Specifikacija kompletna (ovaj CLAUDE.md)
|
||||||
|
|
||||||
|
### Struktura fajlova
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/server/main.go — Entry point, MySQL konekcija, migracije, wire-up
|
||||||
|
internal/config/config.go — .env loading, DSN builder (multiStatements=true)
|
||||||
|
internal/model/
|
||||||
|
license.go — Product, License, LicenseWithActivation modeli
|
||||||
|
activation.go — Activation model
|
||||||
|
audit.go — AuditEntry model
|
||||||
|
request.go — Request/Response strukture (API)
|
||||||
|
internal/repository/
|
||||||
|
license_repo.go — License CRUD, product queries, stats
|
||||||
|
activation_repo.go — Activation CRUD, deactivate, force release
|
||||||
|
audit_repo.go — Audit log insert/list
|
||||||
|
internal/service/
|
||||||
|
license_service.go — License CRUD biznis logika, expiry kalkulacija
|
||||||
|
activation_service.go — Activate, Deactivate, Validate, ForceRelease
|
||||||
|
crypto_service.go — RSA-2048 potpisivanje (SHA-256, PKCS1v15)
|
||||||
|
keygen.go — Generisanje ključeva: {PREFIX}-XXXX-XXXX-XXXX-XXXX
|
||||||
|
internal/handler/
|
||||||
|
client_handler.go — POST /api/v1/activate, deactivate, validate
|
||||||
|
admin_handler.go — Admin CRUD API endpointi
|
||||||
|
dashboard_handler.go — Dashboard stranice, in-memory sesije
|
||||||
|
helpers.go — writeJSON, writeError, clientIP
|
||||||
|
internal/middleware/
|
||||||
|
auth.go — API key auth (X-API-Key header)
|
||||||
|
ratelimit.go — In-memory sliding window rate limiter
|
||||||
|
internal/router/router.go — Sve rute (client, admin, dashboard)
|
||||||
|
migrations/
|
||||||
|
001_create_tables.sql — products, licenses, activations, audit_log
|
||||||
|
002_seed_products.sql — ESIR, ARV, LIGHT_TICKET
|
||||||
|
templates/
|
||||||
|
layout/base.html — Glavni layout sa navbar-om
|
||||||
|
pages/login.html — Login stranica
|
||||||
|
pages/dashboard.html — Dashboard sa statistikama
|
||||||
|
pages/licenses.html — Lista licenci sa filterima
|
||||||
|
pages/license-new.html — Forma za novu licencu
|
||||||
|
pages/license-detail.html — Detalji licence, aktivacije, audit
|
||||||
|
pages/audit.html — Globalni audit log
|
||||||
|
static/css/style.css — Kompletni CSS
|
||||||
|
static/js/htmx.min.js — htmx biblioteka
|
||||||
|
crypto/private.pem — RSA-2048 privatni ključ (chmod 600)
|
||||||
|
crypto/public.pem — RSA javni ključ
|
||||||
|
```
|
||||||
|
|
||||||
|
### Konfiguracija (.env)
|
||||||
|
|
||||||
|
```
|
||||||
|
APP_PORT=8090
|
||||||
|
DB_HOST=localhost / DB_PORT=3306
|
||||||
|
DB_NAME=dal_license_db
|
||||||
|
DB_USER=license / DB_PASS=license_pass_2026
|
||||||
|
ADMIN_API_KEY=dal-admin-key-2026-supersecret-change-me
|
||||||
|
ADMIN_PASSWORD=DalAdmin2026!
|
||||||
|
RSA_PRIVATE_KEY_PATH=./crypto/private.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### DSN napomena
|
||||||
|
|
||||||
|
DSN sadrži `multiStatements=true` — neophodno za izvršavanje migracija sa više SQL naredbi u jednom `db.Exec()` pozivu.
|
||||||
|
|
||||||
|
### Testirano (22/22)
|
||||||
|
|
||||||
|
1. Health endpoint
|
||||||
|
2. Products list
|
||||||
|
3. Create license (sva 3 proizvoda)
|
||||||
|
4. List licenses
|
||||||
|
5. License detail
|
||||||
|
6. Activate license
|
||||||
|
7. Already activated (drugi hardver)
|
||||||
|
8. Deactivate license
|
||||||
|
9. Re-activate (novi hardver)
|
||||||
|
10. Validate license
|
||||||
|
11. Revoke license
|
||||||
|
12. Validate revoked (odbijeno)
|
||||||
|
13. Invalid license key
|
||||||
|
14. API key auth (401 bez ključa)
|
||||||
|
15. Stats endpoint
|
||||||
|
16. Audit log
|
||||||
|
17. Dashboard login
|
||||||
|
18. Dashboard index
|
||||||
|
19. Licenses page
|
||||||
|
20. New license form
|
||||||
|
21. License detail page
|
||||||
|
22. Audit page
|
||||||
|
|
||||||
|
### Sledeći koraci
|
||||||
|
|
||||||
|
- Faza 8 (Light-Ticket): Integracija sa license serverom
|
||||||
|
- ARV: Integracija sa license serverom
|
||||||
|
- ESIR: Integracija sa license serverom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Kreirano: mart 2026*
|
||||||
|
*Ažurirano: 04.03.2026 — Kompletna implementacija, 22/22 testova*
|
||||||
|
*Autor: Nenad Đukić / DAL d.o.o.*
|
||||||
58
README.md
Normal file
58
README.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# DAL License Server
|
||||||
|
|
||||||
|
Univerzalni licencni server za sve DAL proizvode (ESIR, ARV, Light-Ticket).
|
||||||
|
|
||||||
|
## Funkcionalnosti
|
||||||
|
|
||||||
|
- Kreiranje i upravljanje licencama za N proizvoda
|
||||||
|
- RSA-2048 potpisivanje licencnih podataka
|
||||||
|
- Aktivacija/deaktivacija licenci sa machine fingerprint-om
|
||||||
|
- Admin dashboard (htmx) za upravljanje
|
||||||
|
- REST API za klijentske aplikacije i admin operacije
|
||||||
|
- Audit log svih akcija
|
||||||
|
- Rate limiting na klijentskim endpointima
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **Backend:** Go 1.22+ (net/http, bez framework-a)
|
||||||
|
- **Baza:** MySQL 8.0
|
||||||
|
- **Admin UI:** htmx + Go html/template
|
||||||
|
- **Kripto:** RSA-2048 (potpis), AES-256-GCM (enkripcija na klijentima)
|
||||||
|
|
||||||
|
## Brzi start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Kopiraj konfiguraciju
|
||||||
|
cp .env.example .env
|
||||||
|
# Uredi .env sa pravim vrednostima
|
||||||
|
|
||||||
|
# 2. Kreiraj MySQL bazu
|
||||||
|
mysql -u root -e "CREATE DATABASE dal_license_db CHARACTER SET utf8mb4;"
|
||||||
|
mysql -u root -e "CREATE USER 'license'@'localhost' IDENTIFIED BY 'TVOJA_LOZINKA';"
|
||||||
|
mysql -u root -e "GRANT ALL ON dal_license_db.* TO 'license'@'localhost';"
|
||||||
|
|
||||||
|
# 3. Generiši RSA ključeve (ako ne postoje)
|
||||||
|
openssl genrsa -out crypto/private.pem 2048
|
||||||
|
openssl rsa -in crypto/private.pem -pubout -out crypto/public.pem
|
||||||
|
chmod 600 crypto/private.pem
|
||||||
|
|
||||||
|
# 4. Pokreni server
|
||||||
|
go run cmd/server/main.go
|
||||||
|
# Server dostupan na http://localhost:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dokumentacija
|
||||||
|
|
||||||
|
- [Specifikacija](docs/SPEC.md)
|
||||||
|
- [Arhitektura](docs/ARCHITECTURE.md)
|
||||||
|
- [Setup i deployment](docs/SETUP.md)
|
||||||
|
- [API dokumentacija](API.md)
|
||||||
|
- [Test checklista](TESTING.md)
|
||||||
|
|
||||||
|
## Port
|
||||||
|
|
||||||
|
Server radi na portu **8090** (konfigurisano u `.env`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*DAL d.o.o. | Mart 2026*
|
||||||
105
TESTING.md
Normal file
105
TESTING.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# DAL License Server — Test Checklista
|
||||||
|
|
||||||
|
## Health
|
||||||
|
- [ ] GET /api/v1/health vraca {"status":"ok"}
|
||||||
|
|
||||||
|
## Proizvodi
|
||||||
|
- [ ] GET /api/v1/admin/products vraca 3 proizvoda (ESIR, ARV, LIGHT_TICKET)
|
||||||
|
|
||||||
|
## Kreiranje licence
|
||||||
|
- [ ] Kreiranje ESIR licence sa svim poljima
|
||||||
|
- [ ] Kreiranje ARV licence sa svim poljima
|
||||||
|
- [ ] Kreiranje LIGHT_TICKET licence sa svim poljima
|
||||||
|
- [ ] Kreiranje bez customer_name → error
|
||||||
|
- [ ] Kreiranje sa nepostojecim proizvodom → error
|
||||||
|
- [ ] Generisani kljuc ima pravilan prefix (ESIR-, ARV-, LT-)
|
||||||
|
- [ ] Generisani kljuc ima format XXXX-XXXX-XXXX-XXXX
|
||||||
|
|
||||||
|
## Lista licenci
|
||||||
|
- [ ] GET /api/v1/admin/licenses vraca sve licence
|
||||||
|
- [ ] Filter po proizvodu radi ispravno
|
||||||
|
|
||||||
|
## Detalji licence
|
||||||
|
- [ ] GET /api/v1/admin/licenses/{id} vraca sve podatke
|
||||||
|
- [ ] Nepostojeci ID → 404
|
||||||
|
|
||||||
|
## Aktivacija
|
||||||
|
- [ ] Aktivacija sa validnim kljucem i fingerprint-om → 200 + potpis
|
||||||
|
- [ ] Response sadrzi RSA-SHA256 potpis
|
||||||
|
- [ ] Response sadrzi sve licencne podatke (limits, features, customer)
|
||||||
|
- [ ] Ponovna aktivacija istog fingerprint-a → 200 (isti racunar)
|
||||||
|
- [ ] Aktivacija sa drugog racunara (drugi fingerprint) → ALREADY_ACTIVATED error
|
||||||
|
- [ ] Aktivacija sa nepostojecim kljucem → INVALID_KEY error
|
||||||
|
- [ ] Aktivacija opozvane licence → KEY_REVOKED error
|
||||||
|
|
||||||
|
## Deaktivacija
|
||||||
|
- [ ] Deaktivacija aktivne licence → 200
|
||||||
|
- [ ] Deaktivacija vec deaktivirane → error
|
||||||
|
- [ ] Deaktivacija sa pogresnim fingerprint-om → error
|
||||||
|
|
||||||
|
## Ponovna aktivacija
|
||||||
|
- [ ] Posle deaktivacije, aktivacija sa novim fingerprint-om → 200
|
||||||
|
|
||||||
|
## Online validacija
|
||||||
|
- [ ] Validacija aktivne licence → valid: true
|
||||||
|
- [ ] Validacija opozvane licence → valid: false / revoked: true
|
||||||
|
- [ ] Validacija sa pogresnim fingerprint-om → error
|
||||||
|
- [ ] Validacija nepostojeceg kljuca → error
|
||||||
|
|
||||||
|
## Opozivanje (Revoke)
|
||||||
|
- [ ] Revoke licence → uspesno
|
||||||
|
- [ ] Revoke vec opozvane → error
|
||||||
|
- [ ] Posle revoke-a, aktivacija odbija → KEY_REVOKED
|
||||||
|
|
||||||
|
## Force Release
|
||||||
|
- [ ] Release aktivacije → uspesno
|
||||||
|
- [ ] Posle release-a, aktivacija sa novog racunara → 200
|
||||||
|
|
||||||
|
## API Key autentifikacija
|
||||||
|
- [ ] Admin endpoint bez X-API-Key → 401
|
||||||
|
- [ ] Admin endpoint sa pogresnim kljucem → 401
|
||||||
|
- [ ] Admin endpoint sa validnim kljucem → 200
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
- [ ] Vise od 10 activate zahteva u minuti → 429
|
||||||
|
- [ ] Vise od 60 validate zahteva u minuti → 429
|
||||||
|
|
||||||
|
## Statistike
|
||||||
|
- [ ] GET /api/v1/admin/stats vraca podatke po proizvodima
|
||||||
|
- [ ] Brojevi se azuriraju nakon kreiranja/aktiviranja licence
|
||||||
|
|
||||||
|
## Audit log
|
||||||
|
- [ ] GET /api/v1/admin/audit vraca log
|
||||||
|
- [ ] Aktivacija se loguje
|
||||||
|
- [ ] Deaktivacija se loguje
|
||||||
|
- [ ] Revoke se loguje
|
||||||
|
- [ ] Kreiranje licence se loguje
|
||||||
|
- [ ] Force release se loguje
|
||||||
|
|
||||||
|
## Dashboard — Login
|
||||||
|
- [ ] GET /login prikazuje login formu
|
||||||
|
- [ ] Login sa ispravnom lozinkom → redirect na /dashboard
|
||||||
|
- [ ] Login sa pogresnom lozinkom → error
|
||||||
|
- [ ] Pristup /dashboard bez logina → redirect na /login
|
||||||
|
|
||||||
|
## Dashboard — Stranice
|
||||||
|
- [ ] /dashboard prikazuje statistike po proizvodu
|
||||||
|
- [ ] /licenses prikazuje tabelu licenci
|
||||||
|
- [ ] /licenses?product=ESIR filtrira po proizvodu
|
||||||
|
- [ ] /licenses/new prikazuje formu za novu licencu
|
||||||
|
- [ ] POST /licenses kreira licencu i prikazuje detalje
|
||||||
|
- [ ] /licenses/{id} prikazuje detalje licence sa aktivacijama
|
||||||
|
- [ ] /audit prikazuje audit log
|
||||||
|
|
||||||
|
## Dashboard — Akcije
|
||||||
|
- [ ] Revoke iz dashboard-a → uspesno, prikazuje poruku
|
||||||
|
- [ ] Force release iz dashboard-a → uspesno
|
||||||
|
|
||||||
|
## Bezbednost
|
||||||
|
- [ ] .env nije u git-u
|
||||||
|
- [ ] crypto/private.pem nije u git-u
|
||||||
|
- [ ] Licencni kljuc je maskiran u logovima
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Poslednje azuriranje: mart 2026*
|
||||||
89
cmd/server/main.go
Normal file
89
cmd/server/main.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/config"
|
||||||
|
"dal-license-server/internal/handler"
|
||||||
|
"dal-license-server/internal/repository"
|
||||||
|
"dal-license-server/internal/router"
|
||||||
|
"dal-license-server/internal/service"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// Connect to MySQL
|
||||||
|
db, err := sql.Open("mysql", cfg.DSN())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("DB open: ", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
log.Fatal("DB ping: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
runMigrations(db)
|
||||||
|
|
||||||
|
// Crypto service
|
||||||
|
cryptoSvc, err := service.NewCryptoService(cfg.RSAPrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Crypto: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
licenseRepo := repository.NewLicenseRepo(db)
|
||||||
|
activationRepo := repository.NewActivationRepo(db)
|
||||||
|
auditRepo := repository.NewAuditRepo(db)
|
||||||
|
|
||||||
|
// Services
|
||||||
|
licenseSvc := service.NewLicenseService(licenseRepo, auditRepo)
|
||||||
|
activationSvc := service.NewActivationService(activationRepo, licenseRepo, auditRepo, cryptoSvc, licenseSvc)
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
clientHandler := handler.NewClientHandler(activationSvc)
|
||||||
|
adminHandler := handler.NewAdminHandler(licenseSvc, activationSvc, auditRepo)
|
||||||
|
dashboardHandler := handler.NewDashboardHandler(licenseSvc, activationSvc, auditRepo, "templates", cfg.AdminPassword)
|
||||||
|
|
||||||
|
// Rate limits
|
||||||
|
rlActivate, _ := strconv.Atoi(cfg.RateLimitActivate)
|
||||||
|
if rlActivate == 0 {
|
||||||
|
rlActivate = 10
|
||||||
|
}
|
||||||
|
rlValidate, _ := strconv.Atoi(cfg.RateLimitValidate)
|
||||||
|
if rlValidate == 0 {
|
||||||
|
rlValidate = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router
|
||||||
|
mux := router.Setup(clientHandler, adminHandler, dashboardHandler, cfg.AdminAPIKey, rlActivate, rlValidate)
|
||||||
|
|
||||||
|
addr := ":" + cfg.Port
|
||||||
|
fmt.Printf("DAL License Server pokrenut na http://localhost%s\n", addr)
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrations(db *sql.DB) {
|
||||||
|
files := []string{"migrations/001_create_tables.sql", "migrations/002_seed_products.sql"}
|
||||||
|
for _, f := range files {
|
||||||
|
data, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Migration %s: %v", f, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err = db.Exec(string(data))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Migration %s: %v (may already exist)", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crypto/public.pem
Normal file
9
crypto/public.pem
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA123J/L0kCZ7q03EAnRAg
|
||||||
|
x0wg97ywKlu5AmYliPYc3ucuN50JNC3fsKgH2mBYY5oDpEoTYGw+DNj8c7BrrHGV
|
||||||
|
xjbKpJmoC/9eguAOQQSF9lFKem3/84S3hGSXQMY1+yEwRduNB8rplFDajRwQMwyY
|
||||||
|
THS4J6n7/nYJdx8ppQpNSNqYu52w2obkU7dJMaQlPwMXzrXRfUdtNSgbdFlaD6PQ
|
||||||
|
Hb7KOU+spt32+ys6hkr3h4EdQjUidOZYDySVRPiElcOsl25R1L6ApOw+RIMfs7wW
|
||||||
|
72c5RFpzp0R5Cj9BuDe+bk85H4WrHFOcBnCEzvn/ww3wnrj2ERVD9TFh4KrY5ZC6
|
||||||
|
qQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
158
docs/ARCHITECTURE.md
Normal file
158
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
# DAL License Server — Arhitektura
|
||||||
|
|
||||||
|
## Dijagram sistema
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ DAL LICENSE SERVER │
|
||||||
|
│ port: 8090 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Admin API │ │ Klijent │ │ Admin Dashboard │ │
|
||||||
|
│ │ (CRUD) │ │ API │ │ (htmx) │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────┼───────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────┴────────┐ │
|
||||||
|
│ │ MySQL baza │ │
|
||||||
|
│ │ dal_license_db │ │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ RSA-2048 Private Key (samo ovde, nikad ne izlazi) │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
│ │ │
|
||||||
|
┌────┴────┐ ┌────┴────┐ ┌────┴────────┐
|
||||||
|
│ ESIR │ │ ARV │ │ Light-Ticket │
|
||||||
|
│ klijent │ │ klijent │ │ klijent │
|
||||||
|
└─────────┘ └─────────┘ └──────────────┘
|
||||||
|
Svaki ima RSA public key ugrađen u binary
|
||||||
|
```
|
||||||
|
|
||||||
|
## Struktura projekta
|
||||||
|
|
||||||
|
```
|
||||||
|
dal-license-server/
|
||||||
|
├── cmd/server/main.go — Entry point, MySQL konekcija, migracije
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/config.go — .env loading, DSN builder
|
||||||
|
│ ├── model/
|
||||||
|
│ │ ├── license.go — Product, License, LicenseWithActivation
|
||||||
|
│ │ ├── activation.go — Activation model
|
||||||
|
│ │ ├── audit.go — AuditEntry model
|
||||||
|
│ │ └── request.go — Request/Response strukture
|
||||||
|
│ ├── repository/
|
||||||
|
│ │ ├── license_repo.go — License CRUD, product queries, stats
|
||||||
|
│ │ ├── activation_repo.go — Activation CRUD, deactivate, force release
|
||||||
|
│ │ └── audit_repo.go — Audit log insert/list
|
||||||
|
│ ├── service/
|
||||||
|
│ │ ├── license_service.go — License CRUD biznis logika
|
||||||
|
│ │ ├── activation_service.go — Activate, Deactivate, Validate, ForceRelease
|
||||||
|
│ │ ├── crypto_service.go — RSA-2048 potpisivanje (SHA-256, PKCS1v15)
|
||||||
|
│ │ └── keygen.go — Generisanje ključeva
|
||||||
|
│ ├── handler/
|
||||||
|
│ │ ├── client_handler.go — POST /api/v1/activate, deactivate, validate
|
||||||
|
│ │ ├── admin_handler.go — Admin CRUD API
|
||||||
|
│ │ ├── dashboard_handler.go — Dashboard stranice, in-memory sesije
|
||||||
|
│ │ └── helpers.go — writeJSON, writeError, clientIP
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.go — API key auth (X-API-Key header)
|
||||||
|
│ │ └── ratelimit.go — In-memory sliding window rate limiter
|
||||||
|
│ └── router/router.go — Sve rute
|
||||||
|
├── templates/
|
||||||
|
│ ├── layout/base.html — Glavni layout sa navbar-om
|
||||||
|
│ └── pages/ — login, dashboard, licenses, audit...
|
||||||
|
├── static/
|
||||||
|
│ ├── css/style.css — CSS
|
||||||
|
│ └── js/htmx.min.js — htmx biblioteka
|
||||||
|
├── crypto/
|
||||||
|
│ ├── private.pem — RSA-2048 privatni ključ (ne u git-u!)
|
||||||
|
│ └── public.pem — RSA javni ključ
|
||||||
|
├── migrations/
|
||||||
|
│ ├── 001_create_tables.sql — products, licenses, activations, audit_log
|
||||||
|
│ └── 002_seed_products.sql — ESIR, ARV, LIGHT_TICKET seed
|
||||||
|
└── .env — Konfiguracija (ne u git-u!)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slojevi
|
||||||
|
|
||||||
|
### 1. Handler (HTTP)
|
||||||
|
|
||||||
|
Prima HTTP zahteve, parsira input, poziva servis, vraca JSON ili HTML response. Tri grupe:
|
||||||
|
|
||||||
|
- **ClientHandler** — API za klijentske aplikacije (activate, deactivate, validate)
|
||||||
|
- **AdminHandler** — REST API za admin operacije (CRUD licence, stats, audit)
|
||||||
|
- **DashboardHandler** — htmx stranice za admin dashboard
|
||||||
|
|
||||||
|
### 2. Service (biznis logika)
|
||||||
|
|
||||||
|
- **LicenseService** — Kreiranje licenci, keygen, validacija tipova, expiry kalkulacija
|
||||||
|
- **ActivationService** — Aktivacija sa RSA potpisom, deaktivacija, online validacija, force release
|
||||||
|
- **CryptoService** — RSA-SHA256 potpisivanje licencnog JSON-a
|
||||||
|
|
||||||
|
### 3. Repository (baza)
|
||||||
|
|
||||||
|
- **LicenseRepo** — License i Product CRUD, filteri, statistike
|
||||||
|
- **ActivationRepo** — Activation CRUD, deaktivacija, force release
|
||||||
|
- **AuditRepo** — Insert i listanje audit log-a
|
||||||
|
|
||||||
|
### 4. Middleware
|
||||||
|
|
||||||
|
- **APIKeyAuth** — Provera `X-API-Key` header-a za admin API
|
||||||
|
- **RateLimit** — In-memory sliding window, per-IP, konfigurisani limiti
|
||||||
|
|
||||||
|
## Baza podataka (MySQL)
|
||||||
|
|
||||||
|
### Tabele
|
||||||
|
|
||||||
|
```
|
||||||
|
products — Definicija proizvoda (ESIR, ARV, LIGHT_TICKET)
|
||||||
|
licenses — Izdate licence sa limitima i feature-ima
|
||||||
|
activations — Aktivacije licenci (machine fingerprint, hostname, OS)
|
||||||
|
audit_log — Log svih akcija
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relacije
|
||||||
|
|
||||||
|
```
|
||||||
|
products 1 ──── N licenses
|
||||||
|
licenses 1 ──── N activations
|
||||||
|
licenses 1 ──── N audit_log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kljucni indeksi
|
||||||
|
|
||||||
|
- `licenses.license_key` — UNIQUE
|
||||||
|
- `activations (license_id, is_active)` — Brza provera aktivnih aktivacija
|
||||||
|
- `audit_log (created_at)` — Sortiranje po vremenu
|
||||||
|
|
||||||
|
## Autentifikacija
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
- `X-API-Key` header sa API kljucem iz `.env`
|
||||||
|
- Minimum 32 karaktera
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
- Username/password login (password iz `.env`)
|
||||||
|
- In-memory session (cookie-based)
|
||||||
|
|
||||||
|
### Klijentski API
|
||||||
|
- Bez autentifikacije (javni endpointi)
|
||||||
|
- Zastiteni rate limiting-om
|
||||||
|
|
||||||
|
## Kripto
|
||||||
|
|
||||||
|
- **RSA-2048** — Potpisivanje licencnih podataka pri aktivaciji
|
||||||
|
- **SHA-256** — Hash za machine fingerprint
|
||||||
|
- **PKCS1v15** — Shema potpisa
|
||||||
|
- Isti par kljuceva za sve proizvode
|
||||||
|
- Private key samo na serveru, public key ugrađen u klijentske binary-je
|
||||||
|
|
||||||
|
## Migracije
|
||||||
|
|
||||||
|
Pokrecu se automatski pri startu servera (`runMigrations` u `main.go`). DSN koristi `multiStatements=true` za izvrsavanje SQL fajlova sa vise naredbi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Mart 2026*
|
||||||
162
docs/SETUP.md
Normal file
162
docs/SETUP.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# DAL License Server — Setup i Deployment
|
||||||
|
|
||||||
|
## Preduslovi
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- MySQL 8.0
|
||||||
|
- OpenSSL (za generisanje RSA kljuceva)
|
||||||
|
|
||||||
|
## Instalacija
|
||||||
|
|
||||||
|
### 1. Kloniraj projekat
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/projects
|
||||||
|
git clone http://localhost:3000/dal/dal-license-server.git
|
||||||
|
cd dal-license-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Instaliraj Go zavisnosti
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Kreiraj MySQL bazu
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE dal_license_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'license'@'localhost' IDENTIFIED BY 'TVOJA_LOZINKA';
|
||||||
|
GRANT ALL PRIVILEGES ON dal_license_db.* TO 'license'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Generiši RSA kljuceve
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p crypto
|
||||||
|
openssl genrsa -out crypto/private.pem 2048
|
||||||
|
openssl rsa -in crypto/private.pem -pubout -out crypto/public.pem
|
||||||
|
chmod 600 crypto/private.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vazno:** `private.pem` nikad ne sme uci u git. `public.pem` se deli sa klijentskim aplikacijama.
|
||||||
|
|
||||||
|
### 5. Konfiguracija
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Uredi `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_PORT=8090
|
||||||
|
APP_ENV=production
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=dal_license_db
|
||||||
|
DB_USER=license
|
||||||
|
DB_PASS=tvoja_lozinka
|
||||||
|
|
||||||
|
ADMIN_API_KEY=generisi-min-32-char-kljuc
|
||||||
|
ADMIN_PASSWORD=jaka_lozinka
|
||||||
|
SESSION_SECRET=random-32-char-string
|
||||||
|
|
||||||
|
RSA_PRIVATE_KEY_PATH=./crypto/private.pem
|
||||||
|
|
||||||
|
RATE_LIMIT_ACTIVATE=10
|
||||||
|
RATE_LIMIT_VALIDATE=60
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Pokretanje
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Server se pokrece na `http://localhost:8090`. Migracije se izvrsavaju automatski pri prvom pokretanju.
|
||||||
|
|
||||||
|
## Produkcijski deployment
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o dal-license-server cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Systemd servis
|
||||||
|
|
||||||
|
Kreiraj `/etc/systemd/system/dal-license-server.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=DAL License Server
|
||||||
|
After=network.target mysql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/projects/dal-license-server
|
||||||
|
ExecStart=/root/projects/dal-license-server/dal-license-server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
EnvironmentFile=/root/projects/dal-license-server/.env
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable dal-license-server
|
||||||
|
systemctl start dal-license-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTPS (reverse proxy)
|
||||||
|
|
||||||
|
Preporuceno: Caddy ili Nginx kao reverse proxy sa TLS:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Caddy primer
|
||||||
|
license.dal.rs {
|
||||||
|
reverse_proxy localhost:8090
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
### MySQL backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysqldump -u license -p dal_license_db > backup_$(date +%Y%m%d).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### RSA kljucevi
|
||||||
|
|
||||||
|
Obavezno backup-ovati `crypto/private.pem` na sigurno mesto. Gubitak private key-a znaci da nijedna postojeca licenca ne moze biti verifikovana.
|
||||||
|
|
||||||
|
## Testiranje
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Svi testovi
|
||||||
|
go test ./... -v -count=1
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8090/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pristup
|
||||||
|
|
||||||
|
| Interfejs | URL | Auth |
|
||||||
|
|-----------|-----|------|
|
||||||
|
| Dashboard | http://localhost:8090/dashboard | Username + password iz .env |
|
||||||
|
| Admin API | http://localhost:8090/api/v1/admin/* | X-API-Key header |
|
||||||
|
| Klijentski API | http://localhost:8090/api/v1/* | Bez auth-a |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Mart 2026*
|
||||||
99
docs/SPEC.md
Normal file
99
docs/SPEC.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# DAL License Server — Specifikacija
|
||||||
|
|
||||||
|
## Cilj
|
||||||
|
|
||||||
|
Univerzalni licencni server koji zamenjuje stari `esir-license-server` i podržava sve DAL proizvode: ESIR, ARV, Light-Ticket, i buduće aplikacije.
|
||||||
|
|
||||||
|
## Principi
|
||||||
|
|
||||||
|
- **Jedan server, svi proizvodi** — License Server opslužuje N proizvoda sa jednog mesta
|
||||||
|
- **RSA potpisivanje** — Server ima private key, klijenti imaju public key ugrađen u binary
|
||||||
|
- **Offline rad** — Klijentske aplikacije rade offline sa lokalnim `license.enc` fajlom
|
||||||
|
- **Audit sve** — Svaka akcija (aktivacija, deaktivacija, revoke) se loguje
|
||||||
|
|
||||||
|
## Proizvodi
|
||||||
|
|
||||||
|
| Proizvod | Prefix | Default limiti |
|
||||||
|
|----------|--------|----------------|
|
||||||
|
| ESIR Fiskalizacija | `ESIR-` | max_installations: 1 |
|
||||||
|
| ARV Evidencija RV | `ARV-` | max_employees: 50, max_readers: 4 |
|
||||||
|
| Light-Ticket | `LT-` | max_operators: 3 |
|
||||||
|
|
||||||
|
## Tipovi licenci
|
||||||
|
|
||||||
|
| Tip | Trajanje | Opis |
|
||||||
|
|-----|----------|------|
|
||||||
|
| TRIAL | 30 dana | Besplatno testiranje, bez obnove |
|
||||||
|
| MONTHLY | 30 dana | Mesecna pretplata |
|
||||||
|
| ANNUAL | 365 dana | Godisnja licenca |
|
||||||
|
| PERPETUAL | Bez isteka | Kupljena zauvek (expires_at = NULL) |
|
||||||
|
|
||||||
|
### Grace period
|
||||||
|
|
||||||
|
- Default: 30 dana posle isteka licence
|
||||||
|
- Konfigurisano per licenca (`grace_days` kolona)
|
||||||
|
- Tokom grace perioda: pun rad + upozorenje u klijentskoj aplikaciji
|
||||||
|
- Posle grace perioda: read-only rezim (GET dozvoljen, POST/PUT/DELETE blokiran)
|
||||||
|
|
||||||
|
## Licencni kljuc — format
|
||||||
|
|
||||||
|
```
|
||||||
|
{PREFIX}-XXXX-XXXX-XXXX-XXXX
|
||||||
|
|
||||||
|
Primeri:
|
||||||
|
ESIR-K7M2-9P4N-R3W8-J6T1
|
||||||
|
ARV-A3B5-C8D2-E7F4-G9H6
|
||||||
|
LT-M4N8-P2Q6-R5S3-T7U9
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prefix se cita iz `products.key_prefix`
|
||||||
|
- 4 grupe po 4 alfanumericka karaktera
|
||||||
|
- Karakteri: A-H, J-N, P-Y, 2-9 (bez O/0/I/1 konfuzije)
|
||||||
|
- Generisanje: `crypto/rand`
|
||||||
|
|
||||||
|
## Tok — od kupovine do rada
|
||||||
|
|
||||||
|
### 1. Admin kreira licencu
|
||||||
|
|
||||||
|
Admin kroz dashboard bira proizvod, unosi podatke firme, tip licence i limite. Server generise jedinstven kljuc.
|
||||||
|
|
||||||
|
### 2. Klijent aktivira
|
||||||
|
|
||||||
|
Klijentska aplikacija salje serveru: kljuc + machine_fingerprint + app_version + OS. Server proverava validnost, potpisuje licencne podatke RSA private key-em, vraca JSON + RSA potpis. Klijent kreira `license.enc` lokalno.
|
||||||
|
|
||||||
|
### 3. Svakodnevni rad (offline)
|
||||||
|
|
||||||
|
Aplikacija cita `license.enc`, dekriptuje, proverava RSA potpis (public key), proverava fingerprint i rok. Internet nije potreban.
|
||||||
|
|
||||||
|
### 4. Opciona online provera
|
||||||
|
|
||||||
|
Jednom dnevno (ako ima internet), aplikacija proverava da licenca nije revocirana na serveru.
|
||||||
|
|
||||||
|
## Light-Ticket paketi
|
||||||
|
|
||||||
|
| Paket | max_operators | Tip licence |
|
||||||
|
|-------|--------------|-------------|
|
||||||
|
| Starter | 3 | MONTHLY ili PERPETUAL |
|
||||||
|
| Pro | 10 | MONTHLY ili PERPETUAL |
|
||||||
|
| Enterprise | 0 (neograniceno) | MONTHLY ili PERPETUAL |
|
||||||
|
|
||||||
|
- Admin rucno upisuje `max_operators` prilikom kreiranja licence
|
||||||
|
- Sve features su iste za sve pakete — razlikuje se samo `max_operators`
|
||||||
|
- Tipovi licence za LT: MONTHLY i PERPETUAL (TRIAL i ANNUAL nisu predvidjeni za V1)
|
||||||
|
|
||||||
|
## Korisnici sistema
|
||||||
|
|
||||||
|
1. **Admin** — Upravlja licencama kroz dashboard ili API
|
||||||
|
2. **Klijentske aplikacije** — ESIR, ARV, Light-Ticket komuniciraju sa serverom za aktivaciju/validaciju
|
||||||
|
|
||||||
|
## Bezbednost
|
||||||
|
|
||||||
|
- `private.pem` nikad u git-u, permisije 600
|
||||||
|
- Admin API key minimum 32 karaktera
|
||||||
|
- Rate limiting na activate/validate (zastita od brute force)
|
||||||
|
- Licencni kljuc u logovima maskiran: `LT-K7M2-****-****-J6T1`
|
||||||
|
- HTTPS u produkciji (TLS termination na reverse proxy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Kreirano: mart 2026*
|
||||||
7
go.mod
Normal file
7
go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module dal-license-server
|
||||||
|
|
||||||
|
go 1.23.6
|
||||||
|
|
||||||
|
require github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
|
||||||
|
require filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
80
internal/config/config.go
Normal file
80
internal/config/config.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
Env string
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBName string
|
||||||
|
DBUser string
|
||||||
|
DBPass string
|
||||||
|
AdminAPIKey string
|
||||||
|
AdminPassword string
|
||||||
|
SessionSecret string
|
||||||
|
RSAPrivateKey string
|
||||||
|
RateLimitActivate string
|
||||||
|
RateLimitValidate string
|
||||||
|
LogLevel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
loadEnvFile(".env")
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
Port: getEnv("APP_PORT", "8090"),
|
||||||
|
Env: getEnv("APP_ENV", "development"),
|
||||||
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
|
DBPort: getEnv("DB_PORT", "3306"),
|
||||||
|
DBName: getEnv("DB_NAME", "dal_license_db"),
|
||||||
|
DBUser: getEnv("DB_USER", "license"),
|
||||||
|
DBPass: getEnv("DB_PASS", ""),
|
||||||
|
AdminAPIKey: getEnv("ADMIN_API_KEY", ""),
|
||||||
|
AdminPassword: getEnv("ADMIN_PASSWORD", "admin123"),
|
||||||
|
SessionSecret: getEnv("SESSION_SECRET", "change-me"),
|
||||||
|
RSAPrivateKey: getEnv("RSA_PRIVATE_KEY_PATH", "./crypto/private.pem"),
|
||||||
|
RateLimitActivate: getEnv("RATE_LIMIT_ACTIVATE", "10"),
|
||||||
|
RateLimitValidate: getEnv("RATE_LIMIT_VALIDATE", "60"),
|
||||||
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) DSN() string {
|
||||||
|
return c.DBUser + ":" + c.DBPass + "@tcp(" + c.DBHost + ":" + c.DBPort + ")/" + c.DBName + "?parseTime=true&charset=utf8mb4&multiStatements=true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnvFile(path string) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
if os.Getenv(key) == "" {
|
||||||
|
os.Setenv(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/handler/admin_handler.go
Normal file
162
internal/handler/admin_handler.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"dal-license-server/internal/repository"
|
||||||
|
"dal-license-server/internal/service"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminHandler struct {
|
||||||
|
licenses *service.LicenseService
|
||||||
|
activation *service.ActivationService
|
||||||
|
audit *repository.AuditRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminHandler(licenses *service.LicenseService, activation *service.ActivationService, audit *repository.AuditRepo) *AdminHandler {
|
||||||
|
return &AdminHandler{licenses: licenses, activation: activation, audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
products, err := h.licenses.GetProducts()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Greska pri ucitavanju proizvoda")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, products)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListLicenses(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, err := h.licenses.List(product, status, search)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Greska pri ucitavanju licenci")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, licenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.CreateLicenseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "Neispravan zahtev")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := h.licenses.Create(&req, clientIP(r))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "CREATE_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, license)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) GetLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Neispravan ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
license, err := h.licenses.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "Licenca nije pronadjena")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, license)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Neispravan ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.UpdateLicenseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "Neispravan zahtev")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.licenses.Update(id, &req, clientIP(r)); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "UPDATE_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Licenca azurirana"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) RevokeLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Neispravan ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.RevokeRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
if err := h.licenses.Revoke(id, req.Reason, clientIP(r)); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "REVOKE_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Licenca opozvana"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ReleaseLicense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Neispravan ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.activation.ForceRelease(id, clientIP(r)); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "RELEASE_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Aktivacija oslobodjena"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListActivations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_ID", "Neispravan ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acts, err := h.activation.ListByLicense(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Greska")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if acts == nil {
|
||||||
|
acts = []model.Activation{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, acts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) AuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := h.audit.Recent(100)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Greska")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []model.AuditEntry{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.licenses.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Greska")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
69
internal/handler/client_handler.go
Normal file
69
internal/handler/client_handler.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"dal-license-server/internal/service"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientHandler struct {
|
||||||
|
activation *service.ActivationService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientHandler(activation *service.ActivationService) *ClientHandler {
|
||||||
|
return &ClientHandler{activation: activation}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.ActivateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "Neispravan zahtev")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.LicenseKey == "" || req.MachineFingerprint == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "license_key i machine_fingerprint su obavezni")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.activation.Activate(&req, clientIP(r))
|
||||||
|
if err != nil {
|
||||||
|
writeLicenseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientHandler) Deactivate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.DeactivateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "Neispravan zahtev")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.activation.Deactivate(&req, clientIP(r))
|
||||||
|
if err != nil {
|
||||||
|
writeLicenseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientHandler) Validate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.ValidateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "Neispravan zahtev")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.activation.Validate(&req, clientIP(r))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Greska pri validaciji")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
283
internal/handler/dashboard_handler.go
Normal file
283
internal/handler/dashboard_handler.go
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"dal-license-server/internal/repository"
|
||||||
|
"dal-license-server/internal/service"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DashboardHandler struct {
|
||||||
|
licenses *service.LicenseService
|
||||||
|
activation *service.ActivationService
|
||||||
|
audit *repository.AuditRepo
|
||||||
|
templates map[string]*template.Template
|
||||||
|
sessions map[string]time.Time
|
||||||
|
mu sync.RWMutex
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardHandler(licenses *service.LicenseService, activation *service.ActivationService, audit *repository.AuditRepo, tmplDir, password 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,
|
||||||
|
templates: make(map[string]*template.Template),
|
||||||
|
sessions: make(map[string]time.Time),
|
||||||
|
password: password,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) isLoggedIn(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie("dash_session")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
exp, ok := h.sessions[c.Value]
|
||||||
|
if !ok || time.Now().After(exp) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DashboardHandler) RequireLogin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.isLoggedIn(r) {
|
||||||
|
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) {
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if password != h.password {
|
||||||
|
h.render(w, "login.html", map[string]interface{}{"Error": "Pogresna lozinka"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
sid := hex.EncodeToString(b)
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.sessions[sid] = time.Now().Add(8 * time.Hour)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
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()
|
||||||
|
delete(h.sessions, c.Value)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
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.render(w, "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.render(w, "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.render(w, "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.render(w, "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.render(w, "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.render(w, "audit.html", map[string]interface{}{
|
||||||
|
"Entries": entries,
|
||||||
|
"ActivePage": "audit",
|
||||||
|
})
|
||||||
|
}
|
||||||
41
internal/handler/helpers.go
Normal file
41
internal/handler/helpers.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"dal-license-server/internal/service"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
writeJSON(w, status, model.ErrorResponse{
|
||||||
|
Error: model.ErrorDetail{Code: code, Message: message},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLicenseError(w http.ResponseWriter, err error) {
|
||||||
|
if le, ok := err.(*service.LicenseError); ok {
|
||||||
|
resp := model.ErrorResponse{
|
||||||
|
Error: model.ErrorDetail{Code: le.Code, Message: le.Message, Details: le.Details},
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusBadRequest, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Interna greska servera")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
return xff
|
||||||
|
}
|
||||||
|
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||||
|
return xri
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
20
internal/middleware/auth.go
Normal file
20
internal/middleware/auth.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func APIKeyAuth(apiKey string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := r.Header.Get("X-API-Key")
|
||||||
|
if key == "" || key != apiKey {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error":{"code":"UNAUTHORIZED","message":"Invalid or missing API key"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
65
internal/middleware/auth_test.go
Normal file
65
internal/middleware/auth_test.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIKeyAuth_ValidKey(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mw := APIKeyAuth("test-api-key-12345")
|
||||||
|
wrapped := mw(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/admin/licenses", nil)
|
||||||
|
req.Header.Set("X-API-Key", "test-api-key-12345")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("validan kljuc mora proci, dobijen status %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyAuth_MissingKey(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mw := APIKeyAuth("test-api-key-12345")
|
||||||
|
wrapped := mw(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/admin/licenses", nil)
|
||||||
|
// No X-API-Key header
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("bez kljuca mora biti 401, dobijen %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyAuth_WrongKey(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mw := APIKeyAuth("correct-key")
|
||||||
|
wrapped := mw(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/admin/licenses", nil)
|
||||||
|
req.Header.Set("X-API-Key", "wrong-key")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
wrapped.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("pogresan kljuc mora biti 401, dobijen %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/middleware/ratelimit.go
Normal file
64
internal/middleware/ratelimit.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
visitors map[string][]time.Time
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(limit int) *rateLimiter {
|
||||||
|
return &rateLimiter{
|
||||||
|
visitors: make(map[string][]time.Time),
|
||||||
|
limit: limit,
|
||||||
|
window: time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *rateLimiter) allow(ip string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-rl.window)
|
||||||
|
|
||||||
|
var valid []time.Time
|
||||||
|
for _, t := range rl.visitors[ip] {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
valid = append(valid, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) >= rl.limit {
|
||||||
|
rl.visitors[ip] = valid
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.visitors[ip] = append(valid, now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func RateLimit(limit int) func(http.Handler) http.Handler {
|
||||||
|
rl := newRateLimiter(limit)
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := r.RemoteAddr
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
ip = xff
|
||||||
|
}
|
||||||
|
if !rl.allow(ip) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte(`{"error":{"code":"RATE_LIMITED","message":"Previse zahteva, pokusajte ponovo za minut"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
91
internal/middleware/ratelimit_test.go
Normal file
91
internal/middleware/ratelimit_test.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRateLimiter_Allow(t *testing.T) {
|
||||||
|
rl := newRateLimiter(3)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if !rl.allow("1.2.3.4") {
|
||||||
|
t.Errorf("zahtev %d bi trebalo da prodje", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.allow("1.2.3.4") {
|
||||||
|
t.Error("4. zahtev ne sme proci (limit 3)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter_DifferentIPs(t *testing.T) {
|
||||||
|
rl := newRateLimiter(2)
|
||||||
|
|
||||||
|
rl.allow("1.1.1.1")
|
||||||
|
rl.allow("1.1.1.1")
|
||||||
|
|
||||||
|
// Razlicit IP — mora proci
|
||||||
|
if !rl.allow("2.2.2.2") {
|
||||||
|
t.Error("razlicit IP mora proci")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimit_Middleware(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
limited := RateLimit(2)(handler)
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:1234"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
limited.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("zahtev %d: ocekivan 200, dobijen %d", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3rd request should be rate limited
|
||||||
|
req := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:1234"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
limited.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("3. zahtev: ocekivan 429, dobijen %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimit_XForwardedFor(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
limited := RateLimit(1)(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:1234"
|
||||||
|
req.Header.Set("X-Forwarded-For", "5.5.5.5")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
limited.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("prvi zahtev mora proci")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second from same X-Forwarded-For
|
||||||
|
req2 := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
req2.RemoteAddr = "10.0.0.2:5678" // razlicit RemoteAddr
|
||||||
|
req2.Header.Set("X-Forwarded-For", "5.5.5.5") // isti X-FF
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
limited.ServeHTTP(w2, req2)
|
||||||
|
|
||||||
|
if w2.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("drugi zahtev sa istim XFF mora biti blokiran, dobijen %d", w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/model/activation.go
Normal file
20
internal/model/activation.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Activation struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
LicenseID int64 `json:"license_id"`
|
||||||
|
MachineFingerprint string `json:"machine_fingerprint"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
OSInfo string `json:"os_info"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
ActivatedAt time.Time `json:"activated_at"`
|
||||||
|
DeactivatedAt sql.NullTime `json:"deactivated_at"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
19
internal/model/audit.go
Normal file
19
internal/model/audit.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditEntry struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
LicenseID sql.NullInt64 `json:"license_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Details json.RawMessage `json:"details"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Joined
|
||||||
|
LicenseKey string `json:"license_key,omitempty"`
|
||||||
|
}
|
||||||
129
internal/model/license.go
Normal file
129
internal/model/license.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Product struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
DefaultLimits json.RawMessage `json:"default_limits"`
|
||||||
|
AvailableFeatures json.RawMessage `json:"available_features"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type License struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProductID int64 `json:"product_id"`
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
LicenseType string `json:"license_type"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
CustomerPIB string `json:"customer_pib"`
|
||||||
|
CustomerEmail string `json:"customer_email"`
|
||||||
|
Limits json.RawMessage `json:"limits"`
|
||||||
|
Features json.RawMessage `json:"features"`
|
||||||
|
IssuedAt time.Time `json:"issued_at"`
|
||||||
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
||||||
|
GraceDays int `json:"grace_days"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||||
|
RevokedReason string `json:"revoked_reason"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Joined fields
|
||||||
|
ProductCode string `json:"product_code,omitempty"`
|
||||||
|
ProductName string `json:"product_name,omitempty"`
|
||||||
|
ProductPrefix string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) IsExpired() bool {
|
||||||
|
if !l.ExpiresAt.Valid {
|
||||||
|
return false // PERPETUAL
|
||||||
|
}
|
||||||
|
return time.Now().After(l.ExpiresAt.Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) IsInGrace() bool {
|
||||||
|
if !l.ExpiresAt.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !l.IsExpired() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
graceEnd := l.ExpiresAt.Time.AddDate(0, 0, l.GraceDays)
|
||||||
|
return time.Now().Before(graceEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) IsGraceExpired() bool {
|
||||||
|
if !l.ExpiresAt.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
graceEnd := l.ExpiresAt.Time.AddDate(0, 0, l.GraceDays)
|
||||||
|
return time.Now().After(graceEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) MaskedKey() string {
|
||||||
|
if len(l.LicenseKey) < 10 {
|
||||||
|
return l.LicenseKey
|
||||||
|
}
|
||||||
|
return l.LicenseKey[:len(l.ProductPrefix)+4] + "-****-****-" + l.LicenseKey[len(l.LicenseKey)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) StatusText() string {
|
||||||
|
if l.Revoked {
|
||||||
|
return "Opozvana"
|
||||||
|
}
|
||||||
|
if !l.Active {
|
||||||
|
return "Neaktivna"
|
||||||
|
}
|
||||||
|
if l.IsGraceExpired() {
|
||||||
|
return "Istekla (grace)"
|
||||||
|
}
|
||||||
|
if l.IsInGrace() {
|
||||||
|
return "Grace period"
|
||||||
|
}
|
||||||
|
if l.IsExpired() {
|
||||||
|
return "Istekla"
|
||||||
|
}
|
||||||
|
if l.LicenseType == "TRIAL" {
|
||||||
|
return "Trial"
|
||||||
|
}
|
||||||
|
return "Aktivna"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) StatusClass() string {
|
||||||
|
switch l.StatusText() {
|
||||||
|
case "Aktivna":
|
||||||
|
return "status-active"
|
||||||
|
case "Trial":
|
||||||
|
return "status-trial"
|
||||||
|
case "Grace period":
|
||||||
|
return "status-grace"
|
||||||
|
case "Istekla", "Istekla (grace)":
|
||||||
|
return "status-expired"
|
||||||
|
case "Opozvana":
|
||||||
|
return "status-revoked"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) ExpiresAtFormatted() string {
|
||||||
|
if !l.ExpiresAt.Valid {
|
||||||
|
return "Neograniceno"
|
||||||
|
}
|
||||||
|
return l.ExpiresAt.Time.Format("02.01.2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseWithActivation struct {
|
||||||
|
License
|
||||||
|
ActiveActivations int `json:"active_activations"`
|
||||||
|
}
|
||||||
221
internal/model/license_test.go
Normal file
221
internal/model/license_test.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsExpired_Perpetual(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{Valid: false},
|
||||||
|
}
|
||||||
|
if l.IsExpired() {
|
||||||
|
t.Error("PERPETUAL licenca ne sme biti istekla")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExpired_Active(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{
|
||||||
|
Time: time.Now().Add(24 * time.Hour),
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if l.IsExpired() {
|
||||||
|
t.Error("licenca koja istice sutra ne sme biti istekla")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExpired_Expired(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{
|
||||||
|
Time: time.Now().Add(-24 * time.Hour),
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !l.IsExpired() {
|
||||||
|
t.Error("licenca koja je istekla juce mora biti istekla")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInGrace_NotExpired(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{
|
||||||
|
Time: time.Now().Add(24 * time.Hour),
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
GraceDays: 30,
|
||||||
|
}
|
||||||
|
if l.IsInGrace() {
|
||||||
|
t.Error("licenca koja nije istekla ne sme biti u grace periodu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInGrace_InGrace(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{
|
||||||
|
Time: time.Now().Add(-5 * 24 * time.Hour), // istekla pre 5 dana
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
GraceDays: 30,
|
||||||
|
}
|
||||||
|
if !l.IsInGrace() {
|
||||||
|
t.Error("licenca istekla pre 5 dana sa 30 dana grace-a mora biti u grace-u")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInGrace_GraceExpired(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{
|
||||||
|
Time: time.Now().Add(-35 * 24 * time.Hour), // istekla pre 35 dana
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
GraceDays: 30,
|
||||||
|
}
|
||||||
|
if l.IsInGrace() {
|
||||||
|
t.Error("licenca kojoj je grace prosao ne sme biti u grace-u")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInGrace_Perpetual(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{Valid: false},
|
||||||
|
GraceDays: 30,
|
||||||
|
}
|
||||||
|
if l.IsInGrace() {
|
||||||
|
t.Error("PERPETUAL licenca ne sme biti u grace periodu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsGraceExpired(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{
|
||||||
|
Time: time.Now().Add(-40 * 24 * time.Hour),
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
GraceDays: 30,
|
||||||
|
}
|
||||||
|
if !l.IsGraceExpired() {
|
||||||
|
t.Error("grace period je prosao, mora biti expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsGraceExpired_Perpetual(t *testing.T) {
|
||||||
|
l := &License{
|
||||||
|
ExpiresAt: sql.NullTime{Valid: false},
|
||||||
|
GraceDays: 30,
|
||||||
|
}
|
||||||
|
if l.IsGraceExpired() {
|
||||||
|
t.Error("PERPETUAL licenca ne moze imati istekao grace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaskedKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
prefix string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"LT-K7M2-9P4N-R3W8-J6T1", "LT-", "LT-K7M2-****-****-J6T1"},
|
||||||
|
{"ESIR-A3B5-C8D2-E7F4-G9H6", "ESIR-", "ESIR-A3B5-****-****-G9H6"},
|
||||||
|
{"ARV-X2Y3-Z4A5-B6C7-D8E9", "ARV-", "ARV-X2Y3-****-****-D8E9"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
l := &License{LicenseKey: tt.key, ProductPrefix: tt.prefix}
|
||||||
|
got := l.MaskedKey()
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("MaskedKey(%q) = %q, ocekivano %q", tt.key, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
license License
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "revoked",
|
||||||
|
license: License{Revoked: true, Active: true},
|
||||||
|
expected: "Opozvana",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inactive",
|
||||||
|
license: License{Active: false},
|
||||||
|
expected: "Neaktivna",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "active",
|
||||||
|
license: License{
|
||||||
|
Active: true,
|
||||||
|
ExpiresAt: sql.NullTime{Time: time.Now().Add(24 * time.Hour), Valid: true},
|
||||||
|
},
|
||||||
|
expected: "Aktivna",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trial",
|
||||||
|
license: License{
|
||||||
|
Active: true,
|
||||||
|
LicenseType: "TRIAL",
|
||||||
|
ExpiresAt: sql.NullTime{Time: time.Now().Add(24 * time.Hour), Valid: true},
|
||||||
|
},
|
||||||
|
expected: "Trial",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "in_grace",
|
||||||
|
license: License{
|
||||||
|
Active: true,
|
||||||
|
ExpiresAt: sql.NullTime{Time: time.Now().Add(-5 * 24 * time.Hour), Valid: true},
|
||||||
|
GraceDays: 30,
|
||||||
|
},
|
||||||
|
expected: "Grace period",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grace_expired",
|
||||||
|
license: License{
|
||||||
|
Active: true,
|
||||||
|
ExpiresAt: sql.NullTime{Time: time.Now().Add(-40 * 24 * time.Hour), Valid: true},
|
||||||
|
GraceDays: 30,
|
||||||
|
},
|
||||||
|
expected: "Istekla (grace)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.license.StatusText()
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("StatusText() = %q, ocekivano %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusClass(t *testing.T) {
|
||||||
|
l := &License{Active: true, ExpiresAt: sql.NullTime{Time: time.Now().Add(24 * time.Hour), Valid: true}}
|
||||||
|
if l.StatusClass() != "status-active" {
|
||||||
|
t.Errorf("StatusClass() = %q, ocekivano 'status-active'", l.StatusClass())
|
||||||
|
}
|
||||||
|
|
||||||
|
l2 := &License{Revoked: true, Active: true}
|
||||||
|
if l2.StatusClass() != "status-revoked" {
|
||||||
|
t.Errorf("StatusClass() = %q, ocekivano 'status-revoked'", l2.StatusClass())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiresAtFormatted(t *testing.T) {
|
||||||
|
l := &License{ExpiresAt: sql.NullTime{Valid: false}}
|
||||||
|
if l.ExpiresAtFormatted() != "Neograniceno" {
|
||||||
|
t.Errorf("ocekivano 'Neograniceno', dobijeno %q", l.ExpiresAtFormatted())
|
||||||
|
}
|
||||||
|
|
||||||
|
date := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
l2 := &License{ExpiresAt: sql.NullTime{Time: date, Valid: true}}
|
||||||
|
expected := "01.04.2026"
|
||||||
|
if l2.ExpiresAtFormatted() != expected {
|
||||||
|
t.Errorf("ocekivano %q, dobijeno %q", expected, l2.ExpiresAtFormatted())
|
||||||
|
}
|
||||||
|
}
|
||||||
116
internal/model/request.go
Normal file
116
internal/model/request.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Client API requests
|
||||||
|
type ActivateRequest struct {
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
MachineFingerprint string `json:"machine_fingerprint"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeactivateRequest struct {
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
MachineFingerprint string `json:"machine_fingerprint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateRequest struct {
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
MachineFingerprint string `json:"machine_fingerprint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client API responses
|
||||||
|
type ActivateResponse struct {
|
||||||
|
License LicenseData `json:"license"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseData struct {
|
||||||
|
LicenseKey string `json:"license_key"`
|
||||||
|
Product string `json:"product"`
|
||||||
|
LicenseType string `json:"license_type"`
|
||||||
|
IssuedAt string `json:"issued_at"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
ActivatedAt string `json:"activated_at"`
|
||||||
|
MachineFingerprint string `json:"machine_fingerprint"`
|
||||||
|
GraceDays int `json:"grace_days"`
|
||||||
|
Limits json.RawMessage `json:"limits"`
|
||||||
|
Features json.RawMessage `json:"features"`
|
||||||
|
Customer CustomerData `json:"customer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateResponse struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeactivateResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
CanReactivate bool `json:"can_reactivate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error ErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorDetail struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details interface{} `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
type CreateLicenseRequest struct {
|
||||||
|
ProductID int64 `json:"product_id"`
|
||||||
|
LicenseType string `json:"license_type"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
CustomerPIB string `json:"customer_pib"`
|
||||||
|
CustomerEmail string `json:"customer_email"`
|
||||||
|
Limits json.RawMessage `json:"limits"`
|
||||||
|
Features json.RawMessage `json:"features"`
|
||||||
|
GraceDays int `json:"grace_days"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateLicenseRequest struct {
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
CustomerPIB string `json:"customer_pib"`
|
||||||
|
CustomerEmail string `json:"customer_email"`
|
||||||
|
Limits json.RawMessage `json:"limits"`
|
||||||
|
Features json.RawMessage `json:"features"`
|
||||||
|
GraceDays int `json:"grace_days"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevokeRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsResponse struct {
|
||||||
|
TotalLicenses int `json:"total_licenses"`
|
||||||
|
ActiveLicenses int `json:"active_licenses"`
|
||||||
|
ExpiredLicenses int `json:"expired_licenses"`
|
||||||
|
RevokedLicenses int `json:"revoked_licenses"`
|
||||||
|
ActiveActivations int `json:"active_activations"`
|
||||||
|
ByProduct []ProductStats `json:"by_product"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductStats struct {
|
||||||
|
ProductCode string `json:"product_code"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Active int `json:"active"`
|
||||||
|
Expired int `json:"expired"`
|
||||||
|
InGrace int `json:"in_grace"`
|
||||||
|
Trial int `json:"trial"`
|
||||||
|
ActiveActivations int `json:"active_activations"`
|
||||||
|
}
|
||||||
85
internal/repository/activation_repo.go
Normal file
85
internal/repository/activation_repo.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivationRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActivationRepo(db *sql.DB) *ActivationRepo {
|
||||||
|
return &ActivationRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) Create(a *model.Activation) (int64, error) {
|
||||||
|
res, err := r.db.Exec(`INSERT INTO activations (license_id, machine_fingerprint, hostname, os_info, app_version, ip_address)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.LicenseID, a.MachineFingerprint, a.Hostname, a.OSInfo, a.AppVersion, a.IPAddress)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create activation: %w", err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) GetActiveByLicense(licenseID int64) (*model.Activation, error) {
|
||||||
|
a := &model.Activation{}
|
||||||
|
err := r.db.QueryRow(`SELECT id, license_id, machine_fingerprint, hostname, os_info, app_version, ip_address, activated_at, deactivated_at, is_active, last_seen_at
|
||||||
|
FROM activations WHERE license_id = ? AND is_active = TRUE LIMIT 1`, licenseID).
|
||||||
|
Scan(&a.ID, &a.LicenseID, &a.MachineFingerprint, &a.Hostname, &a.OSInfo, &a.AppVersion, &a.IPAddress, &a.ActivatedAt, &a.DeactivatedAt, &a.IsActive, &a.LastSeenAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) GetByLicenseAndFingerprint(licenseID int64, fingerprint string) (*model.Activation, error) {
|
||||||
|
a := &model.Activation{}
|
||||||
|
err := r.db.QueryRow(`SELECT id, license_id, machine_fingerprint, hostname, os_info, app_version, ip_address, activated_at, deactivated_at, is_active, last_seen_at
|
||||||
|
FROM activations WHERE license_id = ? AND machine_fingerprint = ? AND is_active = TRUE`, licenseID, fingerprint).
|
||||||
|
Scan(&a.ID, &a.LicenseID, &a.MachineFingerprint, &a.Hostname, &a.OSInfo, &a.AppVersion, &a.IPAddress, &a.ActivatedAt, &a.DeactivatedAt, &a.IsActive, &a.LastSeenAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) ListByLicense(licenseID int64) ([]model.Activation, error) {
|
||||||
|
rows, err := r.db.Query(`SELECT id, license_id, machine_fingerprint, hostname, os_info, app_version, ip_address, activated_at, deactivated_at, is_active, last_seen_at
|
||||||
|
FROM activations WHERE license_id = ? ORDER BY activated_at DESC`, licenseID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var acts []model.Activation
|
||||||
|
for rows.Next() {
|
||||||
|
var a model.Activation
|
||||||
|
rows.Scan(&a.ID, &a.LicenseID, &a.MachineFingerprint, &a.Hostname, &a.OSInfo, &a.AppVersion, &a.IPAddress, &a.ActivatedAt, &a.DeactivatedAt, &a.IsActive, &a.LastSeenAt)
|
||||||
|
acts = append(acts, a)
|
||||||
|
}
|
||||||
|
return acts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) Deactivate(licenseID int64, fingerprint string) error {
|
||||||
|
_, err := r.db.Exec(`UPDATE activations SET is_active = FALSE, deactivated_at = NOW() WHERE license_id = ? AND machine_fingerprint = ? AND is_active = TRUE`,
|
||||||
|
licenseID, fingerprint)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) ForceRelease(licenseID int64) error {
|
||||||
|
_, err := r.db.Exec(`UPDATE activations SET is_active = FALSE, deactivated_at = NOW() WHERE license_id = ? AND is_active = TRUE`, licenseID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) UpdateLastSeen(id int64) {
|
||||||
|
r.db.Exec("UPDATE activations SET last_seen_at = NOW() WHERE id = ?", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ActivationRepo) CountActive() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow("SELECT COUNT(*) FROM activations WHERE is_active = TRUE").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
66
internal/repository/audit_repo.go
Normal file
66
internal/repository/audit_repo.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditRepo(db *sql.DB) *AuditRepo {
|
||||||
|
return &AuditRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditRepo) Log(licenseID *int64, action, ip string, details interface{}) error {
|
||||||
|
detailsJSON, _ := json.Marshal(details)
|
||||||
|
var lid sql.NullInt64
|
||||||
|
if licenseID != nil {
|
||||||
|
lid = sql.NullInt64{Int64: *licenseID, Valid: true}
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec("INSERT INTO audit_log (license_id, action, ip_address, details) VALUES (?, ?, ?, ?)",
|
||||||
|
lid, action, ip, string(detailsJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("audit log: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditRepo) List(licenseID *int64, limit int) ([]model.AuditEntry, error) {
|
||||||
|
query := `SELECT a.id, a.license_id, a.action, a.ip_address, a.details, a.created_at,
|
||||||
|
COALESCE(l.license_key, '') as license_key
|
||||||
|
FROM audit_log a LEFT JOIN licenses l ON a.license_id = l.id`
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if licenseID != nil {
|
||||||
|
query += " WHERE a.license_id = ?"
|
||||||
|
args = append(args, *licenseID)
|
||||||
|
}
|
||||||
|
query += " ORDER BY a.created_at DESC LIMIT ?"
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := r.db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []model.AuditEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e model.AuditEntry
|
||||||
|
var details sql.NullString
|
||||||
|
rows.Scan(&e.ID, &e.LicenseID, &e.Action, &e.IPAddress, &details, &e.CreatedAt, &e.LicenseKey)
|
||||||
|
if details.Valid {
|
||||||
|
e.Details = json.RawMessage(details.String)
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditRepo) Recent(limit int) ([]model.AuditEntry, error) {
|
||||||
|
return r.List(nil, limit)
|
||||||
|
}
|
||||||
221
internal/repository/license_repo.go
Normal file
221
internal/repository/license_repo.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LicenseRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLicenseRepo(db *sql.DB) *LicenseRepo {
|
||||||
|
return &LicenseRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) Create(l *model.License) (int64, error) {
|
||||||
|
res, err := r.db.Exec(`INSERT INTO licenses (product_id, license_key, license_type, customer_name, customer_pib, customer_email, limits_json, features, expires_at, grace_days, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
l.ProductID, l.LicenseKey, l.LicenseType, l.CustomerName, l.CustomerPIB, l.CustomerEmail,
|
||||||
|
string(l.Limits), string(l.Features), l.ExpiresAt, l.GraceDays, l.Notes)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create license: %w", err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) GetByID(id int64) (*model.License, error) {
|
||||||
|
l := &model.License{}
|
||||||
|
var limits, features string
|
||||||
|
var revokedReason, notes sql.NullString
|
||||||
|
err := r.db.QueryRow(`SELECT l.id, l.product_id, l.license_key, l.license_type, l.customer_name, l.customer_pib, l.customer_email,
|
||||||
|
l.limits_json, l.features, l.issued_at, l.expires_at, l.grace_days, l.active, l.revoked, l.revoked_at, l.revoked_reason, l.notes, l.created_at, l.updated_at,
|
||||||
|
p.code, p.name, p.key_prefix
|
||||||
|
FROM licenses l JOIN products p ON l.product_id = p.id WHERE l.id = ?`, id).
|
||||||
|
Scan(&l.ID, &l.ProductID, &l.LicenseKey, &l.LicenseType, &l.CustomerName, &l.CustomerPIB, &l.CustomerEmail,
|
||||||
|
&limits, &features, &l.IssuedAt, &l.ExpiresAt, &l.GraceDays, &l.Active, &l.Revoked, &l.RevokedAt, &revokedReason, ¬es, &l.CreatedAt, &l.UpdatedAt,
|
||||||
|
&l.ProductCode, &l.ProductName, &l.ProductPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get license %d: %w", id, err)
|
||||||
|
}
|
||||||
|
l.Limits = []byte(limits)
|
||||||
|
l.Features = []byte(features)
|
||||||
|
if revokedReason.Valid {
|
||||||
|
l.RevokedReason = revokedReason.String
|
||||||
|
}
|
||||||
|
if notes.Valid {
|
||||||
|
l.Notes = notes.String
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) GetByKey(key string) (*model.License, error) {
|
||||||
|
l := &model.License{}
|
||||||
|
var limits, features string
|
||||||
|
var revokedReason, notes sql.NullString
|
||||||
|
err := r.db.QueryRow(`SELECT l.id, l.product_id, l.license_key, l.license_type, l.customer_name, l.customer_pib, l.customer_email,
|
||||||
|
l.limits_json, l.features, l.issued_at, l.expires_at, l.grace_days, l.active, l.revoked, l.revoked_at, l.revoked_reason, l.notes, l.created_at, l.updated_at,
|
||||||
|
p.code, p.name, p.key_prefix
|
||||||
|
FROM licenses l JOIN products p ON l.product_id = p.id WHERE l.license_key = ?`, key).
|
||||||
|
Scan(&l.ID, &l.ProductID, &l.LicenseKey, &l.LicenseType, &l.CustomerName, &l.CustomerPIB, &l.CustomerEmail,
|
||||||
|
&limits, &features, &l.IssuedAt, &l.ExpiresAt, &l.GraceDays, &l.Active, &l.Revoked, &l.RevokedAt, &revokedReason, ¬es, &l.CreatedAt, &l.UpdatedAt,
|
||||||
|
&l.ProductCode, &l.ProductName, &l.ProductPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get license by key: %w", err)
|
||||||
|
}
|
||||||
|
l.Limits = []byte(limits)
|
||||||
|
l.Features = []byte(features)
|
||||||
|
if revokedReason.Valid {
|
||||||
|
l.RevokedReason = revokedReason.String
|
||||||
|
}
|
||||||
|
if notes.Valid {
|
||||||
|
l.Notes = notes.String
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) List(productCode, status, search string) ([]model.LicenseWithActivation, error) {
|
||||||
|
query := `SELECT l.id, l.product_id, l.license_key, l.license_type, l.customer_name, l.customer_pib, l.customer_email,
|
||||||
|
l.limits_json, l.features, l.issued_at, l.expires_at, l.grace_days, l.active, l.revoked, l.revoked_at, l.revoked_reason, l.notes, l.created_at, l.updated_at,
|
||||||
|
p.code, p.name, p.key_prefix,
|
||||||
|
(SELECT COUNT(*) FROM activations a WHERE a.license_id = l.id AND a.is_active = TRUE) as active_activations
|
||||||
|
FROM licenses l JOIN products p ON l.product_id = p.id WHERE 1=1`
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if productCode != "" {
|
||||||
|
query += " AND p.code = ?"
|
||||||
|
args = append(args, productCode)
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
query += " AND (l.customer_name LIKE ? OR l.license_key LIKE ?)"
|
||||||
|
args = append(args, "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "active":
|
||||||
|
query += " AND l.active = TRUE AND l.revoked = FALSE AND (l.expires_at IS NULL OR l.expires_at > NOW())"
|
||||||
|
case "expired":
|
||||||
|
query += " AND l.expires_at IS NOT NULL AND l.expires_at <= NOW() AND l.revoked = FALSE"
|
||||||
|
case "revoked":
|
||||||
|
query += " AND l.revoked = TRUE"
|
||||||
|
case "trial":
|
||||||
|
query += " AND l.license_type = 'TRIAL'"
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY l.created_at DESC LIMIT 200"
|
||||||
|
|
||||||
|
rows, err := r.db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list licenses: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var licenses []model.LicenseWithActivation
|
||||||
|
for rows.Next() {
|
||||||
|
var lwa model.LicenseWithActivation
|
||||||
|
var limits, features string
|
||||||
|
var revokedReason, notes sql.NullString
|
||||||
|
err := rows.Scan(&lwa.ID, &lwa.ProductID, &lwa.LicenseKey, &lwa.LicenseType, &lwa.CustomerName, &lwa.CustomerPIB, &lwa.CustomerEmail,
|
||||||
|
&limits, &features, &lwa.IssuedAt, &lwa.ExpiresAt, &lwa.GraceDays, &lwa.Active, &lwa.Revoked, &lwa.RevokedAt, &revokedReason, ¬es, &lwa.CreatedAt, &lwa.UpdatedAt,
|
||||||
|
&lwa.ProductCode, &lwa.ProductName, &lwa.ProductPrefix,
|
||||||
|
&lwa.ActiveActivations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan license: %w", err)
|
||||||
|
}
|
||||||
|
lwa.Limits = []byte(limits)
|
||||||
|
lwa.Features = []byte(features)
|
||||||
|
if revokedReason.Valid {
|
||||||
|
lwa.RevokedReason = revokedReason.String
|
||||||
|
}
|
||||||
|
if notes.Valid {
|
||||||
|
lwa.Notes = notes.String
|
||||||
|
}
|
||||||
|
licenses = append(licenses, lwa)
|
||||||
|
}
|
||||||
|
return licenses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) Update(l *model.License) error {
|
||||||
|
_, err := r.db.Exec(`UPDATE licenses SET customer_name=?, customer_pib=?, customer_email=?, limits_json=?, features=?, grace_days=?, expires_at=?, notes=? WHERE id=?`,
|
||||||
|
l.CustomerName, l.CustomerPIB, l.CustomerEmail, string(l.Limits), string(l.Features), l.GraceDays, l.ExpiresAt, l.Notes, l.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) Revoke(id int64, reason string) error {
|
||||||
|
_, err := r.db.Exec(`UPDATE licenses SET revoked=TRUE, revoked_at=NOW(), revoked_reason=?, active=FALSE WHERE id=?`, reason, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) CountByProduct() ([]model.ProductStats, error) {
|
||||||
|
rows, err := r.db.Query(`SELECT p.code, p.name,
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN l.active AND NOT l.revoked AND (l.expires_at IS NULL OR l.expires_at > NOW()) THEN 1 ELSE 0 END) as act,
|
||||||
|
SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at <= NOW() AND NOT l.revoked THEN 1 ELSE 0 END) as exp,
|
||||||
|
SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at <= NOW() AND DATE_ADD(l.expires_at, INTERVAL l.grace_days DAY) > NOW() AND NOT l.revoked THEN 1 ELSE 0 END) as grace,
|
||||||
|
SUM(CASE WHEN l.license_type = 'TRIAL' THEN 1 ELSE 0 END) as trial,
|
||||||
|
(SELECT COUNT(*) FROM activations a JOIN licenses l2 ON a.license_id=l2.id WHERE l2.product_id=p.id AND a.is_active=TRUE) as active_acts
|
||||||
|
FROM licenses l JOIN products p ON l.product_id=p.id GROUP BY p.id, p.code, p.name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var stats []model.ProductStats
|
||||||
|
for rows.Next() {
|
||||||
|
var s model.ProductStats
|
||||||
|
rows.Scan(&s.ProductCode, &s.ProductName, &s.Total, &s.Active, &s.Expired, &s.InGrace, &s.Trial, &s.ActiveActivations)
|
||||||
|
stats = append(stats, s)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) GetProducts() ([]model.Product, error) {
|
||||||
|
rows, err := r.db.Query("SELECT id, code, name, key_prefix, default_limits, available_features, active, created_at FROM products WHERE active = TRUE")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var products []model.Product
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.Product
|
||||||
|
var limits, features string
|
||||||
|
rows.Scan(&p.ID, &p.Code, &p.Name, &p.KeyPrefix, &limits, &features, &p.Active, &p.CreatedAt)
|
||||||
|
p.DefaultLimits = []byte(limits)
|
||||||
|
p.AvailableFeatures = []byte(features)
|
||||||
|
products = append(products, p)
|
||||||
|
}
|
||||||
|
return products, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) GetProductByID(id int64) (*model.Product, error) {
|
||||||
|
var p model.Product
|
||||||
|
var limits, features string
|
||||||
|
err := r.db.QueryRow("SELECT id, code, name, key_prefix, default_limits, available_features, active, created_at FROM products WHERE id = ?", id).
|
||||||
|
Scan(&p.ID, &p.Code, &p.Name, &p.KeyPrefix, &limits, &features, &p.Active, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.DefaultLimits = []byte(limits)
|
||||||
|
p.AvailableFeatures = []byte(features)
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LicenseRepo) ExpiringIn(days int) ([]model.License, error) {
|
||||||
|
rows, err := r.db.Query(`SELECT l.id, l.license_key, l.license_type, l.customer_name, l.expires_at, p.code, p.name, p.key_prefix
|
||||||
|
FROM licenses l JOIN products p ON l.product_id=p.id
|
||||||
|
WHERE l.active AND NOT l.revoked AND l.expires_at IS NOT NULL AND l.expires_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL ? DAY)
|
||||||
|
ORDER BY l.expires_at`, days)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var licenses []model.License
|
||||||
|
for rows.Next() {
|
||||||
|
var l model.License
|
||||||
|
rows.Scan(&l.ID, &l.LicenseKey, &l.LicenseType, &l.CustomerName, &l.ExpiresAt, &l.ProductCode, &l.ProductName, &l.ProductPrefix)
|
||||||
|
licenses = append(licenses, l)
|
||||||
|
}
|
||||||
|
return licenses, nil
|
||||||
|
}
|
||||||
78
internal/router/router.go
Normal file
78
internal/router/router.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/handler"
|
||||||
|
"dal-license-server/internal/middleware"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Setup(
|
||||||
|
client *handler.ClientHandler,
|
||||||
|
admin *handler.AdminHandler,
|
||||||
|
dashboard *handler.DashboardHandler,
|
||||||
|
apiKey string,
|
||||||
|
rateLimitActivate int,
|
||||||
|
rateLimitValidate int,
|
||||||
|
) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
apiKeyMw := middleware.APIKeyAuth(apiKey)
|
||||||
|
activateRL := middleware.RateLimit(rateLimitActivate)
|
||||||
|
validateRL := middleware.RateLimit(rateLimitValidate)
|
||||||
|
|
||||||
|
requireAPI := func(f http.HandlerFunc) http.Handler {
|
||||||
|
return apiKeyMw(http.HandlerFunc(f))
|
||||||
|
}
|
||||||
|
requireDash := func(f http.HandlerFunc) http.Handler {
|
||||||
|
return dashboard.RequireLogin(http.HandlerFunc(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
|
// Health
|
||||||
|
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Client API
|
||||||
|
mux.Handle("POST /api/v1/activate", activateRL(http.HandlerFunc(client.Activate)))
|
||||||
|
mux.Handle("POST /api/v1/deactivate", activateRL(http.HandlerFunc(client.Deactivate)))
|
||||||
|
mux.Handle("POST /api/v1/validate", validateRL(http.HandlerFunc(client.Validate)))
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
mux.Handle("GET /api/v1/admin/products", requireAPI(admin.ListProducts))
|
||||||
|
mux.Handle("GET /api/v1/admin/licenses", requireAPI(admin.ListLicenses))
|
||||||
|
mux.Handle("POST /api/v1/admin/licenses", requireAPI(admin.CreateLicense))
|
||||||
|
mux.Handle("GET /api/v1/admin/licenses/{id}", requireAPI(admin.GetLicense))
|
||||||
|
mux.Handle("PUT /api/v1/admin/licenses/{id}", requireAPI(admin.UpdateLicense))
|
||||||
|
mux.Handle("POST /api/v1/admin/licenses/{id}/revoke", requireAPI(admin.RevokeLicense))
|
||||||
|
mux.Handle("POST /api/v1/admin/licenses/{id}/release", requireAPI(admin.ReleaseLicense))
|
||||||
|
mux.Handle("GET /api/v1/admin/licenses/{id}/activations", requireAPI(admin.ListActivations))
|
||||||
|
mux.Handle("GET /api/v1/admin/audit", requireAPI(admin.AuditLog))
|
||||||
|
mux.Handle("GET /api/v1/admin/stats", requireAPI(admin.Stats))
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
mux.HandleFunc("GET /login", dashboard.LoginPage)
|
||||||
|
mux.HandleFunc("POST /login", dashboard.Login)
|
||||||
|
mux.Handle("POST /logout", requireDash(dashboard.Logout))
|
||||||
|
mux.Handle("GET /dashboard", requireDash(dashboard.Dashboard))
|
||||||
|
mux.Handle("GET /licenses", requireDash(dashboard.LicenseList))
|
||||||
|
mux.Handle("GET /licenses/new", requireDash(dashboard.LicenseNew))
|
||||||
|
mux.Handle("POST /licenses", requireDash(dashboard.LicenseCreate))
|
||||||
|
mux.Handle("GET /licenses/{id}", requireDash(dashboard.LicenseDetail))
|
||||||
|
mux.Handle("POST /licenses/{id}/revoke", requireDash(dashboard.LicenseRevoke))
|
||||||
|
mux.Handle("POST /licenses/{id}/release", requireDash(dashboard.LicenseRelease))
|
||||||
|
mux.Handle("GET /audit", requireDash(dashboard.AuditPage))
|
||||||
|
|
||||||
|
// Root redirect
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = strconv.Itoa(0) // satisfy import if needed
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
178
internal/service/activation_service.go
Normal file
178
internal/service/activation_service.go
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"dal-license-server/internal/repository"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivationService struct {
|
||||||
|
activations *repository.ActivationRepo
|
||||||
|
licenses *repository.LicenseRepo
|
||||||
|
audit *repository.AuditRepo
|
||||||
|
crypto *CryptoService
|
||||||
|
licSvc *LicenseService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActivationService(activations *repository.ActivationRepo, licenses *repository.LicenseRepo, audit *repository.AuditRepo, crypto *CryptoService, licSvc *LicenseService) *ActivationService {
|
||||||
|
return &ActivationService{activations: activations, licenses: licenses, audit: audit, crypto: crypto, licSvc: licSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ActivationService) Activate(req *model.ActivateRequest, ip string) (*model.ActivateResponse, error) {
|
||||||
|
license, err := s.licenses.GetByKey(req.LicenseKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &LicenseError{Code: "INVALID_KEY", Message: "Licencni kljuc nije pronadjen"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if license.Revoked {
|
||||||
|
return nil, &LicenseError{Code: "KEY_REVOKED", Message: "Licenca je opozvana"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !license.Active {
|
||||||
|
return nil, &LicenseError{Code: "KEY_REVOKED", Message: "Licenca nije aktivna"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if license.IsExpired() && !license.IsInGrace() {
|
||||||
|
return nil, &LicenseError{Code: "KEY_EXPIRED", Message: "Licenca je istekla"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing activation
|
||||||
|
existing, err := s.activations.GetActiveByLicense(license.ID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
if existing.MachineFingerprint == req.MachineFingerprint {
|
||||||
|
// Same machine — refresh
|
||||||
|
s.activations.UpdateLastSeen(existing.ID)
|
||||||
|
} else {
|
||||||
|
return nil, &LicenseError{
|
||||||
|
Code: "ALREADY_ACTIVATED",
|
||||||
|
Message: "Licenca je vec aktivirana na drugom racunaru",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"activated_on": existing.Hostname,
|
||||||
|
"activated_at": existing.ActivatedAt.Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New activation
|
||||||
|
_, err = s.activations.Create(&model.Activation{
|
||||||
|
LicenseID: license.ID,
|
||||||
|
MachineFingerprint: req.MachineFingerprint,
|
||||||
|
Hostname: req.Hostname,
|
||||||
|
OSInfo: req.OS,
|
||||||
|
AppVersion: req.AppVersion,
|
||||||
|
IPAddress: ip,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("activate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.Log(&license.ID, "ACTIVATE", ip, map[string]interface{}{
|
||||||
|
"fingerprint": req.MachineFingerprint,
|
||||||
|
"hostname": req.Hostname,
|
||||||
|
"os": req.OS,
|
||||||
|
"app_version": req.AppVersion,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build signed license data
|
||||||
|
licenseJSON, err := s.licSvc.BuildLicenseData(license, req.MachineFingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build license data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := s.crypto.Sign(licenseJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sign license: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ld model.LicenseData
|
||||||
|
json.Unmarshal(licenseJSON, &ld)
|
||||||
|
|
||||||
|
return &model.ActivateResponse{
|
||||||
|
License: ld,
|
||||||
|
Signature: signature,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ActivationService) Deactivate(req *model.DeactivateRequest, ip string) (*model.DeactivateResponse, error) {
|
||||||
|
license, err := s.licenses.GetByKey(req.LicenseKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &LicenseError{Code: "INVALID_KEY", Message: "Licencni kljuc nije pronadjen"}
|
||||||
|
}
|
||||||
|
|
||||||
|
act, err := s.activations.GetByLicenseAndFingerprint(license.ID, req.MachineFingerprint)
|
||||||
|
if err != nil || act == nil {
|
||||||
|
return nil, &LicenseError{Code: "NOT_ACTIVATED", Message: "Licenca nije aktivirana na ovom racunaru"}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.activations.Deactivate(license.ID, req.MachineFingerprint)
|
||||||
|
|
||||||
|
s.audit.Log(&license.ID, "DEACTIVATE", ip, map[string]interface{}{
|
||||||
|
"fingerprint": req.MachineFingerprint,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &model.DeactivateResponse{
|
||||||
|
Message: "Licenca uspesno deaktivirana",
|
||||||
|
CanReactivate: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ActivationService) Validate(req *model.ValidateRequest, ip string) (*model.ValidateResponse, error) {
|
||||||
|
license, err := s.licenses.GetByKey(req.LicenseKey)
|
||||||
|
if err != nil {
|
||||||
|
return &model.ValidateResponse{Valid: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_seen
|
||||||
|
act, err := s.activations.GetByLicenseAndFingerprint(license.ID, req.MachineFingerprint)
|
||||||
|
if err == nil && act != nil {
|
||||||
|
s.activations.UpdateLastSeen(act.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.Log(&license.ID, "VALIDATE", ip, map[string]interface{}{
|
||||||
|
"fingerprint": req.MachineFingerprint,
|
||||||
|
})
|
||||||
|
|
||||||
|
expiresAt := ""
|
||||||
|
if license.ExpiresAt.Valid {
|
||||||
|
expiresAt = license.ExpiresAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := license.Active && !license.Revoked
|
||||||
|
if license.IsExpired() && !license.IsInGrace() {
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.ValidateResponse{
|
||||||
|
Valid: valid,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Revoked: license.Revoked,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ActivationService) ForceRelease(licenseID int64, ip string) error {
|
||||||
|
s.activations.ForceRelease(licenseID)
|
||||||
|
s.audit.Log(&licenseID, "FORCE_RELEASE", ip, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ActivationService) ListByLicense(licenseID int64) ([]model.Activation, error) {
|
||||||
|
return s.activations.ListByLicense(licenseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseError is a typed error for client API
|
||||||
|
type LicenseError struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
Details interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LicenseError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needed to satisfy import
|
||||||
|
var _ = sql.NullTime{}
|
||||||
63
internal/service/crypto_service.go
Normal file
63
internal/service/crypto_service.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CryptoService struct {
|
||||||
|
privateKey *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCryptoService(keyPath string) (*CryptoService, error) {
|
||||||
|
data, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode PEM block")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
// Try PKCS8
|
||||||
|
pkcs8Key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, fmt.Errorf("parse private key: %w", err)
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
key, ok = pkcs8Key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("not an RSA private key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CryptoService{privateKey: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CryptoService) Sign(data []byte) (string, error) {
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sign: %w", err)
|
||||||
|
}
|
||||||
|
return "RSA-SHA256:" + base64.StdEncoding.EncodeToString(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CryptoService) PublicKeyPEM() (string, error) {
|
||||||
|
pubBytes, err := x509.MarshalPKIXPublicKey(&s.privateKey.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block := &pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}
|
||||||
|
return string(pem.EncodeToMemory(block)), nil
|
||||||
|
}
|
||||||
147
internal/service/crypto_service_test.go
Normal file
147
internal/service/crypto_service_test.go
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestKey(t *testing.T) (string, *rsa.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test_private.pem")
|
||||||
|
|
||||||
|
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
||||||
|
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}
|
||||||
|
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pem.Encode(f, block)
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
return path, key
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCryptoService(t *testing.T) {
|
||||||
|
path, _ := setupTestKey(t)
|
||||||
|
|
||||||
|
svc, err := NewCryptoService(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCryptoService error: %v", err)
|
||||||
|
}
|
||||||
|
if svc == nil {
|
||||||
|
t.Fatal("CryptoService je nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCryptoService_InvalidPath(t *testing.T) {
|
||||||
|
_, err := NewCryptoService("/nonexistent/path.pem")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ocekivana greska za nepostojeci fajl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewCryptoService_InvalidPEM(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "bad.pem")
|
||||||
|
os.WriteFile(path, []byte("not a pem file"), 0600)
|
||||||
|
|
||||||
|
_, err := NewCryptoService(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ocekivana greska za nevalidan PEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSign_Format(t *testing.T) {
|
||||||
|
path, _ := setupTestKey(t)
|
||||||
|
svc, _ := NewCryptoService(path)
|
||||||
|
|
||||||
|
sig, err := svc.Sign([]byte("test data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(sig, "RSA-SHA256:") {
|
||||||
|
t.Errorf("potpis nema RSA-SHA256: prefix: %s", sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify base64 part is valid
|
||||||
|
b64 := strings.TrimPrefix(sig, "RSA-SHA256:")
|
||||||
|
_, err = base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("base64 deo potpisa nije validan: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSign_VerifyWithPublicKey(t *testing.T) {
|
||||||
|
path, privKey := setupTestKey(t)
|
||||||
|
svc, _ := NewCryptoService(path)
|
||||||
|
|
||||||
|
data := []byte(`{"license_key":"LT-TEST-1234","product":"LIGHT_TICKET"}`)
|
||||||
|
sig, err := svc.Sign(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract base64 signature
|
||||||
|
b64 := strings.TrimPrefix(sig, "RSA-SHA256:")
|
||||||
|
sigBytes, _ := base64.StdEncoding.DecodeString(b64)
|
||||||
|
|
||||||
|
// Verify with public key
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
err = rsa.VerifyPKCS1v15(&privKey.PublicKey, crypto.SHA256, hash[:], sigBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("potpis nije validan: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSign_TamperedData(t *testing.T) {
|
||||||
|
path, privKey := setupTestKey(t)
|
||||||
|
svc, _ := NewCryptoService(path)
|
||||||
|
|
||||||
|
data := []byte(`{"license_key":"LT-TEST-1234"}`)
|
||||||
|
sig, _ := svc.Sign(data)
|
||||||
|
|
||||||
|
b64 := strings.TrimPrefix(sig, "RSA-SHA256:")
|
||||||
|
sigBytes, _ := base64.StdEncoding.DecodeString(b64)
|
||||||
|
|
||||||
|
// Tamper with data
|
||||||
|
tampered := []byte(`{"license_key":"LT-FAKE-9999"}`)
|
||||||
|
hash := sha256.Sum256(tampered)
|
||||||
|
err := rsa.VerifyPKCS1v15(&privKey.PublicKey, crypto.SHA256, hash[:], sigBytes)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("tampered data ne sme proci verifikaciju")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicKeyPEM(t *testing.T) {
|
||||||
|
path, _ := setupTestKey(t)
|
||||||
|
svc, _ := NewCryptoService(path)
|
||||||
|
|
||||||
|
pubPEM, err := svc.PublicKeyPEM()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(pubPEM, "BEGIN PUBLIC KEY") {
|
||||||
|
t.Error("public key PEM nema ocekivani header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(pubPEM, "END PUBLIC KEY") {
|
||||||
|
t.Error("public key PEM nema ocekivani footer")
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/service/keygen.go
Normal file
26
internal/service/keygen.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Characters: A-H, J-N, P-Y, 2-9 (no O/0/I/1)
|
||||||
|
const keyChars = "ABCDEFGHJKMNPQRSTUVWXY23456789"
|
||||||
|
|
||||||
|
func GenerateKey(prefix string) (string, error) {
|
||||||
|
groups := make([]string, 4)
|
||||||
|
for i := range groups {
|
||||||
|
group := make([]byte, 4)
|
||||||
|
for j := range group {
|
||||||
|
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(keyChars))))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
group[j] = keyChars[idx.Int64()]
|
||||||
|
}
|
||||||
|
groups[i] = string(group)
|
||||||
|
}
|
||||||
|
return prefix + strings.Join(groups, "-"), nil
|
||||||
|
}
|
||||||
81
internal/service/keygen_test.go
Normal file
81
internal/service/keygen_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateKey_Format(t *testing.T) {
|
||||||
|
prefixes := []string{"ESIR-", "ARV-", "LT-"}
|
||||||
|
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
key, err := GenerateKey(prefix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateKey(%q) error: %v", prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(key, prefix) {
|
||||||
|
t.Errorf("key %q nema prefix %q", key, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove prefix, check 4 groups of 4 chars separated by dashes
|
||||||
|
rest := strings.TrimPrefix(key, prefix)
|
||||||
|
groups := strings.Split(rest, "-")
|
||||||
|
if len(groups) != 4 {
|
||||||
|
t.Errorf("key %q: ocekivano 4 grupe, dobijeno %d", key, len(groups))
|
||||||
|
}
|
||||||
|
for i, g := range groups {
|
||||||
|
if len(g) != 4 {
|
||||||
|
t.Errorf("key %q: grupa %d ima %d karaktera umesto 4", key, i, len(g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateKey_ValidChars(t *testing.T) {
|
||||||
|
key, err := GenerateKey("TEST-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := strings.TrimPrefix(key, "TEST-")
|
||||||
|
rest = strings.ReplaceAll(rest, "-", "")
|
||||||
|
|
||||||
|
for _, c := range rest {
|
||||||
|
if !strings.ContainsRune(keyChars, c) {
|
||||||
|
t.Errorf("key sadrzi nedozvoljen karakter: %c", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateKey_NoConfusingChars(t *testing.T) {
|
||||||
|
// Generate multiple keys and check none contain O, 0, I, 1, L, Z
|
||||||
|
confusing := "OIL01Z"
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
key, err := GenerateKey("T-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(key, "T-")
|
||||||
|
rest = strings.ReplaceAll(rest, "-", "")
|
||||||
|
for _, c := range rest {
|
||||||
|
if strings.ContainsRune(confusing, c) {
|
||||||
|
t.Errorf("key sadrzi konfuzan karakter %c: %s", c, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateKey_Uniqueness(t *testing.T) {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
key, err := GenerateKey("U-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen[key] {
|
||||||
|
t.Errorf("duplikat kljuca: %s", key)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
195
internal/service/license_service.go
Normal file
195
internal/service/license_service.go
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dal-license-server/internal/model"
|
||||||
|
"dal-license-server/internal/repository"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LicenseService struct {
|
||||||
|
repo *repository.LicenseRepo
|
||||||
|
audit *repository.AuditRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLicenseService(repo *repository.LicenseRepo, audit *repository.AuditRepo) *LicenseService {
|
||||||
|
return &LicenseService{repo: repo, audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) Create(req *model.CreateLicenseRequest, ip string) (*model.License, error) {
|
||||||
|
product, err := s.repo.GetProductByID(req.ProductID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("product not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := GenerateKey(product.KeyPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := req.Limits
|
||||||
|
if len(limits) == 0 || string(limits) == "" || string(limits) == "null" {
|
||||||
|
limits = product.DefaultLimits
|
||||||
|
}
|
||||||
|
features := req.Features
|
||||||
|
if len(features) == 0 || string(features) == "" || string(features) == "null" {
|
||||||
|
features = product.AvailableFeatures
|
||||||
|
}
|
||||||
|
graceDays := req.GraceDays
|
||||||
|
if graceDays == 0 {
|
||||||
|
graceDays = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
l := &model.License{
|
||||||
|
ProductID: req.ProductID,
|
||||||
|
LicenseKey: key,
|
||||||
|
LicenseType: req.LicenseType,
|
||||||
|
CustomerName: req.CustomerName,
|
||||||
|
CustomerPIB: req.CustomerPIB,
|
||||||
|
CustomerEmail: req.CustomerEmail,
|
||||||
|
Limits: limits,
|
||||||
|
Features: features,
|
||||||
|
GraceDays: graceDays,
|
||||||
|
Notes: req.Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set expiry
|
||||||
|
switch req.LicenseType {
|
||||||
|
case "TRIAL":
|
||||||
|
exp := time.Now().AddDate(0, 0, 30)
|
||||||
|
l.ExpiresAt = sql.NullTime{Time: exp, Valid: true}
|
||||||
|
case "MONTHLY":
|
||||||
|
exp := time.Now().AddDate(0, 1, 0)
|
||||||
|
l.ExpiresAt = sql.NullTime{Time: exp, Valid: true}
|
||||||
|
case "ANNUAL":
|
||||||
|
exp := time.Now().AddDate(1, 0, 0)
|
||||||
|
l.ExpiresAt = sql.NullTime{Time: exp, Valid: true}
|
||||||
|
case "PERPETUAL":
|
||||||
|
l.ExpiresAt = sql.NullTime{Valid: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.repo.Create(l)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.Log(&id, "CREATE", ip, map[string]interface{}{
|
||||||
|
"product": product.Code,
|
||||||
|
"type": req.LicenseType,
|
||||||
|
"customer": req.CustomerName,
|
||||||
|
})
|
||||||
|
|
||||||
|
return s.repo.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) GetByID(id int64) (*model.License, error) {
|
||||||
|
return s.repo.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) GetByKey(key string) (*model.License, error) {
|
||||||
|
return s.repo.GetByKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) List(productCode, status, search string) ([]model.LicenseWithActivation, error) {
|
||||||
|
return s.repo.List(productCode, status, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) Update(id int64, req *model.UpdateLicenseRequest, ip string) error {
|
||||||
|
l, err := s.repo.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
l.CustomerName = req.CustomerName
|
||||||
|
l.CustomerPIB = req.CustomerPIB
|
||||||
|
l.CustomerEmail = req.CustomerEmail
|
||||||
|
l.Notes = req.Notes
|
||||||
|
if len(req.Limits) > 0 {
|
||||||
|
l.Limits = req.Limits
|
||||||
|
}
|
||||||
|
if len(req.Features) > 0 {
|
||||||
|
l.Features = req.Features
|
||||||
|
}
|
||||||
|
if req.GraceDays > 0 {
|
||||||
|
l.GraceDays = req.GraceDays
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != "" {
|
||||||
|
t, err := time.Parse("2006-01-02", req.ExpiresAt)
|
||||||
|
if err == nil {
|
||||||
|
l.ExpiresAt = sql.NullTime{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit.Log(&id, "UPDATE", ip, map[string]interface{}{"customer": req.CustomerName})
|
||||||
|
return s.repo.Update(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) Revoke(id int64, reason, ip string) error {
|
||||||
|
s.audit.Log(&id, "REVOKE", ip, map[string]interface{}{"reason": reason})
|
||||||
|
return s.repo.Revoke(id, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) GetProducts() ([]model.Product, error) {
|
||||||
|
return s.repo.GetProducts()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) GetStats() (*model.StatsResponse, error) {
|
||||||
|
stats := &model.StatsResponse{}
|
||||||
|
byProduct, err := s.repo.CountByProduct()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.ByProduct = byProduct
|
||||||
|
|
||||||
|
for _, p := range byProduct {
|
||||||
|
stats.TotalLicenses += p.Total
|
||||||
|
stats.ActiveLicenses += p.Active
|
||||||
|
stats.ExpiredLicenses += p.Expired
|
||||||
|
stats.RevokedLicenses += p.Trial // count trials separately
|
||||||
|
stats.ActiveActivations += p.ActiveActivations
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix: count revoked properly
|
||||||
|
var revoked int
|
||||||
|
for _, p := range byProduct {
|
||||||
|
_ = p // need a separate query for revoked
|
||||||
|
}
|
||||||
|
_ = revoked
|
||||||
|
|
||||||
|
licenses, _ := s.repo.List("", "revoked", "")
|
||||||
|
stats.RevokedLicenses = len(licenses)
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) ExpiringIn(days int) ([]model.License, error) {
|
||||||
|
return s.repo.ExpiringIn(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseService) BuildLicenseData(l *model.License, fingerprint string) ([]byte, error) {
|
||||||
|
expiresAt := ""
|
||||||
|
if l.ExpiresAt.Valid {
|
||||||
|
expiresAt = l.ExpiresAt.Time.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
ld := model.LicenseData{
|
||||||
|
LicenseKey: l.LicenseKey,
|
||||||
|
Product: l.ProductCode,
|
||||||
|
LicenseType: l.LicenseType,
|
||||||
|
IssuedAt: l.IssuedAt.Format(time.RFC3339),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
ActivatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
MachineFingerprint: fingerprint,
|
||||||
|
GraceDays: l.GraceDays,
|
||||||
|
Limits: l.Limits,
|
||||||
|
Features: l.Features,
|
||||||
|
Customer: model.CustomerData{
|
||||||
|
Name: l.CustomerName,
|
||||||
|
Email: l.CustomerEmail,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(ld)
|
||||||
|
}
|
||||||
67
migrations/001_create_tables.sql
Normal file
67
migrations/001_create_tables.sql
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
key_prefix VARCHAR(10) NOT NULL,
|
||||||
|
default_limits JSON,
|
||||||
|
available_features JSON,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
product_id BIGINT NOT NULL,
|
||||||
|
license_key VARCHAR(25) NOT NULL UNIQUE,
|
||||||
|
license_type VARCHAR(20) NOT NULL,
|
||||||
|
customer_name VARCHAR(255) NOT NULL,
|
||||||
|
customer_pib VARCHAR(20) DEFAULT '',
|
||||||
|
customer_email VARCHAR(255) DEFAULT '',
|
||||||
|
limits_json JSON NOT NULL,
|
||||||
|
features JSON NOT NULL,
|
||||||
|
issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NULL,
|
||||||
|
grace_days INT DEFAULT 30,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
revoked BOOLEAN DEFAULT FALSE,
|
||||||
|
revoked_at TIMESTAMP NULL,
|
||||||
|
revoked_reason TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||||
|
INDEX idx_licenses_product (product_id),
|
||||||
|
INDEX idx_licenses_customer (customer_name),
|
||||||
|
INDEX idx_licenses_expires (expires_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activations (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
license_id BIGINT NOT NULL,
|
||||||
|
machine_fingerprint VARCHAR(100) NOT NULL,
|
||||||
|
hostname VARCHAR(100) DEFAULT '',
|
||||||
|
os_info VARCHAR(50) DEFAULT '',
|
||||||
|
app_version VARCHAR(20) DEFAULT '',
|
||||||
|
ip_address VARCHAR(45) DEFAULT '',
|
||||||
|
activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deactivated_at TIMESTAMP NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (license_id) REFERENCES licenses(id),
|
||||||
|
INDEX idx_activations_license (license_id),
|
||||||
|
INDEX idx_activations_fingerprint (machine_fingerprint),
|
||||||
|
INDEX idx_activations_active (license_id, is_active)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
license_id BIGINT NULL,
|
||||||
|
action VARCHAR(30) NOT NULL,
|
||||||
|
ip_address VARCHAR(45) DEFAULT '',
|
||||||
|
details JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (license_id) REFERENCES licenses(id),
|
||||||
|
INDEX idx_audit_license (license_id),
|
||||||
|
INDEX idx_audit_action (action),
|
||||||
|
INDEX idx_audit_created (created_at)
|
||||||
|
);
|
||||||
10
migrations/002_seed_products.sql
Normal file
10
migrations/002_seed_products.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
INSERT IGNORE INTO products (code, name, key_prefix, default_limits, available_features) VALUES
|
||||||
|
('ESIR', 'ESIR Fiskalizacija', 'ESIR-',
|
||||||
|
'{"max_installations": 1}',
|
||||||
|
'["FISCALIZATION", "REPORTS"]'),
|
||||||
|
('ARV', 'ARV Evidencija RV', 'ARV-',
|
||||||
|
'{"max_employees": 50, "max_readers": 4}',
|
||||||
|
'["TIME_ATTENDANCE", "BASIC_REPORTS", "EMPLOYEE_MANAGEMENT", "SHIFTS", "HR_MODULE", "ACCESS_CONTROL"]'),
|
||||||
|
('LIGHT_TICKET', 'Light-Ticket', 'LT-',
|
||||||
|
'{"max_operators": 3}',
|
||||||
|
'["TICKET_VALIDATION", "REPORTS", "EXCEL_EXPORT", "LIVE_FEED"]');
|
||||||
89
static/css/style.css
Normal file
89
static/css/style.css
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
|
||||||
|
.navbar { background: #1a1a2e; color: #fff; padding: 0.75rem 2rem; display: flex; align-items: center; gap: 2rem; }
|
||||||
|
.nav-brand { font-size: 1.2rem; font-weight: 700; }
|
||||||
|
.nav-links { display: flex; gap: 1rem; flex: 1; }
|
||||||
|
.nav-links a { color: #aaa; text-decoration: none; padding: 0.5rem 1rem; border-radius: 4px; }
|
||||||
|
.nav-links a:hover, .nav-links a.active { color: #fff; background: rgba(255,255,255,0.1); }
|
||||||
|
.nav-user { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
|
||||||
|
h1 { margin-bottom: 1.5rem; color: #1a1a2e; }
|
||||||
|
h2 { margin: 2rem 0 1rem; color: #333; }
|
||||||
|
|
||||||
|
.btn { display: inline-block; padding: 0.5rem 1rem; border: 1px solid #ddd; background: #fff; color: #333; border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 0.9rem; }
|
||||||
|
.btn:hover { background: #f0f0f0; }
|
||||||
|
.btn-primary { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||||
|
.btn-primary:hover { background: #1d4ed8; }
|
||||||
|
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||||
|
.btn-danger:hover { background: #b91c1c; }
|
||||||
|
.btn-warning { background: #f59e0b; color: #fff; border-color: #f59e0b; }
|
||||||
|
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
|
||||||
|
.btn-full { width: 100%; }
|
||||||
|
|
||||||
|
.table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.table th, .table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
|
||||||
|
.table th { background: #f8f9fa; font-weight: 600; color: #555; font-size: 0.85rem; text-transform: uppercase; }
|
||||||
|
.table tr:hover { background: #f8f9fa; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
code { background: #f1f5f9; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.status-active { background: #dcfce7; color: #166534; }
|
||||||
|
.status-trial { background: #dbeafe; color: #1e40af; }
|
||||||
|
.status-grace { background: #fef3c7; color: #92400e; }
|
||||||
|
.status-expired { background: #fee2e2; color: #991b1b; }
|
||||||
|
.status-revoked { background: #fecaca; color: #991b1b; }
|
||||||
|
.badge-ACTIVATE { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-DEACTIVATE { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-VALIDATE { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-REVOKE { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-FORCE_RELEASE { background: #fecaca; color: #991b1b; }
|
||||||
|
.badge-CREATE { background: #e0e7ff; color: #3730a3; }
|
||||||
|
.badge-UPDATE { background: #f3e8ff; color: #6b21a8; }
|
||||||
|
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
||||||
|
.stat-card { background: #fff; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.stat-card .stat-label { font-size: 1rem; font-weight: 700; color: #1a1a2e; margin-bottom: 0.75rem; }
|
||||||
|
.stat-row { display: flex; justify-content: space-between; padding: 0.2rem 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.login-container { max-width: 400px; margin: 100px auto; padding: 2rem; }
|
||||||
|
.login-container h1 { text-align: center; margin-bottom: 2rem; }
|
||||||
|
.login-form { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-group label { display: block; margin-bottom: 0.4rem; font-weight: 600; font-size: 0.9rem; }
|
||||||
|
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 0.6rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.95rem; }
|
||||||
|
.form-group textarea { resize: vertical; }
|
||||||
|
|
||||||
|
.form-card { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); max-width: 700px; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
.form-actions { margin-top: 1.5rem; display: flex; gap: 1rem; }
|
||||||
|
|
||||||
|
.filter-form { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||||||
|
.filter-form select, .filter-form input { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; }
|
||||||
|
|
||||||
|
.page-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
|
.page-header h1 { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; margin-bottom: 2rem; }
|
||||||
|
.detail-card { background: #fff; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.detail-card h3 { margin-bottom: 1rem; }
|
||||||
|
.detail-table { width: 100%; }
|
||||||
|
.detail-table td { padding: 0.4rem 0; vertical-align: top; }
|
||||||
|
.detail-table td:first-child { font-weight: 600; width: 140px; color: #666; }
|
||||||
|
.detail-table pre { margin: 0; font-size: 0.85rem; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
.action-form { margin-bottom: 1rem; }
|
||||||
|
.action-form input { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; width: 100%; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
.audit-details { margin: 0; font-size: 0.8rem; max-width: 300px; overflow: hidden; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
|
||||||
|
.alert { padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
|
||||||
|
.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar { flex-wrap: wrap; }
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
.filter-form { flex-direction: column; }
|
||||||
|
}
|
||||||
1
static/js/htmx.min.js
vendored
Normal file
1
static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
28
templates/layout/base.html
Normal file
28
templates/layout/base.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{{define "app"}}<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{block "page-title" .}}DAL License Server{{end}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-brand">DAL License Server</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/dashboard" class="{{if eq .ActivePage "dashboard"}}active{{end}}">Dashboard</a>
|
||||||
|
<a href="/licenses" class="{{if eq .ActivePage "licenses"}}active{{end}}">Licence</a>
|
||||||
|
<a href="/audit" class="{{if eq .ActivePage "audit"}}active{{end}}">Audit Log</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-user">
|
||||||
|
<form method="POST" action="/logout" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm">Odjava</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container">
|
||||||
|
{{block "page-content" .}}{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
30
templates/pages/audit.html
Normal file
30
templates/pages/audit.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{{define "audit.html"}}{{template "app" .}}{{end}}
|
||||||
|
{{define "page-title"}}Audit Log - DAL License Server{{end}}
|
||||||
|
{{define "page-content"}}
|
||||||
|
<h1>Audit Log</h1>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Vreme</th>
|
||||||
|
<th>Akcija</th>
|
||||||
|
<th>Licenca</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Detalji</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Entries}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatDate .CreatedAt}}</td>
|
||||||
|
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
|
||||||
|
<td>{{if .LicenseKey}}<code>{{.LicenseKey}}</code>{{else}}-{{end}}</td>
|
||||||
|
<td>{{.IPAddress}}</td>
|
||||||
|
<td><pre class="audit-details">{{jsonPretty .Details}}</pre></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5" class="text-center">Nema podataka</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
54
templates/pages/dashboard.html
Normal file
54
templates/pages/dashboard.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{{define "dashboard.html"}}{{template "app" .}}{{end}}
|
||||||
|
{{define "page-title"}}Dashboard - DAL License Server{{end}}
|
||||||
|
{{define "page-content"}}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
|
{{if .Stats}}
|
||||||
|
<div class="stats-grid">
|
||||||
|
{{range .Stats.ByProduct}}
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">{{.ProductName}}</div>
|
||||||
|
<div class="stat-row"><span>Aktivne:</span> <strong>{{.Active}}</strong></div>
|
||||||
|
<div class="stat-row"><span>Istekle:</span> <strong>{{.Expired}}</strong></div>
|
||||||
|
<div class="stat-row"><span>Grace:</span> <strong>{{.InGrace}}</strong></div>
|
||||||
|
<div class="stat-row"><span>Trial:</span> <strong>{{.Trial}}</strong></div>
|
||||||
|
<div class="stat-row"><span>Aktivacija:</span> <strong>{{.ActiveActivations}}</strong></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Expiring}}
|
||||||
|
<h2>Isticu u narednih 7 dana</h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Kljuc</th><th>Proizvod</th><th>Firma</th><th>Istice</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Expiring}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.LicenseKey}}</code></td>
|
||||||
|
<td>{{.ProductName}}</td>
|
||||||
|
<td>{{.CustomerName}}</td>
|
||||||
|
<td>{{.ExpiresAtFormatted}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Poslednja aktivnost</h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Vreme</th><th>Akcija</th><th>Licenca</th><th>IP</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Recent}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatDate .CreatedAt}}</td>
|
||||||
|
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
|
||||||
|
<td>{{if .LicenseKey}}<code>{{.LicenseKey}}</code>{{else}}-{{end}}</td>
|
||||||
|
<td>{{.IPAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4" class="text-center">Nema podataka</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
77
templates/pages/license-detail.html
Normal file
77
templates/pages/license-detail.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{{define "license-detail.html"}}{{template "app" .}}{{end}}
|
||||||
|
{{define "page-title"}}Licenca {{.License.LicenseKey}} - DAL License Server{{end}}
|
||||||
|
{{define "page-content"}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Licenca: <code>{{.License.LicenseKey}}</code></h1>
|
||||||
|
<span class="badge {{.License.StatusClass}}">{{.License.StatusText}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3>Informacije</h3>
|
||||||
|
<table class="detail-table">
|
||||||
|
<tr><td>Proizvod</td><td>{{.License.ProductName}} ({{.License.ProductCode}})</td></tr>
|
||||||
|
<tr><td>Tip</td><td>{{.License.LicenseType}}</td></tr>
|
||||||
|
<tr><td>Firma</td><td>{{.License.CustomerName}}</td></tr>
|
||||||
|
<tr><td>PIB</td><td>{{.License.CustomerPIB}}</td></tr>
|
||||||
|
<tr><td>Email</td><td>{{.License.CustomerEmail}}</td></tr>
|
||||||
|
<tr><td>Izdata</td><td>{{formatDate .License.IssuedAt}}</td></tr>
|
||||||
|
<tr><td>Istice</td><td>{{.License.ExpiresAtFormatted}}</td></tr>
|
||||||
|
<tr><td>Grace period</td><td>{{.License.GraceDays}} dana</td></tr>
|
||||||
|
<tr><td>Limiti</td><td><pre>{{jsonPretty .License.Limits}}</pre></td></tr>
|
||||||
|
<tr><td>Features</td><td><pre>{{jsonPretty .License.Features}}</pre></td></tr>
|
||||||
|
{{if .License.Notes}}<tr><td>Napomena</td><td>{{.License.Notes}}</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3>Akcije</h3>
|
||||||
|
{{if not .License.Revoked}}
|
||||||
|
<form method="POST" action="/licenses/{{.License.ID}}/revoke" class="action-form">
|
||||||
|
<input type="text" name="reason" placeholder="Razlog opoziva">
|
||||||
|
<button type="submit" class="btn btn-danger" onclick="return confirm('Da li ste sigurni?')">Opozovi licencu</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form method="POST" action="/licenses/{{.License.ID}}/release" class="action-form">
|
||||||
|
<button type="submit" class="btn btn-warning" onclick="return confirm('Osloboditi aktivaciju?')">Force Release</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Aktivacije</h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Hostname</th><th>OS</th><th>Verzija</th><th>IP</th><th>Aktivirana</th><th>Poslednji put</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Activations}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Hostname}}</td>
|
||||||
|
<td>{{.OSInfo}}</td>
|
||||||
|
<td>{{.AppVersion}}</td>
|
||||||
|
<td>{{.IPAddress}}</td>
|
||||||
|
<td>{{formatDate .ActivatedAt}}</td>
|
||||||
|
<td>{{formatDate .LastSeenAt}}</td>
|
||||||
|
<td>{{if .IsActive}}<span class="badge status-active">Aktivna</span>{{else}}<span class="badge status-expired">Deaktivirana</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7" class="text-center">Nema aktivacija</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Audit Log</h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Vreme</th><th>Akcija</th><th>IP</th><th>Detalji</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Audit}}
|
||||||
|
<tr>
|
||||||
|
<td>{{formatDate .CreatedAt}}</td>
|
||||||
|
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
|
||||||
|
<td>{{.IPAddress}}</td>
|
||||||
|
<td><pre class="audit-details">{{jsonPretty .Details}}</pre></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4" class="text-center">Nema podataka</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
66
templates/pages/license-new.html
Normal file
66
templates/pages/license-new.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{{define "license-new.html"}}{{template "app" .}}{{end}}
|
||||||
|
{{define "page-title"}}Nova licenca - DAL License Server{{end}}
|
||||||
|
{{define "page-content"}}
|
||||||
|
<h1>Nova licenca</h1>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/licenses" class="form-card">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Proizvod</label>
|
||||||
|
<select name="product_id" required>
|
||||||
|
{{range .Products}}
|
||||||
|
<option value="{{.ID}}">{{.Name}} ({{.Code}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tip licence</label>
|
||||||
|
<select name="license_type" required>
|
||||||
|
<option value="MONTHLY">Mesecna (30 dana)</option>
|
||||||
|
<option value="ANNUAL">Godisnja (365 dana)</option>
|
||||||
|
<option value="PERPETUAL">Trajna</option>
|
||||||
|
<option value="TRIAL">Trial (30 dana)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Firma</label>
|
||||||
|
<input type="text" name="customer_name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PIB</label>
|
||||||
|
<input type="text" name="customer_pib">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="customer_email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Limiti (JSON)</label>
|
||||||
|
<input type="text" name="limits" placeholder='{"max_operators": 3}'>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Grace period (dani)</label>
|
||||||
|
<input type="number" name="grace_days" value="30">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Napomena</label>
|
||||||
|
<textarea name="notes" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Kreiraj licencu</button>
|
||||||
|
<a href="/licenses" class="btn">Otkazi</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
55
templates/pages/licenses.html
Normal file
55
templates/pages/licenses.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{{define "licenses.html"}}{{template "app" .}}{{end}}
|
||||||
|
{{define "page-title"}}Licence - DAL License Server{{end}}
|
||||||
|
{{define "page-content"}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Licence</h1>
|
||||||
|
<a href="/licenses/new" class="btn btn-primary">Nova licenca</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="/licenses" class="filter-form">
|
||||||
|
<select name="product">
|
||||||
|
<option value="">Svi proizvodi</option>
|
||||||
|
{{range .Products}}
|
||||||
|
<option value="{{.Code}}" {{if eq $.Product .Code}}selected{{end}}>{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<select name="status">
|
||||||
|
<option value="">Svi statusi</option>
|
||||||
|
<option value="active" {{if eq .Status "active"}}selected{{end}}>Aktivne</option>
|
||||||
|
<option value="expired" {{if eq .Status "expired"}}selected{{end}}>Istekle</option>
|
||||||
|
<option value="revoked" {{if eq .Status "revoked"}}selected{{end}}>Opozvane</option>
|
||||||
|
<option value="trial" {{if eq .Status "trial"}}selected{{end}}>Trial</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="search" placeholder="Pretraga po firmi..." value="{{.Search}}">
|
||||||
|
<button type="submit" class="btn">Filtriraj</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kljuc</th>
|
||||||
|
<th>Proizvod</th>
|
||||||
|
<th>Firma</th>
|
||||||
|
<th>Tip</th>
|
||||||
|
<th>Istice</th>
|
||||||
|
<th>Aktivacija</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Licenses}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/licenses/{{.ID}}"><code>{{.LicenseKey}}</code></a></td>
|
||||||
|
<td>{{.ProductCode}}</td>
|
||||||
|
<td>{{.CustomerName}}</td>
|
||||||
|
<td>{{.LicenseType}}</td>
|
||||||
|
<td>{{.ExpiresAtFormatted}}</td>
|
||||||
|
<td>{{.ActiveActivations}}</td>
|
||||||
|
<td><span class="badge {{.StatusClass}}">{{.StatusText}}</span></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7" class="text-center">Nema licenci</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
22
templates/pages/login.html
Normal file
22
templates/pages/login.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{{define "login.html"}}<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Prijava - DAL License Server</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<h1>DAL License Server</h1>
|
||||||
|
<form method="POST" action="/login" class="login-form">
|
||||||
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Lozinka</label>
|
||||||
|
<input type="password" name="password" autofocus required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Prijava</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
Loading…
Reference in New Issue
Block a user