Packages

35 built-in plugins for the Hibana web framework: JWT, OAuth, CORS, rate limiting, GraphQL, admin dashboard, i18n, and more.

Current section

Files

Jump to
hibana_plugins lib hibana plugins static.ex
Raw

lib/hibana/plugins/static.ex

defmodule Hibana.Plugins.Static do
@moduledoc """
Static file serving plugin for serving files from disk.
## Features
- Serves files from configured directory
- Automatic content-type detection
- Cache control headers in production
- Gzip support (optional)
- Security: does not serve files outside root
## Usage
# Serve from priv/static at root
plug Hibana.Plugins.Static, at: "/", from: "priv/static"
# Serve at /public prefix
plug Hibana.Plugins.Static, at: "/public", from: "priv/static"
## Options
- `:at` - URL prefix to serve (default: `"/"`)
- `:from` - Directory to serve files from (default: `"priv/static"`)
- `:gzip` - Enable gzip support (default: `false`)
- `:cache_headers` - Add cache headers (default: `true`)
## Caching
In production (`MIX_ENV=prod`), adds cache headers:
Cache-Control: public, max-age=3600
In development, no caching.
## Supported Content-Types
- HTML, CSS, JS, JSON
- Images (PNG, JPG, GIF, SVG, ICO)
- Fonts (WOFF, WOFF2, TTF, EOT)
- PDF, TXT, and binary files
"""
use Hibana.Plugin
import Plug.Conn
@impl true
def init(opts) do
%{
at: Keyword.get(opts, :at, "/"),
from: Keyword.get(opts, :from, "priv/static"),
gzip: Keyword.get(opts, :gzip, false),
cache_headers: Keyword.get(opts, :cache_headers, true)
}
end
@impl true
def call(conn, %{at: at, from: from}) do
case static_path(conn, at, from) do
nil ->
conn
path ->
case File.exists?(path) do
true ->
content_type = content_type_for_path(path)
cache_max_age = if Application.get_env(:hibana, :env) == :prod, do: 3600, else: 0
conn
|> put_resp_content_type(content_type)
|> maybe_cache(cache_max_age)
|> Plug.Conn.send_file(200, path)
|> halt()
false ->
conn
end
end
end
defp static_path(conn, at, from) do
path = conn.path_info |> Enum.join("/")
at_path = String.trim(at, "/")
if String.starts_with?(path, at_path) do
relative_path = String.replace(path, at_path, "", global: false) |> String.trim_leading("/")
# Reject path traversal
if String.contains?(relative_path, "..") do
nil
else
full_path = Path.join(from, relative_path) |> Path.expand()
root = Path.expand(from)
if String.starts_with?(full_path, root) do
full_path
else
nil
end
end
else
nil
end
end
defp content_type_for_path(path) do
case Path.extname(path) do
".html" -> "text/html; charset=utf-8"
".css" -> "text/css; charset=utf-8"
".js" -> "application/javascript; charset=utf-8"
".json" -> "application/json; charset=utf-8"
".png" -> "image/png"
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
".gif" -> "image/gif"
".svg" -> "image/svg+xml"
".ico" -> "image/x-icon"
".woff" -> "font/woff"
".woff2" -> "font/woff2"
".ttf" -> "font/ttf"
".eot" -> "application/vnd.ms-fontobject"
".pdf" -> "application/pdf"
".txt" -> "text/plain; charset=utf-8"
_ -> "application/octet-stream"
end
end
defp maybe_cache(conn, 0), do: conn
defp maybe_cache(conn, max_age) do
put_resp_header(conn, "cache-control", "public, max-age=#{max_age}")
end
end