001    /*
002    Copyright (c) 1996-2012, Damon Hart-Davis
003    All rights reserved.
004    
005    Redistribution and use in source and binary forms, with or without
006    modification, are permitted provided that the following conditions are
007    met:
008    
009      * Redistributions of source code must retain the above copyright
010        notice, this list of conditions and the following disclaimer.
011    
012      * Redistributions in binary form must reproduce the above copyright
013        notice, this list of conditions and the following disclaimer in the
014        documentation and/or other materials provided with the
015        distribution.
016    
017    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
018    IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
019    TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
020    PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
021    OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
022    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
023    LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
024    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
025    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
026    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
027    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
028    */
029    
030    package org.hd.d.pg2k.webSvr.util;
031    
032    import java.io.InvalidObjectException;
033    import java.io.ObjectInputValidation;
034    import java.util.HashMap;
035    import java.util.Iterator;
036    import java.util.Map;
037    
038    import javax.servlet.ServletContext;
039    import javax.servlet.http.HttpServletRequest;
040    
041    import org.hd.d.pg2k.svrCore.AddrTools;
042    import org.hd.d.pg2k.svrCore.GenUtils;
043    import org.hd.d.pg2k.svrCore.Rnd;
044    
045    import ORG.hd.d.IsDebug;
046    
047    /**Web-server dynamic (authenticated) stats-collection support.
048     * This enables a number of types of stats to be collected
049     * in such a way as to be unlikely to be accidentally triggered by a spider
050     * or off a cached Web page/URL somewhere.
051     * <p>
052     * This also facilitates sampling stats so as to not pester any one user too much
053     * or gather more than one data point at a time from them.
054     * <p>
055     * Most operations on this class are constant time on average,
056     * although mutators in particular may perform maintenance work
057     * that does not raise the O() cost when amortised across all calls.
058     * <p>
059     * Access to all significant data structures is under the class lock.
060     */
061    public final class StatsSink
062        {
063        /**Prevent construction of an instance. */
064        private StatsSink() { }
065    
066        /**The name of the HTTP parameter carrying our mandatory unique event ID value. */
067        public static final String HTTP_ID_PARAM_NAME = "ID";
068    
069        /**Map from uniqueListenerID to Listener; never null.
070         * Incoming stats events are looked up in here by ID.
071         */
072        private static final Map<String, AbstractStatsListener> byListenerID = new HashMap<String, AbstractStatsListener>();
073    
074        /**Map from uniqueDataPointID to Listener; never null.
075         * The normal programmatic interface makes use of this.
076         */
077        private static final Map<String, AbstractStatsListener> byDataPointID = new HashMap<String, AbstractStatsListener>();
078    
079        /**Find (and remove) listener identified by listener ID, or null if none live by that ID.
080         * Run under the class lock.
081         * <p>
082         * Has removed the listener from the database, if present beforehand,
083         * by the time it returns.
084         */
085        private static synchronized AbstractStatsListener _getAndRemoveListenerByListenerID(final String listenerID)
086            {
087            // Get the extant listener, if any, by that listener ID.
088            final AbstractStatsListener asl = byListenerID.get(listenerID);
089            if(asl == null) { return(null); }
090    
091            // Zap the (one-shot) listener, whether expired or not...
092            byListenerID.remove(listenerID);
093            byDataPointID.remove(asl.uniqueDataPointID);
094    
095            // If the listener has expired then pretend that it does not even exist.
096            if(asl.isExpired()) { return(null); }
097    
098            return(asl); // OK, listener is live and well so return it.
099            }
100    
101        /**Accept an inbound HTTP stats event.
102         * We will filter it and decide whether to pass it on to an agent,
103         * and if so, which agent.
104         * <p>
105         * No lock is held while the agent/handler is called.
106         * <p>
107         * We must see an "ID" parameter with the unique allocated ID,
108         * or this is rejected out of hand (ignored).
109         * <p>
110         * This may return a non-null value that is treated as a URL to redirect to.
111         * The returned URL may be root-relative.
112         *
113         * @param request  valid non-null HTTP request object; never null
114         *
115         * @return URL, possibly root-relative, to redirect to, or null if none
116         */
117        public static String acceptEvent(final HttpServletRequest request,
118                                         final ServletContext context)
119            {
120            // Spend on average O(1) time clearing expired entries.
121            _sometimesClearExpiredEntries();
122    
123            if(request == null) { return(null); /* Invalid request, so ignore. */ }
124    
125            String redirectURL = null; // Do not redirect at all by default.
126    
127            // Get the ID, if any.
128            final String ID = request.getParameter(HTTP_ID_PARAM_NAME);
129            if(ID == null)
130                {
131    if(IsDebug.isDebug) { context.log("ERROR: StatsSink: invalid request missing ID from: " + request.getRemoteAddr() + '(' + AddrTools.doReverseLookup(request.getRemoteAddr(), true)+ ')'); }
132                return(redirectURL); /* Invalid request, so ignore. */
133                }
134    
135            // If a listener is extant then call it with the HTTP parameters.
136            final AbstractStatsListener asl = _getAndRemoveListenerByListenerID(ID);
137            if(asl != null)
138                {
139                final HashMap<String,String[]> params = new HashMap<String, String[]>(request.getParameterMap());
140                // Log interesting parameters such as click-through ad URL.
141                if((params != null) && !params.isEmpty()) { context.log("INFO: StatsSink: request URL with parameters: " + request.getQueryString()); }
142                redirectURL = asl.handle(params);
143                }
144            else
145                {
146    if(IsDebug.isDebug) { context.log("WARNING: StatsSink: invalid/expired request ID from: " + request.getRemoteAddr() + '(' + AddrTools.doReverseLookup(request.getRemoteAddr(), true)+ ')'); }
147                }
148    
149            return(redirectURL);
150            }
151    
152        /**Returns true if there is a live (non-expired) listener for a given data-point ID.
153         * Useful for helping ensure that we don't try to collect more than one
154         * value for a data point at once.
155         * <p>
156         * This is fast and read-only.
157         *
158         * @param uniqueDataPointID  the unique ID; must not be null
159         */
160        public static synchronized boolean isListenerLiveForDataPoint(final String uniqueDataPointID)
161            {
162            // Spend on average O(1) time clearing expired entries.
163            _sometimesClearExpiredEntries();
164    
165            // May in fact not cause a problem if null,
166            // but does indicate a programming error by the caller.
167            assert(uniqueDataPointID != null) : "uniqueDataPointID must not be null";
168    
169            final AbstractStatsListener asl = byDataPointID.get(uniqueDataPointID);
170            return((asl != null) && !asl.isExpired());
171            }
172    
173        /**Add new listener for named unique data point.
174         * Not added if already expired, though an extant listener will be removed.
175         * <p>
176         * Any extant listener for the same data point is removed.
177         * <p>
178         * This may do clean-up work on some calls.
179         * <p>
180         * This call has an average constant (ie O(1)) call time.
181         * <p>
182         * A caller should be careful to avoid passing in strong references
183         * to objects that should otherwise be GCed while the listener remains active.
184         * It may be wise to use top-level or static inner classes only for example.
185         */
186        public static synchronized void addListenerForDataPoint(final AbstractStatsListener newAsl)
187            {
188            if(newAsl == null)
189                { throw new IllegalArgumentException(); }
190    
191            // Spend on average O(1) time clearing expired entries.
192            _sometimesClearExpiredEntries();
193    
194            // See if there is an extant listener for this uniqueDataPointID.
195            // If so then zap it from both maps first.
196            final AbstractStatsListener oldAsl = byDataPointID.get(newAsl.uniqueDataPointID);
197            if(oldAsl != null)
198                {
199                byDataPointID.remove(oldAsl.uniqueDataPointID);
200                byListenerID.remove(oldAsl.uniqueListenerID);
201                }
202    
203            // Don't add an already-expired listener...
204            if(newAsl.isExpired())
205                { return; }
206    
207            // Now add the new listener to both maps.
208            byDataPointID.put(newAsl.uniqueDataPointID, newAsl);
209            byListenerID.put(newAsl.uniqueListenerID, newAsl);
210    
211    if(IsDebug.isDebug) { System.out.println("INFO: addListenerForDataPoint(): StatsSink (dp) listeners: " + byDataPointID.size()); }
212            }
213    
214        /**Spend on average O(1) (ie constant) time expiring old entries. */
215        private static synchronized void _sometimesClearExpiredEntries()
216            {
217            // See if this caller pulls the short straw to expire old entries.
218            // Amortise cost to be approximately O(1), ie constant cost per call.
219            if(0 == Rnd.fastRnd.nextInt(3 + byDataPointID.size() + byListenerID.size()))
220                { _clearExpiredEntries(); }
221            }
222    
223        /**Clear all expired entries (with the lock held). */
224        private static synchronized void _clearExpiredEntries()
225            {
226            // Remove dead entries from both maps.
227            for(final Iterator<String> it = byDataPointID.keySet().iterator(); it.hasNext();)
228                {
229                if(byDataPointID.get(it.next()).isExpired())
230                    { it.remove(); }
231                }
232            for(final Iterator<String> it = byListenerID.keySet().iterator(); it.hasNext();)
233                {
234                if(byListenerID.get(it.next()).isExpired())
235                    { it.remove(); }
236                }
237            }
238    
239    
240        /**Abstract/base listener object waiting for stats to arrive.
241         * This has an embedded unique listener ID,
242         * cryptographically generated,
243         * and pure-alphanumeric-ASCII suitable for embedding in URLs, etc.
244         */
245        public static abstract class AbstractStatsListener implements ObjectInputValidation
246            {
247            /**Construct an instance.
248             *
249             * @param uniqueDataPointID  unique data-point identifier for a data point to collect,
250             *     only one data point with this name can be requested at any one time
251             *     any previous one being ignored for example; never null nor empty
252             * @param expireBy  time by which this listener should expire
253             *     regardless of whether the listened-for data point has arrived;
254             *     strictly positive
255             */
256            public AbstractStatsListener(final String uniqueDataPointID,
257                                         final long expireBy)
258                {
259                this.uniqueDataPointID = uniqueDataPointID;
260                this.expireBy = expireBy;
261    
262                try { validateObject(); }
263                catch(final InvalidObjectException e) { throw new IllegalArgumentException(e.getMessage(), e); }
264                }
265    
266            /**Unique listener ID: unique-for-all-time pure-alphanumeric-ASCII identifier for this listener.
267             * This ID ends up in external URLs and identifies this listener
268             * until it expires or removes itself.
269             * <p>
270             * Generated from a cryptographically-secure source if possible,
271             * so should not be possible to guess in advance.
272             * <p>
273             * The use of this may make creation of instances of this class expensive.
274             */
275            public final String uniqueListenerID = Long.toString((GenUtils.mustConservePowerExtreme() ? Rnd.fastRnd: Rnd.goodRnd).nextLong() >>> 1, Character.MAX_RADIX);
276    
277            /**Unique data-point identifier: not more than one listener with given ID can exist at once; never null nor empty.
278             * Can be used to contain, for example, something like VOTE-214.21.39,
279             * eg a combination of a stats purpose and a unique identifier for a user such as part of their IP address,
280             * such that we never ask them (or accept responses) for more than one vote per user.
281             * <p>
282             * Note therefore that this is not a unique-for-all-time identifier,
283             * but rather a unique-data-point-to-collect identifier.
284             */
285            public final String uniqueDataPointID;
286    
287            /**Date/time that we expire by (ms).
288             * Once this time has passed this listener will disappear.
289             */
290            private final long expireBy;
291    
292            /**Returns true if this listener has expired. */
293            public boolean isExpired() { return(expireBy <= System.currentTimeMillis()); }
294    
295    
296            /**Handle a (non-null) Map of input parameters (String name to String[] value array).
297             * Return a redirection URL (or null for no redirection).
298             * <p>
299             * The ID string will generally be amongst the properties in its external form.
300             * <p>
301             * As a side-effect, this should so what ever processing it needs
302             * in order to record the data point.
303             * <p>
304             * By default, this listener instance is removed once this routine has been called.
305             * <p>
306             * The ID (and thus this data point) has already been validated
307             * by the time that this call is made.
308             */
309            public abstract String handle(final Map<String,String[]> parameters);
310    
311    
312            /**The hash is based on the data point ID. */
313            @Override
314            public int hashCode() { return(uniqueDataPointID.hashCode()); }
315    
316            /**Equality is on just the data point ID. */
317            @Override
318            public boolean equals(final Object obj)
319                {
320                if(!(obj instanceof AbstractStatsListener)) { return(false); }
321                final AbstractStatsListener other = (AbstractStatsListener) obj;
322                return(uniqueDataPointID.equals(other.uniqueDataPointID));
323                }
324    
325    
326            /**Check state is valid; complain if not. */
327            public void validateObject()
328                    throws InvalidObjectException
329                {
330                if((uniqueDataPointID == null) || (uniqueDataPointID.length() == 0))
331                    { throw new InvalidObjectException("bad object: null or empty uniqueDataPointID"); }
332                if(expireBy <= 0)
333                    { throw new InvalidObjectException("bad object: non-positive expireBy time"); }
334                }
335            }
336        }