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