/*
* 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.auth.SecurityToken;
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.GadgetFeature;
import org.apache.shindig.gadgets.GadgetFeatureRegistry;
import org.apache.shindig.gadgets.JsLibrary;
import org.apache.shindig.gadgets.MessageBundleFactory;
import org.apache.shindig.gadgets.RenderingContext;
import org.apache.shindig.gadgets.UnsupportedFeatureException;
import org.apache.shindig.gadgets.UrlGenerator;
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.spec.Feature;
import org.apache.shindig.gadgets.spec.LocaleSpec;
import org.apache.shindig.gadgets.spec.MessageBundle;
import org.apache.shindig.gadgets.spec.ModulePrefs;
import org.apache.shindig.gadgets.spec.UserPref;
import org.apache.shindig.gadgets.spec.View;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.Text;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
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.Multimap;
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());
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 INSERT_BASE_ELEMENT_KEY = "gadgets.insertBaseElement";
static final String FEATURES_KEY = "gadgets.features";
private final MessageBundleFactory messageBundleFactory;
private final ContainerConfig containerConfig;
private final GadgetFeatureRegistry featureRegistry;
private final UrlGenerator urlGenerator;
private final RpcServiceLookup rpcServiceLookup;
private Set<String> defaultForcedLibs = ImmutableSet.of();
/**
* @param messageBundleFactory Used for injecting message bundles into gadget output.
*/
@Inject
public RenderingGadgetRewriter(MessageBundleFactory messageBundleFactory,
ContainerConfig containerConfig,
GadgetFeatureRegistry featureRegistry,
UrlGenerator urlGenerator,
RpcServiceLookup rpcServiceLookup) {
this.messageBundleFactory = messageBundleFactory;
this.containerConfig = containerConfig;
this.featureRegistry = featureRegistry;
this.urlGenerator = urlGenerator;
this.rpcServiceLookup = rpcServiceLookup;
}
@Inject
public void setDefaultForcedLibs(@Named("shindig.gadget-rewrite.default-forced-libs")String forcedLibs) {
if (forcedLibs != null && forcedLibs.length() > 0) {
defaultForcedLibs = ImmutableSortedSet.copyOf(Arrays.asList(forcedLibs.split(":")));
}
}
public void rewrite(Gadget gadget, MutableContent mutableContent) {
// Don't touch sanitized gadgets.
if (gadget.sanitizeOutput()) {
return;
}
try {
Document document = mutableContent.getDocument();
Element head = (Element)DomUtil.getFirstNamedChildNode(document.getDocumentElement(), "head");
// Remove all the elements currently in head and add them back after we inject content
NodeList children = head.getChildNodes();
List<Node> existingHeadContent = Lists.newArrayListWithCapacity(children.getLength());
for (int i = 0; i < children.getLength(); i++) {
existingHeadContent.add(children.item(i));
}
for (Node n : existingHeadContent) {
head.removeChild(n);
}
// Only inject default styles if no doctype was specified.
if (document.getDoctype() == null) {
Element defaultStyle = document.createElement("style");
defaultStyle.setAttribute("type", "text/css");
head.appendChild(defaultStyle);
defaultStyle.appendChild(defaultStyle.getOwnerDocument().
createTextNode(DEFAULT_CSS));
}
injectBaseTag(gadget, head);
injectFeatureLibraries(gadget, head);
// This can be one script block.
Element mainScriptTag = document.createElement("script");
injectMessageBundles(gadget, mainScriptTag);
injectDefaultPrefs(gadget, mainScriptTag);
injectPreloads(gadget, mainScriptTag);
// We need to inject our script before any developer scripts.
head.appendChild(mainScriptTag);
Element body = (Element)DomUtil.getFirstNamedChildNode(document.getDocumentElement(), "body");
LocaleSpec localeSpec = gadget.getLocale();
if (localeSpec != null) {
body.setAttribute("dir", localeSpec.getLanguageDirection());
}
// re append head content
for (Node node : existingHeadContent) {
head.appendChild(node);
}
injectOnLoadHandlers(body);
mutableContent.documentChanged();
} catch (GadgetException e) {
// TODO: Rewriter interface needs to be modified to handle GadgetException or
// RewriterException or something along those lines.
throw new RuntimeException(e);
}
}
private 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());
}
}
private void injectOnLoadHandlers(Node bodyTag) {
Element onloadScript = bodyTag.getOwnerDocument().createElement("script");
bodyTag.appendChild(onloadScript);
onloadScript.appendChild(bodyTag.getOwnerDocument().createTextNode(
"gadgets.util.runOnLoadHandlers();"));
}
/**
* Injects javascript libraries needed to satisfy feature dependencies.
*/
private void injectFeatureLibraries(Gadget gadget, Node headTag) 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();
String forcedLibs = context.getParameter("libs");
// List of forced libraries we need
Set<String> forced;
// gather the libraries we'll need to generate the forced libs
if (forcedLibs == null || forcedLibs.length() == 0) {
// Don't bother making a mutable copy if the list is empty
forced = (defaultForcedLibs.isEmpty()) ? defaultForcedLibs : Sets.newTreeSet(defaultForcedLibs);
} else {
forced = Sets.newTreeSet(Arrays.asList(forcedLibs.split(":")));
}
if (!forced.isEmpty()) {
String jsUrl = urlGenerator.getBundledJsUrl(forced, context);
Element libsTag = headTag.getOwnerDocument().createElement("script");
libsTag.setAttribute("src", jsUrl);
headTag.appendChild(libsTag);
// Forced transitive deps need to be added as well so that they don't get pulled in twice.
// Without this, a shared dependency between forced and non-forced libs would get pulled into
// both the external forced script and the inlined script.
// TODO: Figure out a clean way to avoid having to call getFeatures twice.
for (GadgetFeature dep : featureRegistry.getFeatures(forced)) {
forced.add(dep.getName());
}
}
// Make this read-only
forced = ImmutableSet.copyOf(forced);
// Inline any libs that weren't forced. The ugly context switch between inline and external
// Js is needed to allow both inline and external scripts declared in feature.xml.
String container = context.getContainer();
Collection<GadgetFeature> features = getFeatures(gadget, forced);
// Precalculate the maximum length in order to avoid excessive garbage generation.
int size = 0;
for (GadgetFeature feature : features) {
for (JsLibrary library : feature.getJsLibraries(RenderingContext.GADGET, container)) {
if (library.getType().equals(JsLibrary.Type.URL)) {
size += library.getContent().length();
}
}
}
// Really inexact.
StringBuilder inlineJs = new StringBuilder(size);
for (GadgetFeature feature : features) {
for (JsLibrary library : feature.getJsLibraries(RenderingContext.GADGET, container)) {
if (library.getType().equals(JsLibrary.Type.URL)) {
if (inlineJs.length() > 0) {
Element inlineTag = headTag.getOwnerDocument().createElement("script");
headTag.appendChild(inlineTag);
inlineTag.appendChild(headTag.getOwnerDocument().createTextNode(inlineJs.toString()));
inlineJs.setLength(0);
}
Element referenceTag = headTag.getOwnerDocument().createElement("script");
referenceTag.setAttribute("src", library.getContent());
headTag.appendChild(referenceTag);
} else {
if (!forced.contains(feature.getName())) {
// already pulled this file in from the shared contents.
if (context.getDebug()) {
inlineJs.append(library.getDebugContent());
} else {
inlineJs.append(library.getContent());
}
inlineJs.append(";\n");
}
}
}
}
inlineJs.append(getLibraryConfig(gadget, features));
if (inlineJs.length() > 0) {
Element inlineTag = headTag.getOwnerDocument().createElement("script");
headTag.appendChild(inlineTag);
inlineTag.appendChild(headTag.getOwnerDocument().createTextNode(inlineJs.toString()));
}
}
/**
* Get all features needed to satisfy this rendering request.
*
* @param forced Forced libraries; added in addition to those found in the spec. Defaults to
* "core".
*/
private Collection<GadgetFeature> getFeatures(Gadget gadget, Collection<String> forced)
throws GadgetException {
Map<String, Feature> features = gadget.getSpec().getModulePrefs().getFeatures();
Set<String> libs = Sets.newHashSet(features.keySet());
if (!forced.isEmpty()) {
libs.addAll(forced);
}
libs.removeAll(gadget.getRemovedFeatures());
libs.addAll(gadget.getAddedFeatures());
Set<String> unsupported = Sets.newHashSet();
Collection<GadgetFeature> feats = featureRegistry.getFeatures(libs, unsupported);
unsupported.removeAll(forced);
if (!unsupported.isEmpty()) {
// Remove non-required libs
Iterator<String> missingIter = unsupported.iterator();
while (missingIter.hasNext()) {
String missing = missingIter.next();
if (!features.get(missing).getRequired()) {
missingIter.remove();
}
}
// Throw error with full list of unsupported libraries
if (!unsupported.isEmpty()) {
throw new UnsupportedFeatureException(unsupported.toString());
}
}
return feats;
}
/**
* 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
*/
private String getLibraryConfig(Gadget gadget, Collection<GadgetFeature> 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 (GadgetFeature feature : reqs) {
String name = feature.getName();
Object conf = features.get(name);
if (conf != null) {
config.put(name, conf);
}
}
}
addHasFeatureConfig(gadget, config);
addOsapiSystemListMethodsConfig(gadget.getContext().getContainer(), config,
gadget.getContext().getHost());
addSecurityTokenConfig(context, config);
return "gadgets.config.init(" + JsonSerializer.serialize(config) + ");\n";
}
private void addSecurityTokenConfig(GadgetContext context, Map<String, Object> config) {
SecurityToken authToken = context.getToken();
if (authToken != null) {
Map<String, String> authConfig = Maps.newHashMapWithExpectedSize(2);
String updatedToken = authToken.getUpdatedToken();
if (updatedToken != null) {
authConfig.put("authToken", updatedToken);
}
String trustedJson = authToken.getTrustedJson();
if (trustedJson != null) {
authConfig.put("trustedJson", trustedJson);
}
config.put("shindig.auth", authConfig);
}
}
private void addHasFeatureConfig(Gadget gadget, Map<String, Object> config) {
// Add gadgets.util support. This is calculated dynamically based on request inputs.
ModulePrefs prefs = gadget.getSpec().getModulePrefs();
Collection<Feature> features = prefs.getFeatures().values();
Map<String, Map<String, Object>> featureMap = Maps.newHashMapWithExpectedSize(features.size());
for (Feature feature : features) {
// Flatten out the multimap a bit for backwards compatibility: map keys
// with just 1 value into the string, treat others as arrays
Map<String, Object> paramFeaturesInConfig = Maps.newHashMap();
for (String paramName : feature.getParams().keySet()) {
Collection<String> paramValues = feature.getParams().get(paramName);
if (paramValues.size() == 1) {
paramFeaturesInConfig.put(paramName, paramValues.iterator().next());
} else {
paramFeaturesInConfig.put(paramName, paramValues);
}
}
featureMap.put(feature.getName(), paramFeaturesInConfig);
}
config.put("core.util", featureMap);
}
private void addOsapiSystemListMethodsConfig(String container, Map<String, Object> config,
String host) {
if (rpcServiceLookup != null) {
Multimap<String, String> endpoints = rpcServiceLookup.getServicesFor(container, host);
config.put("osapi.services", endpoints);
}
}
/**
* Injects message bundles into the gadget output.
* @throws GadgetException If we are unable to retrieve the message bundle.
*/
private void injectMessageBundles(Gadget gadget, Node scriptTag) throws GadgetException {
GadgetContext context = gadget.getContext();
MessageBundle bundle = messageBundleFactory.getBundle(
gadget.getSpec(), context.getLocale(), context.getIgnoreCache());
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.
*/
private void injectDefaultPrefs(Gadget gadget, Node scriptTag) {
List<UserPref> prefs = gadget.getSpec().getUserPrefs();
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.
*/
private 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);
}
}