001package com.pusher.rest;
002
003import com.google.gson.FieldNamingPolicy;
004import com.google.gson.Gson;
005import com.google.gson.GsonBuilder;
006import com.pusher.rest.crypto.CryptoUtil;
007import com.pusher.rest.data.*;
008import com.pusher.rest.marshaller.DataMarshaller;
009import com.pusher.rest.marshaller.DefaultDataMarshaller;
010import com.pusher.rest.util.Prerequisites;
011
012import java.net.URI;
013import java.nio.charset.StandardCharsets;
014import java.util.ArrayList;
015import java.util.Collections;
016import java.util.List;
017import java.util.Map;
018import java.util.regex.Matcher;
019import java.util.regex.Pattern;
020
021/**
022 * Parent class for Pusher clients, deals with anything that isn't IO related.
023 *
024 * @param <T> The return type of the IO calls.
025 *
026 * See {@link Pusher} for the synchronous implementation, {@link PusherAsync} for the asynchronous implementation.
027 */
028public abstract class PusherAbstract<T> {
029    protected static final Gson BODY_SERIALISER = new GsonBuilder()
030            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
031            .disableHtmlEscaping()
032            .create();
033
034    private static final Pattern HEROKU_URL = Pattern.compile("(https?)://(.+):(.+)@(.+:?.*)/apps/(.+)");
035    private static final String ENCRYPTED_CHANNEL_PREFIX = "private-encrypted-";
036
037    protected final String appId;
038    protected final String key;
039    protected final String secret;
040
041    protected String host = "api.pusherapp.com";
042    protected String scheme = "http";
043
044    private DataMarshaller dataMarshaller;
045    private CryptoUtil crypto;
046    private final boolean hasValidEncryptionMasterKey;
047
048    /**
049     * Construct an instance of the Pusher object through which you may interact with the Pusher API.
050     * <p>
051     * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App.
052     * <p>
053     *
054     * @param appId  The ID of the App you will to interact with.
055     * @param key    The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher.
056     * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed.
057     */
058    public PusherAbstract(final String appId, final String key, final String secret) {
059        Prerequisites.nonEmpty("appId", appId);
060        Prerequisites.nonEmpty("key", key);
061        Prerequisites.nonEmpty("secret", secret);
062        Prerequisites.isValidSha256Key("secret", secret);
063
064        this.appId = appId;
065        this.key = key;
066        this.secret = secret;
067        this.hasValidEncryptionMasterKey = false;
068
069        configureDataMarshaller();
070    }
071
072    /**
073     * Construct an instance of the Pusher object through which you may interact with the Pusher API.
074     * <p>
075     * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App.
076     * <p>
077     *
078     * @param appId  The ID of the App you will to interact with.
079     * @param key    The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher.
080     * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed.
081     * @param encryptionMasterKeyBase64 32 byte key, base64 encoded. This key, along with the channel name, are used to derive per-channel encryption keys.
082     */
083    public PusherAbstract(final String appId, final String key, final String secret, final String encryptionMasterKeyBase64) {
084        Prerequisites.nonEmpty("appId", appId);
085        Prerequisites.nonEmpty("key", key);
086        Prerequisites.nonEmpty("secret", secret);
087        Prerequisites.isValidSha256Key("secret", secret);
088        Prerequisites.nonEmpty("encryptionMasterKeyBase64", encryptionMasterKeyBase64);
089
090        this.appId = appId;
091        this.key = key;
092        this.secret = secret;
093
094        this.crypto = new CryptoUtil(encryptionMasterKeyBase64);
095        this.hasValidEncryptionMasterKey = true;
096
097        configureDataMarshaller();
098    }
099
100    public PusherAbstract(final String url) {
101        Prerequisites.nonNull("url", url);
102
103        final Matcher m = HEROKU_URL.matcher(url);
104        if (m.matches()) {
105            this.scheme = m.group(1);
106            this.key = m.group(2);
107            this.secret = m.group(3);
108            this.host = m.group(4);
109            this.appId = m.group(5);
110            this.hasValidEncryptionMasterKey = false;
111        } else {
112            throw new IllegalArgumentException("URL '" + url + "' does not match pattern '<scheme>://<key>:<secret>@<host>[:<port>]/apps/<appId>'");
113        }
114
115        Prerequisites.isValidSha256Key("secret", secret);
116        configureDataMarshaller();
117    }
118
119    private void configureDataMarshaller() {
120        this.dataMarshaller = new DefaultDataMarshaller();
121    }
122
123    protected void setCryptoUtil(CryptoUtil crypto) {
124        this.crypto = crypto;
125    }
126
127    /*
128     * CONFIG
129     */
130
131    /**
132     * For testing or specifying an alternative cluster. See also {@link #setCluster(String)} for the latter.
133     * <p>
134     * Default: api.pusherapp.com
135     *
136     * @param host the API endpoint host
137     */
138    public void setHost(final String host) {
139        Prerequisites.nonNull("host", host);
140
141        this.host = host;
142    }
143
144    /**
145     * For Specifying an alternative cluster.
146     * <p>
147     * See also {@link #setHost(String)} for targetting an arbitrary endpoint.
148     *
149     * @param cluster the Pusher cluster to target
150     */
151    public void setCluster(final String cluster) {
152        Prerequisites.nonNull("cluster", cluster);
153
154        this.host = "api-" + cluster + ".pusher.com";
155    }
156
157    /**
158     * Set whether to use a secure connection to the API (SSL).
159     * <p>
160     * Authentication is secure even without this option, requests cannot be faked or replayed with access
161     * to their plain text, a secure connection is only required if the requests or responses contain
162     * sensitive information.
163     * <p>
164     * Default: false
165     *
166     * @param encrypted whether to use SSL to contact the API
167     */
168    public void setEncrypted(final boolean encrypted) {
169        this.scheme = encrypted ? "https" : "http";
170    }
171
172    /**
173     * Set the Gson instance used to marshal Objects passed to {@link #trigger(List, String, Object)}
174     * Set the marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)}
175     * and friends.
176     * By default, the library marshals the objects provided to JSON using the Gson library
177     * (see https://code.google.com/p/google-gson/ for more details). By providing an instance
178     * here, you may exert control over the marshalling, for example choosing how Java property
179     * names are mapped on to the field names in the JSON representation, allowing you to match
180     * the expected scheme on the client side.
181     * We added the {@link #setDataMarshaller(DataMarshaller)} method to allow specification
182     * of other marshalling libraries. This method was kept around to maintain backwards
183     * compatibility.
184     * @param gson a GSON instance configured to your liking
185     */
186    public void setGsonSerialiser(final Gson gson) {
187        setDataMarshaller(new DefaultDataMarshaller(gson));
188    }
189
190    /**
191     * Set a custom marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)}
192     * and friends.
193     * <p>
194     * By default, the library marshals the objects provided to JSON using the Gson library
195     * (see https://code.google.com/p/google-gson/ for more details). By providing an instance
196     * here, you may exert control over the marshalling, for example choosing how Java property
197     * names are mapped on to the field names in the JSON representation, allowing you to match
198     * the expected scheme on the client side.
199     *
200     * @param marshaller a DataMarshaller instance configured to your liking
201     */
202    public void setDataMarshaller(final DataMarshaller marshaller) {
203        this.dataMarshaller = marshaller;
204    }
205
206    /**
207     * This method provides an override point if the default Gson based serialisation is absolutely
208     * unsuitable for your use case, even with customisation of the Gson instance doing the serialisation.
209     * <p>
210     * For example, in the simplest case, you might already have your data pre-serialised and simply want
211     * to elide the default serialisation:
212     * <pre>
213     * Pusher pusher = new Pusher(appId, key, secret) {
214     *     protected String serialise(final Object data) {
215     *         return (String)data;
216     *     }
217     * };
218     *
219     * pusher.trigger("my-channel", "my-event", "{\"my-data\":\"my-value\"}");
220     * </pre>
221     *
222     * @param data an unserialised event payload
223     * @return a serialised event payload
224     */
225    protected String serialise(final Object data) {
226        return dataMarshaller.marshal(data);
227    }
228
229    /*
230     * REST
231     */
232
233    /**
234     * Publish a message to a single channel.
235     * <p>
236     * The message data should be a POJO, which will be serialised to JSON for submission.
237     * Use {@link #setDataMarshaller(DataMarshaller)} to control the serialisation
238     * <p>
239     * Note that if you do not wish to create classes specifically for the purpose of specifying
240     * the message payload, use Map&lt;String, Object&gt;. These maps will nest just fine.
241     *
242     * @param channel   the channel name on which to trigger the event
243     * @param eventName the name given to the event
244     * @param data      an object which will be serialised to create the event body
245     * @return a {@link Result} object encapsulating the success state and response to the request
246     */
247    public T trigger(final String channel, final String eventName, final Object data) {
248        return trigger(channel, eventName, data, null);
249    }
250
251    /**
252     * Publish identical messages to multiple channels.
253     *
254     * @param channels  the channel names on which to trigger the event
255     * @param eventName the name given to the event
256     * @param data      an object which will be serialised to create the event body
257     * @return a {@link Result} object encapsulating the success state and response to the request
258     */
259    public T trigger(final List<String> channels, final String eventName, final Object data) {
260        return trigger(channels, eventName, data, null);
261    }
262
263    /**
264     * Publish a message to a single channel, excluding the specified socketId from receiving the message.
265     *
266     * @param channel   the channel name on which to trigger the event
267     * @param eventName the name given to the event
268     * @param data      an object which will be serialised to create the event body
269     * @param socketId  a socket id which should be excluded from receiving the event
270     * @return a {@link Result} object encapsulating the success state and response to the request
271     */
272    public T trigger(final String channel, final String eventName, final Object data, final String socketId) {
273        return trigger(Collections.singletonList(channel), eventName, data, socketId);
274    }
275
276    /**
277     * Publish identical messages to multiple channels, excluding the specified socketId from receiving the message.
278     *
279     * @param channels  the channel names on which to trigger the event
280     * @param eventName the name given to the event
281     * @param data      an object which will be serialised to create the event body
282     * @param socketId  a socket id which should be excluded from receiving the event
283     * @return a {@link Result} object encapsulating the success state and response to the request
284     */
285    public T trigger(final List<String> channels, final String eventName, final Object data, final String socketId) {
286        Prerequisites.nonNull("channels", channels);
287        Prerequisites.nonNull("eventName", eventName);
288        Prerequisites.nonNull("data", data);
289        Prerequisites.maxLength("channels", 100, channels);
290        Prerequisites.noNullMembers("channels", channels);
291        Prerequisites.areValidChannels(channels);
292        Prerequisites.isValidSocketId(socketId);
293
294        final String eventBody;
295        final String encryptedChannel = channels.stream()
296            .filter(this::isEncryptedChannel)
297            .findFirst()
298            .orElse("");
299
300        if (encryptedChannel.isEmpty()) {
301            eventBody = serialise(data);
302        } else {
303            requireEncryptionMasterKey();
304
305            if (channels.size() > 1) {
306                throw PusherException.cannotTriggerMultipleChannelsWithEncryption();
307            }
308
309            eventBody = encryptPayload(encryptedChannel, serialise(data));
310        }
311
312        final String body = BODY_SERIALISER.toJson(new TriggerData(channels, eventName, eventBody, socketId));
313
314        return post("/events", body);
315    }
316
317
318    /**
319     * Publish a batch of different events with a single API call.
320     * <p>
321     * The batch is limited to 10 events on our multi-tenant clusters.
322     *
323     * @param batch a list of events to publish
324     * @return a {@link Result} object encapsulating the success state and response to the request
325     */
326    public T trigger(final List<Event> batch) {
327        final List<Event> eventsWithSerialisedBodies = new ArrayList<Event>(batch.size());
328
329        for (final Event e : batch) {
330            final String eventData;
331
332            if (isEncryptedChannel(e.getChannel())) {
333                requireEncryptionMasterKey();
334
335                eventData = encryptPayload(e.getChannel(), serialise(e.getData()));
336            } else {
337                eventData = serialise(e.getData());
338            }
339
340            eventsWithSerialisedBodies.add(
341                    new Event(
342                            e.getChannel(),
343                            e.getName(),
344                            eventData,
345                            e.getSocketId()
346                    )
347            );
348        }
349
350        final String body = BODY_SERIALISER.toJson(new EventBatch(eventsWithSerialisedBodies));
351
352        return post("/batch_events", body);
353    }
354
355    /**
356     * Make a generic HTTP call to the Pusher API.
357     * <p>
358     * See: http://pusher.com/docs/rest_api
359     * <p>
360     * NOTE: the path specified here is relative to that of your app. For example, to access
361     * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]"
362     * at the beginning of the path.
363     *
364     * @param path the path (e.g. /channels) to query
365     * @return a {@link Result} object encapsulating the success state and response to the request
366     */
367    public T get(final String path) {
368        return get(path, Collections.<String, String>emptyMap());
369    }
370
371    /**
372     * Make a generic HTTP call to the Pusher API.
373     * <p>
374     * See: http://pusher.com/docs/rest_api
375     * <p>
376     * Parameters should be a map of query parameters for the HTTP call, and may be null
377     * if none are required.
378     * <p>
379     * NOTE: the path specified here is relative to that of your app. For example, to access
380     * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]"
381     * at the beginning of the path.
382     *
383     * @param path       the path (e.g. /channels) to query
384     * @param parameters query parameters to submit with the request
385     * @return a {@link Result} object encapsulating the success state and response to the request
386     */
387    public T get(final String path, final Map<String, String> parameters) {
388        final String fullPath = "/apps/" + appId + path;
389        final URI uri = SignatureUtil.uri("GET", scheme, host, fullPath, null, key, secret, parameters);
390
391        return doGet(uri);
392    }
393
394    protected abstract T doGet(final URI uri);
395
396    /**
397     * Make a generic HTTP call to the Pusher API.
398     * <p>
399     * The body should be a UTF-8 encoded String
400     * <p>
401     * See: http://pusher.com/docs/rest_api
402     * <p>
403     * NOTE: the path specified here is relative to that of your app. For example, to access
404     * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]"
405     * at the beginning of the path.
406     *
407     * @param path the path (e.g. /channels) to submit
408     * @param body the body to submit
409     * @return a {@link Result} object encapsulating the success state and response to the request
410     */
411    public T post(final String path, final String body) {
412        final String fullPath = "/apps/" + appId + path;
413        final URI uri = SignatureUtil.uri("POST", scheme, host, fullPath, body, key, secret, Collections.<String, String>emptyMap());
414
415        return doPost(uri, body);
416    }
417
418    protected abstract T doPost(final URI uri, final String body);
419
420    /**
421     * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method
422     * will return a java.net.URI which includes all of the appropriate query parameters which sign the request.
423     *
424     * @param method the HTTP method, e.g. GET, POST
425     * @param path   the HTTP path, e.g. /channels
426     * @param body   the HTTP request body, if there is one (otherwise pass null)
427     * @return a URI object which includes the necessary query params for request authentication
428     */
429    public URI signedUri(final String method, final String path, final String body) {
430        return signedUri(method, path, body, Collections.<String, String>emptyMap());
431    }
432
433    /**
434     * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method
435     * will return a java.net.URI which includes all of the appropriate query parameters which sign the request.
436     * <p>
437     * Note that any further query parameters you wish to be add must be specified here, as they form part of the signature.
438     *
439     * @param method     the HTTP method, e.g. GET, POST
440     * @param path       the HTTP path, e.g. /channels
441     * @param body       the HTTP request body, if there is one (otherwise pass null)
442     * @param parameters HTTP query parameters to be included in the request
443     * @return a URI object which includes the necessary query params for request authentication
444     */
445    public URI signedUri(final String method, final String path, final String body, final Map<String, String> parameters) {
446        return SignatureUtil.uri(method, scheme, host, path, body, key, secret, parameters);
447    }
448
449    /*
450     * CHANNEL AUTHENTICATION
451     */
452
453    /**
454     * Generate authentication response to authorise a user on a private channel
455     * <p>
456     * The return value is the complete body which should be returned to a client requesting authorisation.
457     *
458     * @param socketId the socket id of the connection to authenticate
459     * @param channel  the name of the channel which the socket id should be authorised to join
460     * @return an authentication string, suitable for return to the requesting client
461     */
462    public String authenticate(final String socketId, final String channel) {
463        Prerequisites.nonNull("socketId", socketId);
464        Prerequisites.nonNull("channel", channel);
465        Prerequisites.isValidChannel(channel);
466        Prerequisites.isValidSocketId(socketId);
467
468        if (channel.startsWith("presence-")) {
469            throw new IllegalArgumentException("This method is for private channels, use authenticate(String, String, PresenceUser) to authenticate for a presence channel.");
470        }
471        if (!channel.startsWith("private-")) {
472            throw new IllegalArgumentException("Authentication is only applicable to private and presence channels");
473        }
474
475        final String signature = SignatureUtil.sign(socketId + ":" + channel, secret);
476
477        final AuthData authData = new AuthData(key, signature);
478
479        if (isEncryptedChannel(channel)) {
480            requireEncryptionMasterKey();
481
482            authData.setSharedSecret(crypto.generateBase64EncodedSharedSecret(channel));
483        }
484
485        return BODY_SERIALISER.toJson(authData);
486    }
487
488    /**
489     * Generate authentication response to authorise a user on a presence channel
490     * <p>
491     * The return value is the complete body which should be returned to a client requesting authorisation.
492     *
493     * @param socketId the socket id of the connection to authenticate
494     * @param channel  the name of the channel which the socket id should be authorised to join
495     * @param user     a {@link PresenceUser} object which represents the channel data to be associated with the user
496     * @return an authentication string, suitable for return to the requesting client
497     */
498    public String authenticate(final String socketId, final String channel, final PresenceUser user) {
499        Prerequisites.nonNull("socketId", socketId);
500        Prerequisites.nonNull("channel", channel);
501        Prerequisites.nonNull("user", user);
502        Prerequisites.isValidChannel(channel);
503        Prerequisites.isValidSocketId(socketId);
504
505        if (channel.startsWith("private-")) {
506            throw new IllegalArgumentException("This method is for presence channels, use authenticate(String, String) to authenticate for a private channel.");
507        }
508        if (!channel.startsWith("presence-")) {
509            throw new IllegalArgumentException("Authentication is only applicable to private and presence channels");
510        }
511
512        final String channelData = BODY_SERIALISER.toJson(user);
513        final String signature = SignatureUtil.sign(socketId + ":" + channel + ":" + channelData, secret);
514        return BODY_SERIALISER.toJson(new AuthData(key, signature, channelData));
515    }
516
517    /*
518     * WEBHOOK VALIDATION
519     */
520
521    /**
522     * Check the signature on a webhook received from Pusher
523     *
524     * @param xPusherKeyHeader       the X-Pusher-Key header as received in the webhook request
525     * @param xPusherSignatureHeader the X-Pusher-Signature header as received in the webhook request
526     * @param body                   the webhook body
527     * @return enum representing the possible validities of the webhook request
528     */
529    public Validity validateWebhookSignature(final String xPusherKeyHeader, final String xPusherSignatureHeader, final String body) {
530        if (!xPusherKeyHeader.trim().equals(key)) {
531            // We can't validate the signature, because it was signed with a different key to the one we were initialised with.
532            return Validity.SIGNED_WITH_WRONG_KEY;
533        }
534
535        final String recalculatedSignature = SignatureUtil.sign(body, secret);
536        return xPusherSignatureHeader.trim().equals(recalculatedSignature) ? Validity.VALID : Validity.INVALID;
537    }
538
539    private boolean isEncryptedChannel(final String channel) {
540        return channel.startsWith(ENCRYPTED_CHANNEL_PREFIX);
541    }
542
543    private void requireEncryptionMasterKey()
544    {
545        if (hasValidEncryptionMasterKey) {
546            return;
547        }
548
549        throw PusherException.encryptionMasterKeyRequired();
550    }
551
552    private String encryptPayload(final String encryptedChannel, final String payload) {
553        final EncryptedMessage encryptedMsg = crypto.encrypt(
554            encryptedChannel,
555            payload.getBytes(StandardCharsets.UTF_8)
556        );
557
558        return BODY_SERIALISER.toJson(encryptedMsg);
559    }
560}