package er.extensions.appserver; import java.util.Collections; import java.util.Map; import java.util.WeakHashMap; import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.webobjects.appserver.WOApplication; import com.webobjects.appserver.WOComponent; import com.webobjects.appserver.WOContext; import com.webobjects.appserver.WORequestHandler; import com.webobjects.appserver.WOResourceManager; import com.webobjects.appserver.WOResponse; import com.webobjects.foundation.NSArray; import com.webobjects.foundation.NSMutableDictionary; import com.webobjects.foundation.NSMutableSet; import com.webobjects.foundation.NSNotification; import com.webobjects.foundation.NSNotificationCenter; import er.extensions.appserver.ajax.ERXAjaxApplication; import er.extensions.components.ERXStyleSheet; import er.extensions.foundation.ERXProperties; import er.extensions.foundation.ERXSelectorUtilities; import er.extensions.foundation.ERXStringUtilities; /** * ERXResponseRewriter provides several utilities for manipulating a WOResponse * after it has already been "drawn" by previous components. * * @author mschrag * @property er.extensions.loadOnDemand if <code>true</code>, javascript files included in Ajax responses will be loaded on-demand (defaults to <code>true</code>) * @property er.ajax.secureResources if <code>true</code>, load all resources with https (default false) * @property er.ajax.AJComponent.htmlCloseHead the tag to insert in front of (defaults to </head>) * @property er.extensions.ERXResponseRewriter.javascriptTypeAttribute if <code>true</code>, <i>type="text/javascript"</i> * will be added to injected script tags (defaults <code>false</code>). For valid HTML you have to set this to * <code>true</code> for HTML4 and XHTML but HTML5 will default to <i>text/javascript</i> if that attribute * is missing. */ public class ERXResponseRewriter { private static final Logger log = LoggerFactory.getLogger(ERXResponseRewriter.class); private static final String ADDED_RESOURCES_KEY = "ERXResponseRewriter.addedResources"; private static final String PENDING_INSERTS_KEY = "ERXResponseRewriter.pendingInserts"; private static final String SECURE_RESOURCES_KEY = "er.ajax.secureResources"; private static final String ORIGINAL_CONTEXT_ID_KEY = "_originalContextID"; private static final String TOP_INDEX_KEY = "ERXResponseRewriter.topIndex"; private static final String CONTEXT_OBSERVER_KEY = "ERXResponseRewriter.contextObserver"; private static Map<WOComponent, NSMutableDictionary<String, Object>> _ajaxPageUserInfos; private static Map<WOComponent, NSMutableDictionary<String, Object>> _pageUserInfos; private static Delegate _delagate; /** * Represents a resource in a framework, or a fully-qualified URL if * fileName starts with a / or contains :// . * * @author mschrag */ public static class Resource { private String _framework; private String _fileName; public Resource(String url) { _framework = null; _fileName = url; } public Resource(String framework, String fileName) { _framework = framework; _fileName = fileName; } public String framework() { return _framework; } public String fileName() { return _fileName; } @Override public String toString() { return "[Resource: framework = " + _framework + "; name = " + _fileName + "]"; } } /** * ERXResponseRewriter uses the ContextObserver to reset the topIndex value at the end * of the request. You should not need to invoke this class directly. */ public static class ContextObserver { public void didHandleRequest(NSNotification n) { WOContext context = (WOContext)n.object(); NSMutableDictionary<String, Object> pageInfo = ERXResponseRewriter.ajaxPageUserInfo(context); pageInfo.removeObjectForKey(TOP_INDEX_KEY); pageInfo.removeObjectForKey(CONTEXT_OBSERVER_KEY); NSNotificationCenter.defaultCenter().removeObserver(this, WORequestHandler.DidHandleRequestNotification, context); } } /** * The delegate that is called prior to adding resources into the page, * which gives you a chance to deny the addition, or rewrite the addition to * a custom resource. * * @author mschrag */ public static interface Delegate { /** * Called prior to adding resources at all. Returning false will skip * the addition completely. * * @param framework * the requested framework of the addition (can be <code>null</code>) * @param fileName * the requested fileName of the addition (can be a URL, * absolute path, or relative resource path) * @return <code>true</code> if the resource should be added */ public boolean responseRewriterShouldAddResource(String framework, String fileName); /** * Provides the ability to override the requested framework and fileName * with a custom alternative. For example, if you want to replace all * "Ajax" "prototype.js" imports, you can provide your own alternative * "app" "prototype.js". * * @param framework * the requested framework of the addition (can be <code>null</code>) * @param fileName * the requested fileName of the addition (can be a URL, * absolute path, or relative resource path) * @return an alternative Resource, or <code>null</code> to use the requested * resource */ public ERXResponseRewriter.Resource responseRewriterWillAddResource(String framework, String fileName); } /** * TagMissingBehavior specifies several ways the response rewriter should * handle the case of having a missing tag that you attempted to insert in * front of (for instance, if you ask to insert in the head tag and the head * tag does not exist). * * @author mschrag */ public static enum TagMissingBehavior { /** * Top tries to behave like head-insertion by maintaining the same * ordering as head would (first added, first printed). If an Ajax * response, top would push to the front of the response. */ Top, /** * Inline just renders the content at the current location in the * response. */ Inline, /** * Skip does not render the content at all and silently ignores the * missing tag. */ Skip, /** * Like skip, no content will be rendered into the response, but a * warning will be printed onto the console. */ SkipAndWarn } static { ERXResponseRewriter._pageUserInfos = Collections.synchronizedMap(new WeakHashMap<WOComponent, NSMutableDictionary<String, Object>>()); ERXResponseRewriter._ajaxPageUserInfos = Collections.synchronizedMap(new WeakHashMap<WOComponent, NSMutableDictionary<String, Object>>()); } /** * Sets the response rewriter delegate to be used by this Application. * * @param delegate * the response rewriter delegate to be used by this Application, * or <code>null</code> to use the default */ public static void setDelegate(ERXResponseRewriter.Delegate delegate) { ERXResponseRewriter._delagate = delegate; } /** * Returns the page userInfo for the page component of the given context. If * this is the first request for the page user info for a non-ajax request, * the user info will be cleared (so that reloading a page doesn't make the * system believe it has already rendered script and CSS tags, for * instance). If you do not want this behavior, use pageUserInfo(WOContext) * instead. * * @param context * the context to lookup * @return the user info for the page component of the given context */ public static NSMutableDictionary<String, Object> ajaxPageUserInfo(WOContext context) { WOComponent page = context.page(); ERXSession session = ERXSession.session(); boolean sessionStoresPageInfo = session != null && session.storesPageInfo(); @SuppressWarnings("null") Map<WOComponent, NSMutableDictionary<String, Object>> pageInfoDict = sessionStoresPageInfo ? session.pageInfoDictionary() : ERXResponseRewriter._ajaxPageUserInfos; NSMutableDictionary<String, Object> pageInfo = pageInfoDict.get(page); String contextID = context.contextID(); if (contextID == null) { contextID = "none"; } if (pageInfo != null && !ERXAjaxApplication.isAjaxRequest(context.request()) && !contextID.equals(pageInfo.objectForKey(ERXResponseRewriter.ORIGINAL_CONTEXT_ID_KEY))) { pageInfo = null; } if (pageInfo == null) { pageInfo = new NSMutableDictionary<>(); pageInfo.setObjectForKey(contextID, ERXResponseRewriter.ORIGINAL_CONTEXT_ID_KEY); pageInfoDict.put(page, pageInfo); } return pageInfo; } /** * Returns the page userInfo for the page component of the given context. * Unlike ajaxPageUserInfo, information put into pageUserInfo will stay * associated with the page as long as the page exists. * * @param context * the context to lookup * @return the user info for the page component of the given context */ public static NSMutableDictionary<String, Object> pageUserInfo(WOContext context) { return pageUserInfo(context.page()); } /** * Returns the page userInfo for the given page component. * Unlike ajaxPageUserInfo, information put into pageUserInfo will stay * associated with the page as long as the page exists. * * @param page * the component to lookup * @return the user info for the page component of the given context */ public static NSMutableDictionary<String, Object> pageUserInfo(WOComponent page) { NSMutableDictionary<String, Object> pageInfo = ERXResponseRewriter._pageUserInfos.get(page); if (pageInfo == null) { pageInfo = new NSMutableDictionary<>(); ERXResponseRewriter._pageUserInfos.put(page, pageInfo); } return pageInfo; } /** * Returns the tag name that scripts and resources should be inserted above. * Defaults to </head>, but this can be overridden by setting the * property er.ajax.AJComponent.htmlCloseHead. * * @return string that closes the part where resources are inserted into */ public static String _htmlCloseHeadTag() { String closeHeadTag = ERXProperties.stringForKeyWithDefault("er.ajax.AJComponent.htmlCloseHead", "</head>"); return closeHeadTag; } /** * Utility to add the given content into the response before the close of * the head tag. * * @param response * the WOResponse * @param context * the WOContext * @param content * the content to insert. * @param tagMissingBehavior * how to handle the case where the tag is missing * @return whether or not the content was inserted */ public static boolean insertInResponseBeforeHead(WOResponse response, WOContext context, String content, TagMissingBehavior tagMissingBehavior) { return ERXResponseRewriter.insertInResponseBeforeTag(response, context, content, ERXResponseRewriter._htmlCloseHeadTag(), tagMissingBehavior); } /** * Replaces all occurrences of the given pattern in the response with the replacement string. * * @param response the response * @param context the context * @param pattern the pattern to match * @param replacement the replacement value */ public static void replaceAllInResponse(WOResponse response, WOContext context, Pattern pattern, String replacement) { String responseContent = response.contentString(); if (responseContent != null) { String responseReplaced = pattern.matcher(responseContent).replaceAll(replacement); response.setContent(responseReplaced); } } /** * Replaces the first occurrence of the given pattern in the response with the replacement string. * * @param response the response * @param context the context * @param pattern the pattern to match * @param replacement the replacement value */ public static void replaceFirstInResponse(WOResponse response, WOContext context, Pattern pattern, String replacement) { String responseContent = response.contentString(); if (responseContent != null) { String responseReplaced = pattern.matcher(responseContent).replaceFirst(replacement); response.setContent(responseReplaced); } } /** * Utility to add the given content into the response before a particular * HTML tag. * * @param response * the response * @param context * the context * @param content * the content to insert * @param tag * the tag to insert before (in HTML syntax) * @param tagMissingBehavior * how to handle the case where the tag is missing * @return whether or not the content was inserted */ public static boolean insertInResponseBeforeTag(WOResponse response, WOContext context, String content, String tag, TagMissingBehavior tagMissingBehavior) { boolean inserted = false; String responseContent = response.contentString(); int tagIndex; if (tag != null) { tagIndex = responseContent.indexOf(tag); if (tagIndex < 0) { tagIndex = responseContent.toLowerCase().indexOf(tag.toLowerCase()); } } else { tagIndex = -1; } if (tagIndex >= 0) { int insertIndex = tagIndex; if (content.toLowerCase().startsWith("<link") || content.toLowerCase().startsWith("<style")) { int scriptIndex = responseContent.toLowerCase().indexOf("<script"); if (scriptIndex > 0 && scriptIndex < insertIndex) { insertIndex = scriptIndex; } } response.setContent(ERXStringUtilities.insertString(responseContent, content, insertIndex)); inserted = true; } else if (tagMissingBehavior == TagMissingBehavior.Inline) { response.appendContentString(content); inserted = true; } else if (tagMissingBehavior == TagMissingBehavior.Top) { NSMutableDictionary<String, Object> pageInfo = ERXResponseRewriter.ajaxPageUserInfo(context); Integer topIndex = (Integer) pageInfo.objectForKey(ERXResponseRewriter.TOP_INDEX_KEY); if (topIndex == null) { topIndex = Integer.valueOf(0); //Create an observer to reset the topIndex at the end of the request ContextObserver contextObserver = new ContextObserver(); NSNotificationCenter.defaultCenter().addObserver( contextObserver, ERXSelectorUtilities.notificationSelector("didHandleRequest"), WORequestHandler.DidHandleRequestNotification, context); //Stick the observer in the pageInfo dictionary so it isn't garbage collected pageInfo.setObjectForKey(contextObserver, CONTEXT_OBSERVER_KEY); } response.setContent(ERXStringUtilities.insertString(responseContent, content, topIndex)); pageInfo.setObjectForKey(Integer.valueOf(topIndex.intValue() + content.length()), ERXResponseRewriter.TOP_INDEX_KEY); inserted = true; } else if (tagMissingBehavior == TagMissingBehavior.Skip) { // IGNORE } else if (tagMissingBehavior == TagMissingBehavior.SkipAndWarn) { log.warn("There was no {}, so your content did not get added: {}", tag, content); } else { throw new IllegalArgumentException("Unknown tag missing missing: " + tagMissingBehavior + "."); } return inserted; } /** * Adds a script tag with a correct resource URL into the HTML head tag if * it isn't already present in the response, or inserts an Ajax OnDemand tag * if the current request is an Ajax request. * * @param response * the response * @param context * the context * @param framework * the framework that contains the file * @param fileName * the name of the javascript file to add */ public static void addScriptResourceInHead(WOResponse response, WOContext context, String framework, String fileName) { boolean appendTypeAttribute = ERXProperties.booleanForKeyWithDefault("er.extensions.ERXResponseRewriter.javascriptTypeAttribute", false); String scriptStartTag; if (appendTypeAttribute) { scriptStartTag = "<script type=\"text/javascript\" src=\""; } else { scriptStartTag = "<script src=\""; } String scriptEndTag = "\"></script>"; String fallbackStartTag; String fallbackEndTag; if (ERXAjaxApplication.isAjaxRequest(context.request()) && ERXProperties.booleanForKeyWithDefault("er.extensions.loadOnDemand", true)) { if (!ERXAjaxApplication.isAjaxReplacement(context.request()) || ERXProperties.booleanForKeyWithDefault("er.extensions.loadOnDemandDuringReplace", false)) { if (appendTypeAttribute) { fallbackStartTag = "<script type=\"text/javascript\">AOD.loadScript('"; } else { fallbackStartTag = "<script>AOD.loadScript('"; } fallbackEndTag = "')</script>"; } else { fallbackStartTag = null; fallbackEndTag = null; } } else { fallbackStartTag = null; fallbackEndTag = null; } ERXResponseRewriter.addResourceInHead(response, context, framework, fileName, scriptStartTag, scriptEndTag, fallbackStartTag, fallbackEndTag, TagMissingBehavior.Inline); } /** * Adds a stylesheet link tag with a correct resource URL in the HTML head * tag if it isn't already present in the response. * * @param context * the context * @param response * the response * @param framework * the framework that contains the file * @param fileName * the name of the CSS file to add */ public static void addStylesheetResourceInHead(WOResponse response, WOContext context, String framework, String fileName) { ERXResponseRewriter.addStylesheetResourceInHead(response, context, framework, fileName, null); } /** * Adds a stylesheet link tag with a correct resource URL in the HTML head * tag if it isn't already present in the response. * * @param context * the context * @param response * the response * @param framework * the framework that contains the file * @param fileName * the name of the CSS file to add * @param media * the media type of the stylesheet (or <code>null</code> for default) */ public static void addStylesheetResourceInHead(WOResponse response, WOContext context, String framework, String fileName, String media) { String cssStartTag; if (media == null) { cssStartTag = "<link rel=\"stylesheet\" type=\"text/css\" href=\""; } else { cssStartTag = "<link rel=\"stylesheet\" type=\"text/css\" media=\"" + media + "\" href=\""; } String cssEndTag; if (ERXStyleSheet.shouldCloseLinkTags()) { cssEndTag = "\"/>"; } else { cssEndTag = "\">"; } String fallbackStartTag = null; String fallbackEndTag = null; if (ERXAjaxApplication.isAjaxRequest(context.request()) && ERXProperties.booleanForKeyWithDefault("er.extensions.loadOnDemand", true)) { if (ERXProperties.booleanForKeyWithDefault("er.extensions.loadOnDemandDuringReplace", false)) { boolean appendTypeAttribute = ERXProperties.booleanForKeyWithDefault("er.extensions.ERXResponseRewriter.javascriptTypeAttribute", false); fallbackStartTag = (appendTypeAttribute ? "<script type=\"text/javascript\">AOD.loadCSS('" : "<script>AOD.loadCSS('"); fallbackEndTag = "')</script>"; } } ERXResponseRewriter.addResourceInHead(response, context, framework, fileName, cssStartTag, cssEndTag, fallbackStartTag, fallbackEndTag, TagMissingBehavior.Inline); // Q: We use TagMissingBehaviour.Inline in case this is called from inside the // HEAD tag and there is no close tag yet // ERXResponseRewriter.addResourceInHead(response, context, framework, fileName, cssStartTag, cssEndTag, null, null, TagMissingBehavior.Inline); } /** * Adds javascript code in a script tag in the HTML head tag (without a * name). If you call this method multiple times with the same script code, * it will add multiple times. To prevent this, call * addScriptCodeInHead(WOResponse, String, String) passing in a name for * your script. * * @param response * the response to write into * @param context * the context * @param script * the javascript code to insert */ public static void addScriptCodeInHead(WOResponse response, WOContext context, String script) { ERXResponseRewriter.addScriptCodeInHead(response, context, script, null); } /** * Adds javascript code in a script tag in the HTML head tag or inline if * the request is an Ajax request. * * @param response * the response to write into * @param context * the context * @param script * the javascript code to insert * @param scriptName * the name of the script to insert (for duplicate checking) */ public static void addScriptCodeInHead(WOResponse response, WOContext context, String script, String scriptName) { if (scriptName == null || !ERXResponseRewriter.isResourceAddedToHead(context, null, scriptName)) { String js = "<script type=\"text/javascript\">\n" + script + "\n</script>"; boolean inserted = ERXResponseRewriter.insertInResponseBeforeHead(response, context, js, TagMissingBehavior.Top); if (inserted && scriptName != null) { ERXResponseRewriter.resourceAddedToHead(context, null, scriptName); } } } /** * Adds a reference to an arbitrary file with a correct resource URL wrapped * between startTag and endTag in the HTML head tag if it isn't already * present in the response. * * @param response * the response * @param context * the context * @param framework * the framework that contains the file * @param fileName * the name of the file to add * @param startTag * the HTML to prepend before the URL * @param endTag * the HTML to append after the URL */ public static void addResourceInHead(WOResponse response, WOContext context, String framework, String fileName, String startTag, String endTag) { ERXResponseRewriter.addResourceInHead(response, context, framework, fileName, startTag, endTag, TagMissingBehavior.Skip); } /** * Returns the resources that have been added to the head of this page. * * @param context * the context * @return the resources that have been added to the head of this page. */ @SuppressWarnings("unchecked") public static NSMutableSet<String> resourcesAddedToHead(WOContext context) { NSMutableDictionary<String, Object> userInfo = ERXResponseRewriter.ajaxPageUserInfo(context); NSMutableSet<String> addedResources = (NSMutableSet<String>) userInfo.objectForKey(ERXResponseRewriter.ADDED_RESOURCES_KEY); if (addedResources == null) { addedResources = new NSMutableSet<>(); userInfo.setObjectForKey(addedResources, ERXResponseRewriter.ADDED_RESOURCES_KEY); } return addedResources; } /** * Returns whether or not the given resource has been added to the HEAD tag. * * @param context * the context * @param frameworkName * the framework name of the resource * @param resourceName * the name of the resource to check * @return <code>true</code> if the resource has been added to head */ public static boolean isResourceAddedToHead(WOContext context, String frameworkName, String resourceName) { NSMutableSet<String> addedResources = ERXResponseRewriter.resourcesAddedToHead(context); return addedResources.containsObject(frameworkName + "." + resourceName); } /** * Records that the given resource (within the given framework) has been * added to the head of this page. * * @param context * the context * @param frameworkName * the framework name of the resource * @param resourceName * the name of the resource */ public static void resourceAddedToHead(WOContext context, String frameworkName, String resourceName) { NSMutableSet<String> addedResources = ERXResponseRewriter.resourcesAddedToHead(context); addedResources.addObject(frameworkName + "." + resourceName); } /** * Adds a reference to an arbitrary file with a correct resource URL wrapped * between startTag and endTag in the HTML head tag if it isn't already * present in the response. * * @param response * the response * @param context * the context * @param framework * the framework that contains the file * @param fileName * the name of the file to add * @param startTag * the HTML to prepend before the URL * @param endTag * the HTML to append after the URL * @param tagMissingBehavior * how to handle the case where the tag is missing * * @return whether or not the content was added */ public static boolean addResourceInHead(WOResponse response, WOContext context, String framework, String fileName, String startTag, String endTag, TagMissingBehavior tagMissingBehavior) { return ERXResponseRewriter.addResourceInHead(response, context, framework, fileName, startTag, endTag, null, null, tagMissingBehavior); } /** * Adds a reference to an arbitrary file with a correct resource URL wrapped * between startTag and endTag in the HTML head tag if it isn't already * present in the response. * * @param response * the response * @param context * the context * @param framework * the framework that contains the file * @param fileName * the name of the file to add * @param startTag * the HTML to prepend before the URL * @param endTag * the HTML to append after the URL * @param fallbackStartTag * @param fallbackEndTag * @param tagMissingBehavior * how to handle the case where the tag is missing * * @return whether or not the content was added */ public static boolean addResourceInHead(WOResponse response, WOContext context, String framework, String fileName, String startTag, String endTag, String fallbackStartTag, String fallbackEndTag, TagMissingBehavior tagMissingBehavior) { boolean inserted = true; String replacementResourceStr = ERXProperties.stringForKey("er.extensions.ERXResponseRewriter.resource." + framework + "." + fileName); if (replacementResourceStr != null) { int dotIndex = replacementResourceStr.indexOf('.'); framework = replacementResourceStr.substring(0, dotIndex); fileName = replacementResourceStr.substring(dotIndex + 1); } if (!ERXResponseRewriter.isResourceAddedToHead(context, framework, fileName) && (_delagate == null || _delagate.responseRewriterShouldAddResource(framework, fileName))) { boolean insert = true; if (_delagate != null) { Resource replacementResource = _delagate.responseRewriterWillAddResource(framework, fileName); if (replacementResource != null) { framework = replacementResource.framework(); fileName = replacementResource.fileName(); // double-check that the replacement hasn't already been added if (ERXResponseRewriter.isResourceAddedToHead(context, framework, fileName)) { insert = false; } } } if (insert) { String url; if (fileName.indexOf("://") != -1 || fileName.startsWith("/")) { url = fileName; } else { WOResourceManager rm = WOApplication.application().resourceManager(); NSArray languages = null; if (context.hasSession()) { languages = context.session().languages(); } url = rm.urlForResourceNamed(fileName, framework, languages, context.request()); boolean generateCompleteResourceURLs = ERXResourceManager._shouldGenerateCompleteResourceURL(context); boolean secureAllResources = ERXProperties.booleanForKey(ERXResponseRewriter.SECURE_RESOURCES_KEY) && !ERXRequest.isRequestSecure(context.request()); if (generateCompleteResourceURLs || secureAllResources) { url = ERXResourceManager._completeURLForResource(url, secureAllResources ? Boolean.TRUE : null, context); } } String html = startTag + url + endTag + "\n"; if (fallbackStartTag == null && fallbackEndTag == null) { inserted = ERXResponseRewriter.insertInResponseBeforeHead(response, context, html, tagMissingBehavior); } else { inserted = ERXResponseRewriter.insertInResponseBeforeHead(response, context, html, TagMissingBehavior.Skip); if (!inserted) { String fallbackHtml = fallbackStartTag + url + fallbackEndTag + "\n"; inserted = ERXResponseRewriter.insertInResponseBeforeTag(response, context, fallbackHtml, null, TagMissingBehavior.Top); } } if (inserted) { ERXResponseRewriter.resourceAddedToHead(context, framework, fileName); } } } return inserted; } /** * Appends a script tag with or without type attribute depending on the * corresponding property value. * * @param response response object to add opening script tag to */ public static void appendScriptTagOpener(WOResponse response) { boolean appendTypeAttribute = ERXProperties.booleanForKeyWithDefault("er.extensions.ERXResponseRewriter.javascriptTypeAttribute", false); if (appendTypeAttribute) { response.appendContentString("<script type=\"text/javascript\">"); } else { response.appendContentString("<script>"); } } /** * Appends the closing script tag to the given response. * * @param response response object to add closing script tag to */ public static void appendScriptTagCloser(WOResponse response) { response.appendContentString("</script>"); } }