add developer documentation and custom rate limiter with banning
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 46s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 46s
Add a comprehensive guide for developers transitioning from Node/PHP and implement a new middleware to handle IP-based rate limiting with temporary banning functionality. Also refactors configuration loading to use a singleton pattern for better resource management.
This commit is contained in:
896
DOCS.md
Normal file
896
DOCS.md
Normal file
@@ -0,0 +1,896 @@
|
||||
# EMLy API — Documentazione per sviluppatori Node/PHP
|
||||
|
||||
Questa guida spiega l'intera architettura dell'API **emly-api-go** assumendo che tu conosca Node.js (Express/Fastify) o PHP (Laravel/Slim), ma che non abbia mai scritto Go.
|
||||
|
||||
---
|
||||
|
||||
## Indice
|
||||
|
||||
1. [Go per chi viene da Node/PHP](#1-go-per-chi-viene-da-nodephp)
|
||||
2. [Struttura del progetto](#2-struttura-del-progetto)
|
||||
3. [Setup e avvio](#3-setup-e-avvio)
|
||||
4. [go-chi: il router](#4-go-chi-il-router)
|
||||
5. [Middleware](#5-middleware)
|
||||
6. [Handler (i controller)](#6-handler-i-controller)
|
||||
7. [Modelli e database](#7-modelli-e-database)
|
||||
8. [Sistema di autenticazione](#8-sistema-di-autenticazione)
|
||||
9. [Migrazioni del database](#9-migrazioni-del-database)
|
||||
10. [Endpoints API completi](#10-endpoints-api-completi)
|
||||
11. [Come aggiungere un nuovo endpoint](#11-come-aggiungere-un-nuovo-endpoint)
|
||||
|
||||
---
|
||||
|
||||
## 1. Go per chi viene da Node/PHP
|
||||
|
||||
### Analogie rapide
|
||||
|
||||
| Concetto | Node/Express | PHP/Laravel | Go (questo progetto) |
|
||||
|------------------------|-----------------------|---------------------|-----------------------------------|
|
||||
| Entry point | `index.js` | `public/index.php` | `main.go` |
|
||||
| Router | Express, Fastify | Router Laravel | `go-chi/chi` |
|
||||
| Middleware | `app.use(...)` | Middleware Laravel | `r.Use(...)` |
|
||||
| Controller | `req, res` function | Controller class | `http.HandlerFunc` |
|
||||
| ORM / Query builder | Sequelize, Knex | Eloquent | `sqlx` (query raw con struct) |
|
||||
| `.env` | `dotenv` | `vlucas/phpdotenv` | `joho/godotenv` |
|
||||
| Tipi / interfacce | TypeScript interfaces | PHP types | `struct` |
|
||||
| `package.json` | `package.json` | `composer.json` | `go.mod` |
|
||||
| Hot reload | `nodemon` | N/A | `air` |
|
||||
|
||||
### Differenze chiave da tenere a mente
|
||||
|
||||
**Go compila tutto in un singolo binario.** Non c'e' un runtime separato come Node. Il risultato di `go build` e' un `.exe` (o file ELF su Linux) che contiene tutto: il server, i template, le query SQL — tutto embedded.
|
||||
|
||||
**Niente `null` implicito.** In Go ogni variabile ha sempre un valore di default (0, "", false, nil). Gli errori si gestiscono con valori di ritorno, non con eccezioni:
|
||||
|
||||
```go
|
||||
// Node
|
||||
try {
|
||||
const result = await db.query(sql)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
// Go
|
||||
result, err := db.QueryContext(ctx, sql)
|
||||
if err != nil {
|
||||
// gestisci l'errore
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**I package sono come i moduli ES.** Il nome del package in cima al file (es. `package handlers`) e' l'equivalente di un namespace. Le funzioni con la prima lettera maiuscola (`CreateBugReport`) sono pubbliche (exported); quelle minuscole (`jsonError`) sono private al package.
|
||||
|
||||
**Le funzioni ritornano valori multipli.** In Go e' normale avere `(result, error)` come return type. Non ci sono Promise; il codice e' sincrono (il parallelismo si fa con goroutine, non usate direttamente in questo progetto).
|
||||
|
||||
**I `struct` sono i "model".** Non ci sono classi in Go. Uno `struct` con tag `db:"..."` e `json:"..."` funziona sia da schema DB (come un Model Eloquent) che da DTO JSON.
|
||||
|
||||
---
|
||||
|
||||
## 2. Struttura del progetto
|
||||
|
||||
```
|
||||
emly-api-go/
|
||||
├── main.go # Entry point: boot server, DB, middleware globali
|
||||
├── go.mod # Dipendenze (come package.json)
|
||||
├── .env # Variabili d'ambiente locali
|
||||
│
|
||||
└── internal/ # Codice privato dell'applicazione
|
||||
├── config/
|
||||
│ └── config.go # Carica env vars in una struct Config
|
||||
│
|
||||
├── database/
|
||||
│ ├── database.go # Apre la connessione MySQL (pool)
|
||||
│ └── schema/
|
||||
│ ├── migrator.go # Sistema di migration condizionali
|
||||
│ ├── init.sql # Schema base (CREATE TABLE IF NOT EXISTS)
|
||||
│ └── migrations/
|
||||
│ ├── tasks.json # Definisce le migration con condizioni
|
||||
│ ├── 1_bug_reports.sql
|
||||
│ └── 2_users.sql
|
||||
│
|
||||
├── handlers/ # I "controller" — una funzione per endpoint
|
||||
│ ├── response.go # Helper: jsonOK, jsonCreated, jsonError
|
||||
│ ├── health.route.go # GET /v1/health
|
||||
│ ├── bug_report.route.go # Tutti gli endpoint /bug-reports
|
||||
│ ├── admin_auth.route.go # Login, validate, logout
|
||||
│ ├── admin_users.route.go # CRUD utenti admin
|
||||
│ └── templates/
|
||||
│ └── report.txt.tmpl # Template testo per il file ZIP
|
||||
│
|
||||
├── middleware/
|
||||
│ ├── apikey.go # Verifica header X-API-Key
|
||||
│ └── adminKey.go # Verifica header X-Admin-Key
|
||||
│
|
||||
├── models/ # Struct che mappano le tabelle DB e i JSON
|
||||
│ ├── bug_report.go
|
||||
│ ├── bug_report_file.go
|
||||
│ ├── user.go
|
||||
│ ├── session.go
|
||||
│ └── rate_limit_hwid.go
|
||||
│
|
||||
└── routes/
|
||||
├── routes.go # Monta i sub-router sul router root
|
||||
└── v1/
|
||||
├── v1.go # Crea il router /v1 con middleware globale v1
|
||||
├── bug_reports.go # Registra le rotte /bug-reports
|
||||
└── admin.go # Registra le rotte /admin
|
||||
```
|
||||
|
||||
### Perche' `internal/`?
|
||||
|
||||
In Go, tutto cio' che sta dentro `internal/` non puo' essere importato da progetti esterni. E' una convenzione per dire "questo codice e' implementazione privata, non una libreria pubblica". Equivale a non esportare un modulo in Node.
|
||||
|
||||
---
|
||||
|
||||
## 3. Setup e avvio
|
||||
|
||||
### Prerequisiti
|
||||
|
||||
- Go 1.21+
|
||||
- MySQL 8+
|
||||
- `air` per hot-reload in sviluppo: `go install github.com/air-verse/air@latest`
|
||||
|
||||
### Configurazione `.env`
|
||||
|
||||
Copia `.env.example` in `.env`:
|
||||
|
||||
```env
|
||||
PORT=8080
|
||||
|
||||
# DSN MySQL — DEVE includere parseTime=true&loc=UTC
|
||||
DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC
|
||||
DATABASE_NAME=emly
|
||||
|
||||
# Chiavi di autenticazione
|
||||
API_KEY=la-tua-api-key
|
||||
ADMIN_KEY=la-tua-admin-key
|
||||
|
||||
# Pool di connessioni (opzionali, hanno default)
|
||||
DB_MAX_OPEN_CONNS=30
|
||||
DB_MAX_IDLE_CONNS=5
|
||||
DB_CONN_MAX_LIFETIME=5
|
||||
```
|
||||
|
||||
### Comandi
|
||||
|
||||
```bash
|
||||
# Sviluppo con hot-reload (come nodemon)
|
||||
air
|
||||
|
||||
# Build binario di produzione
|
||||
go build -o ./build/emly-api.exe .
|
||||
|
||||
# Avvio diretto senza build
|
||||
go run .
|
||||
|
||||
# Test
|
||||
go test ./...
|
||||
go test ./internal/... -run NomeTest -v
|
||||
```
|
||||
|
||||
### Cosa succede all'avvio
|
||||
|
||||
1. Carica `.env` (se presente)
|
||||
2. Legge la config dalle env vars
|
||||
3. Apre il pool di connessioni MySQL
|
||||
4. Esegue le migrazioni (vedi sezione 9)
|
||||
5. Crea il router chi e registra tutti i middleware e le rotte
|
||||
6. Avvia il server HTTP su `PORT`
|
||||
|
||||
---
|
||||
|
||||
## 4. go-chi: il router
|
||||
|
||||
`go-chi/chi` e' l'equivalente di Express.js per Go. E' un router HTTP leggero e componibile.
|
||||
|
||||
### Analogia Express → Chi
|
||||
|
||||
```javascript
|
||||
// Express
|
||||
const app = express()
|
||||
app.use(morgan('dev'))
|
||||
app.get('/users/:id', (req, res) => { ... })
|
||||
```
|
||||
|
||||
```go
|
||||
// Chi
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { ... })
|
||||
```
|
||||
|
||||
### Differenze nella sintassi dei parametri
|
||||
|
||||
| Express | Chi |
|
||||
|---------------|---------------|
|
||||
| `/users/:id` | `/users/{id}` |
|
||||
| `req.params.id` | `chi.URLParam(r, "id")` |
|
||||
| `req.query.page` | `r.URL.Query().Get("page")` |
|
||||
| `req.body` | `r.Body` (letto con `json.NewDecoder`) |
|
||||
| `req.headers` | `r.Header.Get("X-API-Key")` |
|
||||
|
||||
### API principali di chi
|
||||
|
||||
#### `chi.NewRouter()`
|
||||
Crea un nuovo router. Equivale a `express()` o `new Slim\App()`.
|
||||
|
||||
```go
|
||||
r := chi.NewRouter()
|
||||
```
|
||||
|
||||
#### `r.Use(middleware)`
|
||||
Aggiunge un middleware globale al router (o al gruppo corrente). L'ordine conta: vengono eseguiti nell'ordine di registrazione.
|
||||
|
||||
```go
|
||||
r.Use(middleware.Logger) // loga ogni richiesta
|
||||
r.Use(middleware.Recoverer) // cattura i panic (come un try/catch globale)
|
||||
```
|
||||
|
||||
#### Metodi HTTP: `r.Get`, `r.Post`, `r.Patch`, `r.Delete`
|
||||
|
||||
```go
|
||||
r.Get("/health", handlers.Health(db))
|
||||
r.Post("/bug-reports/", handlers.CreateBugReport(db))
|
||||
r.Patch("/bug-reports/{id}/status", handlers.PatchBugReportStatus(db))
|
||||
r.Delete("/bug-reports/{id}", handlers.DeleteBugReportByID(db))
|
||||
```
|
||||
|
||||
#### `r.Route(pattern, fn)` — Gruppo con prefisso
|
||||
|
||||
Crea un sotto-gruppo con un prefisso comune. Equivale a `express.Router()` o ai route group di Laravel.
|
||||
|
||||
```go
|
||||
// Tutte le rotte dentro la funzione avranno il prefisso /bug-reports
|
||||
r.Route("/bug-reports", func(r chi.Router) {
|
||||
r.Get("/", handlers.GetAllBugReports(db))
|
||||
r.Get("/{id}", handlers.GetBugReportByID(db))
|
||||
})
|
||||
```
|
||||
|
||||
#### `r.Group(fn)` — Gruppo senza prefisso
|
||||
|
||||
Crea un gruppo di rotte che condividono middleware, ma senza aggiungere un prefisso al path. Utile per applicare auth diversa a rotte dello stesso livello.
|
||||
|
||||
```go
|
||||
r.Route("/bug-reports", func(r chi.Router) {
|
||||
|
||||
// Gruppo 1: solo API key
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.APIKeyAuth(db))
|
||||
r.Get("/count", handlers.GetReportsCount(db))
|
||||
r.Post("/", handlers.CreateBugReport(db))
|
||||
})
|
||||
|
||||
// Gruppo 2: API key + Admin key
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.APIKeyAuth(db))
|
||||
r.Use(apimw.AdminKeyAuth(db))
|
||||
r.Get("/", handlers.GetAllBugReports(db))
|
||||
r.Delete("/{id}", handlers.DeleteBugReportByID(db))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### `r.Mount(pattern, handler)` — Monta un sub-router
|
||||
|
||||
Incolla un router separato su un prefisso. Permette di dividere le rotte in file diversi mantenendo tutto componibile. Equivale a `app.use('/v1', v1Router)` in Express.
|
||||
|
||||
```go
|
||||
// routes.go
|
||||
r.Mount("/v1", v1.NewRouter(db))
|
||||
```
|
||||
|
||||
#### `chi.URLParam(r, "name")` — Legge i parametri di percorso
|
||||
|
||||
```go
|
||||
// Rotta: /bug-reports/{id}
|
||||
id := chi.URLParam(r, "id") // es. "42"
|
||||
```
|
||||
|
||||
### Middleware built-in di chi (`go-chi/chi/v5/middleware`)
|
||||
|
||||
Questi sono tutti usati in `main.go`:
|
||||
|
||||
| Middleware | Equivalente Node/Laravel | Cosa fa |
|
||||
|--------------------------|---------------------------------------|-----------------------------------------------------------|
|
||||
| `middleware.RequestID` | `express-request-id` | Aggiunge un UUID univoco `X-Request-Id` ad ogni richiesta |
|
||||
| `middleware.RealIP` | `express-ip` / `TrustProxies` | Legge l'IP reale da `X-Forwarded-For` o `X-Real-IP` |
|
||||
| `middleware.Logger` | `morgan` | Loga metodo, path, status, durata su stdout |
|
||||
| `middleware.Recoverer` | Express error handler / `rescue_from` | Cattura i panic e ritorna 500 invece di crashare |
|
||||
| `middleware.Timeout(30s)`| `connect-timeout` | Cancella la richiesta se supera i 30 secondi |
|
||||
|
||||
### Rate limiting (`go-chi/httprate`)
|
||||
|
||||
```go
|
||||
// main.go — globale: max 100 req/min per IP
|
||||
r.Use(httprate.LimitByIP(100, time.Minute))
|
||||
|
||||
// nelle rotte — per gruppo: max 30 req/min per IP
|
||||
r.Use(httprate.LimitByIP(30, time.Minute))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Middleware
|
||||
|
||||
In questo progetto i middleware custom si trovano in `internal/middleware/`. Un middleware in Go e' una funzione che prende un `http.Handler` e restituisce un `http.Handler`.
|
||||
|
||||
**Concettualmente identico a Express:**
|
||||
|
||||
```javascript
|
||||
// Express
|
||||
function apiKeyAuth(req, res, next) {
|
||||
if (req.headers['x-api-key'] !== process.env.API_KEY) {
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Go/Chi
|
||||
func APIKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler {
|
||||
// Questo blocco gira UNA VOLTA sola all'avvio (come costruttore)
|
||||
allowed := map[string]struct{}{cfg.APIKey: {}}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
// Questo ritorna il middleware vero e proprio
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Header.Get("X-API-Key")
|
||||
if _, ok := allowed[key]; !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return // equivale a NON chiamare next()
|
||||
}
|
||||
next.ServeHTTP(w, r) // equivale a next()
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware disponibili
|
||||
|
||||
| Middleware | Header richiesto | Applicato a |
|
||||
|------------------|-------------------|-------------------------------------------------------|
|
||||
| `APIKeyAuth` | `X-API-Key` | Tutti gli endpoint `/v1/api/bug-reports/*` |
|
||||
| `AdminKeyAuth` | `X-Admin-Key` | Endpoint admin bug-reports + `/v1/api/admin/users/*` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Handler (i controller)
|
||||
|
||||
Gli handler sono in `internal/handlers/`. Ogni file corrisponde a una risorsa (es. `bug_report.route.go`).
|
||||
|
||||
### Pattern factory function
|
||||
|
||||
Gli handler NON sono funzioni dirette. Sono **factory functions** che ricevono `*sqlx.DB` e restituiscono l'handler vero. Questo permette di iniettare la dipendenza del database senza variabili globali.
|
||||
|
||||
```go
|
||||
// Definizione
|
||||
func CreateBugReport(db *sqlx.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// qui hai accesso a `db` tramite closure
|
||||
}
|
||||
}
|
||||
|
||||
// Utilizzo nel router
|
||||
r.Post("/", handlers.CreateBugReport(db))
|
||||
```
|
||||
|
||||
**Analogo Node sarebbe:**
|
||||
|
||||
```javascript
|
||||
function createBugReport(db) {
|
||||
return async (req, res) => {
|
||||
// uso db
|
||||
}
|
||||
}
|
||||
app.post('/', createBugReport(db))
|
||||
```
|
||||
|
||||
### Response helpers (`response.go`)
|
||||
|
||||
Tre funzioni usate in tutti gli handler per rispondere in JSON:
|
||||
|
||||
```go
|
||||
jsonOK(w, payload) // HTTP 200 + JSON
|
||||
jsonCreated(w, payload) // HTTP 201 + JSON
|
||||
jsonError(w, status, msg) // HTTP <status> + { "error": "msg" }
|
||||
```
|
||||
|
||||
### Leggere il body JSON
|
||||
|
||||
```go
|
||||
// In Go (come json.parse in Node)
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### Leggere query string
|
||||
|
||||
```go
|
||||
page := r.URL.Query().Get("page") // ?page=2
|
||||
status := r.URL.Query().Get("status") // ?status=new
|
||||
```
|
||||
|
||||
### Upload multipart (form-data)
|
||||
|
||||
```go
|
||||
// Legge fino a 32MB in memoria
|
||||
r.ParseMultipartForm(32 << 20)
|
||||
|
||||
name := r.FormValue("name") // campo testo
|
||||
file, header, err := r.FormFile("screenshot") // file upload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Modelli e database
|
||||
|
||||
### I modelli (`internal/models/`)
|
||||
|
||||
I modelli sono semplici struct con tag speciali. Non c'e' ORM: le query sono SQL raw.
|
||||
|
||||
```go
|
||||
type BugReport struct {
|
||||
ID uint64 `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Email string `db:"email" json:"email"`
|
||||
// json:"-" significa: NON includere mai questo campo nella risposta JSON
|
||||
PasswordHash string `db:"password_hash" json:"-"`
|
||||
}
|
||||
```
|
||||
|
||||
- `db:"nome_colonna"` — mappa la colonna SQL al campo Go (usato da `sqlx`)
|
||||
- `json:"nome_campo"` — mappa il campo Go alla chiave JSON (usato da `encoding/json`)
|
||||
- `json:"-"` — campo nascosto nelle risposte JSON (es. `password_hash`, `data` dei file)
|
||||
- `json:"campo,omitempty"` — il campo viene omesso dal JSON se e' zero/nil
|
||||
|
||||
### sqlx — Query database
|
||||
|
||||
`sqlx` e' un wrapper sottile su `database/sql`. Non e' un ORM: si scrive SQL a mano e si mappa il risultato su struct.
|
||||
|
||||
```go
|
||||
// Legge UNA riga in una struct (GetContext)
|
||||
// Equivale a: db.query('SELECT ... WHERE id = ?', [id]).then(rows => rows[0])
|
||||
var report models.BugReport
|
||||
err := db.GetContext(r.Context(), &report,
|
||||
"SELECT * FROM bug_reports WHERE id = ?", id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// nessun risultato trovato
|
||||
}
|
||||
|
||||
// Legge PIU' righe in uno slice (SelectContext)
|
||||
// Equivale a: db.query('SELECT ...').then(rows => rows)
|
||||
var reports []models.BugReport
|
||||
err := db.SelectContext(r.Context(), &reports,
|
||||
"SELECT * FROM bug_reports ORDER BY created_at DESC")
|
||||
|
||||
// Esegue INSERT/UPDATE/DELETE (ExecContext)
|
||||
result, err := db.ExecContext(r.Context(),
|
||||
"INSERT INTO bug_reports (name, email) VALUES (?, ?)", name, email)
|
||||
reportID, _ := result.LastInsertId()
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
```
|
||||
|
||||
**Perche' `r.Context()`?** Il context trasporta la deadline di timeout (30s impostata in `main.go`). Se la richiesta viene cancellata (timeout o client disconnect), la query SQL viene interrotta automaticamente. E' come `AbortController` in Node ma automatico.
|
||||
|
||||
### Tipi custom (enum-like)
|
||||
|
||||
Go non ha enum. Si usa un `type` su `string` con costanti:
|
||||
|
||||
```go
|
||||
type BugReportStatus string
|
||||
|
||||
const (
|
||||
BugReportStatusNew BugReportStatus = "new"
|
||||
BugReportStatusInReview BugReportStatus = "in_review"
|
||||
BugReportStatusResolved BugReportStatus = "resolved"
|
||||
BugReportStatusClosed BugReportStatus = "closed"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Sistema di autenticazione
|
||||
|
||||
L'API ha **due livelli di autenticazione separati**, entrambi header-based (niente cookie, niente JWT per le rotte API).
|
||||
|
||||
### Livello 1: API Key (`X-API-Key`)
|
||||
|
||||
Usata da client esterni (es. l'applicazione desktop) per inviare bug report.
|
||||
|
||||
```
|
||||
X-API-Key: la-tua-api-key
|
||||
```
|
||||
|
||||
Il valore e' configurato in `.env` come `API_KEY`. Il middleware `APIKeyAuth` carica la chiave in una mappa all'avvio e fa un lookup O(1) ad ogni richiesta.
|
||||
|
||||
### Livello 2: Admin Key (`X-Admin-Key`)
|
||||
|
||||
Usata per accedere agli endpoint di sola lettura/scrittura amministrativa.
|
||||
|
||||
```
|
||||
X-Admin-Key: la-tua-admin-key
|
||||
```
|
||||
|
||||
Il valore e' configurato in `.env` come `ADMIN_KEY`.
|
||||
|
||||
### Livello 3: Sessione utente (`X-Session-Token`)
|
||||
|
||||
Gli endpoint `/v1/api/admin/auth/*` gestiscono login/logout di utenti admin con sessioni su database.
|
||||
|
||||
**Flusso login:**
|
||||
|
||||
```
|
||||
POST /v1/api/admin/auth/login
|
||||
Body: { "username": "...", "password": "..." }
|
||||
|
||||
Response: {
|
||||
"session_id": "<64-char hex token>",
|
||||
"user": { "id": "...", "username": "...", "role": "admin", ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Flusso validate:**
|
||||
|
||||
```
|
||||
GET /v1/api/admin/auth/validate
|
||||
Headers: X-Session-Token: <session_id>
|
||||
|
||||
Response: { "success": true, "user": { ... } }
|
||||
```
|
||||
|
||||
**Flusso logout:**
|
||||
|
||||
```
|
||||
POST /v1/api/admin/auth/logout
|
||||
Headers: X-Session-Token: <session_id>
|
||||
```
|
||||
|
||||
**Dettagli implementativi:**
|
||||
- Password hashed con **argon2id** (formato PHC, compatibile con `@node-rs/argon2`)
|
||||
- Sessioni salvate in tabella `session` con scadenza a **30 giorni**
|
||||
- Le sessioni scadute vengono eliminate automaticamente alla prima validazione fallita
|
||||
- UUID utenti generati con `crypto/rand` (UUID v4)
|
||||
- Session ID: 32 byte random, encoded come hex (64 caratteri)
|
||||
|
||||
---
|
||||
|
||||
## 9. Migrazioni del database
|
||||
|
||||
Il sistema di migration e' custom e si trova in `internal/database/schema/`. Non usa librerie esterne come `golang-migrate`.
|
||||
|
||||
### Come funziona
|
||||
|
||||
All'avvio, `schema.Migrate(db, cfg.Database)` esegue questi passi:
|
||||
|
||||
1. **Controlla se il database e' vuoto** — Se non ci sono tabelle, esegue `init.sql` che crea tutto lo schema base con `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
2. **Controlla le tabelle attese** — Se il DB non e' vuoto ma mancano tabelle, riesegue `init.sql`.
|
||||
|
||||
3. **Esegue le migration condizionali** — Legge `migrations/tasks.json` e per ogni task valuta le condizioni. Un task viene eseguito solo se almeno una condizione e' vera.
|
||||
|
||||
### `tasks.json` — Definizione migration
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "1_bug_reports",
|
||||
"sql_file": "1_bug_reports.sql",
|
||||
"description": "Add hostname, os_user columns and their indexes to bug_reports.",
|
||||
"conditions": [
|
||||
{ "type": "column_not_exists", "table": "bug_reports", "column": "hostname" },
|
||||
{ "type": "column_not_exists", "table": "bug_reports", "column": "os_user" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tipi di condizione supportati
|
||||
|
||||
| Tipo | Esegui la migration se... |
|
||||
|---------------------|----------------------------------------------------|
|
||||
| `column_not_exists` | la colonna non esiste nella tabella |
|
||||
| `column_exists` | la colonna esiste nella tabella |
|
||||
| `index_not_exists` | l'indice non esiste nella tabella |
|
||||
| `index_exists` | l'indice esiste nella tabella |
|
||||
| `table_not_exists` | la tabella non esiste nel database |
|
||||
| `table_exists` | la tabella esiste nel database |
|
||||
|
||||
### Come aggiungere una migration
|
||||
|
||||
1. Crea il file SQL in `internal/database/schema/migrations/3_nome.sql`
|
||||
2. Aggiungi il task in `tasks.json` con le condizioni appropriate
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "3_nome",
|
||||
"sql_file": "3_nome.sql",
|
||||
"description": "Descrizione della migration.",
|
||||
"conditions": [
|
||||
{ "type": "column_not_exists", "table": "bug_reports", "column": "nuova_colonna" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Endpoints API completi
|
||||
|
||||
Base URL: `http://localhost:8080`
|
||||
|
||||
### Header di autenticazione
|
||||
|
||||
| Header | Richiesto per |
|
||||
|-------------------|-------------------------------------------------------|
|
||||
| `X-API-Key` | Tutti gli endpoint `/v1/api/bug-reports/*` |
|
||||
| `X-Admin-Key` | Endpoint admin bug-reports + `/v1/api/admin/users/*` |
|
||||
| `X-Session-Token` | `/v1/api/admin/auth/validate` e `/logout` |
|
||||
|
||||
---
|
||||
|
||||
### Pubblici
|
||||
|
||||
#### `GET /`
|
||||
Ping. Ritorna il testo `emly-api-go`.
|
||||
|
||||
#### `GET /v1/health`
|
||||
```json
|
||||
{ "status": "ok", "db": "ok" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bug Reports — Solo `X-API-Key`
|
||||
|
||||
#### `POST /v1/api/bug-reports/`
|
||||
Crea un nuovo bug report. Content-Type: `multipart/form-data`.
|
||||
|
||||
| Campo | Tipo | Obbligatorio | Descrizione |
|
||||
|---------------|--------|:------------:|-------------------------------------|
|
||||
| `name` | string | si | Nome del reporter |
|
||||
| `email` | string | si | Email del reporter |
|
||||
| `description` | string | si | Descrizione del bug |
|
||||
| `hwid` | string | no | Hardware ID |
|
||||
| `hostname` | string | no | Nome macchina |
|
||||
| `os_user` | string | no | Utente OS |
|
||||
| `system_info` | string | no | JSON serializzato con info sistema |
|
||||
| `attachment` | file | no | File allegato generico |
|
||||
| `screenshot` | file | no | Screenshot del bug |
|
||||
| `log` | file | no | File di log |
|
||||
|
||||
**Response 201:**
|
||||
```json
|
||||
{ "success": true, "report_id": 42, "message": "Bug report submitted successfully" }
|
||||
```
|
||||
|
||||
#### `GET /v1/api/bug-reports/count`
|
||||
```json
|
||||
{ "count": 128 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bug Reports — `X-API-Key` + `X-Admin-Key`
|
||||
|
||||
#### `GET /v1/api/bug-reports/`
|
||||
Lista paginata con filtri.
|
||||
|
||||
| Query param | Default | Descrizione |
|
||||
|-------------|---------|----------------------------------------------------------|
|
||||
| `page` | 1 | Numero pagina |
|
||||
| `page_size` | 20 | Risultati per pagina (max 100) |
|
||||
| `status` | - | Filtra per stato: `new`, `in_review`, `resolved`, `closed` |
|
||||
| `search` | - | Cerca in hostname, os_user, name, email |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"data": [ /* array di BugReportListItem */ ],
|
||||
"total": 128,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 7
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /v1/api/bug-reports/{id}`
|
||||
Dettaglio singolo report.
|
||||
|
||||
#### `GET /v1/api/bug-reports/{id}/status`
|
||||
```json
|
||||
{ "status": "new" }
|
||||
```
|
||||
|
||||
#### `PATCH /v1/api/bug-reports/{id}/status`
|
||||
Body: stringa raw (non JSON) con il nuovo stato.
|
||||
```
|
||||
in_review
|
||||
```
|
||||
|
||||
#### `GET /v1/api/bug-reports/{id}/files`
|
||||
Lista dei file allegati al report (senza dati binari).
|
||||
|
||||
#### `GET /v1/api/bug-reports/{id}/files/{file_id}`
|
||||
Scarica il file specifico. Response con `Content-Type` originale e `Content-Disposition: attachment`.
|
||||
|
||||
#### `GET /v1/api/bug-reports/{id}/download`
|
||||
Scarica un file `.zip` contenente:
|
||||
- `report.txt` — report formattato dal template
|
||||
- `screenshot/nome.png`, `log/nome.log`, `attachment/nome.bin` — file allegati organizzati per ruolo
|
||||
|
||||
#### `DELETE /v1/api/bug-reports/{id}`
|
||||
```json
|
||||
{ "message": "bug report deleted successfully" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Auth Admin — Nessuna chiave richiesta (gestisce le proprie credenziali)
|
||||
|
||||
#### `POST /v1/api/admin/auth/login`
|
||||
```json
|
||||
// Request
|
||||
{ "username": "admin", "password": "secret" }
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"session_id": "a3f9...<64 chars>",
|
||||
"user": { "id": "uuid", "username": "admin", "displayname": "Admin", "role": "admin", "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /v1/api/admin/auth/validate`
|
||||
Header: `X-Session-Token: <session_id>`
|
||||
|
||||
```json
|
||||
{ "success": true, "user": { ... } }
|
||||
```
|
||||
|
||||
#### `POST /v1/api/admin/auth/logout`
|
||||
Header: `X-Session-Token: <session_id>`
|
||||
```json
|
||||
{ "logged_out": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Gestione Utenti — Solo `X-Admin-Key`
|
||||
|
||||
#### `GET /v1/api/admin/users/`
|
||||
Lista tutti gli utenti.
|
||||
|
||||
#### `POST /v1/api/admin/users/`
|
||||
```json
|
||||
// Request
|
||||
{ "username": "mario", "displayname": "Mario Rossi", "password": "secret", "role": "user" }
|
||||
// Response 201: oggetto User
|
||||
```
|
||||
|
||||
#### `GET /v1/api/admin/users/{id}`
|
||||
|
||||
#### `PATCH /v1/api/admin/users/{id}`
|
||||
Aggiorna solo i campi forniti. Campi modificabili: `displayname`, `enabled`.
|
||||
```json
|
||||
{ "displayname": "Nuovo Nome", "enabled": false }
|
||||
```
|
||||
|
||||
#### `POST /v1/api/admin/users/{id}/reset-password`
|
||||
```json
|
||||
{ "password": "nuova-password" }
|
||||
```
|
||||
|
||||
#### `DELETE /v1/api/admin/users/{id}`
|
||||
|
||||
---
|
||||
|
||||
### Modello `BugReport`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Mario Rossi",
|
||||
"email": "mario@example.com",
|
||||
"description": "L'app crasha all'avvio",
|
||||
"hwid": "ABC123",
|
||||
"hostname": "DESKTOP-XYZ",
|
||||
"os_user": "mario",
|
||||
"submitter_ip": "192.168.1.1",
|
||||
"system_info": { "os": "Windows 11", "ram": "16GB" },
|
||||
"status": "new",
|
||||
"created_at": "2026-03-23T10:00:00Z",
|
||||
"updated_at": "2026-03-23T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Stati possibili:** `new` → `in_review` → `resolved` / `closed`
|
||||
|
||||
### Modello `BugReportFile`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"report_id": 42,
|
||||
"file_role": "screenshot",
|
||||
"filename": "crash.png",
|
||||
"mime_type": "image/png",
|
||||
"file_size": 204800,
|
||||
"created_at": "2026-03-23T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Ruoli file:** `attachment`, `screenshot`, `log`
|
||||
|
||||
### Modello `User`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-v4",
|
||||
"username": "mario",
|
||||
"displayname": "Mario Rossi",
|
||||
"role": "admin",
|
||||
"enabled": true,
|
||||
"created_at": "2026-03-23T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Nota:** `password_hash` non viene mai esposto nelle risposte JSON (`json:"-"`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Come aggiungere un nuovo endpoint
|
||||
|
||||
Esempio: aggiungere `GET /v1/api/bug-reports/{id}/summary`.
|
||||
|
||||
### Step 1 — Scrivi l'handler in `internal/handlers/bug_report.route.go`
|
||||
|
||||
```go
|
||||
func GetBugReportSummary(db *sqlx.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing id parameter")
|
||||
return
|
||||
}
|
||||
|
||||
// esegui la query...
|
||||
jsonOK(w, map[string]string{"summary": "..."})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Registra la rotta in `internal/routes/v1/bug_reports.go`
|
||||
|
||||
Aggiungila nel gruppo con le permission corrette:
|
||||
|
||||
```go
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apimw.APIKeyAuth(db))
|
||||
r.Use(apimw.AdminKeyAuth(db))
|
||||
r.Use(httprate.LimitByIP(30, time.Minute))
|
||||
|
||||
r.Get("/", handlers.GetAllBugReports(db))
|
||||
r.Get("/{id}", handlers.GetBugReportByID(db))
|
||||
r.Get("/{id}/summary", handlers.GetBugReportSummary(db)) // <-- aggiunto qui
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
### Step 3 — Build e test
|
||||
|
||||
```bash
|
||||
go build ./... # verifica che compili
|
||||
go test ./... # esegui i test
|
||||
air # hot-reload in sviluppo
|
||||
```
|
||||
|
||||
Non serve nessun file di routing separato, nessun decoratore, nessuna annotation. La rotta e' attiva immediatamente alla ricompilazione.
|
||||
5
go.mod
5
go.mod
@@ -10,7 +10,10 @@ require (
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.42.0 // indirect
|
||||
require (
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -23,3 +23,5 @@ golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -17,7 +18,17 @@ type Config struct {
|
||||
ConnMaxLifetime int
|
||||
}
|
||||
|
||||
var (
|
||||
instance *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func Load() *Config {
|
||||
once.Do(func() { instance = load() })
|
||||
return instance
|
||||
}
|
||||
|
||||
func load() *Config {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
|
||||
140
internal/middleware/ratelimit.ban.go
Normal file
140
internal/middleware/ratelimit.ban.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// middleware/ratelimit.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
failures int
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
banned sync.Map // ip -> unban time
|
||||
|
||||
// config
|
||||
rps rate.Limit // richieste/sec normali
|
||||
burst int
|
||||
maxFails int // quanti 429 prima del ban
|
||||
banDur time.Duration // durata ban
|
||||
cleanEvery time.Duration
|
||||
}
|
||||
|
||||
func NewRateLimiter(rps float64, burst, maxFails int, banDur time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
rps: rate.Limit(rps),
|
||||
burst: burst,
|
||||
maxFails: maxFails,
|
||||
banDur: banDur,
|
||||
cleanEvery: 5 * time.Minute,
|
||||
}
|
||||
go rl.cleanupLoop()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) getIP(r *http.Request) string {
|
||||
// Rispetta X-Forwarded-For se dietro Traefik/proxy
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
// Prendi il primo IP (quello del client originale)
|
||||
if h, _, err := net.SplitHostPort(ip); err == nil {
|
||||
return h
|
||||
}
|
||||
return ip
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
return host
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) getVisitor(ip string) *visitor {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
v, ok := rl.visitors[ip]
|
||||
if !ok {
|
||||
v = &visitor{
|
||||
limiter: rate.NewLimiter(rl.rps, rl.burst),
|
||||
}
|
||||
rl.visitors[ip] = v
|
||||
}
|
||||
v.lastSeen = time.Now()
|
||||
return v
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := rl.getIP(r)
|
||||
|
||||
// Controlla ban attivo
|
||||
if unbanAt, banned := rl.banned.Load(ip); banned {
|
||||
if time.Now().Before(unbanAt.(time.Time)) {
|
||||
w.Header().Set("Retry-After", unbanAt.(time.Time).Format(time.RFC1123))
|
||||
http.Error(w, "too many requests - temporarily banned", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Ban scaduto
|
||||
rl.banned.Delete(ip)
|
||||
}
|
||||
|
||||
v := rl.getVisitor(ip)
|
||||
|
||||
if !v.limiter.Allow() {
|
||||
rl.mu.Lock()
|
||||
v.failures++
|
||||
fails := v.failures
|
||||
rl.mu.Unlock()
|
||||
|
||||
if fails >= rl.maxFails {
|
||||
unbanAt := time.Now().Add(rl.banDur)
|
||||
rl.banned.Store(ip, unbanAt)
|
||||
// Opzionale: loga il ban
|
||||
w.Header().Set("Retry-After", unbanAt.Format(time.RFC1123))
|
||||
http.Error(w, "banned", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset failures su richiesta legittima
|
||||
rl.mu.Lock()
|
||||
v.failures = 0
|
||||
rl.mu.Unlock()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(rl.cleanEvery)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
rl.mu.Lock()
|
||||
for ip, v := range rl.visitors {
|
||||
if time.Since(v.lastSeen) > 10*time.Minute {
|
||||
delete(rl.visitors, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
// Pulisci anche i ban scaduti
|
||||
rl.banned.Range(func(k, v any) bool {
|
||||
if time.Now().After(v.(time.Time)) {
|
||||
rl.banned.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
11
main.go
11
main.go
@@ -16,6 +16,8 @@ import (
|
||||
"emly-api-go/internal/database"
|
||||
"emly-api-go/internal/database/schema"
|
||||
"emly-api-go/internal/routes"
|
||||
|
||||
emlyMiddleware "emly-api-go/internal/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -52,6 +54,15 @@ func main() {
|
||||
// Global rate limit to 100 requests per minute
|
||||
r.Use(httprate.LimitByIP(100, time.Minute))
|
||||
|
||||
rl := emlyMiddleware.NewRateLimiter(
|
||||
5, // 5 req/sec per IP
|
||||
10, // burst fino a 10
|
||||
20, // ban dopo 20 violazioni
|
||||
30*time.Minute, // ban di 15 minuti
|
||||
)
|
||||
|
||||
r.Use(rl.Handler)
|
||||
|
||||
routes.RegisterAll(r, db)
|
||||
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
|
||||
Reference in New Issue
Block a user