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
|
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 (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.1 // indirect
|
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/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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -17,7 +18,17 @@ type Config struct {
|
|||||||
ConnMaxLifetime int
|
ConnMaxLifetime int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
instance *Config
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
|
once.Do(func() { instance = load() })
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() *Config {
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
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"
|
||||||
"emly-api-go/internal/database/schema"
|
"emly-api-go/internal/database/schema"
|
||||||
"emly-api-go/internal/routes"
|
"emly-api-go/internal/routes"
|
||||||
|
|
||||||
|
emlyMiddleware "emly-api-go/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -52,6 +54,15 @@ func main() {
|
|||||||
// Global rate limit to 100 requests per minute
|
// Global rate limit to 100 requests per minute
|
||||||
r.Use(httprate.LimitByIP(100, time.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)
|
routes.RegisterAll(r, db)
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||||
|
|||||||
Reference in New Issue
Block a user