Lezione 4 di 7 · 25 min di lettura
Middleware, pagine e moduli
Funzioni che avvolgono il gestore. Scrivere un middleware con use, quelli già pronti di wisp per i log e gli errori, costruire pagine HTML senza aprire la porta agli attacchi, e ricevere i dati di un modulo.
Avvolgere il gestore
Ci sono cose che un server deve fare per ogni richiesta, qualunque sia la strada: scrivere una riga di log, controllare una chiave di accesso, aggiungere un’intestazione, trasformare un crash in una risposta 500. Ripeterle in ogni strada sarebbe noioso, e prima o poi ne dimenticheresti una.
La soluzione si chiama middleware: una funzione che sta “in mezzo” tra la richiesta e il gestore. Riceve come ultimo argomento il resto del lavoro, una funzione next che produce la risposta, e decide lei cosa farne: chiamarla o no, e cosa fare prima e dopo. È la forma perfetta per use (lezione 5.3).
Ecco due middleware scritti a mano, uno che agisce dopo e uno che agisce prima:
import gleam/http.{Get}
import gleam/int
import gleam/io
import gleam/list
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate
pub fn handle_request(request: Request) -> Response {
use <- add_server_header
use <- require_key(request)
wisp.ok() |> wisp.string_body("The secret recipe")
}
fn add_server_header(next: fn() -> Response) -> Response {
next()
|> wisp.set_header("server", "gleam")
}
fn require_key(request: Request, next: fn() -> Response) -> Response {
case list.key_find(request.headers, "x-key") {
Ok("open-sesame") -> next()
_ -> wisp.response(401) |> wisp.string_body("Who are you?")
}
}
pub fn main() -> Nil {
let without_key = simulate.request(Get, "/")
let with_key = simulate.header(without_key, "x-key", "open-sesame")
print(handle_request(without_key))
print(handle_request(with_key))
}
fn print(response: Response) -> Nil {
io.println(
int.to_string(response.status)
<> " "
<> simulate.read_body(response)
<> " "
<> string.inspect(response.headers),
)
}401 Who are you? [#("server", "gleam")]
200 The secret recipe [#("content-type", "text/plain"), #("server", "gleam")]add_server_headerchiama subitonext(), e poi aggiunge un’intestazione alla risposta che ne esce. Agisce dopo.require_keyguarda le intestazioni della richiesta (una lista di coppie, quindilist.key_find): se la chiave è giusta chiamanext(), altrimenti risponde lei, con401(Unauthorized, “non autorizzato”), e il gestore non viene mai eseguito. Agisce prima.wisp.response(401)crea una risposta vuota con quel codice.simulate.headeraggiunge un’intestazione a una richiesta finta.
L’ordine dei use conta: sono scatole una dentro l’altra, e la prima è la più esterna. add_server_header avvolge tutto, quindi anche il 401 riceve l’intestazione server. Se scambi le due righe, il 401 esce da require_key prima di passare da add_server_header, e l’intestazione manca.
I middleware di wisp
Wisp ne ha alcuni già pronti. I due che userai sempre sono il log e la rete di sicurezza per i crash:
import gleam/erlang/process
import mist
import wisp.{type Request, type Response}
import wisp/wisp_mist
pub fn main() -> Nil {
wisp.configure_logger()
let assert Ok(_) =
wisp_mist.handler(handle_request, wisp.random_string(64))
|> mist.new
|> mist.port(8000)
|> mist.start
process.sleep_forever()
}
fn handle_request(request: Request) -> Response {
use <- wisp.log_request(request)
use <- wisp.rescue_crashes
case wisp.path_segments(request) {
[] -> wisp.html_response("<h1>All good</h1>", 200)
["crash"] -> panic as "the kitchen is on fire"
_ -> wisp.not_found()
}
}Avvialo, e chiedi con curl localhost:8000/, poi localhost:8000/crash, poi localhost:8000/nope. Il secondo risponde Internal server error; nel terminale del server compare il diario:
Listening on http://127.0.0.1:8000
INFO 200 GET /
EROR function="handle_request" line=21 message="the kitchen is on fire" module="logged_server" file="src/logged_server.gleam" gleam_error=Panic class=Errored
INFO 500 GET /crash
INFO 404 GET /nopewisp.configure_logger(), chiamato una volta inmain, prepara il sistema di log di Erlang: senza, i messaggiINFOnon si vedono.wisp.log_request(request)scrive una riga per ogni richiesta, con codice di stato, metodo e percorso. È il primouse, quindi il più esterno: vede la risposta finale, anche il500.wisp.rescue_crashesesegue il resto dentro una rete: se qualcosa si schianta (unpanic, unlet assertfallito), scrive l’errore nel log, con modulo, riga e messaggio, e risponde500invece di lasciar cadere la richiesta.
Nel terminale le etichette INFO ed EROR sono colorate. I log vanno sempre letti dove gira il server, non nel browser: l’utente vede solo Internal server error, e non deve sapere niente della tua cucina in fiamme.
Gli esempi ufficiali di wisp raccolgono i middleware in una funzione sola, chiamata di solito middleware, che ogni gestore usa come primo use. Oltre ai due visti qui, di solito ci mettono wisp.method_override, wisp.handle_head e wisp.csrf_known_header_protection: protezioni e comodità di HTTP che puoi scoprire nella documentazione di wisp, quando ti serviranno.
Pagine HTML
Una pagina HTML è una stringa, e si costruisce come qualsiasi altra stringa: con <>. C’è però un’insidia enorme, che conviene conoscere dal primo giorno. Immagina una pagina che saluta con il nome letto dalla query, e qualcuno che come nome scrive un pezzo di HTML:
import gleam/io
import wisp
pub fn main() -> Nil {
let name = "<script>alert('hi')</script>"
io.println("<p>Hello, " <> name <> "</p>")
io.println("<p>Hello, " <> wisp.escape_html(name) <> "</p>")
io.println(wisp.escape_html("Tom & Jerry \"forever\""))
}<p>Hello, <script>alert('hi')</script></p>
<p>Hello, <script>alert('hi')</script></p>
Tom & Jerry "forever"Nella prima riga, il “nome” è diventato codice: il browser vedrebbe un tag <script> ed eseguirebbe il JavaScript dentro. wisp.escape_html sostituisce i caratteri speciali dell’HTML con le loro entità: < diventa <, & diventa &, le virgolette " e '. Il browser le mostra come i caratteri originali, ma non le interpreta più come HTML.
La regola è semplice: tutto quello che arriva da fuori (query, moduli, JSON, file caricati, anche dati salvati da altri utenti) passa da wisp.escape_html prima di finire in una pagina.
Dettagli nerd Cos'è un attacco XSS?
XSS sta per cross-site scripting. Succede quando un sito mette nelle sue pagine del testo scritto da un utente senza proteggerlo: l’attaccante scrive del JavaScript al posto del suo nome o di un commento, e quel codice viene eseguito nel browser di chiunque apra la pagina, come se l’avesse scritto il sito.
Da lì, lo script può fare tutto quello che il sito può fare per conto dell’utente: leggere i dati della pagina, mandare richieste con il suo account, mostrare un finto modulo di accesso. È uno degli attacchi più comuni del web, e si previene sempre allo stesso modo: il testo degli utenti non diventa mai HTML senza essere “disinnescato” con l’escape.
Ricevere un modulo
I moduli HTML (form) sono il modo più antico per mandare dati a un server: caselle di testo e un pulsante. Con method='post', il browser manda una richiesta POST con i valori nel corpo, codificati come una query (name=Ada&city=London) e l’intestazione content-type: application/x-www-form-urlencoded.
Wisp li legge con il middleware wisp.require_form: se la richiesta è un modulo, passa al resto un valore FormData, il cui campo values è una lista di coppie #(nome, valore); se non lo è, risponde da solo con un errore.
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate
pub fn handle_request(request: Request) -> Response {
case request.method {
Get -> show_form()
Post -> greet(request)
_ -> wisp.method_not_allowed([Get, Post])
}
}
fn show_form() -> Response {
let html =
"<form method='post'>
<input name='name'>
<input name='city'>
<button>Send</button>
</form>"
wisp.html_response(html, 200)
}
fn greet(request: Request) -> Response {
use form <- wisp.require_form(request)
io.println(string.inspect(form.values))
let result = {
use name <- result.try(list.key_find(form.values, "name"))
use city <- result.try(list.key_find(form.values, "city"))
Ok(wisp.escape_html(name) <> " from " <> wisp.escape_html(city))
}
case result {
Ok(text) -> wisp.html_response("<p>Welcome, " <> text <> "!</p>", 200)
Error(_) -> wisp.bad_request("name and city are required")
}
}
pub fn main() -> Nil {
let response =
simulate.request(Post, "/")
|> simulate.form_body([#("name", "Ada"), #("city", "London")])
|> handle_request
io.println(simulate.read_body(response))
let response =
simulate.request(Post, "/")
|> simulate.form_body([#("name", "Ada")])
|> handle_request
io.println(simulate.read_body(response))
let response =
simulate.request(Post, "/")
|> simulate.string_body("name=Ada")
|> handle_request
io.println(
int.to_string(response.status) <> " " <> simulate.read_body(response),
)
}[#("city", "London"), #("name", "Ada")]
<p>Welcome, Ada from London!</p>
[#("name", "Ada")]
Bad request: name and city are required
415 Unsupported media typesimulate.form_bodycrea una richiesta finta con un modulo, esattamente come la manderebbe il browser.- La prima riga stampata mostra un dettaglio utile: wisp ordina i campi per nome,
cityprima diname, qualunque fosse l’ordine nel modulo. - Ogni campo può mancare, quindi
list.key_finddà unResult, e iuse ... <- result.trysi fermano al primo che manca (lezione 5.3). Se ne manca uno, la risposta è400. - La terza richiesta ha un corpo di testo semplice, non un modulo:
require_formrisponde415(Unsupported Media Type, “tipo di contenuto non supportato”) senza nemmeno chiamare il resto. - I valori finiscono nella pagina, quindi passano da
escape_html.
Per vederlo con il browser, crea un modulo form_server che lo avvia come library_server nella lezione precedente, e apri http://localhost:8000: compili le caselle, premi Send, e il browser fa la richiesta POST. Da curl, -d manda proprio un modulo:
curl -d 'name=Ada&city=Paris' localhost:8000<p>Welcome, Ada from Paris!</p>Intanto, nel terminale del server, compare la riga [#("city", "Paris"), #("name", "Ada")]: la stampa greet con io.println, e un server scrive dove gira lui, non nel terminale di chi fa la richiesta.
Quiz
In un gestore scrivi prima use <- wisp.rescue_crashes e poi use <- wisp.log_request(request). Cosa succede alla riga di log quando una richiesta si schianta?
Esercizio · sul tuo computer
L'ordinazione
Crea src/order_form.gleam, con un gestore per le ordinazioni di un ristorante:
- accetta solo
POST(conrequire_method), e legge un modulo con i campidishequantity; - se
quantityè un numero intero maggiore di zero risponde con la pagina<p>2 x pizza</p>(quantità,x, piatto protetto conescape_html); altrimenti400coninvalid order; - un middleware scritto da te,
no_cache, aggiunge a ogni risposta, anche agli errori, l’intestazionecache-control: no-store.
Nel main, manda quattro ordinazioni: pizza 2, <i>soup</i> 1, pizza lots, pizza 0, e stampa codice e corpo di ognuna. Poi manda un GET e stampa solo le intestazioni della risposta con string.inspect:
200 <p>2 x pizza</p>
200 <p>1 x <i>soup</i></p>
400 Bad request: invalid order
400 Bad request: invalid order
[#("allow", "POST"), #("cache-control", "no-store")]Suggerimento: int.parse restituisce un Result, e può stare nella stessa catena di result.try dei campi. Per “maggiore di zero”, una guardia nel case (lezione 2.5).
Mostra una soluzione (prima prova da solo!)
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate
pub fn handle_request(request: Request) -> Response {
use <- no_cache
use <- wisp.require_method(request, Post)
use form <- wisp.require_form(request)
let order = {
use dish <- result.try(list.key_find(form.values, "dish"))
use quantity <- result.try(list.key_find(form.values, "quantity"))
use quantity <- result.try(int.parse(quantity))
Ok(#(dish, quantity))
}
case order {
Ok(#(dish, quantity)) if quantity > 0 ->
wisp.html_response(
"<p>"
<> int.to_string(quantity)
<> " x "
<> wisp.escape_html(dish)
<> "</p>",
200,
)
_ -> wisp.bad_request("invalid order")
}
}
fn no_cache(next: fn() -> Response) -> Response {
next()
|> wisp.set_header("cache-control", "no-store")
}
pub fn main() -> Nil {
[
[#("dish", "pizza"), #("quantity", "2")],
[#("dish", "<i>soup</i>"), #("quantity", "1")],
[#("dish", "pizza"), #("quantity", "lots")],
[#("dish", "pizza"), #("quantity", "0")],
]
|> list.each(fn(values) {
let response =
simulate.request(Post, "/order")
|> simulate.form_body(values)
|> handle_request
io.println(
int.to_string(response.status) <> " " <> simulate.read_body(response),
)
})
let response = handle_request(simulate.request(Get, "/order"))
io.println(string.inspect(response.headers))
}no_cache è il use più esterno, quindi avvolge anche il 405 di require_method: l’ultima riga lo dimostra. L’intestazione cache-control: no-store chiede al browser di non conservare la risposta: per un’ordinazione, non ha senso rivederne una vecchia.
Ricapitolando
- Un middleware è una funzione che riceve il resto del lavoro (
next) come ultimo argomento: si usa conuse, può agire prima (e magari rispondere lei) o dopo (e cambiare la risposta). - L’ordine dei
useè l’ordine delle scatole: il primo è il più esterno. wisp.configure_logger()inmain, poiuse <- wisp.log_request(request)per una riga di log a richiesta, euse <- wisp.rescue_crashesper trasformare un crash in un500.- Le pagine HTML sono stringhe; tutto ciò che arriva da fuori passa da
wisp.escape_html, contro gli attacchi XSS. use form <- wisp.require_form(request)legge un modulo:form.valuesè una lista di coppie, ordinata per nome. Se il corpo non è un modulo, risponde415.- Per le prove:
simulate.form_bodyesimulate.header; da curl,-d 'a=1&b=2'.
Nella prossima lezione il server parlerà con altri programmi invece che con le persone: un’API JSON.