Skip to content

Static Files and Assets

Generate asset URLs with the same helper in Dart and Khnum templates:

asset('css/app.css'); // /css/app.css
<link rel="stylesheet" href="{{ asset('css/app.css') }}">

A Laravel application never serves its own CSS — nginx does, from public/. Maat applications usually run without a web server in front, so the framework does it, through one piece of global middleware.

Serving public/

.withMiddleware(
(middleware) => middleware.use([
PublicFiles(publicPath()),
TrimStrings(),
Cors(),
]),
)

Put it first. It runs before routing, so a request naming a real file answers immediately and everything else falls through to the router untouched — including a directory, a missing file, and any path that tried to climb out of the directory.

Only GET and HEAD are served; POST /css/app.css belongs to the router. Responses carry content-type (from the file’s extension), content-length, last-modified and cache-control, and answer 304 to a matching If-Modified-Since.

PublicFiles(publicPath()) // cache for an hour
PublicFiles(publicPath(), maxAge: Duration.zero) // no cache-control

File bodies stream from disk, so serving a large file does not first copy the whole body into application memory. A web server or CDN is still the better production boundary for range requests and edge caching.

Linking to Assets

<link rel="stylesheet" href="{{ asset('css/app.css') }}">

asset() returns a root-relative URL by default. Set app.asset_url and the same call points at a CDN instead, with no template change:

Map<String, dynamic> get app => {
'asset_url': env('ASSET_URL', ''),
};

publicPath() is the filesystem counterpart: publicPath() is the directory, publicPath('css/app.css') a file inside it.

Outside debug mode, asset() appends a ?v=<token> cache buster derived from the file’s modification time and size. It omits the token in debug mode and when the file does not exist, keeping a missing development build visible as a normal 404 rather than turning page rendering into a 500.

Path Traversal

Requests are not trusted to stay inside public/. The candidate path is joined, normalised and canonicalised, then checked to be inside the resolved root; the file’s own symlink target is resolved and checked again. Nothing is decided by scanning the request for .., because that check does not hold:

GET /..%2f..%2fetc/passwd

Uri.pathSegments percent-decodes each segment, so this arrives as the single segment ../../etc — a scan for a .. segment never sees it. Normalising and then testing containment does. A %00 in the path is refused outright: it decodes to a real NUL, which truncates the name for the operating system but not for the Dart string holding it.

Compiling Assets

A new application ships with resources/css/app.css (Tailwind’s entrypoint) and the maat tailwind command already wired up — no bundler configuration to write:

Terminal window
maat tailwind --minify
maat tailwind --watch

maat tailwind downloads Tailwind’s standalone CLI on first use, so the whole pipeline needs neither Node nor node_modules. See Frontend for the CSS entrypoint’s @import/@source rules, the binary cache, and how this fits alongside maat serve.

Keep the compiled output out of version control and build it on deploy, or commit it and skip the build step — either works, since the framework only ever reads the finished file.