All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.shindig.gadgets.render.RenderingGadgetRewriter Maven / Gradle / Ivy

Go to download

Renders gadgets, provides the gadget metadata service, and serves all javascript required by the OpenSocial specification.

There is a newer version: 3.0.0-beta4
Show newest version
/*
 * 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.MessageBundleFactory;
import org.apache.shindig.gadgets.UnsupportedFeatureException;
import org.apache.shindig.gadgets.UrlGenerator;
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.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.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());
  
  private static final int INLINE_JS_BUFFER = 50;

  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";

  protected final MessageBundleFactory messageBundleFactory;
  protected final ContainerConfig containerConfig;
  protected final FeatureRegistry featureRegistry;
  protected final UrlGenerator urlGenerator;
  protected final RpcServiceLookup rpcServiceLookup;
  protected Set defaultExternLibs = ImmutableSet.of();

  /**
   * @param messageBundleFactory Used for injecting message bundles into gadget output.
   */
  @Inject
  public RenderingGadgetRewriter(MessageBundleFactory messageBundleFactory,
                                 ContainerConfig containerConfig,
                                 FeatureRegistry 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) {
      defaultExternLibs = ImmutableSortedSet.copyOf(Arrays.asList(forcedLibs.split(":")));
    }
  }

  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");

      // Remove all the elements currently in head and add them back after we inject content
      NodeList children = head.getChildNodes();
      List existingHeadContent = Lists.newArrayListWithExpectedSize(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");
      GadgetContext context = gadget.getContext();
      MessageBundle bundle = messageBundleFactory.getBundle(
          gadget.getSpec(), context.getLocale(), context.getIgnoreCache());
      injectMessageBundles(bundle, 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");

      body.setAttribute("dir", bundle.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 RewritingException(e.getLocalizedMessage(), e);
    }
  }

  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();"));
  }

  /**
   * Injects javascript libraries needed to satisfy feature dependencies.
   */
  protected 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 externParam = context.getParameter("libs");

    // List of extern libraries we need
    Set extern;

    // gather the libraries we'll need to generate the extern libs
    if (externParam == null || externParam.length() == 0) {
      // Don't bother making a mutable copy if the list is empty
      extern = (defaultExternLibs.isEmpty()) ? defaultExternLibs :
          Sets.newTreeSet(defaultExternLibs);
    } else {
      extern = Sets.newTreeSet(Arrays.asList(externParam.split(":")));
    }
    
    if (!extern.isEmpty()) {
      String jsUrl = urlGenerator.getBundledJsUrl(extern, context);
      Element libsTag = headTag.getOwnerDocument().createElement("script");
      libsTag.setAttribute("src", jsUrl);
      headTag.appendChild(libsTag);
    }
    
    List unsupported = Lists.newLinkedList();
    List externResources =
        featureRegistry.getFeatureResources(gadget.getContext(), extern, 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 featureMap = gadget.getSpec().getModulePrefs().getFeatures();
    List gadgetFeatureKeys = Lists.newArrayList(gadget.getDirectFeatureDeps());
    List gadgetResources =
        featureRegistry.getFeatureResources(gadget.getContext(), gadgetFeatureKeys, unsupported);
    if (!unsupported.isEmpty()) {
      List 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());
      }
    }
    
    // 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.
    List inlineResources = Lists.newArrayList(gadgetResources);
    inlineResources.removeAll(externResources);

    // 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();
        }
      }
    }

    List allRequested = Lists.newArrayList(gadgetFeatureKeys);
    allRequested.addAll(extern);
    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.appendChild(inlineTag);
          inlineTag.appendChild(headTag.getOwnerDocument().createTextNode(inlineJs.toString()));
          inlineJs.setLength(0);
        }
        Element referenceTag = headTag.getOwnerDocument().createElement("script");
        referenceTag.setAttribute("src", theContent);
        headTag.appendChild(referenceTag);
      } else {
        inlineJs.append(theContent).append(";\n");
      }
    }

    inlineJs.append(libraryConfig);

    if (inlineJs.length() > 0) {
      Element inlineTag = headTag.getOwnerDocument().createElement("script");
      headTag.appendChild(inlineTag);
      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
   */
  private String getLibraryConfig(Gadget gadget, List reqs)
      throws GadgetException {
    GadgetContext context = gadget.getContext();

    Map features = containerConfig.getMap(context.getContainer(), FEATURES_KEY);

    Map 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);
        }
      }
    }

    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 config) {
    SecurityToken authToken = context.getToken();
    if (authToken != null) {
      Map 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 config) {
    // Add gadgets.util support. This is calculated dynamically based on request inputs.
    ModulePrefs prefs = gadget.getSpec().getModulePrefs();
    Collection features = prefs.getFeatures().values();
    Map> 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 paramFeaturesInConfig = Maps.newHashMap();
      for (String paramName : feature.getParams().keySet()) {
        Collection 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 config, 
      String host) {
    if (rpcServiceLookup != null) {
      Multimap 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.
   */
  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) {
    List prefs = gadget.getSpec().getUserPrefs();
    Map 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 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);
  }
}