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}