/*
* Copyright 2012 Janrain, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.janrain.backplane.server;
import com.janrain.backplane.common.AuthException;
import com.janrain.backplane.common.BackplaneServerException;
import com.janrain.backplane.common.DateTimeUtils;
import com.janrain.backplane.common.HmacHashUtils;
import com.janrain.backplane.config.BackplaneConfig;
import com.janrain.backplane.dao.DaoException;
import com.janrain.backplane.server1.dao.BP1DAOs;
import com.janrain.backplane.server1.model.Backplane1Message;
import com.janrain.backplane.server1.model.BusConfig1;
import com.janrain.backplane.server1.model.BusConfig1Fields;
import com.janrain.backplane.server1.model.BusUser;
import com.janrain.backplane.server1.model.BusUserFields;
import com.janrain.commons.supersimpledb.SimpleDBException;
import com.janrain.util.RandomUtils;
import com.janrain.util.ServletUtil;
import com.janrain.utils.AnalyticsLogger;
import com.yammer.metrics.Metrics;
import com.yammer.metrics.core.Histogram;
import com.yammer.metrics.core.MetricName;
import com.yammer.metrics.core.TimerContext;
import org.apache.commons.codec.binary.Base64;
import org.apache.log4j.Logger;
import org.codehaus.jackson.map.ObjectMapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import scala.collection.JavaConversions;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* Backplane API implementation.
*
* @author Johnny Bufu
*/
@Controller
@RequestMapping(value="/*")
@SuppressWarnings({"UnusedDeclaration"})
public class Backplane1Controller {
// - PUBLIC
@RequestMapping(value = "/", method = { RequestMethod.GET, RequestMethod.HEAD })
public ModelAndView greetings(HttpServletRequest request, HttpServletResponse response) {
if (RequestMethod.HEAD.toString().equals(request.getMethod())) {
response.setContentLength(0);
}
return new ModelAndView("welcome");
}
@RequestMapping(value = "/{version}/bus/{bus}", method = RequestMethod.GET)
public @ResponseBody List<Map<String,Object>> getBusMessages(
@PathVariable String version,
@RequestHeader(value = "Authorization", required = false) String basicAuth,
@PathVariable String bus,
@RequestParam(value = "since", defaultValue = "") String since,
@RequestParam(value = "sticky", required = false) String sticky )
throws AuthException, SimpleDBException, BackplaneServerException, DaoException {
final TimerContext context = getBusMessagesTime.time();
try {
checkAuth(basicAuth, bus, BusConfig1Fields.GETALL_USERS());
List<Backplane1Message> messages = JavaConversions.seqAsJavaList(
BP1DAOs.messageDao().retrieveMessagesByBus(bus, since, sticky) );
List<Map<String,Object>> frames = new ArrayList<Map<String, Object>>();
for (Backplane1Message message : messages) {
frames.add(JavaConversions.mapAsJavaMap(message.asFrame(version)));
}
return frames;
} finally {
context.stop();
}
}
@RequestMapping(value = "/{version}/bus/{bus}/channel/{channel}", method = RequestMethod.GET)
public ResponseEntity<String> getChannel(
HttpServletRequest request, HttpServletResponse response,
@PathVariable String version,
@PathVariable String bus,
@PathVariable String channel,
@RequestHeader(value = "Referer", required = false) String referer,
@RequestParam(required = false) String callback,
@RequestParam(value = "since", required = false) String since,
@RequestParam(value = "sticky", defaultValue = "false") String sticky)
throws SimpleDBException, AuthException, BackplaneServerException {
logger.debug("request started");
try {
boolean newChannel = NEW_CHANNEL_LAST_PATH.equals(channel);
String resp;
List<Backplane1Message> messages = new ArrayList<Backplane1Message>();
if (newChannel) {
resp = newChannel();
aniLogNewChannel(request, referer, version, bus, resp.substring(1, resp.length()-1));
} else {
messages = getChannelMessages(bus, channel, since, sticky);
resp = messagesToFrames(messages, version);
aniLogPollMessages(request, referer, version, bus, channel, messages);
}
return new ResponseEntity<String>(
resp,
new HttpHeaders() {{
add("Content-Type", "application/json");
}},
HttpStatus.OK);
} finally {
logger.debug("request ended");
}
}
@RequestMapping(value = "/{version}/bus/{bus}/channel/{channel}", method = RequestMethod.POST)
public @ResponseBody String postToChannel(
HttpServletRequest request, HttpServletResponse response,
@PathVariable String version,
@RequestHeader(value = "Authorization", required = false) String basicAuth,
@RequestBody List<Map<String,Object>> messages,
@PathVariable String bus,
@PathVariable String channel) throws AuthException, SimpleDBException, BackplaneServerException, DaoException {
BusUser user = checkAuth(basicAuth, bus, BusConfig1Fields.POST_USERS());
final TimerContext context = postMessagesTime.time();
try {
//Block post if the caller has exceeded the message post limit
if (BP1DAOs.messageDao().messageCount(channel) >= BackplaneConfig.getDefaultMaxMessageLimit()) {
logger.warn("Channel " + bus + ":" + channel + " has reached the maximum of " +
BackplaneConfig.getDefaultMaxMessageLimit() + " messages");
throw new BackplaneServerException("Message limit exceeded for this channel");
}
BusConfig1 busConfig = BP1DAOs.busDao().get(bus).getOrElse(null);
// For analytics.
String channelId = "https://" + request.getServerName() + "/" + version + "/bus/" + bus + "/channel/" + channel;
String clientId = user.id();
for(Map<String,Object> messageData : messages) {
Backplane1Message message = new Backplane1Message(bus, channel,
busConfig.retentionTimeSeconds(),
busConfig.retentionTimeStickySeconds(),
messageData);
BP1DAOs.messageDao().store(message);
aniLogNewMessage(version, bus, channelId, clientId);
}
return "";
} finally {
context.stop();
}
}
/**
* Handle auth errors
*/
@ExceptionHandler
@ResponseBody
public Map<String, String> handle(final AuthException e, HttpServletResponse response) {
logger.error("Backplane authentication error: " + e.getMessage(), BackplaneConfig.getDebugException(e));
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return new HashMap<String,String>() {{
put(ERR_MSG_FIELD, e.getMessage());
}};
}
@ExceptionHandler
@ResponseBody
public Map<String, String> handle(final BackplaneServerException bse, HttpServletResponse response) {
logger.error("Backplane server error: " + bse.getMessage(), BackplaneConfig.getDebugException(bse));
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
return new HashMap<String,String>() {{
put(ERR_MSG_FIELD, BackplaneConfig.isDebugMode() ? bse.getMessage() : "Service unavailable");
}};
}
/**
* Handle all other errors
*/
@ExceptionHandler
@ResponseBody
public Map<String, String> handle(final Exception e, HttpServletRequest request, HttpServletResponse response) {
String path = request.getPathInfo();
logger.error("Error handling backplane request for " + path + ": " + e.getMessage(), BackplaneConfig.getDebugException(e));
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return new HashMap<String,String>() {{
put(ERR_MSG_FIELD, BackplaneConfig.isDebugMode() ? e.getMessage() : "Error processing request.");
}};
}
// - PRIVATE
private static final Logger logger = Logger.getLogger(Backplane1Controller.class);
private static final String NEW_CHANNEL_LAST_PATH = "new";
private static final String ERR_MSG_FIELD = "ERR_MSG";
private static final int CHANNEL_NAME_LENGTH = 32;
private final com.yammer.metrics.core.Timer getBusMessagesTime =
Metrics.newTimer(new MetricName("v1", this.getClass().getName().replace(".","_"), "get_bus_messages_time"), TimeUnit.MILLISECONDS, TimeUnit.MINUTES);
private final com.yammer.metrics.core.Timer getChannelMessagesTime =
Metrics.newTimer(new MetricName("v1", this.getClass().getName().replace(".","_"), "get_channel_messages_time"), TimeUnit.MILLISECONDS, TimeUnit.MINUTES);
private final com.yammer.metrics.core.Timer getNewChannelTime =
Metrics.newTimer(new MetricName("v1", this.getClass().getName().replace(".","_"), "get_new_channel_time"), TimeUnit.MILLISECONDS, TimeUnit.MINUTES);
private final com.yammer.metrics.core.Timer postMessagesTime =
Metrics.newTimer(new MetricName("v1", this.getClass().getName().replace(".","_"), "post_messages_time"), TimeUnit.MILLISECONDS, TimeUnit.MINUTES);
private final Histogram payLoadSizesOnGets = Metrics.newHistogram(new MetricName("v1", this.getClass().getName().replace(".","_"), "payload_sizes_gets"));
@Inject
private AnalyticsLogger anilogger;
private BusUser checkAuth(String basicAuth, String bus, BusConfig1Fields.EnumVal permissionField) throws AuthException, BackplaneServerException, DaoException {
// authN
String userPass = null;
if ( basicAuth == null || ! basicAuth.startsWith("Basic ") || basicAuth.length() < 7) {
authError("Invalid Authorization header: " + basicAuth);
} else {
try {
userPass = new String(Base64.decodeBase64(basicAuth.substring(6).getBytes("utf-8")));
} catch (UnsupportedEncodingException e) {
authError("Cannot check authentication, unsupported encoding: utf-8"); // shouldn't happen
}
}
@SuppressWarnings({"ConstantConditions"})
int delim = userPass.indexOf(":");
if (delim == -1) {
authError("Invalid Basic auth token: " + userPass);
}
String user = userPass.substring(0, delim);
String pass = userPass.substring(delim + 1);
BusUser userEntry = BP1DAOs.userDao().get(user).getOrElse(null);
if (userEntry == null) {
authError("User not found: " + user);
} else if ( ! HmacHashUtils.checkHmacHash(pass, userEntry.get(BusUserFields.PWDHASH()).get()) ) {
authError("Incorrect password for user " + user);
}
// authZ
BusConfig1 busConfig = BP1DAOs.busDao().get(bus).getOrElse(null);
if (busConfig == null) {
authError("Bus configuration not found for " + bus);
} else if (!busConfig.isAllowed(user, permissionField)) {
authError("User " + user + " not among the uses in " + permissionField + " on bus " + bus);
}
return userEntry;
}
private void authError(String errMsg) throws AuthException {
logger.error(errMsg);
try {
throw new AuthException("Access denied. " + (BackplaneConfig.isDebugMode() ? errMsg : ""));
} catch (Exception e) {
throw new AuthException("Access denied.");
}
}
private String newChannel() {
final TimerContext context = getNewChannelTime.time();
String newChannel = "\"" + RandomUtils.randomString(CHANNEL_NAME_LENGTH) +"\"";
context.stop();
return newChannel;
}
private List<Backplane1Message> getChannelMessages(final String bus, final String channel, final String since, final String sticky) throws SimpleDBException, BackplaneServerException {
final TimerContext context = getChannelMessagesTime.time();
try {
return JavaConversions.seqAsJavaList( BP1DAOs.messageDao().retrieveMessagesByChannel(channel, since, sticky) );
} catch (Exception e) {
throw new BackplaneServerException(e.getMessage(), e);
} finally {
context.stop();
}
}
private String messagesToFrames(List<Backplane1Message> messages, final String version) throws BackplaneServerException {
try {
List<Map<String,Object>> frames = new ArrayList<Map<String, Object>>();
for (Backplane1Message message : messages) {
frames.add(JavaConversions.mapAsJavaMap(message.asFrame(version)));
}
ObjectMapper mapper = new ObjectMapper();
try {
String payload = mapper.writeValueAsString(frames);
payLoadSizesOnGets.update(payload.length());
return payload;
} catch (IOException e) {
String errMsg = "Error converting frames to JSON: " + e.getMessage();
logger.error(errMsg, BackplaneConfig.getDebugException(e));
throw new BackplaneServerException(errMsg, e);
}
} catch (BackplaneServerException bse) {
throw bse;
} catch (Exception e) {
throw new BackplaneServerException(e.getMessage(), e);
}
}
private void aniLogNewChannel(HttpServletRequest request, String referer, String version, String bus, String channel) {
if (!anilogger.isEnabled()) {
return;
}
String channelId = "https://" + request.getServerName() + "/" + version + "/bus/" + bus + "/channel/" + channel;
String siteHost = (referer != null) ? ServletUtil.getHostFromUrl(referer) : null;
Map<String,Object> aniEvent = new HashMap<String,Object>();
aniEvent.put("channel_id", channelId);
aniEvent.put("bus", bus);
aniEvent.put("version", version);
aniEvent.put("site_host", siteHost);
aniLog("new_channel", aniEvent);
}
private void aniLogPollMessages(HttpServletRequest request, String referer, String version, String bus, String channel, List<Backplane1Message> messages) {
if (!anilogger.isEnabled()) {
return;
}
String channelId = "https://" + request.getServerName() + "/" + version + "/bus/" + bus + "/channel/" + channel;
String siteHost = (referer != null) ? ServletUtil.getHostFromUrl(referer) : null;
Map<String,Object> aniEvent = new HashMap<String,Object>();
aniEvent.put("channel_id", channelId);
aniEvent.put("bus", bus);
aniEvent.put("version", version);
aniEvent.put("site_host", siteHost);
List<String> messageIds = new ArrayList<String>();
for (Backplane1Message message : messages) {
messageIds.add(message.id());
}
aniEvent.put("message_ids", messageIds);
aniLog("poll_messages", aniEvent);
}
private void aniLogNewMessage(String version, String bus, String channelId, String clientId) {
if (!anilogger.isEnabled()) {
return;
}
Map<String,Object> aniEvent = new HashMap<String,Object>();
aniEvent.put("channel_id", channelId);
aniEvent.put("bus", bus);
aniEvent.put("version", version);
aniEvent.put("client_id", clientId);
aniLog("new_message", aniEvent);
}
private void aniLog(String eventName, Map<String,Object> eventData) {
ObjectMapper mapper = new ObjectMapper();
String time = DateTimeUtils.ISO8601.get().format(new Date(System.currentTimeMillis()));
eventData.put("time", time);
try {
anilogger.log(eventName, mapper.writeValueAsString(eventData));
} catch (Exception e) {
String errMsg = "Error sending analytics event: " + e.getMessage();
logger.error(errMsg, BackplaneConfig.getDebugException(e));
}
}
}