sourceafBedSheet::PodHandler.fan

using afIoc::Inject
using afIoc::Scope
using afIocConfig::Config
using afBeanUtils::ArgNotFoundErr

** (Service) - 
** A 'ClientAssetProducer' that maps URLs to file resources inside pods. 
**
** To access a pod resource use URLs in the format:
** 
**   /<baseUrl>/<podName>/<fileName>
** 
** By default the 'baseUrl' is '/pod/' which means you should always be able to access the flux icon.
** 
**   /pod/icons/x256/flux.png
** 
** Change the base url in the application defaults:
** 
** pre>
** syntax: fantom
** @Contribute { serviceType=ApplicationDefaults# } 
** Void contributeAppDefaults(Configuration config) {
**     config[BedSheetConfigIds.podHandlerBaseUrl] = `/some/other/url/`
** }
** <pre
** 
** Set the base url to 'null' to disable the serving of pod resources.
** 
** 
** 
** Serve Fantom Code
** =================
** Fantom compiles all classes in a pod annotated with the '@Js' facet into a Javascript file that is saved in the pod.
** These pod '.js' files can then be served up with 'PodHandler' allowing you execute Fantom code in the browser.
** 
** Here is an example that calls 'alert()' via Fantom's DOM pod. To run, just serve the example as static HTML:
** 
** pre>
** syntax: html
** 
** <!DOCTYPE html>
** <html>
** <head>
**     <script type="text/javascript" src="/pod/sys/sys.js"></script>
**     <script type="text/javascript" src="/pod/gfx/gfx.js"></script>
**     <script type="text/javascript" src="/pod/web/web.js"></script>
**     <script type="text/javascript" src="/pod/dom/dom.js"></script>
** </head>
** <body>
**     <h1>Old Skool Example</h1>
** 
**     <script type="text/javascript">
**         fan.dom.Win.cur().alert("Hello Mum!");
**     </script>
** </body>
** </html>
** <pre
** 
** Note that the order in which the pod '.js' files are listed is very important; each pod's dependencies must be listed before the pod itself.
** 
** Fantom code may also be executed via the [web::WebUtil.jsMain()]`web::WebUtil.jsMain` method.
** 
** A much cleaner way injecting Fantom code is to use the [Duvet library]`http://eggbox.fantomfactory.org/pods/afDuvet` which uses 
** [RequireJS]`http://requirejs.org/docs/api.html` to wrap up the Fantom code as dependency managed Javascript modules. 
** 
** 
** 
** Resource Whitelist
** ==================
** Because pods may contain sensitive data, the entire contents of all the pods are NOT available by default. Oh no!
** 'PodHandler' has a whitelist of Regexes that specify which pod files are allowed to be served.
** If a pod resource doesn't match a regex, it doesn't get served.
** 
** By default only a handful of files with common web extensions are allowed. These include:
** 
** pre>
** .      web files: .css .htm .html .js .xhtml .js.map
**      image files: .bmp .gif .ico .jpg .png
**      sound files: .mp3 .ogg .wav .opus
**   web font files: .eot .otf .svg .ttf .woff
**      other files: .csv .txt .xml
** <pre
** 
** To add or remove whitelist regexs, contribute to 'PodHandler':
**  
** pre>
** syntax: fantom
** @Contribute { serviceType=PodHandler# } 
** static Void contributePodHandler(Configuration conf) {
**     conf.remove(".txt")                      // prevent .txt files from being served
**     conf["acmePodFiles"] = "^fan://acme/.*$" // serve all files from the acme pod
** }
** <pre
const mixin PodHandler : ClientAssetProducer {
    
    ** The local URL under which pod resources are served.
    ** 
    ** Set by `BedSheetConfigIds.podHandlerBaseUrl`, defaults to '/pods/'.
    abstract Uri? baseUrl()

    ** Given a local URL (a simple URL relative to the WebMod), this returns a corresponding (cached) 'FileAsset'.
    ** Throws 'ArgErr' if the URL is not mapped or does not exist.
    abstract ClientAsset? fromLocalUrl(Uri localUrl, Bool checked := true)

    ** Given a pod resource file, this returns a corresponding (cached) 'FileAsset'. 
    ** The URI must adhere to the 'fan://<pod>/<file>' scheme notation.
    ** Throws 'ArgErr' if the URL is not mapped or does not exist.
    abstract ClientAsset? fromPodResource(Uri podResource, Bool checked := true)
}

internal const class PodHandlerImpl : PodHandler {

    @Config { id="afBedSheet.podHandler.baseUrl" }
    @Inject override const Uri?                 baseUrl
    @Inject private const |->ClientAssetCache|  assetCache
    @Inject private const Scope                 scope
            private const Regex[]               whitelistFilters
    
    new make(Regex[] filters, |This|? in) {
        this.whitelistFilters = filters

        in?.call(this)
        
        if (baseUrl == null)
            return

        if (!baseUrl.isPathOnly)
            throw BedSheetErr(BsErrMsgs.urlMustBePathOnly(baseUrl, `/pod/`))
        if (!baseUrl.isPathAbs)
            throw BedSheetErr(BsErrMsgs.urlMustStartWithSlash(baseUrl, `/pod/`))
        if (!baseUrl.isDir)
            throw BedSheetErr(BsErrMsgs.urlMustEndWithSlash(baseUrl, `/pod/`))
    }

    override ClientAsset? produceAsset(Uri localUrl) {
        _fromLocalUrl(localUrl, false, false)
    }

    override ClientAsset? fromLocalUrl(Uri localUrl, Bool checked := true) {
        _fromLocalUrl(localUrl, checked, true)
    }
    
    override ClientAsset? fromPodResource(Uri podUrl, Bool checked := true) {
        _fromPodResource(podUrl, checked, true)
    }   

    ClientAsset? _fromLocalUrl(Uri localUrl, Bool checked, Bool cache) {
        if (baseUrl == null)
            if (checked) throw Err(BsErrMsgs.podHandler_disabled)
            else return null

        Utils.validateLocalUrl(localUrl, `/pod/icons/x256/flux.png`)
        if (!localUrl.toStr.startsWith(baseUrl.toStr))
            if (checked) throw ArgErr(BsErrMsgs.podHandler_urlNotMapped(localUrl, baseUrl))
            else return null

        remainingUrl := localUrl.relTo(baseUrl)

        return fromPodResource(`fan://${remainingUrl}`, checked)
    }
    
    ClientAsset? _fromPodResource(Uri podUrl, Bool checked, Bool cache) {
        if (baseUrl == null)
            if (checked) throw Err(BsErrMsgs.podHandler_disabled)
            else return null
 
        if (podUrl.scheme != "fan")
            if (checked) throw ArgErr(BsErrMsgs.podHandler_urlNotFanScheme(podUrl))
            else return null

        resource := (Obj?) null
        try resource = podUrl.get
        catch 
            if (checked) throw ArgErr(BsErrMsgs.podHandler_urlDoesNotResolve(podUrl))
            else return null

        if (resource isnot File)    // WTF!?
            if (checked) throw ArgErr(BsErrMsgs.podHandler_urlNotFile(podUrl, resource))
            else return null

        file    := (File) resource
        podPath := file.uri.toStr
        if (!whitelistFilters.any { it.matches(podPath) })
            if (checked) throw ArgNotFoundErr(BsErrMsgs.podHandler_notInWhitelist(podPath), whitelistFilters)
            else return null
        
        host        := file.uri.host.toUri.plusSlash
        path        := file.uri.pathOnly.relTo(`/`)
        localUrl    := baseUrl + host + path

        makeFunc := |Uri key->ClientAsset?| {
            if (!file.exists)
                if (checked) throw ArgErr(BsErrMsgs.fileNotFound(file))
                else return null
            
            return scope.build(FileAsset#, [localUrl, file])
        }
        
        return cache ? assetCache().getAndUpdateOrMake(localUrl, makeFunc) : makeFunc(localUrl) 
    }   
}