sourcedraft::DraftMod.fan

//
// Copyright (c) 2011, Andy Frank
// Licensed under the MIT License
//
// History:
//   14 May 2011  Andy Frank  Creation
//

using concurrent
using util
using web
using webmod

**
** DraftMod
**
abstract const class DraftMod : WebMod
{
  ** Constructor.
  new make()
  {
    router = Router { routes=[,] }

    // check for pubDir prop
    dir := typeof.pod.config("pubDir")
    if (dir != null) pubDir = dir.toUri.toFile
  }

  ** Router model.
  const Router router

  **
  ** Directory to publish as public files under '/pub/' URI:
  **   pubDir := `/foo/bar/`
  **   /foo/bar/index.css     =>  `/pub/index.css`
  **   /foo/bar/img/logo.png  =>  `/pub/img/logo.png`
  **
  ** The pubDir may also be defined as a [config]`sys::Env.config`
  ** property in 'etc/draft/config.props'
  **
  const File? pubDir := null

  **
  ** Directory to write log files to.  If left null, no logging
  ** will be performed.
  **
  const File? logDir := null

  **
  ** Format of the web log records as a string of names.
  ** See [webmod]`webmod::pod-doc#log`
  **
  const Str logFields := "date time c-ip cs(X-Real-IP) cs-method " +
                         "cs-uri-stem cs-uri-query sc-status time-taken " +
                         "cs(User-Agent) cs(Referer) cs(Cookie)"

  **
  ** Map of URI path names to sub-WebMods. Sub mods are checked
  ** for matching routes before we process our own routes.
  **
  // TODO: not sure how this works yet
  @NoDoc
  const Str:WebMod subMods := Str:WebMod[:]

  ** Invoked prior to serviceing the current request.
  virtual Void onBeforeService(Str:Str args) {}

  ** Invoked after serviceing the current request.
  virtual Void onAfterService(Str:Str args) {}

  ** Service incoming request.
  override Void onService()
  {
    try
    {
      // set mod
      req.mod = this

      // check for sub mod
      sub := subMods[req.modRel.path.first ?: ""]
      if (sub != null)
      {
        req.mod = sub
        req.modBase = req.modBase + `$req.modRel.path.first/`
        sub.onService
        return
      }

      // check for pub
      if (req.uri.path.first == "pub" && pubDir != null)
        { onServicePub; return }

      // check for pod
      if (req.uri.path.first == "pod")
        { onServicePod; return }

      // match req to Route
      match := router.match(req.modRel, req.method)
      if (match == null) throw DraftErr(404)
      req.stash["draft.route"] = match
      req.stash["draft.route.meta"] = match.meta

      // access session here before response is commited so
      // session cookie has a chance to be added to res header
      dummy := flash

      // allow pre-service
      onBeforeService(match.args)
      if (res.isDone) return

      // delegate to Route.handler
      h := match.route.handler
      args := h.params.isEmpty ? null : [match.args]
      weblet := h.parent == typeof ? this : h.parent.make
      weblet.trap(h.name, args)

      // allow post-service
      onAfterService(match.args)

      // store flash for next req
      req.session["draft.flash"] = flash.res.ro.toImmutable

      // TODO - force flush here?
      // res.out.flush
    }
    catch (Err err)
    {
      // do not spam logs with socket errs; should we just
      // suppress all java.net.SocketExceptions?
      if (err.msg.contains("java.net.SocketException: Broken pipe")) return
      if (err.msg.contains("java.net.SocketException: Connection reset")) return

      // wrap in DraftErr and log
      if (err isnot DraftErr) err = DraftErr(500, err)
      logErr(err)
      onErr(err)
    }
    finally
    {
      // log requst
      logMod?.onService
    }
  }

  ** Service a pub request.
  private Void onServicePub()
  {
    file := pubDir + req.uri[1..-1]
    if (!file.exists) throw DraftErr(404)
    FileWeblet(file).onService
  }

  ** Service a pod request.
  private Void onServicePod()
  {
    // must have at least 3 path segments
    path := req.uri.path
    if (path.size < 2) throw DraftErr(404)

    // lookup pod
    pod := Pod.find(path[1], false)
    if (pod == null) throw DraftErr(404)

    // lookup file
    file := pod.file(`/` + req.uri[2..-1].pathOnly, false)
    if (file == null) throw DraftErr(404)
    FileWeblet(file).onService
  }

//////////////////////////////////////////////////////////////////////////
// Flash
//////////////////////////////////////////////////////////////////////////

  ** Get flash instance for this request.
  Flash flash()
  {
    flash := req.stash["draft.flash"]
    if (flash == null)
    {
      map := req.session["draft.flash"] ?: Str:Str[:]
      req.stash["draft.flash"] = flash = Flash(map)
    }
    return flash
  }

//////////////////////////////////////////////////////////////////////////
// Lifecycle
//////////////////////////////////////////////////////////////////////////

  ** Handle startup tasks.
  override Void onStart()
  {
    if (logDir != null)
    {
      // start LogMod
      logMod := LogMod { dir=logDir; filename="web-{YYYY-MM}.log"; fields=logFields }
      this.logModRef.val = logMod
      logMod.onStart
    }
  }

  private LogMod? logMod() { logModRef.val }
  private const AtomicRef logModRef := AtomicRef(null)

//////////////////////////////////////////////////////////////////////////
// Errs
//////////////////////////////////////////////////////////////////////////

  ** Handle an error condition during a request.
  virtual Void onErr(DraftErr err)
  {
    // first check if this was a redirect
    if (err.redirectUri != null)
    {
      res.redirect(err.redirectUri, err.errCode)
      return
    }

    // pick best err msg
    msg := err.errCode == 500 && err.cause != null ? err.cause.msg : err.msg

    // setup response if not already commited
    if (!res.isCommitted)
    {
      res.statusCode = err.errCode
      res.headers["Content-Type"] = "text/html; charset=UTF-8"
      res.headers["Draft-Err-Msg"] = msg
    }

    // send HTML response
    out := res.out
    out.docType
    out.html
    out.head
      .title.esc(err.msg).titleEnd
      .style.w("pre,td { font-family:monospace; }
                td:first-child { color:#888; padding-right:1em; }").styleEnd
      .headEnd
    out.body
      // msg
      out.h1.esc(err.msg).h1End
      if (err.msg != msg) out.h2.esc(msg).h2End
      if (!err.errCode.toStr.startsWith("4"))
      {
        out.hr
        // req headers
        out.table
        req.headers.each |v,k| { out.tr.td.w(k).tdEnd.td.w(v).tdEnd.trEnd }
        out.tableEnd
        out.hr
        // stack trace
        out.pre.w(err.traceToStr).preEnd
      }
    out.bodyEnd
    out.htmlEnd
  }

  ** Log error.
  private Void logErr(DraftErr err)
  {
    // don't spam logs for favicon/robots.txt or 404
    if (req.uri == `/favicon.ico`) return
    if (req.uri == `/robots.txt`) return
    if (err.errCode == 404) return
    if (err.redirectUri != null) return

    buf := StrBuf()
    buf.add("$err.msg - $req.uri\n")
    req.headers.each |v,k| { buf.add("  $k: $v\n") }
    err.traceToStr.splitLines.each |s| { buf.add("  $s\n") }
    log.err(buf.toStr.trim)
  }

  ** Log for DraftMod.
  private const static Log log := Log.get("draft")
}