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}