Serve files

use "files"
use stallion = "stallion"

class val ServeFiles is Handler
  """
  Serve files from a directory on disk.

  Small files (below the chunk threshold) are served as a single response
  with `Content-Length`. Large files are streamed using chunked transfer
  encoding. When the client does not support chunked encoding (HTTP/1.0),
  small files are still served normally, but large files are rejected
  with 505 HTTP Version Not Supported to prevent memory exhaustion.

  HEAD requests are optimized: the handler responds with `Content-Type` and
  `Content-Length` headers without reading the file, regardless of file size.

  Responses include caching headers:

  - **ETag**: Weak ETag computed from file metadata (`W/"<inode>-<size>-<mtime>"`).
    On Windows, `FileInfo.inode` is always 0, reducing collision resistance
    to size+mtime only.
  - **Last-Modified**: RFC 7231 IMF-fixdate from the file's modification time.
  - **Cache-Control**: Configurable via the `cache_control` constructor
    parameter. Defaults to `"public, max-age=3600"`. Pass `None` to omit.

  Conditional requests are supported per RFC 7232:

  - `If-None-Match` is checked first (ETag comparison using weak matching).
  - `If-Modified-Since` is checked only when `If-None-Match` is absent.
  - When either matches, the handler responds with 304 Not Modified (cache
    headers included, no body).

  Custom content types can be added via the `content_types` parameter:

  ```pony
  let types = hobby.ContentTypes
    .add("webp", "image/webp")
    .add("avif", "image/avif")
  hobby.ServeFiles(root where content_types = types)
  ```

  Routes must use `*filepath` as the wildcard parameter name:

  ```pony
  use "files"
  use hobby = "hobby"
  use stallion = "stallion"
  use lori = "lori"

  actor Main
    new create(env: Env) =>
      let auth = lori.TCPListenAuth(env.root)
      let root = FilePath(FileAuth(env.root), "./public")
      hobby.Application
        .>get("/static/*filepath", hobby.ServeFiles(root))
        .serve(auth, stallion.ServerConfig("0.0.0.0", "8080"), env.out)
  ```

  Path traversal is prevented by Pony's `FilePath.from()`, which rejects
  any resolved path that is not a child of the base directory.

  When a request resolves to a directory, `ServeFiles` looks for an
  `index.html` file inside it. If found, the index file is served with the
  correct `text/html` content type and caching headers. If no `index.html`
  exists, the directory request returns 404.
  """
  let _root: FilePath
  let _chunk_threshold: USize
  let _cache_control: (String | None)
  let _content_types: ContentTypes

  new val create(root: FilePath, chunk_threshold: USize = 1024,
    cache_control: (String | None) = "public, max-age=3600",
    content_types: ContentTypes = ContentTypes)
  =>
    """
    Create a handler that serves files under `root`.

    `root` must have `FileLookup`, `FileStat`, and `FileRead` capabilities.
    `chunk_threshold` is the file size in kilobytes at or above which
    chunked streaming is used instead of a single response. Default: 1024
    (1 MB).

    `cache_control` sets the `Cache-Control` header value. Defaults to
    `"public, max-age=3600"` (1 hour). Pass `None` to omit the header.

    `content_types` controls the file extension to MIME type mapping.
    Defaults to a `ContentTypes` with 17 common extensions. Chain
    `.add()` calls to add custom mappings.

    If the route uses a wildcard name other than `*filepath`, param lookup
    will fail and the handler will return 500. Always use `*filepath`.
    """
    _root = root
    _chunk_threshold = chunk_threshold * 1024
    _cache_control = cache_control
    _content_types = content_types

  fun apply(ctx: Context ref) ? =>
    // Extract the wildcard param — errors if not named "filepath" (500)
    let filepath = ctx.param("filepath")?

    // Resolve path safely — errors on traversal attempts
    var resolved = try
      FilePath.from(_root, filepath)?
    else
      ctx.respond(stallion.StatusNotFound, "Not Found")
      return
    end

    // Stat the file — errors if file doesn't exist
    var info = try
      FileInfo(resolved)?
    else
      ctx.respond(stallion.StatusNotFound, "Not Found")
      return
    end

    // Directory → try serving index.html
    if info.directory then
      resolved = try
        FilePath.from(resolved, "index.html")?
      else
        ctx.respond(stallion.StatusNotFound, "Not Found")
        return
      end
      info = try
        FileInfo(resolved)?
      else
        ctx.respond(stallion.StatusNotFound, "Not Found")
        return
      end
    end

    // After index fallback, non-file entries (e.g., symlinks) still 404
    if not info.file then
      ctx.respond(stallion.StatusNotFound, "Not Found")
      return
    end

    let content_type = _content_types(Path.ext(resolved.path))

    // Compute cache identifiers from file metadata
    (let mod_secs, _) = info.modified_time
    let etag = _ETag(info.inode, info.size, mod_secs)
    let last_modified = _HttpDate(mod_secs)

    // Conditional request validation (RFC 7232 §3):
    // If-None-Match takes precedence over If-Modified-Since
    let not_modified = match ctx.request.headers.get("if-none-match")
    | let inm: String => _ETag.matches(inm, etag)
    else
      match ctx.request.headers.get("if-modified-since")
      | let ims: String => ims == last_modified
      else
        false
      end
    end

    if not_modified then
      let headers = recover val
        let h = stallion.Headers
          .>set("ETag", etag)
          .>set("Last-Modified", last_modified)
        match _cache_control
        | let cc: String => h.>set("Cache-Control", cc)
        end
        h
      end
      ctx.respond_with_headers(stallion.StatusNotModified, headers, "")
      return
    end

    // HEAD optimization: respond with headers only, skip file I/O entirely.
    // Content-Length is always set from stat size — even for files that GET
    // would stream with chunked encoding — since HEAD with Content-Length is
    // more useful to clients (e.g., checking download size) and is explicitly
    // allowed by RFC 7231 §4.3.2.
    if ctx.request.method is stallion.HEAD then
      let headers = recover val
        let h = stallion.Headers
          .>set("Content-Type", content_type)
          .>set("Content-Length", info.size.string())
          .>set("ETag", etag)
          .>set("Last-Modified", last_modified)
        match _cache_control
        | let cc: String => h.>set("Cache-Control", cc)
        end
        h
      end
      ctx.respond_with_headers(stallion.StatusOK, headers, "")
      return
    end

    if info.size < _chunk_threshold then
      // Small file: read entirely and respond with Content-Length
      let file = File.open(resolved)
      if file.errno() isnt FileOK then error end
      let body = file.read(info.size)
      file.dispose()
      let body_size = body.size()
      let headers = recover val
        let h = stallion.Headers
          .>set("Content-Type", content_type)
          .>set("Content-Length", body_size.string())
          .>set("ETag", etag)
          .>set("Last-Modified", last_modified)
        match _cache_control
        | let cc: String => h.>set("Cache-Control", cc)
        end
        h
      end
      ctx.respond_with_headers(stallion.StatusOK, headers, consume body)
    else
      // Large file: open before starting stream so failure produces 500
      let file: File iso = recover iso
        let f = File.open(resolved)
        if f.errno() isnt FileOK then error end
        f
      end

      let headers = recover val
        let h = stallion.Headers
          .>set("Content-Type", content_type)
          .>set("ETag", etag)
          .>set("Last-Modified", last_modified)
        match _cache_control
        | let cc: String => h.>set("Cache-Control", cc)
        end
        h
      end
      match ctx.start_streaming(stallion.StatusOK, headers)?
      | let sender: StreamSender tag =>
        _FileStreamer(consume file, sender)
      | stallion.ChunkedNotSupported =>
        file.dispose()
        ctx.respond(stallion.StatusHTTPVersionNotSupported,
          "HTTP Version Not Supported")
      end
    end