package votorola.s.gwt.stage; // Copyright 2012, Michael Allan. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Votorola Software"), to deal in the Votorola Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of the Votorola Software, and to permit persons to whom the Votorola Software is furnished to do so, subject to the following conditions: The preceding copyright notice and this permission notice shall be included in all copies or substantial portions of the Votorola Software. THE VOTOROLA SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE VOTOROLA SOFTWARE OR THE USE OR OTHER DEALINGS IN THE VOTOROLA SOFTWARE. import com.google.gwt.core.client.*; import com.google.gwt.dom.client.*; import com.google.gwt.http.client.URL; import com.google.gwt.jsonp.client.JsonpRequestBuilder; import com.google.gwt.user.client.Cookies; import com.google.gwt.user.client.rpc.AsyncCallback; import java.util.regex.*; import votorola.a.web.gwt.*; import votorola.g.lang.*; import votorola.g.net.*; import votorola.g.web.gwt.*; /** The relayer of state across an active link from one theatre page (referrer) to another * (destination). A single instance is created for use during stage initialization. */ public final class ReferrerRelayer { // FIX: Document the design. Consider window.name as primary store instead of (or in // addition to) cookies. Won't work for dragged links, but will work across domains // of origin. http://en.wikipedia.org/wiki/HTTP_cookie#window.name /** Creates the single instance of ReferrerRelayer. */ ReferrerRelayer() {} private static ReferrerRelayer instance; { if( instance != null ) throw new IllegalStateException(); instance = ReferrerRelayer.this; } // -------------------------------------------------------------------------------- /** Attempts to enable the forward relayer, which will then store the state of the * stage on detection of each link gesture. */ void activate() { if( cookieDomainOrNull == null ) return; // cannot cleanly decide on type of relay store (cookie or WAP) // RootPanelM.get().addHandler( ReferrerRelayer.this, ClickEvent.getType() ); //// GWT is wrapped too tight for that to work. Widgets can do it using //// onBrowserEvent(), but here is the DOM way: activateJS(); } private native void activateJS() /*-{ var that = this; var listener = function(e) { that.@votorola.s.gwt.stage.ReferrerRelayer::onPotentialLinkGesture(Lcom/google/gwt/dom/client/NativeEvent;Z)( e, false ); }; // Register for the capturing phase (true) in order to gain time to effect the // relay during the brief moment prior to link activation: $wnd.document.body.addEventListener( 'click', listener, true ); // Click always fires when viewport transits a link to a new page. // http://www.w3.org/TR/DOM-Level-3-Events/#event-flow-activation $wnd.document.body.addEventListener( 'dragstart', listener, true ); // Drag may be used to drop a link into a different viewport, for example. // On drag events, see http://dev.w3.org/html5/spec/dnd.html var backstopListener = function(e) { that.@votorola.s.gwt.stage.ReferrerRelayer::onPotentialLinkGesture(Lcom/google/gwt/dom/client/NativeEvent;Z)( e, true ); }; $wnd.document.body.addEventListener( 'mousedown', backstopListener, true ); // A click handler is not always permitted to run when a link is being // activated and the document about to be canned (Chromium 18), so backstop // it with eager handling of the prior mouse press. }-*/; /** Returns a cookie domain (e.g. ".mydomain.dom") suitable for use in a relay cookie, * or null if no domain can be determined from the provided host specification. * * @param host the host specification per Net.{@linkplain Net#widestCookieDomain * widestCookieDomain}, or null. If this is an Internet address * ("206.248.142.181") or "localhost", then the host ought to have no siblings * under the same domain that serve theatre pages to which it links. */ public static String cookieDomain( final String host ) { // Widen the host domain to the broadest allowed for the page. The ideal form of // store for this purpose would be tied to the script origin, but that does not // appear to be available on the client side. Access to cookies is restricted to // domain stamps that match the page (not script) origin (Firefox 9). Access to // the session store is likewise restricted, what a script writes to the store on // x.com/page cannot be read back on y.com/page, and vice versa (Firefox 9). return Net.widestCookieDomain( host ); } /** The path for all relay cookies. */ public static final String COOKIE_PATH = "/"; // all paths /** The security mode for all relay cookies. */ public static final boolean COOKIE_SECURE = false; /** The key under which the state of the stage is stored. */ public static final String KEY = "voStage.relay"; /** The key under which the URL of the link target (stripped of any fragment) is * stored. This is set either by the referrer or redirection services it employs, * such as WP_Draft.{@linkplain votorola.s.wic.WP_Draft#maybeRedirectToDraft * maybeRedirectToDraft}. It is crucial that no subsequent redirection * alter the destination URL or the destination page will reject the relay because of * a URL mismatch. */ public static final String KEY_HREF = "voStage.relayHREF"; // separately storing this part simplifies the corrections that are stored by // redirection services like WP_Draft.maybeRedirectToDraft /** The time limit for resolving the referrer in cases of deferred resolution, which * is {@value} ms. */ // static final int MAX_REFERRER_RESOLUTION_MS = 5_000; /// GWT 2.4 can't talk JDK 1.7 static final int MAX_REFERRER_RESOLUTION_MS = 5000; /** Attempts to resolve the referrer and immediately calls back to stage.{@linkplain * Stage#init1(TheatrePageJS,ReferrerRelayer,boolean) init1} with the result. */ void resolveReferrer( final Stage stage ) { // Do not rely here on document.referrer to determine the link's domain relation // and thus the type of store to inspect. That will fail when the link is // mediated by a redirector such as s.wic.WP_Draft. The store type would then // depend on the relation between this page and the redirector, not the referrer. // If the use of the redirector could be detected, then the correct relation would // be known. But detection is difficult in practice. [1] // // Do not rely on the absence of a "referer" header to indicate the absence of // storage, because the header is not necessarily set for dragged links (not set // by Firefox 9). Therefore inspect the relatively cheap cookie store first, then // fall back to the more expensive WAP store. [2] // // [1] OPT We might allow the redirector to overload the 'referer' header in some // innocent way, maybe by appending a fragment containing its host name. Then // unecessary WAP calls could be avoided in all cases but dragged links. But // overloading may cause problematic side effects. // [2] OPT We might multiplex the WAP call with others that usually occur on page // initialization. Then the overhead would be reduced to little more than a // database lookup on the server side. final String loc = stage.pageLocation(); // Inspect relatively cheap cookie store first. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final String href = Cookies.getCookie( KEY_HREF ); // if( loc.equals( href )) //// but href for cookie relay (same domain) may be relative, so: if( href != null && loc.endsWith( href )) { if( cookieDomainOrNull != null ) { CookiesX.removeCookie( KEY_HREF, cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); assert Cookies.getCookie(KEY_HREF) == null: KEY_HREF + " cookie deleted"; } final String referrerJSON = Cookies.getCookie( KEY ); if( referrerJSON == null ) // malformed storage, assume none { stage.init1( /*referrer*/null, ReferrerRelayer.this, /*isReferencePending*/false ); return; } if( cookieDomainOrNull != null ) { CookiesX.removeCookie( KEY, cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); assert Cookies.getCookie(KEY) == null: KEY + " cookie deleted"; } final TheatrePageJS referrer = JsonUtils.unsafeEval( referrerJSON ); // cookie written by ReferrerRelayer.this, safe to use fast eval stage.init1( referrer, ReferrerRelayer.this, /*isReferencePending*/false ); return; } // Fall back to the more expensive WAP store. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final StringBuilder b = GWTX.stringBuilderClear(); b.append( App.getServletContextLocation() ); b.append( "/wap?wCall=sStore&sGet=" ).append( KEY ); b.append( "&sGet=" ).append( KEY_HREF ); b.append( '\'' ).append( URL.encodeQueryString( loc )); // get only if value matches loc b.append( "&wNonce=" ).append( URLX.serialNonce() ); // per a.web.wap.ResponseConfiguration.headNoCache() final JsonpRequestBuilder jsonp = new JsonpRequestBuilder(); jsonp.setCallbackParam( App.i().jsonpWAP().getCallbackParam() ); jsonp.setTimeout( MAX_REFERRER_RESOLUTION_MS ); jsonp.requestObject( b.toString(), new AsyncCallback() { public void onFailure( final Throwable x ) { stage.init2b_async( /*referrer*/null ); } public void onSuccess( final JavaScriptObject response ) { TheatrePageJS referrer = null; final JavaScriptObject s = response._get( "s" ); final JavaScriptObject values = s._get( "value" ); final String href = values._get( KEY_HREF ); if( href != null ) { assert href.equals( loc ); final String referrerJSON = values._get( KEY ); if( referrerJSON != null ) referrer = JsonUtils.safeEval( referrerJSON ); // written by ReferrerRelayer.this, but others may possibly overwrite } stage.init2b_async( referrer ); } }); stage.init1( /*referrer*/null, ReferrerRelayer.this, /*isReferencePending*/true ); } //// P r i v a t e /////////////////////////////////////////////////////////////////////// private static final int BACKSTOP_FILTERING_WINDOW_MS = 3000; // Time interval during which apparently backstopped link gestures are ignored as // duplicates. Backstopping is implemented in activateJS(). It detects gestural // antecedants in addition to the more-or-less definitive gesture. The latter often // follows immediately, of course, and we want to avoid re-sending the associated // storage request on the wire. However we cannot aggressively filter the // duplicates based on value ("already stored that") because the store may have been // altered in the meantime by another page in the history stack (BFCached) or in a // separate window. Therefore we restrict filtering to gestures that can be defined // as backstopped, and part of the definition is the short period of time elapsed // since the last backstop gesture. private String backstopHREFRaw; private long backstopStorageTime; // ms private final String cookieDomainOrNull = cookieDomain( Document.get().getDomain() ); // getDomain() actually returns name or address private void onPotentialLinkGesture( final NativeEvent e, final boolean isBackstop ) { for( JavaScriptObject node = e.getEventTarget(); Element.is(node); ) { final Element element = node.cast(); final String name = element.getTagName(); if( "A".equals(name) || "a".equals(name) ) { final String hrefRaw = element.getAttribute( "href" ); if( hrefRaw == null ) return; if( !isBackstop && hrefRaw.equals( backstopHREFRaw )) { final int elapsed = (int)( System.currentTimeMillis() - backstopStorageTime ); if( elapsed < BACKSTOP_FILTERING_WINDOW_MS ) return; // duplicate gesture } final String href = URIX.fragmentStripped( hrefRaw ); if( href.length() == 0 ) return; // pure fragment, thus self link final Stage stage = Stage.i(); final String loc = stage.pageLocation(); if( href.equals( loc )) return; // self link if( isBackstop ) { backstopHREFRaw = hrefRaw; backstopStorageTime = System.currentTimeMillis(); } StringBuilder b = GWTX.stringBuilderClear(); stage.appendJSON( b ); Stage.StateChunker.appendJSON( b, "pageLocation", loc ); stage.wrapJSON( b ); // yields a TheatrePageJS final String pageJSON = b.toString(); // Store state for relay. cf. s.wic.WP_Draft // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final boolean isDestinationSameDomain = cookieDomainOrNull != null && cookieDomainOrNull.equals( cookieDomain( element._getString( "hostname" ))); if( isDestinationSameDomain ) // then use cookie { // final Date expiryDate = new Date( System.currentTimeMillis() + 1000L/*ms per s*/ // * 60/*s per minute*/ * 5/*minutes*/ ); //// simpler to just expire at end of session assert Cookies.getUriEncode(): "cookies automatically URI encoded"; Cookies.setCookie( KEY_HREF, href, /*expires:with session*/null, cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); Cookies.setCookie( KEY, pageJSON, /*expires:with session*/null, cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); } else // destination is other domain or unknown relation, so use WAP { StringBuilderX.clear( b ); b.append( App.getServletContextLocation() ); b.append( "/wap?wCall=sStore&sPut=" ).append( KEY_HREF ); b.append( '\'' ).append( URL.encodeQueryString( href )); b.append( "&sPut=" ).append( KEY ); b.append( '\'' ).append( URL.encodeQueryString( pageJSON )); b.append( "&wNonce=" ).append( URLX.serialNonce() ); // per a.web.wap.ResponseConfiguration.headNoCache() App.i().jsonpWAP().send( b.toString() ); } return; } node = element.getParentNode(); } } }