/**
* Copyright (C) 2010-2017 Structr GmbH
*
* This file is part of Structr <http://structr.org>.
*
* Structr is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Structr is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Structr. If not, see <http://www.gnu.org/licenses/>.
*/
package org.structr.websocket;
import com.google.gson.Gson;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WebSocketListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.structr.api.config.Settings;
import org.structr.common.AccessMode;
import org.structr.common.SecurityContext;
import org.structr.common.error.FrameworkException;
import org.structr.console.Console;
import org.structr.core.GraphObject;
import org.structr.core.Services;
import org.structr.core.app.App;
import org.structr.core.app.StructrApp;
import org.structr.core.auth.Authenticator;
import org.structr.core.entity.Principal;
import org.structr.core.graph.Tx;
import org.structr.rest.auth.AuthHelper;
import org.structr.rest.auth.SessionHelper;
import org.structr.web.entity.FileBase;
import org.structr.web.entity.User;
import org.structr.websocket.command.AbstractCommand;
import org.structr.websocket.command.FileUploadHandler;
import org.structr.websocket.command.LoginCommand;
import org.structr.websocket.message.MessageBuilder;
import org.structr.websocket.message.WebSocketMessage;
//~--- classes ----------------------------------------------------------------
/**
*
*
*
*/
public class StructrWebSocket implements WebSocketListener {
private static final Logger logger = LoggerFactory.getLogger(StructrWebSocket.class.getName());
private static final Map<String, Class> commandSet = new LinkedHashMap<>();
//~--- fields ---------------------------------------------------------
private Session session = null;
private Gson gson = null;
private HttpServletRequest request = null;
private SecurityContext securityContext = null;
private WebsocketController syncController = null;
private Map<String, FileUploadHandler> uploads = null;
private Authenticator authenticator = null;
private String pagePath = null;
private Console console = null;
//~--- constructors ---------------------------------------------------
public StructrWebSocket() {}
public StructrWebSocket(final WebsocketController syncController, final Gson gson, final Authenticator authenticator) {
this.uploads = new LinkedHashMap<>();
this.syncController = syncController;
this.gson = gson;
this.authenticator = authenticator;
}
//~--- methods --------------------------------------------------------
public void setRequest(final HttpServletRequest request) {
this.request = request;
}
@Override
public void onWebSocketConnect(final Session session) {
logger.debug("New connection with protocol {}", session.getProtocolVersion());
this.session = session;
syncController.registerClient(this);
pagePath = request.getQueryString();
}
@Override
public void onWebSocketClose(final int closeCode, final String message) {
logger.debug("Connection closed with closeCode {} and message {}", new Object[]{closeCode, message});
final App app = StructrApp.getInstance(securityContext);
try (final Tx tx = app.tx()) {
this.session = null;
syncController.unregisterClient(this);
// flush and close open uploads
for (FileUploadHandler upload : uploads.values()) {
upload.finish();
}
tx.success();
uploads.clear();
} catch (FrameworkException fex) {
logger.error("Error while closing connection", fex);
}
}
@Override
public void onWebSocketText(final String data) {
final Services servicesInstance = Services.getInstance();
// wait for service layer to be initialized
while (!servicesInstance.isInitialized()) {
try { Thread.sleep(1000); } catch(InterruptedException iex) { }
}
if (data == null) {
logger.warn("Empty text message received.");
return;
}
logger.debug("############################################################ RECEIVED \n{}", data.substring(0, Math.min(data.length(), 1000)));
// parse web socket data from JSON
final WebSocketMessage webSocketData = gson.fromJson(data, WebSocketMessage.class);
final App app = StructrApp.getInstance(securityContext);
final String command = webSocketData.getCommand();
final Class type = commandSet.get(command);
final String sessionIdFromMessage = webSocketData.getSessionId();
if (type != null) {
try (final Tx tx = app.tx()) {
if (sessionIdFromMessage != null) {
// try to authenticated this connection by sessionId
authenticate(sessionIdFromMessage);
}
// we only permit LOGIN commands if authentication based on sessionId was not successful
if (!isAuthenticated() && !type.equals(LoginCommand.class)) {
// send 401 Authentication Required
send(MessageBuilder.status().code(401).message("").build(), true);
tx.success();
return;
}
tx.success();
} catch (FrameworkException t) {
logger.warn("Unable to parse message.", t);
}
// process message
try {
AbstractCommand abstractCommand = (AbstractCommand) type.newInstance();
abstractCommand.setWebSocket(this);
abstractCommand.setSession(session);
abstractCommand.setCallback(webSocketData.getCallback());
// The below blocks allow a websocket command to manage its own
// transactions in case of bulk processing commands etc.
if (abstractCommand.requiresEnclosingTransaction()) {
try (final Tx tx = app.tx()) {
// store authenticated-Flag in webSocketData
// so the command can access it
webSocketData.setSessionValid(isAuthenticated());
abstractCommand.processMessage(webSocketData);
// commit transaction
tx.success();
}
} else {
try (final Tx tx = app.tx()) {
// store authenticated-Flag in webSocketData
// so the command can access it
webSocketData.setSessionValid(isAuthenticated());
// commit transaction
tx.success();
}
// process message without transaction context!
abstractCommand.processMessage(webSocketData);
}
} catch (FrameworkException | InstantiationException | IllegalAccessException t) {
try (final Tx tx = app.tx()) {
// send 400 Bad Request
if (t instanceof FrameworkException) {
final FrameworkException fex = (FrameworkException)t;
send(MessageBuilder.status().code(fex.getStatus()).message(fex.toString()).jsonErrorObject(fex.toJSON()).callback(webSocketData.getCallback()).build(), true);
} else {
send(MessageBuilder.status().code(400).message(t.toString()).build(), true);
}
// commit transaction
tx.success();
} catch (FrameworkException fex) {
logger.warn("", fex);
}
return;
}
} else {
logger.warn("Unknown command {}", command);
// send 400 Bad Request
send(MessageBuilder.status().code(400).message("Unknown command").build(), true);
return;
}
}
public void send(final WebSocketMessage message, final boolean clearSessionId) {
boolean isAuthenticated = false;
try (final Tx tx = StructrApp.getInstance(securityContext).tx()) {
isAuthenticated = isAuthenticated();
tx.success();
} catch (FrameworkException t) {
logger.warn("", t);
}
// return session status to client
message.setSessionValid(isAuthenticated);
// whether to clear the token (all command except LOGIN (for now) should absolutely do this!)
if (clearSessionId) {
message.setSessionId(null);
}
if ("LOGIN".equals(message.getCommand()) && !isAuthenticated) {
message.setMessage("User has no backend access.");
message.setCode(403);
//logger.warn("NOT sending message to unauthenticated client.");
}
try (final Tx tx = StructrApp.getInstance(securityContext).tx()) {
if (message.getCode() == 0) {
// default is: 200 OK
message.setCode(200);
}
final String msg = gson.toJson(message, WebSocketMessage.class);
logger.debug("################### Private message: {}", message.getCommand());
logger.debug("############################################################ SENDING \n{}", msg);
// Clear custom view here. This is necessary because the security context is reused for all websocket frames.
if (securityContext != null) {
securityContext.clearCustomView();
}
session.getRemote().sendString(msg);
tx.success();
} catch (Throwable t) {
// ignore
logger.debug("Unable to send websocket message to remote client");
}
}
// ----- file handling -----
public void createFileUploadHandler(FileBase file) {
final String uuid = file.getProperty(GraphObject.id);
uploads.put(uuid, new FileUploadHandler(file));
}
public void removeFileUploadHandler(final String uuid) {
uploads.remove(uuid);
}
private FileUploadHandler handleExistingFile(final String uuid) {
FileUploadHandler newHandler = null;
try {
FileBase file = (FileBase) StructrApp.getInstance(securityContext).getNodeById(uuid);
if (file != null) {
newHandler = new FileUploadHandler(file);
//uploads.put(uuid, newHandler);
}
} catch (FrameworkException ex) {
logger.warn("File not found with id " + uuid, ex);
}
return newHandler;
}
public void handleFileChunk(final String uuid, final int sequenceNumber, final int chunkSize, final byte[] data, final int chunks) throws IOException {
FileUploadHandler upload = uploads.get(uuid);
if (upload == null) {
upload = handleExistingFile(uuid);
}
if (upload != null) {
upload.handleChunk(sequenceNumber, chunkSize, data, chunks);
}
}
private void authenticate(final String sessionId) {
final Principal user = AuthHelper.getPrincipalForSessionId(sessionId);
if (user != null) {
try {
final boolean sessionValid = !SessionHelper.isSessionTimedOut(SessionHelper.getSessionBySessionId(sessionId));
if (sessionValid) {
this.setAuthenticated(sessionId, user);
} else {
logger.warn("Session {} timed out - last accessed by {} ({})", sessionId, user.getName(), user.getUuid());
SessionHelper.clearSession(sessionId);
SessionHelper.invalidateSession(SessionHelper.getSessionBySessionId(sessionId));
AuthHelper.sendLogoutNotification(user);
invalidateConsole();
}
} catch (FrameworkException ex) {
logger.warn("FXE", ex);
}
}
}
public static void addCommand(final Class command) {
try {
final AbstractCommand msg = (AbstractCommand) command.newInstance();
commandSet.put(msg.getCommand(), command);
} catch (Throwable t) {
logger.error("Unable to add command {}", command.getName());
}
}
public Session getSession() {
return session;
}
public HttpServletRequest getRequest() {
return request;
}
public Principal getCurrentUser() {
return (securityContext == null ? null : securityContext.getUser(false));
}
public SecurityContext getSecurityContext() {
return securityContext;
}
public String getPagePath() {
return pagePath;
}
public boolean isAuthenticated() {
final Principal user = getCurrentUser();
return (user != null && (isPrivilegedUser(user) || isFrontendWebsocketAccessEnabled()));
}
public boolean isPrivilegedUser(Principal user) {
return (user != null && (user.getProperty(Principal.isAdmin) || user.getProperty(User.backendUser)));
}
public boolean isFrontendWebsocketAccessEnabled() {
return Settings.WebsocketFrontendAccess.getValue();
}
public Authenticator getAuthenticator() {
return authenticator;
}
public void invalidateConsole() {
this.console = null;
}
public Console getConsole() {
if (this.securityContext != null) {
if (this.console != null) {
return this.console;
} else {
this.console = new Console(securityContext, null);
return this.console;
}
}
return null;
}
//~--- set methods ----------------------------------------------------
public void setAuthenticated(final String sessionId, final Principal user) {
this.securityContext = SecurityContext.getInstance(user, AccessMode.Backend);
}
@Override
public void onWebSocketBinary(final byte[] bytes, int i, int i1) {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public void onWebSocketError(final Throwable t) {
logger.debug("Error in StructrWebSocket occured", t);
}
}