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 }