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}