001/* 002 * Copyright 2020 Vonage 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package com.vonage.client.sms.callback; 017 018 019import com.vonage.client.auth.RequestSigning; 020import com.vonage.client.sms.HexUtil; 021import com.vonage.client.sms.callback.messages.MO; 022 023import javax.servlet.ServletException; 024import javax.servlet.http.HttpServlet; 025import javax.servlet.http.HttpServletRequest; 026import javax.servlet.http.HttpServletResponse; 027import java.io.IOException; 028import java.io.PrintWriter; 029import java.math.BigDecimal; 030import java.text.ParseException; 031import java.text.SimpleDateFormat; 032import java.util.Date; 033import java.util.concurrent.Executor; 034import java.util.concurrent.Executors; 035 036/** 037 * An abstract Servlet that receives and parses an incoming callback request for an MO message. 038 * This class parses and validates the request, optionally checks any provided signature or credentials, 039 * and constructs an MO object for your subclass to consume. 040 * <p> 041 * Note: This servlet will immediately ack the callback as soon as it is validated. Your subclass will 042 * consume the callback object asynchronously. This is because it is important to keep latency of 043 * the acknowledgement to a minimum in order to maintain throughput when operating at any sort of volume. 044 * You are responsible for persisting this object in the event of any failure whilst processing 045 * 046 * @author Paul Cook 047 */ 048public abstract class AbstractMOServlet extends HttpServlet { 049 050 private static final long serialVersionUID = 8745764381059238419L; 051 052 private static final int MAX_CONSUMER_THREADS = 10; 053 054 private static final ThreadLocal<SimpleDateFormat> TIMESTAMP_DATE_FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat( 055 "yyyy-MM-dd HH:mm:ss")); 056 057 private final boolean validateSignature; 058 private final String signatureSharedSecret; 059 private final boolean validateUsernamePassword; 060 private final String expectedUsername; 061 private final String expectedPassword; 062 063 protected Executor consumer; 064 065 public AbstractMOServlet(final boolean validateSignature, final String signatureSharedSecret, final boolean validateUsernamePassword, final String expectedUsername, final String expectedPassword) { 066 this.validateSignature = validateSignature; 067 this.signatureSharedSecret = signatureSharedSecret; 068 this.validateUsernamePassword = validateUsernamePassword; 069 this.expectedUsername = expectedUsername; 070 this.expectedPassword = expectedPassword; 071 072 this.consumer = Executors.newFixedThreadPool(MAX_CONSUMER_THREADS); 073 } 074 075 @Override 076 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 077 handleRequest(request, response); 078 } 079 080 @Override 081 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 082 handleRequest(request, response); 083 } 084 085 private void validateRequest(HttpServletRequest request) throws VonageCallbackRequestValidationException { 086 boolean passed = true; 087 if (this.validateUsernamePassword) { 088 String username = request.getParameter("username"); 089 String password = request.getParameter("password"); 090 if (this.expectedUsername != null) if (!this.expectedUsername.equals(username)) passed = false; 091 if (this.expectedPassword != null) if (!this.expectedPassword.equals(password)) passed = false; 092 } 093 094 if (!passed) { 095 throw new VonageCallbackRequestValidationException("Bad Credentials"); 096 } 097 098 if (this.validateSignature) { 099 if (!RequestSigning.verifyRequestSignature(request, this.signatureSharedSecret)) { 100 throw new VonageCallbackRequestValidationException("Bad Signature"); 101 } 102 } 103 } 104 105 private void handleRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { 106 response.setContentType("text/plain"); 107 108 try { 109 validateRequest(request); 110 111 String messageId = request.getParameter("messageId"); 112 String sender = request.getParameter("msisdn"); 113 String destination = request.getParameter("to"); 114 if (sender == null || destination == null || messageId == null) { 115 throw new VonageCallbackRequestValidationException("Missing mandatory fields"); 116 } 117 118 MO.MESSAGE_TYPE messageType = parseMessageType(request.getParameter("type")); 119 120 BigDecimal price = parsePrice(request.getParameter("price")); 121 Date timeStamp = parseTimeStamp(request.getParameter("message-timestamp")); 122 123 MO mo = new MO(messageId, messageType, sender, destination, price, timeStamp); 124 if (messageType == MO.MESSAGE_TYPE.TEXT || messageType == MO.MESSAGE_TYPE.UNICODE) { 125 String messageBody = request.getParameter("text"); 126 if (messageBody == null) { 127 throw new VonageCallbackRequestValidationException("Missing text field"); 128 } 129 mo.setTextData(messageBody, request.getParameter("keyword")); 130 } else if (messageType == MO.MESSAGE_TYPE.BINARY) { 131 byte[] data = parseBinaryData(request.getParameter("data")); 132 if (data == null) { 133 throw new VonageCallbackRequestValidationException("Missing data field"); 134 } 135 mo.setBinaryData(data, parseBinaryData(request.getParameter("udh"))); 136 } 137 extractConcatenationData(request, mo); 138 139 // TODO: These are undocumented: 140 mo.setNetworkCode(request.getParameter("network-code")); 141 mo.setSessionId(request.getParameter("sessionId")); 142 143 // Push the task to an async consumption thread 144 ConsumeTask task = new ConsumeTask(this, mo); 145 this.consumer.execute(task); 146 147 // immediately ack the receipt 148 try (PrintWriter out = response.getWriter()) { 149 out.print("OK"); 150 out.flush(); 151 } 152 } catch (VonageCallbackRequestValidationException exc) { 153 // TODO: Log this - it's mainly for our own use! 154 response.sendError(400, exc.getMessage()); 155 } 156 } 157 158 private static void extractConcatenationData(HttpServletRequest request, MO mo) throws VonageCallbackRequestValidationException { 159 String concatString = request.getParameter("concat"); 160 if (concatString != null && concatString.equals("true")) { 161 int totalParts; 162 int partNumber; 163 String reference = request.getParameter("concat-ref"); 164 try { 165 totalParts = Integer.parseInt(request.getParameter("concat-total")); 166 partNumber = Integer.parseInt(request.getParameter("concat-part")); 167 } catch (Exception e) { 168 throw new VonageCallbackRequestValidationException("bad concat fields"); 169 } 170 mo.setConcatenationData(reference, totalParts, partNumber); 171 } 172 } 173 174 private static MO.MESSAGE_TYPE parseMessageType(String str) throws VonageCallbackRequestValidationException { 175 if (str != null) for (MO.MESSAGE_TYPE type : MO.MESSAGE_TYPE.values()) 176 if (type.getType().equals(str)) return type; 177 throw new VonageCallbackRequestValidationException("Unrecognized message type: " + str); 178 } 179 180 private static Date parseTimeStamp(String str) throws VonageCallbackRequestValidationException { 181 if (str != null) { 182 try { 183 return TIMESTAMP_DATE_FORMAT.get().parse(str); 184 } catch (ParseException e) { 185 throw new VonageCallbackRequestValidationException("Bad message-timestamp format", e); 186 } 187 } 188 return null; 189 } 190 191 private static BigDecimal parsePrice(String str) throws VonageCallbackRequestValidationException { 192 if (str != null) { 193 try { 194 return new BigDecimal(str); 195 } catch (Exception e) { 196 throw new VonageCallbackRequestValidationException("Bad price field", e); 197 } 198 } 199 return null; 200 } 201 202 private static byte[] parseBinaryData(String str) { 203 if (str != null) return HexUtil.hexToBytes(str); 204 return null; 205 } 206 207 /** 208 * This is the task that is pushed to the thread pool upon receipt of an incoming MO callback 209 * It detaches the consumption of the MO from the acknowledgement of the incoming http request 210 */ 211 private static final class ConsumeTask implements Runnable, java.io.Serializable { 212 213 private static final long serialVersionUID = -5270583545977374866L; 214 215 private final AbstractMOServlet parent; 216 private final MO mo; 217 218 public ConsumeTask(final AbstractMOServlet parent, final MO mo) { 219 this.parent = parent; 220 this.mo = mo; 221 } 222 223 @Override 224 public void run() { 225 this.parent.consume(this.mo); 226 } 227 } 228 229 /** 230 * This method is asynchronously passed a complete MO instance to be dealt with by your application logic 231 * 232 * @param mo The message object that was provided in the HTTP request. 233 */ 234 public abstract void consume(MO mo); 235 236}