001package 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. 002 003import com.google.gwt.core.client.*; 004import com.google.gwt.dom.client.*; 005import com.google.gwt.http.client.URL; 006import com.google.gwt.jsonp.client.JsonpRequestBuilder; 007import com.google.gwt.user.client.Cookies; 008import com.google.gwt.user.client.rpc.AsyncCallback; 009import java.util.regex.*; 010import votorola.a.web.gwt.*; 011import votorola.g.lang.*; 012import votorola.g.net.*; 013import votorola.g.web.gwt.*; 014 015 016/** The relayer of state across an active link from one theatre page (referrer) to another 017 * (destination). A single instance is created for use during stage initialization. 018 */ 019public final class ReferrerRelayer 020{ 021 022 // FIX: Document the design. Consider window.name as primary store instead of (or in 023 // addition to) cookies. Won't work for dragged links, but will work across domains 024 // of origin. http://en.wikipedia.org/wiki/HTTP_cookie#window.name 025 026 027 /** Creates the single instance of ReferrerRelayer. 028 */ 029 ReferrerRelayer() {} 030 031 032 private static ReferrerRelayer instance; 033 034 { 035 if( instance != null ) throw new IllegalStateException(); 036 037 instance = ReferrerRelayer.this; 038 } 039 040 041 042 // -------------------------------------------------------------------------------- 043 044 045 /** Attempts to enable the forward relayer, which will then store the state of the 046 * stage on detection of each link gesture. 047 */ 048 void activate() 049 { 050 if( cookieDomainOrNull == null ) return; 051 // cannot cleanly decide on type of relay store (cookie or WAP) 052 053 // RootPanelM.get().addHandler( ReferrerRelayer.this, ClickEvent.getType() ); 054 //// GWT is wrapped too tight for that to work. Widgets can do it using 055 //// onBrowserEvent(), but here is the DOM way: 056 activateJS(); 057 } 058 059 060 private native void activateJS() 061 /*-{ 062 var that = this; 063 var listener = function(e) 064 { 065 that.@votorola.s.gwt.stage.ReferrerRelayer::onPotentialLinkGesture(Lcom/google/gwt/dom/client/NativeEvent;Z)( 066 e, false ); 067 }; 068 // Register for the capturing phase (true) in order to gain time to effect the 069 // relay during the brief moment prior to link activation: 070 $wnd.document.body.addEventListener( 'click', listener, true ); 071 // Click always fires when viewport transits a link to a new page. 072 // http://www.w3.org/TR/DOM-Level-3-Events/#event-flow-activation 073 $wnd.document.body.addEventListener( 'dragstart', listener, true ); 074 // Drag may be used to drop a link into a different viewport, for example. 075 // On drag events, see http://dev.w3.org/html5/spec/dnd.html 076 var backstopListener = function(e) 077 { 078 that.@votorola.s.gwt.stage.ReferrerRelayer::onPotentialLinkGesture(Lcom/google/gwt/dom/client/NativeEvent;Z)( 079 e, true ); 080 }; 081 $wnd.document.body.addEventListener( 'mousedown', backstopListener, true ); 082 // A click handler is not always permitted to run when a link is being 083 // activated and the document about to be canned (Chromium 18), so backstop 084 // it with eager handling of the prior mouse press. 085 }-*/; 086 087 088 089 /** Returns a cookie domain (e.g. ".mydomain.dom") suitable for use in a relay cookie, 090 * or null if no domain can be determined from the provided host specification. 091 * 092 * @param host the host specification per Net.{@linkplain Net#widestCookieDomain 093 * widestCookieDomain}, or null. If this is an Internet address 094 * ("206.248.142.181") or "localhost", then the host ought to have no siblings 095 * under the same domain that serve theatre pages to which it links. 096 */ 097 public static String cookieDomain( final String host ) 098 { 099 // Widen the host domain to the broadest allowed for the page. The ideal form of 100 // store for this purpose would be tied to the script origin, but that does not 101 // appear to be available on the client side. Access to cookies is restricted to 102 // domain stamps that match the page (not script) origin (Firefox 9). Access to 103 // the session store is likewise restricted, what a script writes to the store on 104 // x.com/page cannot be read back on y.com/page, and vice versa (Firefox 9). 105 return Net.widestCookieDomain( host ); 106 } 107 108 109 110 /** The path for all relay cookies. 111 */ 112 public static final String COOKIE_PATH = "/"; // all paths 113 114 115 116 /** The security mode for all relay cookies. 117 */ 118 public static final boolean COOKIE_SECURE = false; 119 120 121 122 /** The key under which the state of the stage is stored. 123 */ 124 public static final String KEY = "voStage.relay"; 125 126 127 128 /** The key under which the URL of the link target (stripped of any fragment) is 129 * stored. This is set either by the referrer or redirection services it employs, 130 * such as WP_Draft.{@linkplain votorola.s.wic.WP_Draft#maybeRedirectToDraft 131 * maybeRedirectToDraft}. It is crucial that no <em>subsequent</em> redirection 132 * alter the destination URL or the destination page will reject the relay because of 133 * a URL mismatch. 134 */ 135 public static final String KEY_HREF = "voStage.relayHREF"; 136 // separately storing this part simplifies the corrections that are stored by 137 // redirection services like WP_Draft.maybeRedirectToDraft 138 139 140 141 /** The time limit for resolving the referrer in cases of deferred resolution, which 142 * is {@value} ms. 143 */ 144 // static final int MAX_REFERRER_RESOLUTION_MS = 5_000; 145 /// GWT 2.4 can't talk JDK 1.7 146 static final int MAX_REFERRER_RESOLUTION_MS = 5000; 147 148 149 150 /** Attempts to resolve the referrer and immediately calls back to stage.{@linkplain 151 * Stage#init1(TheatrePageJS,ReferrerRelayer,boolean) init1} with the result. 152 */ 153 void resolveReferrer( final Stage stage ) 154 { 155 // Do not rely here on document.referrer to determine the link's domain relation 156 // and thus the type of store to inspect. That will fail when the link is 157 // mediated by a redirector such as s.wic.WP_Draft. The store type would then 158 // depend on the relation between this page and the redirector, not the referrer. 159 // If the use of the redirector could be detected, then the correct relation would 160 // be known. But detection is difficult in practice. [1] 161 // 162 // Do not rely on the absence of a "referer" header to indicate the absence of 163 // storage, because the header is not necessarily set for dragged links (not set 164 // by Firefox 9). Therefore inspect the relatively cheap cookie store first, then 165 // fall back to the more expensive WAP store. [2] 166 // 167 // [1] OPT We might allow the redirector to overload the 'referer' header in some 168 // innocent way, maybe by appending a fragment containing its host name. Then 169 // unecessary WAP calls could be avoided in all cases but dragged links. But 170 // overloading may cause problematic side effects. 171 // [2] OPT We might multiplex the WAP call with others that usually occur on page 172 // initialization. Then the overhead would be reduced to little more than a 173 // database lookup on the server side. 174 175 final String loc = stage.pageLocation(); 176 177 // Inspect relatively cheap cookie store first. 178 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 179 final String href = Cookies.getCookie( KEY_HREF ); 180 // if( loc.equals( href )) 181 //// but href for cookie relay (same domain) may be relative, so: 182 if( href != null && loc.endsWith( href )) 183 { 184 if( cookieDomainOrNull != null ) 185 { 186 CookiesX.removeCookie( KEY_HREF, cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); 187 assert Cookies.getCookie(KEY_HREF) == null: KEY_HREF + " cookie deleted"; 188 } 189 final String referrerJSON = Cookies.getCookie( KEY ); 190 if( referrerJSON == null ) // malformed storage, assume none 191 { 192 stage.init1( /*referrer*/null, ReferrerRelayer.this, /*isReferencePending*/false ); 193 return; 194 } 195 196 if( cookieDomainOrNull != null ) 197 { 198 CookiesX.removeCookie( KEY, cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); 199 assert Cookies.getCookie(KEY) == null: KEY + " cookie deleted"; 200 } 201 final TheatrePageJS referrer = JsonUtils.unsafeEval( referrerJSON ); 202 // cookie written by ReferrerRelayer.this, safe to use fast eval 203 stage.init1( referrer, ReferrerRelayer.this, /*isReferencePending*/false ); 204 return; 205 } 206 207 // Fall back to the more expensive WAP store. 208 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 209 final StringBuilder b = GWTX.stringBuilderClear(); 210 b.append( App.getServletContextLocation() ); 211 b.append( "/wap?wCall=sStore&sGet=" ).append( KEY ); 212 b.append( "&sGet=" ).append( KEY_HREF ); 213 b.append( '\'' ).append( URL.encodeQueryString( loc )); // get only if value matches loc 214 b.append( "&wNonce=" ).append( URLX.serialNonce() ); 215 // per a.web.wap.ResponseConfiguration.headNoCache() 216 final JsonpRequestBuilder jsonp = new JsonpRequestBuilder(); 217 jsonp.setCallbackParam( App.i().jsonpWAP().getCallbackParam() ); 218 jsonp.setTimeout( MAX_REFERRER_RESOLUTION_MS ); 219 jsonp.requestObject( b.toString(), new AsyncCallback<JavaScriptObject>() 220 { 221 public void onFailure( final Throwable x ) { stage.init2b_async( /*referrer*/null ); } 222 public void onSuccess( final JavaScriptObject response ) 223 { 224 TheatrePageJS referrer = null; 225 final JavaScriptObject s = response._get( "s" ); 226 final JavaScriptObject values = s._get( "value" ); 227 final String href = values._get( KEY_HREF ); 228 if( href != null ) 229 { 230 assert href.equals( loc ); 231 final String referrerJSON = values._get( KEY ); 232 if( referrerJSON != null ) referrer = JsonUtils.safeEval( referrerJSON ); 233 // written by ReferrerRelayer.this, but others may possibly overwrite 234 } 235 stage.init2b_async( referrer ); 236 } 237 }); 238 stage.init1( /*referrer*/null, ReferrerRelayer.this, /*isReferencePending*/true ); 239 } 240 241 242//// P r i v a t e /////////////////////////////////////////////////////////////////////// 243 244 245 private static final int BACKSTOP_FILTERING_WINDOW_MS = 3000; 246 // Time interval during which apparently backstopped link gestures are ignored as 247 // duplicates. Backstopping is implemented in activateJS(). It detects gestural 248 // antecedants in addition to the more-or-less definitive gesture. The latter often 249 // follows immediately, of course, and we want to avoid re-sending the associated 250 // storage request on the wire. However we cannot aggressively filter the 251 // duplicates based on value ("already stored that") because the store may have been 252 // altered in the meantime by another page in the history stack (BFCached) or in a 253 // separate window. Therefore we restrict filtering to gestures that can be defined 254 // as backstopped, and part of the definition is the short period of time elapsed 255 // since the last backstop gesture. 256 257 258 259 private String backstopHREFRaw; 260 261 262 263 private long backstopStorageTime; // ms 264 265 266 267 private final String cookieDomainOrNull = cookieDomain( Document.get().getDomain() ); 268 // getDomain() actually returns name or address 269 270 271 272 private void onPotentialLinkGesture( final NativeEvent e, final boolean isBackstop ) 273 { 274 for( JavaScriptObject node = e.getEventTarget(); Element.is(node); ) 275 { 276 final Element element = node.cast(); 277 final String name = element.getTagName(); 278 if( "A".equals(name) || "a".equals(name) ) 279 { 280 final String hrefRaw = element.getAttribute( "href" ); 281 if( hrefRaw == null ) return; 282 283 if( !isBackstop && hrefRaw.equals( backstopHREFRaw )) 284 { 285 final int elapsed = (int)( System.currentTimeMillis() - backstopStorageTime ); 286 if( elapsed < BACKSTOP_FILTERING_WINDOW_MS ) return; // duplicate gesture 287 } 288 289 final String href = URIX.fragmentStripped( hrefRaw ); 290 if( href.length() == 0 ) return; // pure fragment, thus self link 291 292 final Stage stage = Stage.i(); 293 final String loc = stage.pageLocation(); 294 if( href.equals( loc )) return; // self link 295 296 if( isBackstop ) 297 { 298 backstopHREFRaw = hrefRaw; 299 backstopStorageTime = System.currentTimeMillis(); 300 } 301 StringBuilder b = GWTX.stringBuilderClear(); 302 stage.appendJSON( b ); 303 Stage.StateChunker.appendJSON( b, "pageLocation", loc ); 304 stage.wrapJSON( b ); // yields a TheatrePageJS 305 final String pageJSON = b.toString(); 306 307 // Store state for relay. cf. s.wic.WP_Draft 308 // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 309 final boolean isDestinationSameDomain = cookieDomainOrNull != null 310 && cookieDomainOrNull.equals( cookieDomain( element._getString( "hostname" ))); 311 if( isDestinationSameDomain ) // then use cookie 312 { 313 // final Date expiryDate = new Date( System.currentTimeMillis() + 1000L/*ms per s*/ 314 // * 60/*s per minute*/ * 5/*minutes*/ ); 315 //// simpler to just expire at end of session 316 assert Cookies.getUriEncode(): "cookies automatically URI encoded"; 317 Cookies.setCookie( KEY_HREF, href, /*expires:with session*/null, 318 cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); 319 Cookies.setCookie( KEY, pageJSON, /*expires:with session*/null, 320 cookieDomainOrNull, COOKIE_PATH, COOKIE_SECURE ); 321 } 322 else // destination is other domain or unknown relation, so use WAP 323 { 324 StringBuilderX.clear( b ); 325 b.append( App.getServletContextLocation() ); 326 b.append( "/wap?wCall=sStore&sPut=" ).append( KEY_HREF ); 327 b.append( '\'' ).append( URL.encodeQueryString( href )); 328 b.append( "&sPut=" ).append( KEY ); 329 b.append( '\'' ).append( URL.encodeQueryString( pageJSON )); 330 b.append( "&wNonce=" ).append( URLX.serialNonce() ); 331 // per a.web.wap.ResponseConfiguration.headNoCache() 332 App.i().jsonpWAP().send( b.toString() ); 333 } 334 return; 335 } 336 node = element.getParentNode(); 337 } 338 } 339 340 341}