001/* 002 * Copyright (c) 2011-2017 Nexmo Inc 003 * 004 * Permission is hereby granted, free of charge, to any person obtaining a copy 005 * of this software and associated documentation files (the "Software"), to deal 006 * in the Software without restriction, including without limitation the rights 007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 008 * copies of the Software, and to permit persons to whom the Software is 009 * furnished to do so, subject to the following conditions: 010 * 011 * The above copyright notice and this permission notice shall be included in 012 * all copies or substantial portions of the Software. 013 * 014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 020 * THE SOFTWARE. 021 */ 022package com.nexmo.client.sms.callback; 023 024 025import com.nexmo.client.auth.RequestSigning; 026import com.nexmo.client.sms.HexUtil; 027import com.nexmo.client.sms.callback.messages.MO; 028 029import javax.servlet.ServletException; 030import javax.servlet.http.HttpServlet; 031import javax.servlet.http.HttpServletRequest; 032import javax.servlet.http.HttpServletResponse; 033import java.io.IOException; 034import java.io.PrintWriter; 035import java.math.BigDecimal; 036import java.text.ParseException; 037import java.text.SimpleDateFormat; 038import java.util.Date; 039import java.util.concurrent.Executor; 040import java.util.concurrent.Executors; 041 042/** 043 * An abstract Servlet that receives and parses an incoming callback request for an MO message. 044 * This class parses and validates the request, optionally checks any provided signature or credentials, 045 * and constructs an MO object for your subclass to consume. 046 * <p> 047 * Note: This servlet will immediately ack the callback as soon as it is validated. Your subclass will 048 * consume the callback object asynchronously. This is because it is important to keep latency of 049 * the acknowledgement to a minimum in order to maintain throughput when operating at any sort of volume. 050 * You are responsible for persisting this object in the event of any failure whilst processing 051 * 052 * @author Paul Cook 053 */ 054public abstract class AbstractMOServlet extends HttpServlet { 055 056 private static final long serialVersionUID = 8745764381059238419L; 057 058 private static final int MAX_CONSUMER_THREADS = 10; 059 060 private static final ThreadLocal<SimpleDateFormat> TIMESTAMP_DATE_FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat( 061 "yyyy-MM-dd HH:mm:ss")); 062 063 private final boolean validateSignature; 064 private final String signatureSharedSecret; 065 private final boolean validateUsernamePassword; 066 private final String expectedUsername; 067 private final String expectedPassword; 068 069 protected Executor consumer; 070 071 public AbstractMOServlet(final boolean validateSignature, final String signatureSharedSecret, final boolean validateUsernamePassword, final String expectedUsername, final String expectedPassword) { 072 this.validateSignature = validateSignature; 073 this.signatureSharedSecret = signatureSharedSecret; 074 this.validateUsernamePassword = validateUsernamePassword; 075 this.expectedUsername = expectedUsername; 076 this.expectedPassword = expectedPassword; 077 078 this.consumer = Executors.newFixedThreadPool(MAX_CONSUMER_THREADS); 079 } 080 081 @Override 082 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 083 handleRequest(request, response); 084 } 085 086 @Override 087 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 088 handleRequest(request, response); 089 } 090 091 private void validateRequest(HttpServletRequest request) throws NexmoCallbackRequestValidationException { 092 boolean passed = true; 093 if (this.validateUsernamePassword) { 094 String username = request.getParameter("username"); 095 String password = request.getParameter("password"); 096 if (this.expectedUsername != null) if (!this.expectedUsername.equals(username)) passed = false; 097 if (this.expectedPassword != null) if (!this.expectedPassword.equals(password)) passed = false; 098 } 099 100 if (!passed) { 101 throw new NexmoCallbackRequestValidationException("Bad Credentials"); 102 } 103 104 if (this.validateSignature) { 105 if (!RequestSigning.verifyRequestSignature(request, this.signatureSharedSecret)) { 106 throw new NexmoCallbackRequestValidationException("Bad Signature"); 107 } 108 } 109 } 110 111 private void handleRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { 112 response.setContentType("text/plain"); 113 114 try { 115 validateRequest(request); 116 117 String messageId = request.getParameter("messageId"); 118 String sender = request.getParameter("msisdn"); 119 String destination = request.getParameter("to"); 120 if (sender == null || destination == null || messageId == null) { 121 throw new NexmoCallbackRequestValidationException("Missing mandatory fields"); 122 } 123 124 MO.MESSAGE_TYPE messageType = parseMessageType(request.getParameter("type")); 125 126 BigDecimal price = parsePrice(request.getParameter("price")); 127 Date timeStamp = parseTimeStamp(request.getParameter("message-timestamp")); 128 129 MO mo = new MO(messageId, messageType, sender, destination, price, timeStamp); 130 if (messageType == MO.MESSAGE_TYPE.TEXT || messageType == MO.MESSAGE_TYPE.UNICODE) { 131 String messageBody = request.getParameter("text"); 132 if (messageBody == null) { 133 throw new NexmoCallbackRequestValidationException("Missing text field"); 134 } 135 mo.setTextData(messageBody, request.getParameter("keyword")); 136 } else if (messageType == MO.MESSAGE_TYPE.BINARY) { 137 byte[] data = parseBinaryData(request.getParameter("data")); 138 if (data == null) { 139 throw new NexmoCallbackRequestValidationException("Missing data field"); 140 } 141 mo.setBinaryData(data, parseBinaryData(request.getParameter("udh"))); 142 } 143 extractConcatenationData(request, mo); 144 145 // TODO: These are undocumented: 146 mo.setNetworkCode(request.getParameter("network-code")); 147 mo.setSessionId(request.getParameter("sessionId")); 148 149 // Push the task to an async consumption thread 150 ConsumeTask task = new ConsumeTask(this, mo); 151 this.consumer.execute(task); 152 153 // immediately ack the receipt 154 try (PrintWriter out = response.getWriter()) { 155 out.print("OK"); 156 out.flush(); 157 } 158 } catch (NexmoCallbackRequestValidationException exc) { 159 // TODO: Log this - it's mainly for our own use! 160 response.sendError(400, exc.getMessage()); 161 } 162 } 163 164 private static void extractConcatenationData(HttpServletRequest request, MO mo) throws NexmoCallbackRequestValidationException { 165 String concatString = request.getParameter("concat"); 166 if (concatString != null && concatString.equals("true")) { 167 int totalParts; 168 int partNumber; 169 String reference = request.getParameter("concat-ref"); 170 try { 171 totalParts = Integer.parseInt(request.getParameter("concat-total")); 172 partNumber = Integer.parseInt(request.getParameter("concat-part")); 173 } catch (Exception e) { 174 throw new NexmoCallbackRequestValidationException("bad concat fields"); 175 } 176 mo.setConcatenationData(reference, totalParts, partNumber); 177 } 178 } 179 180 private static MO.MESSAGE_TYPE parseMessageType(String str) throws NexmoCallbackRequestValidationException { 181 if (str != null) for (MO.MESSAGE_TYPE type : MO.MESSAGE_TYPE.values()) 182 if (type.getType().equals(str)) return type; 183 throw new NexmoCallbackRequestValidationException("Unrecognized message type: " + str); 184 } 185 186 private static Date parseTimeStamp(String str) throws NexmoCallbackRequestValidationException { 187 if (str != null) { 188 try { 189 return TIMESTAMP_DATE_FORMAT.get().parse(str); 190 } catch (ParseException e) { 191 throw new NexmoCallbackRequestValidationException("Bad message-timestamp format", e); 192 } 193 } 194 return null; 195 } 196 197 private static BigDecimal parsePrice(String str) throws NexmoCallbackRequestValidationException { 198 if (str != null) { 199 try { 200 return new BigDecimal(str); 201 } catch (Exception e) { 202 throw new NexmoCallbackRequestValidationException("Bad price field", e); 203 } 204 } 205 return null; 206 } 207 208 private static byte[] parseBinaryData(String str) { 209 if (str != null) return HexUtil.hexToBytes(str); 210 return null; 211 } 212 213 /** 214 * This is the task that is pushed to the thread pool upon receipt of an incoming MO callback 215 * It detaches the consumption of the MO from the acknowledgement of the incoming http request 216 */ 217 private static final class ConsumeTask implements Runnable, java.io.Serializable { 218 219 private static final long serialVersionUID = -5270583545977374866L; 220 221 private final AbstractMOServlet parent; 222 private final MO mo; 223 224 public ConsumeTask(final AbstractMOServlet parent, final MO mo) { 225 this.parent = parent; 226 this.mo = mo; 227 } 228 229 @Override 230 public void run() { 231 this.parent.consume(this.mo); 232 } 233 } 234 235 /** 236 * This method is asynchronously passed a complete MO instance to be dealt with by your application logic 237 * 238 * @param mo The message object that was provided in the HTTP request. 239 */ 240 public abstract void consume(MO mo); 241 242}