/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.shindig.gadgets.render;
import org.apache.shindig.common.JsonSerializer;
import org.apache.shindig.common.uri.Uri;
import org.apache.shindig.common.xml.DomUtil;
import org.apache.shindig.config.ContainerConfig;
import org.apache.shindig.gadgets.Gadget;
import org.apache.shindig.gadgets.GadgetContext;
import org.apache.shindig.gadgets.GadgetException;
import org.apache.shindig.gadgets.MessageBundleFactory;
import org.apache.shindig.gadgets.UnsupportedFeatureException;
import org.apache.shindig.gadgets.config.ConfigContributor;
import org.apache.shindig.gadgets.features.FeatureRegistry;
import org.apache.shindig.gadgets.features.FeatureResource;
import org.apache.shindig.gadgets.preload.PreloadException;
import org.apache.shindig.gadgets.preload.PreloadedData;
import org.apache.shindig.gadgets.rewrite.GadgetRewriter;
import org.apache.shindig.gadgets.rewrite.MutableContent;
import org.apache.shindig.gadgets.rewrite.RewritingException;
import org.apache.shindig.gadgets.spec.Feature;
import org.apache.shindig.gadgets.spec.MessageBundle;
import org.apache.shindig.gadgets.spec.UserPref;
import org.apache.shindig.gadgets.spec.View;
import org.apache.shindig.gadgets.uri.JsUriManager;
import org.apache.commons.lang.StringUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.Text;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.inject.Inject;
import com.google.inject.name.Named;
/**
* Produces a valid HTML document for the gadget output, automatically inserting appropriate HTML
* document wrapper data as needed.
*
* Currently, this is only invoked directly since the rewriting infrastructure doesn't properly
* deal with uncacheable rewrite operations.
*
* TODO: Break this up into multiple rewriters.
*
* Should be:
*
* - UserPrefs injection
* - Javascript injection (including configuration)
* - html document normalization
*/
public class RenderingGadgetRewriter implements GadgetRewriter {
private static final Logger LOG = Logger.getLogger(RenderingGadgetRewriter.class.getName());
private static final int INLINE_JS_BUFFER = 50;
protected static final String DEFAULT_CSS =
"body,td,div,span,p{font-family:arial,sans-serif;}" +
"a {color:#0000cc;}a:visited {color:#551a8b;}" +
"a:active {color:#ff0000;}" +
"body{margin: 0px;padding: 0px;background-color:white;}";
static final String IS_GADGET_BEACON = "window['__isgadget']=true;";
static final String INSERT_BASE_ELEMENT_KEY = "gadgets.insertBaseElement";
static final String FEATURES_KEY = "gadgets.features";
protected final MessageBundleFactory messageBundleFactory;
protected final ContainerConfig containerConfig;
protected final FeatureRegistry featureRegistry;
protected final JsUriManager jsUriManager;
protected final Map<String, ConfigContributor> configContributors;
protected Set<String> defaultExternLibs = ImmutableSet.of();
protected Boolean externalizeFeatures = false;
/**
* @param messageBundleFactory Used for injecting message bundles into gadget output.
*/
@Inject
public RenderingGadgetRewriter(MessageBundleFactory messageBundleFactory,
ContainerConfig containerConfig,
FeatureRegistry featureRegistry,
JsUriManager jsUriManager,
Map<String, ConfigContributor> configContributors) {
this.messageBundleFactory = messageBundleFactory;
this.containerConfig = containerConfig;
this.featureRegistry = featureRegistry;
this.jsUriManager = jsUriManager;
this.configContributors = configContributors;
}
@Inject
public void setDefaultForcedLibs(@Named("shindig.gadget-rewrite.default-forced-libs")String forcedLibs) {
if (StringUtils.isNotBlank(forcedLibs)) {
defaultExternLibs = ImmutableSortedSet.copyOf(StringUtils.split(forcedLibs, ':'));
}
}
@Inject(optional = true)
public void setExternalizeFeatureLibs(@Named("shindig.gadget-rewrite.externalize-feature-libs")Boolean externalizeFeatures) {
this.externalizeFeatures = externalizeFeatures;
}
public void rewrite(Gadget gadget, MutableContent mutableContent) throws RewritingException {
// Don't touch sanitized gadgets.
if (gadget.sanitizeOutput()) {
return;
}
try {
Document document = mutableContent.getDocument();
Element head = (Element)DomUtil.getFirstNamedChildNode(document.getDocumentElement(), "head");
// Insert new content before any of the existing children of the head element
Node firstHeadChild = head.getFirstChild();
// Only inject default styles if no doctype was specified.
if (document.getDoctype() == null) {
Element defaultStyle = document.createElement("style");
defaultStyle.setAttribute("type", "text/css");
head.insertBefore(defaultStyle, firstHeadChild);
defaultStyle.appendChild(defaultStyle.getOwnerDocument().
createTextNode(DEFAULT_CSS));
}
injectBaseTag(gadget, head);
injectGadgetBeacon(gadget, head, firstHeadChild);
injectFeatureLibraries(gadget, head, firstHeadChild);
// This can be one script block.
Element mainScriptTag = document.createElement("script");
GadgetContext context = gadget.getContext();
MessageBundle bundle = messageBundleFactory.getBundle(
gadget.getSpec(), context.getLocale(), context.getIgnoreCache(), context.getContainer());
injectMessageBundles(bundle, mainScriptTag);
injectDefaultPrefs(gadget, mainScriptTag);
injectPreloads(gadget, mainScriptTag);
// We need to inject our script before any developer scripts.
head.insertBefore(mainScriptTag, firstHeadChild);
Element body = (Element)DomUtil.getFirstNamedChildNode(document.getDocumentElement(), "body");
body.setAttribute("dir", bundle.getLanguageDirection());
injectOnLoadHandlers(body);
mutableContent.documentChanged();
} catch (GadgetException e) {
throw new RewritingException(e.getLocalizedMessage(), e, e.getHttpStatusCode());
}
}
protected void injectBaseTag(Gadget gadget, Node headTag) {
GadgetContext context = gadget.getContext();
if (containerConfig.getBool(context.getContainer(), INSERT_BASE_ELEMENT_KEY)) {
Uri base = gadget.getSpec().getUrl();
View view = gadget.getCurrentView();
if (view != null && view.getHref() != null) {
base = view.getHref();
}
Element baseTag = headTag.getOwnerDocument().createElement("base");
baseTag.setAttribute("href", base.toString());
headTag.insertBefore(baseTag, headTag.getFirstChild());
}
}
protected void injectOnLoadHandlers(Node bodyTag) {
Element onloadScript = bodyTag.getOwnerDocument().createElement("script");
bodyTag.appendChild(onloadScript);
onloadScript.appendChild(bodyTag.getOwnerDocument().createTextNode(
"gadgets.util.runOnLoadHandlers();"));
}
protected void injectGadgetBeacon(Gadget gadget, Node headTag, Node firstHeadChild)
throws GadgetException {
Element beaconNode = headTag.getOwnerDocument().createElement("script");
beaconNode.setTextContent(IS_GADGET_BEACON);
headTag.insertBefore(beaconNode, firstHeadChild);
}
/**
* Injects javascript libraries needed to satisfy feature dependencies.
*/
protected void injectFeatureLibraries(Gadget gadget, Node headTag, Node firstHeadChild)
throws GadgetException {
// TODO: If there isn't any js in the document, we can skip this. Unfortunately, that means
// both script tags (easy to detect) and event handlers (much more complex).
GadgetContext context = gadget.getContext();
// Set of extern libraries requested by the container
Set<String> externForcedLibs = defaultExternLibs;
// gather the libraries we'll need to generate the extern libs
String externParam = context.getParameter("libs");
if (StringUtils.isNotBlank(externParam)) {
externForcedLibs = Sets.newTreeSet(Arrays.asList(StringUtils.split(externParam, ':')));
}
if (!externForcedLibs.isEmpty()) {
String jsUrl = jsUriManager.makeExternJsUri(gadget, externForcedLibs).toString();
Element libsTag = headTag.getOwnerDocument().createElement("script");
libsTag.setAttribute("src", jsUrl);
headTag.insertBefore(libsTag, firstHeadChild);
}
List<String> unsupported = Lists.newLinkedList();
List<FeatureResource> externForcedResources =
featureRegistry.getFeatureResources(context, externForcedLibs, unsupported);
if (!unsupported.isEmpty()) {
LOG.info("Unknown feature(s) in extern &libs=: " + unsupported.toString());
unsupported.clear();
}
// Get all resources requested by the gadget's requires/optional features.
Map<String, Feature> featureMap = gadget.getSpec().getModulePrefs().getFeatures();
List<String> gadgetFeatureKeys = Lists.newArrayList(gadget.getDirectFeatureDeps());
List<FeatureResource> gadgetResources =
featureRegistry.getFeatureResources(context, gadgetFeatureKeys, unsupported);
if (!unsupported.isEmpty()) {
List<String> requiredUnsupported = Lists.newLinkedList();
for (String notThere : unsupported) {
if (!featureMap.containsKey(notThere) || featureMap.get(notThere).getRequired()) {
// if !containsKey, the lib was forced with Gadget.addFeature(...) so implicitly req'd.
requiredUnsupported.add(notThere);
}
}
if (!requiredUnsupported.isEmpty()) {
throw new UnsupportedFeatureException(requiredUnsupported.toString());
}
}
// Inline or externalize the gadgetFeatureKeys
List<FeatureResource> inlineResources = Lists.newArrayList();
List<String> allRequested = Lists.newArrayList(gadgetFeatureKeys);
if (externalizeFeatures) {
Set<String> externGadgetLibs = Sets.newTreeSet(featureRegistry.getFeatures(gadgetFeatureKeys));
externGadgetLibs.removeAll(externForcedLibs);
if (!externGadgetLibs.isEmpty()) {
String jsUrl = jsUriManager.makeExternJsUri(gadget, externGadgetLibs).toString();
Element libsTag = headTag.getOwnerDocument().createElement("script");
libsTag.setAttribute("src", jsUrl);
headTag.insertBefore(libsTag, firstHeadChild);
}
} else {
inlineResources.addAll(gadgetResources);
}
// Calculate inlineResources as all resources that are needed by the gadget to
// render, minus all those included through externResources.
// TODO: profile and if needed, optimize this a bit.
if (!externForcedLibs.isEmpty()) {
allRequested.addAll(externForcedLibs);
inlineResources.removeAll(externForcedResources);
}
// Precalculate the maximum length in order to avoid excessive garbage generation.
int size = 0;
for (FeatureResource resource : inlineResources) {
if (!resource.isExternal()) {
if (context.getDebug()) {
size += resource.getDebugContent().length();
} else {
size += resource.getContent().length();
}
}
}
String libraryConfig =
getLibraryConfig(gadget, featureRegistry.getFeatures(allRequested));
// Size has a small fudge factor added to it for delimiters and such.
StringBuilder inlineJs = new StringBuilder(size + libraryConfig.length() + INLINE_JS_BUFFER);
// Inline any libs that weren't extern. The ugly context switch between inline and external
// Js is needed to allow both inline and external scripts declared in feature.xml.
for (FeatureResource resource : inlineResources) {
String theContent = context.getDebug() ? resource.getDebugContent() : resource.getContent();
if (resource.isExternal()) {
if (inlineJs.length() > 0) {
Element inlineTag = headTag.getOwnerDocument().createElement("script");
headTag.insertBefore(inlineTag, firstHeadChild);
inlineTag.appendChild(headTag.getOwnerDocument().createTextNode(inlineJs.toString()));
inlineJs.setLength(0);
}
Element referenceTag = headTag.getOwnerDocument().createElement("script");
referenceTag.setAttribute("src", theContent);
headTag.insertBefore(referenceTag, firstHeadChild);
} else {
inlineJs.append(theContent).append(";\n");
}
}
inlineJs.append(libraryConfig);
if (inlineJs.length() > 0) {
Element inlineTag = headTag.getOwnerDocument().createElement("script");
headTag.insertBefore(inlineTag, firstHeadChild);
inlineTag.appendChild(headTag.getOwnerDocument().createTextNode(inlineJs.toString()));
}
}
/**
* Creates a set of all configuration needed to satisfy the requested feature set.
*
* Appends special configuration for gadgets.util.hasFeature and gadgets.util.getFeatureParams to
* the output js.
*
* This can't be handled via the normal configuration mechanism because it is something that
* varies per request.
*
* @param reqs The features needed to satisfy the request.
* @throws GadgetException If there is a problem with the gadget auth token
*/
protected String getLibraryConfig(Gadget gadget, List<String> reqs)
throws GadgetException {
GadgetContext context = gadget.getContext();
Map<String, Object> features = containerConfig.getMap(context.getContainer(), FEATURES_KEY);
Map<String, Object> config
= Maps.newHashMapWithExpectedSize(features == null ? 2 : features.size() + 2);
if (features != null) {
// Discard what we don't care about.
for (String name : reqs) {
Object conf = features.get(name);
if (conf != null) {
config.put(name, conf);
}
// See if this feature has configuration data
ConfigContributor contributor = configContributors.get(name);
if (contributor != null) {
contributor.contribute(config, gadget);
}
}
}
return "gadgets.config.init(" + JsonSerializer.serialize(config) + ");\n";
}
/**
* Injects message bundles into the gadget output.
* @throws GadgetException If we are unable to retrieve the message bundle.
*/
protected void injectMessageBundles(MessageBundle bundle, Node scriptTag) throws GadgetException {
String msgs = bundle.toJSONString();
Text text = scriptTag.getOwnerDocument().createTextNode("gadgets.Prefs.setMessages_(");
text.appendData(msgs);
text.appendData(");");
scriptTag.appendChild(text);
}
/**
* Injects default values for user prefs into the gadget output.
*/
protected void injectDefaultPrefs(Gadget gadget, Node scriptTag) {
Collection<UserPref> prefs = gadget.getSpec().getUserPrefs().values();
Map<String, String> defaultPrefs = Maps.newHashMapWithExpectedSize(prefs.size());
for (UserPref up : prefs) {
defaultPrefs.put(up.getName(), up.getDefaultValue());
}
Text text = scriptTag.getOwnerDocument().createTextNode("gadgets.Prefs.setDefaultPrefs_(");
text.appendData(JsonSerializer.serialize(defaultPrefs));
text.appendData(");");
scriptTag.appendChild(text);
}
/**
* Injects preloads into the gadget output.
*
* If preloading fails for any reason, we just output an empty object.
*/
protected void injectPreloads(Gadget gadget, Node scriptTag) {
List<Object> preload = Lists.newArrayList();
for (PreloadedData preloaded : gadget.getPreloads()) {
try {
preload.addAll(preloaded.toJson());
} catch (PreloadException pe) {
// This will be thrown in the event of some unexpected exception. We can move on.
LOG.log(Level.WARNING, "Unexpected error when preloading", pe);
}
}
Text text = scriptTag.getOwnerDocument().createTextNode("gadgets.io.preloaded_=");
text.appendData(JsonSerializer.serialize(preload));
text.appendData(";");
scriptTag.appendChild(text);
}
}