///|
/// Default maximum body size: 1 MiB.
const DEFAULT_BODY_LIMIT : Int32 = 1024 * 1024
///|
/// Parses `application/json` request bodies into `req.json_body()` and also
/// keeps the raw text in `req.body()`.
pub fn express_json(limit? : Int32) -> Middleware {
let max_size = match limit {
Some(value) => value
None => DEFAULT_BODY_LIMIT
}
fn(req, res, next) {
if res.is_aborted() {
return
}
let content_type = req.header("content-type").to_lower()
let is_json = content_type.has_prefix("application/json") ||
content_type.contains("+json")
if !is_json || !has_request_body(req) {
next.proceed()
return
}
if parse_content_length(req.header("content-length")) > max_size {
res.status(413).send("Payload Too Large")
return
}
read_body(req, res, max_size, fn(body, ok) {
if res.is_aborted() {
return
}
if !ok {
res.status(413).send("Payload Too Large")
return
}
req.set_body(body)
if body == "" {
next.proceed()
return
}
let parsed : Json? = Some(@json.parse(body)) catch { _ => None }
match parsed {
Some(value) => {
req.set_json_body(Some(value))
next.proceed()
}
None => res.status(400).send("Invalid JSON")
}
})
}
}
///|
/// Parses `application/x-www-form-urlencoded` bodies into `req.form_map()`.
/// With `extended` the nested form (`a[b]=1`) is also parsed into
/// `req.form_json()`, like `express.urlencoded({ extended: true })`.
pub fn express_urlencoded(limit? : Int32, extended? : Bool) -> Middleware {
let max_size = match limit {
Some(value) => value
None => DEFAULT_BODY_LIMIT
}
let extended = match extended {
Some(value) => value
None => false
}
fn(req, res, next) {
if res.is_aborted() {
return
}
let content_type = req.header("content-type").to_lower()
if !content_type.has_prefix("application/x-www-form-urlencoded") ||
!has_request_body(req) {
next.proceed()
return
}
if parse_content_length(req.header("content-length")) > max_size {
res.status(413).send("Payload Too Large")
return
}
read_body(req, res, max_size, fn(body, ok) {
if res.is_aborted() {
return
}
if !ok {
res.status(413).send("Payload Too Large")
return
}
req.set_body(body)
req.set_form(parse_urlencoded(body))
if extended {
req.set_form_json(Some(parse_urlencoded_nested(body)))
}
next.proceed()
})
}
}
///|
/// Reads a `text/*` body into `req.body()`.
pub fn express_text(limit? : Int32) -> Middleware {
let max_size = match limit {
Some(value) => value
None => DEFAULT_BODY_LIMIT
}
fn(req, res, next) {
if res.is_aborted() {
return
}
let content_type = req.header("content-type").to_lower()
if !content_type.has_prefix("text/") || !has_request_body(req) {
next.proceed()
return
}
if parse_content_length(req.header("content-length")) > max_size {
res.status(413).send("Payload Too Large")
return
}
read_body(req, res, max_size, fn(body, ok) {
if res.is_aborted() {
return
}
if !ok {
res.status(413).send("Payload Too Large")
return
}
req.set_body(body)
next.proceed()
})
}
}
///|
/// Reads a binary body into `req.body_bytes()`, and its lossy text form into
/// `req.body()`, like `express.raw()`. The raw bytes are always binary safe.
pub fn express_raw(limit? : Int32) -> Middleware {
let max_size = match limit {
Some(value) => value
None => DEFAULT_BODY_LIMIT
}
fn(req, res, next) {
if res.is_aborted() {
return
}
let content_type = req.header("content-type").to_lower()
if !content_type.has_prefix("application/octet-stream") ||
!has_request_body(req) {
next.proceed()
return
}
if parse_content_length(req.header("content-length")) > max_size {
res.status(413).send("Payload Too Large")
return
}
read_body(req, res, max_size, fn(body, ok) {
if res.is_aborted() {
return
}
if !ok {
res.status(413).send("Payload Too Large")
return
}
req.set_body(body)
next.proceed()
})
}
}
///|
/// Serves files below `root`, like `express.static`: `Content-Type`, `ETag`,
/// `Last-Modified`, byte ranges and `Cache-Control` come from `res.send_file`.
/// Missing files fall through to the next middleware unless `fallthrough` is
/// disabled.
pub fn express_static(root : String, options? : FileOptions) -> Middleware {
let options = match options {
Some(value) => value
None => FileOptions::default()
}
fn(req, res, next) {
if res.is_aborted() {
return
}
let http_method = req.http_method()
if http_method != "GET" && http_method != "HEAD" {
next.proceed()
return
}
let decoded = percent_decode_path(req.path())
if is_unsafe_path(decoded) {
res.status(403).send("Forbidden")
return
}
let mut full_path = join_path(root, decoded)
let mut kind = fs_kind(full_path)
if kind == 2 {
if options.redirect && !req.path().has_suffix("/") {
let original = req.original_url()
let path_only = match original.find("?") {
Some(index) => original[:index].to_owned()
None => original
}
let query = original[path_only.length():].to_owned()
let url = encode_url(path_only + "/") + query
let body = "Redirecting to " +
escape_html(url) +
"
"
ignore(res.status(301).set("Location", url))
ignore(res.set("Content-Type", "text/html; charset=UTF-8"))
ignore(res.set("Content-Length", body.length().to_string()))
res.send(body)
return
}
if options.index != "" {
let index_file = join_path(full_path, options.index)
if fs_kind(index_file) == 1 {
full_path = index_file
kind = 1
}
}
}
if kind != 1 {
for extension in options.extensions {
let candidate = full_path + "." + extension
if fs_kind(candidate) == 1 {
full_path = candidate
kind = 1
break
}
}
}
if kind != 1 {
if options.fallthrough {
next.proceed()
} else {
res.status(404).send("Not Found")
}
return
}
if has_dotfile_segment(full_path) && options.dotfiles != "allow" {
if options.dotfiles == "deny" {
res.status(403).send("Forbidden")
} else if options.fallthrough {
next.proceed()
} else {
res.status(404).send("Not Found")
}
return
}
res.send_file(full_path, options~)
}
}
///|
/// Permissive CORS middleware, including preflight responses.
pub fn cors() -> Middleware {
fn(req, res, next) {
ignore(res.set("Access-Control-Allow-Origin", "*"))
if req.http_method() == "OPTIONS" {
ignore(
res.set(
"Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
),
)
let requested = req.header("access-control-request-headers")
let headers = if requested == "" { "Content-Type" } else { requested }
ignore(res.set("Access-Control-Allow-Headers", headers))
res.status(204).send_empty()
} else {
next.proceed()
}
}
}
///|
/// Minimal request logger.
pub fn logger() -> Middleware {
fn(req, _res, next) {
println(req.http_method() + " " + req.path())
next.proceed()
}
}
// ---------- helpers ----------
///|
fn has_request_body(req : Req) -> Bool {
let http_method = req.http_method()
if http_method == "GET" || http_method == "HEAD" {
return false
}
if parse_content_length(req.header("content-length")) > 0 {
return true
}
req.header("transfer-encoding") != ""
}
///|
fn parse_content_length(value : String) -> Int32 {
if value == "" {
return 0
}
let mut size = 0
for ch in value {
let digit = ch.to_int() - 48
if digit < 0 || digit > 9 {
return 0
}
size = size * 10 + digit
}
size
}
///|
fn is_unsafe_path(path : String) -> Bool {
for segment in split_path(path) {
if segment == ".." {
return true
}
}
false
}
///|
/// MIME type for a file extension, without parameters. `""` when unknown.
pub fn mime_by_extension(extension : String) -> String {
match extension {
"html" | "htm" => "text/html"
"css" => "text/css"
"js" | "mjs" => "application/javascript"
"json" | "map" => "application/json"
"txt" | "md" => "text/plain"
"xml" => "application/xml"
"svg" => "image/svg+xml"
"png" => "image/png"
"jpg" | "jpeg" => "image/jpeg"
"gif" => "image/gif"
"webp" => "image/webp"
"ico" => "image/x-icon"
"woff" => "font/woff"
"woff2" => "font/woff2"
"ttf" => "font/ttf"
"bin" => "application/octet-stream"
"text" => "text/plain"
"wasm" => "application/wasm"
"pdf" => "application/pdf"
"zip" => "application/zip"
_ => ""
}
}
///|
/// The charset `mime-types` appends for a type: UTF-8 for `text/*` and for
/// the JSON/JavaScript types, nothing otherwise.
fn mime_default_charset(mime : String) -> String {
if mime.has_prefix("text/") {
"utf-8"
} else if mime == "application/json" ||
mime == "application/javascript" ||
mime == "application/xml" {
"utf-8"
} else {
""
}
}
///|
/// Resolves an extension or media type to a full `Content-Type` value the way
/// `mime-types.contentType()` does. Unknown input is returned unchanged.
fn mime_content_type(value : String) -> String {
let mime = if value.contains("/") {
value
} else {
mime_by_extension(value.to_lower())
}
if mime == "" {
return value
}
if mime.contains("charset") {
return mime
}
let charset = mime_default_charset(mime)
if charset == "" {
mime
} else {
mime + "; charset=" + charset
}
}
///|
/// Maps a negotiation token (`html`, `text/html`) to a media type. `""` when
/// the token is neither a known extension nor a media type.
fn mime_for_type_name(name : String) -> String {
if name.contains("/") {
name
} else {
mime_by_extension(name.to_lower())
}
}
///|
/// MIME type for a file path, with the charset used by `express.static`.
pub fn mime_type(path : String) -> String {
let extension = match path.rev_split_once(".") {
Some((_, ext)) => ext.to_owned().to_lower()
None => ""
}
let mime = mime_by_extension(extension)
if mime == "" {
return "application/octet-stream"
}
let charset = mime_default_charset(mime)
if charset == "" {
mime
} else {
mime + "; charset=" + charset
}
}