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:
djuka 2026-03-04 07:42:25 +00:00
commit dc0114e4b7
50 changed files with 4762 additions and 0 deletions

View File

@ -0,0 +1,3 @@
{
"next": 1
}

8
.claude/project.json Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
.env
*.exe
dal-license-server
crypto/private.pem
log/*.log
data/

272
API.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}
}
}

View 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)
}

View 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)
}

View 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",
})
}

View 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
}

View 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)
})
}
}

View 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)
}
}

View 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)
})
}
}

View 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)
}
}

View 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
View 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
View 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"`
}

View 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
View 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"`
}

View 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
}

View 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)
}

View 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, &notes, &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, &notes, &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, &notes, &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
View 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
}

View 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{}

View 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
}

View 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")
}
}

View 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
}

View 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
}
}

View 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)
}

View 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)
);

View 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
View 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

File diff suppressed because one or more lines are too long

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}

View 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}}