blob: b735be811e2a63bf16fe09f6e4cfcc3ef75f8546 [file] [log] [blame]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.cloud.servlet;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.inject.Inject;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.apache.cloudstack.framework.security.keys.KeysManager;
import org.apache.commons.codec.binary.Base64;
import org.apache.log4j.Logger;
import org.springframework.stereotype.Component;
import org.springframework.web.context.support.SpringBeanAutowiringSupport;
import com.cloud.vm.VmDetailConstants;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.cloud.consoleproxy.ConsoleProxyManager;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.host.HostVO;
import com.cloud.hypervisor.Hypervisor;
import com.cloud.resource.ResourceState;
import com.cloud.server.ManagementServer;
import com.cloud.storage.GuestOSVO;
import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.User;
import com.cloud.uservm.UserVm;
import com.cloud.utils.ConstantTimeComparator;
import com.cloud.utils.Pair;
import com.cloud.utils.Ternary;
import com.cloud.utils.db.EntityManager;
import com.cloud.utils.db.TransactionLegacy;
import com.cloud.vm.UserVmDetailVO;
import com.cloud.vm.VirtualMachine;
import com.cloud.vm.VirtualMachineManager;
import com.cloud.vm.dao.UserVmDetailsDao;
/**
* Thumbnail access : /console?cmd=thumbnail&vm=xxx&w=xxx&h=xxx
* Console access : /conosole?cmd=access&vm=xxx
* Authentication : /console?cmd=auth&vm=xxx&sid=xxx
*/
@Component("consoleServlet")
public class ConsoleProxyServlet extends HttpServlet {
private static final long serialVersionUID = -5515382620323808168L;
public static final Logger s_logger = Logger.getLogger(ConsoleProxyServlet.class.getName());
private static final int DEFAULT_THUMBNAIL_WIDTH = 144;
private static final int DEFAULT_THUMBNAIL_HEIGHT = 110;
@Inject
AccountManager _accountMgr;
@Inject
VirtualMachineManager _vmMgr;
@Inject
ManagementServer _ms;
@Inject
EntityManager _entityMgr;
@Inject
UserVmDetailsDao _userVmDetailsDao;
@Inject
KeysManager _keysMgr;
static KeysManager s_keysMgr;
private final Gson _gson = new GsonBuilder().create();
public ConsoleProxyServlet() {
}
@Override
public void init(ServletConfig config) throws ServletException {
SpringBeanAutowiringSupport.processInjectionBasedOnServletContext(this, config.getServletContext());
s_keysMgr = _keysMgr;
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
doGet(req, resp);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
try {
if (_accountMgr == null || _vmMgr == null || _ms == null) {
sendResponse(resp, "Service is not ready");
return;
}
if (_keysMgr.getHashKey() == null) {
s_logger.debug("Console/thumbnail access denied. Ticket service is not ready yet");
sendResponse(resp, "Service is not ready");
return;
}
String userId = null;
String account = null;
Account accountObj = null;
Map<String, Object[]> params = new HashMap<String, Object[]>();
params.putAll(req.getParameterMap());
HttpSession session = req.getSession(false);
if (session == null) {
if (verifyRequest(params)) {
userId = (String)params.get("userid")[0];
account = (String)params.get("account")[0];
accountObj = (Account)params.get("accountobj")[0];
} else {
s_logger.debug("Invalid web session or API key in request, reject console/thumbnail access");
sendResponse(resp, "Access denied. Invalid web session or API key in request");
return;
}
} else {
// adjust to latest API refactoring changes
if (session.getAttribute("userid") != null) {
userId = ((Long)session.getAttribute("userid")).toString();
}
accountObj = (Account)session.getAttribute("accountobj");
if (accountObj != null) {
account = "" + accountObj.getId();
}
}
// Do a sanity check here to make sure the user hasn't already been deleted
if ((userId == null) || (account == null) || (accountObj == null) || !verifyUser(Long.valueOf(userId))) {
s_logger.debug("Invalid user/account, reject console/thumbnail access");
sendResponse(resp, "Access denied. Invalid or inconsistent account is found");
return;
}
String cmd = req.getParameter("cmd");
if (cmd == null || !isValidCmd(cmd)) {
s_logger.debug("invalid console servlet command: " + cmd);
sendResponse(resp, "");
return;
}
String vmIdString = req.getParameter("vm");
VirtualMachine vm = _entityMgr.findByUuid(VirtualMachine.class, vmIdString);
if (vm == null) {
s_logger.info("invalid console servlet command parameter: " + vmIdString);
sendResponse(resp, "");
return;
}
Long vmId = vm.getId();
if (!checkSessionPermision(req, vmId, accountObj)) {
sendResponse(resp, "Permission denied");
return;
}
if (cmd.equalsIgnoreCase("thumbnail")) {
handleThumbnailRequest(req, resp, vmId);
} else if (cmd.equalsIgnoreCase("access")) {
handleAccessRequest(req, resp, vmId);
} else {
handleAuthRequest(req, resp, vmId);
}
} catch (Throwable e) {
s_logger.error("Unexepected exception in ConsoleProxyServlet", e);
sendResponse(resp, "Server Internal Error");
}
}
private void handleThumbnailRequest(HttpServletRequest req, HttpServletResponse resp, long vmId) {
VirtualMachine vm = _vmMgr.findById(vmId);
if (vm == null) {
s_logger.warn("VM " + vmId + " does not exist, sending blank response for thumbnail request");
sendResponse(resp, "");
return;
}
if (vm.getHostId() == null) {
s_logger.warn("VM " + vmId + " lost host info, sending blank response for thumbnail request");
sendResponse(resp, "");
return;
}
HostVO host = _ms.getHostBy(vm.getHostId());
if (host == null) {
s_logger.warn("VM " + vmId + "'s host does not exist, sending blank response for thumbnail request");
sendResponse(resp, "");
return;
}
String rootUrl = _ms.getConsoleAccessUrlRoot(vmId);
if (rootUrl == null) {
sendResponse(resp, "");
return;
}
int w = DEFAULT_THUMBNAIL_WIDTH;
int h = DEFAULT_THUMBNAIL_HEIGHT;
String value = req.getParameter("w");
try {
w = Integer.parseInt(value);
} catch (NumberFormatException e) {
s_logger.info("[ignored] not a number: " + value);
}
value = req.getParameter("h");
try {
h = Integer.parseInt(value);
} catch (NumberFormatException e) {
s_logger.info("[ignored] not a number: " + value);
}
try {
resp.sendRedirect(composeThumbnailUrl(rootUrl, vm, host, w, h));
} catch (IOException e) {
s_logger.info("Client may already close the connection", e);
}
}
private void handleAccessRequest(HttpServletRequest req, HttpServletResponse resp, long vmId) {
VirtualMachine vm = _vmMgr.findById(vmId);
if (vm == null) {
s_logger.warn("VM " + vmId + " does not exist, sending blank response for console access request");
sendResponse(resp, "");
return;
}
if (vm.getHostId() == null) {
s_logger.warn("VM " + vmId + " lost host info, sending blank response for console access request");
sendResponse(resp, "");
return;
}
HostVO host = _ms.getHostBy(vm.getHostId());
if (host == null) {
s_logger.warn("VM " + vmId + "'s host does not exist, sending blank response for console access request");
sendResponse(resp, "");
return;
}
if (Hypervisor.HypervisorType.LXC.equals(vm.getHypervisorType())){
sendResponse(resp, "<html><body><p>Console access is not supported for LXC</p></body></html>");
return;
}
String rootUrl = _ms.getConsoleAccessUrlRoot(vmId);
if (rootUrl == null) {
sendResponse(resp, "<html><body><p>Console access will be ready in a few minutes. Please try it again later.</p></body></html>");
return;
}
String vmName = vm.getHostName();
if (vm.getType() == VirtualMachine.Type.User) {
UserVm userVm = _entityMgr.findById(UserVm.class, vmId);
String displayName = userVm.getDisplayName();
if (displayName != null && !displayName.isEmpty() && !displayName.equals(vmName)) {
vmName += "(" + displayName + ")";
}
}
StringBuffer sb = new StringBuffer();
sb.append("<html><title>").append(escapeHTML(vmName)).append("</title><frameset><frame src=\"").append(composeConsoleAccessUrl(rootUrl, vm, host));
sb.append("\"></frame></frameset></html>");
s_logger.debug("the console url is :: " + sb.toString());
sendResponse(resp, sb.toString());
}
private void handleAuthRequest(HttpServletRequest req, HttpServletResponse resp, long vmId) {
// TODO authentication channel between console proxy VM and management server needs to be secured,
// the data is now being sent through private network, but this is apparently not enough
VirtualMachine vm = _vmMgr.findById(vmId);
if (vm == null) {
s_logger.warn("VM " + vmId + " does not exist, sending failed response for authentication request from console proxy");
sendResponse(resp, "failed");
return;
}
if (vm.getHostId() == null) {
s_logger.warn("VM " + vmId + " lost host info, failed response for authentication request from console proxy");
sendResponse(resp, "failed");
return;
}
HostVO host = _ms.getHostBy(vm.getHostId());
if (host == null) {
s_logger.warn("VM " + vmId + "'s host does not exist, sending failed response for authentication request from console proxy");
sendResponse(resp, "failed");
return;
}
String sid = req.getParameter("sid");
if (sid == null || !sid.equals(vm.getVncPassword())) {
s_logger.warn("sid " + sid + " in url does not match stored sid.");
sendResponse(resp, "failed");
return;
}
sendResponse(resp, "success");
}
// put the ugly stuff here
static public Ternary<String, String, String> parseHostInfo(String hostInfo) {
String host = null;
String tunnelUrl = null;
String tunnelSession = null;
s_logger.info("Parse host info returned from executing GetVNCPortCommand. host info: " + hostInfo);
if (hostInfo != null) {
if (hostInfo.startsWith("consoleurl")) {
String tokens[] = hostInfo.split("&");
if (hostInfo.length() > 19 && hostInfo.indexOf('/', 19) > 19) {
host = hostInfo.substring(19, hostInfo.indexOf('/', 19)).trim();
tunnelUrl = tokens[0].substring("consoleurl=".length());
tunnelSession = tokens[1].split("=")[1];
} else {
host = "";
}
} else if (hostInfo.startsWith("instanceId")) {
host = hostInfo.substring(hostInfo.indexOf('=') + 1);
} else {
host = hostInfo;
}
} else {
host = hostInfo;
}
return new Ternary<String, String, String>(host, tunnelUrl, tunnelSession);
}
private String getEncryptorPassword() {
String key = _keysMgr.getEncryptionKey();
String iv = _keysMgr.getEncryptionIV();
ConsoleProxyPasswordBasedEncryptor.KeyIVPair keyIvPair = new ConsoleProxyPasswordBasedEncryptor.KeyIVPair(key, iv);
return _gson.toJson(keyIvPair);
}
private String composeThumbnailUrl(String rootUrl, VirtualMachine vm, HostVO hostVo, int w, int h) {
StringBuffer sb = new StringBuffer(rootUrl);
String host = hostVo.getPrivateIpAddress();
Pair<String, Integer> portInfo = _ms.getVncPort(vm);
Ternary<String, String, String> parsedHostInfo = parseHostInfo(portInfo.first());
String sid = vm.getVncPassword();
String tag = vm.getUuid();
int port = -1;
if (portInfo.second() == -9) {
//for hyperv
port = Integer.parseInt(_ms.findDetail(hostVo.getId(), "rdp.server.port").getValue());
} else {
port = portInfo.second();
}
String ticket = genAccessTicket(parsedHostInfo.first(), String.valueOf(port), sid, tag);
ConsoleProxyPasswordBasedEncryptor encryptor = new ConsoleProxyPasswordBasedEncryptor(getEncryptorPassword());
ConsoleProxyClientParam param = new ConsoleProxyClientParam();
param.setClientHostAddress(parsedHostInfo.first());
param.setClientHostPort(portInfo.second());
param.setClientHostPassword(sid);
param.setClientTag(tag);
param.setTicket(ticket);
if (portInfo.second() == -9) {
//For Hyperv Clinet Host Address will send Instance id
param.setHypervHost(host);
param.setUsername(_ms.findDetail(hostVo.getId(), "username").getValue());
param.setPassword(_ms.findDetail(hostVo.getId(), "password").getValue());
}
if (parsedHostInfo.second() != null && parsedHostInfo.third() != null) {
param.setClientTunnelUrl(parsedHostInfo.second());
param.setClientTunnelSession(parsedHostInfo.third());
}
sb.append("/ajaximg?token=" + encryptor.encryptObject(ConsoleProxyClientParam.class, param));
sb.append("&w=").append(w).append("&h=").append(h).append("&key=0");
if (s_logger.isDebugEnabled()) {
s_logger.debug("Compose thumbnail url: " + sb.toString());
}
return sb.toString();
}
private String composeConsoleAccessUrl(String rootUrl, VirtualMachine vm, HostVO hostVo) {
StringBuffer sb = new StringBuffer(rootUrl);
String host = hostVo.getPrivateIpAddress();
Pair<String, Integer> portInfo = null;
if (hostVo.getHypervisorType() == Hypervisor.HypervisorType.KVM &&
(hostVo.getResourceState().equals(ResourceState.ErrorInMaintenance) ||
hostVo.getResourceState().equals(ResourceState.ErrorInPrepareForMaintenance))) {
UserVmDetailVO detailAddress = _userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_ADDRESS);
UserVmDetailVO detailPort = _userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_PORT);
if (detailAddress != null && detailPort != null) {
portInfo = new Pair<>(detailAddress.getValue(), Integer.valueOf(detailPort.getValue()));
} else {
s_logger.warn("KVM Host in ErrorInMaintenance/ErrorInPrepareForMaintenance but " +
"no VNC Address/Port was available. Falling back to default one from MS.");
}
}
if (portInfo == null) {
portInfo = _ms.getVncPort(vm);
}
if (s_logger.isDebugEnabled())
s_logger.debug("Port info " + portInfo.first());
Ternary<String, String, String> parsedHostInfo = parseHostInfo(portInfo.first());
int port = -1;
if (portInfo.second() == -9) {
//for hyperv
port = Integer.parseInt(_ms.findDetail(hostVo.getId(), "rdp.server.port").getValue());
} else {
port = portInfo.second();
}
String sid = vm.getVncPassword();
UserVmDetailVO details = _userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KEYBOARD);
String tag = vm.getUuid();
String ticket = genAccessTicket(parsedHostInfo.first(), String.valueOf(port), sid, tag);
ConsoleProxyPasswordBasedEncryptor encryptor = new ConsoleProxyPasswordBasedEncryptor(getEncryptorPassword());
ConsoleProxyClientParam param = new ConsoleProxyClientParam();
param.setClientHostAddress(parsedHostInfo.first());
param.setClientHostPort(port);
param.setClientHostPassword(sid);
param.setClientTag(tag);
param.setTicket(ticket);
if (details != null) {
param.setLocale(details.getValue());
}
if (portInfo.second() == -9) {
//For Hyperv Clinet Host Address will send Instance id
param.setHypervHost(host);
param.setUsername(_ms.findDetail(hostVo.getId(), "username").getValue());
param.setPassword(_ms.findDetail(hostVo.getId(), "password").getValue());
}
if (parsedHostInfo.second() != null && parsedHostInfo.third() != null) {
param.setClientTunnelUrl(parsedHostInfo.second());
param.setClientTunnelSession(parsedHostInfo.third());
}
if (param.getHypervHost() != null || !ConsoleProxyManager.NoVncConsoleDefault.value()) {
sb.append("/ajax?token=" + encryptor.encryptObject(ConsoleProxyClientParam.class, param));
} else {
sb.append("/resource/noVNC/vnc.html?port=" + ConsoleProxyManager.DEFAULT_NOVNC_PORT + "&token="
+ encryptor.encryptObject(ConsoleProxyClientParam.class, param));
}
// for console access, we need guest OS type to help implement keyboard
long guestOs = vm.getGuestOSId();
GuestOSVO guestOsVo = _ms.getGuestOs(guestOs);
if (guestOsVo.getCategoryId() == 6)
sb.append("&guest=windows");
if (s_logger.isDebugEnabled()) {
s_logger.debug("Compose console url: " + sb.toString());
}
return sb.toString();
}
public static String genAccessTicket(String host, String port, String sid, String tag) {
return genAccessTicket(host, port, sid, tag, new Date());
}
public static String genAccessTicket(String host, String port, String sid, String tag, Date normalizedHashTime) {
String params = "host=" + host + "&port=" + port + "&sid=" + sid + "&tag=" + tag;
try {
Mac mac = Mac.getInstance("HmacSHA1");
long ts = normalizedHashTime.getTime();
ts = ts / 60000; // round up to 1 minute
String secretKey = s_keysMgr.getHashKey();
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1");
mac.init(keySpec);
mac.update(params.getBytes());
mac.update(String.valueOf(ts).getBytes());
byte[] encryptedBytes = mac.doFinal();
return Base64.encodeBase64String(encryptedBytes);
} catch (Exception e) {
s_logger.error("Unexpected exception ", e);
}
return "";
}
private void sendResponse(HttpServletResponse resp, String content) {
try {
resp.setContentType("text/html");
resp.getWriter().print(content);
} catch (IOException e) {
s_logger.info("Client may already close the connection", e);
}
}
private boolean checkSessionPermision(HttpServletRequest req, long vmId, Account accountObj) {
VirtualMachine vm = _vmMgr.findById(vmId);
if (vm == null) {
s_logger.debug("Console/thumbnail access denied. VM " + vmId + " does not exist in system any more");
return false;
}
// root admin can access anything
if (_accountMgr.isRootAdmin(accountObj.getId()))
return true;
switch (vm.getType()) {
case User:
try {
_accountMgr.checkAccess(accountObj, null, true, vm);
} catch (PermissionDeniedException ex) {
if (_accountMgr.isNormalUser(accountObj.getId())) {
if (s_logger.isDebugEnabled()) {
s_logger.debug("VM access is denied. VM owner account " + vm.getAccountId() + " does not match the account id in session " +
accountObj.getId() + " and caller is a normal user");
}
} else if (_accountMgr.isDomainAdmin(accountObj.getId())
|| accountObj.getType() == Account.ACCOUNT_TYPE_READ_ONLY_ADMIN) {
if(s_logger.isDebugEnabled()) {
s_logger.debug("VM access is denied. VM owner account " + vm.getAccountId()
+ " does not match the account id in session " + accountObj.getId() + " and the domain-admin caller does not manage the target domain");
}
}
return false;
}
break;
case DomainRouter:
case ConsoleProxy:
case SecondaryStorageVm:
return false;
default:
s_logger.warn("Unrecoginized virtual machine type, deny access by default. type: " + vm.getType());
return false;
}
return true;
}
private boolean isValidCmd(String cmd) {
if (cmd.equalsIgnoreCase("thumbnail") || cmd.equalsIgnoreCase("access") || cmd.equalsIgnoreCase("auth")) {
return true;
}
return false;
}
public boolean verifyUser(Long userId) {
// copy from ApiServer.java, a bit ugly here
User user = _accountMgr.getUserIncludingRemoved(userId);
Account account = null;
if (user != null) {
account = _accountMgr.getAccount(user.getAccountId());
}
if ((user == null) || (user.getRemoved() != null) || !user.getState().equals(Account.State.enabled) || (account == null) ||
!account.getState().equals(Account.State.enabled)) {
s_logger.warn("Deleted/Disabled/Locked user with id=" + userId + " attempting to access public API");
return false;
}
return true;
}
// copied and modified from ApiServer.java.
// TODO need to replace the whole servlet with a API command
private boolean verifyRequest(Map<String, Object[]> requestParameters) {
try {
String apiKey = null;
String secretKey = null;
String signature = null;
String unsignedRequest = null;
// - build a request string with sorted params, make sure it's all lowercase
// - sign the request, verify the signature is the same
List<String> parameterNames = new ArrayList<String>();
for (Object paramNameObj : requestParameters.keySet()) {
parameterNames.add((String)paramNameObj); // put the name in a list that we'll sort later
}
Collections.sort(parameterNames);
for (String paramName : parameterNames) {
// parameters come as name/value pairs in the form String/String[]
String paramValue = ((String[])requestParameters.get(paramName))[0];
if ("signature".equalsIgnoreCase(paramName)) {
signature = paramValue;
} else {
if ("apikey".equalsIgnoreCase(paramName)) {
apiKey = paramValue;
}
if (unsignedRequest == null) {
unsignedRequest = paramName + "=" + URLEncoder.encode(paramValue, "UTF-8").replaceAll("\\+", "%20");
} else {
unsignedRequest = unsignedRequest + "&" + paramName + "=" + URLEncoder.encode(paramValue, "UTF-8").replaceAll("\\+", "%20");
}
}
}
// if api/secret key are passed to the parameters
if ((signature == null) || (apiKey == null)) {
if (s_logger.isDebugEnabled()) {
s_logger.debug("expired session, missing signature, or missing apiKey -- ignoring request...sig: " + signature + ", apiKey: " + apiKey);
}
return false; // no signature, bad request
}
TransactionLegacy txn = TransactionLegacy.open(TransactionLegacy.CLOUD_DB);
txn.close();
User user = null;
// verify there is a user with this api key
Pair<User, Account> userAcctPair = _accountMgr.findUserByApiKey(apiKey);
if (userAcctPair == null) {
s_logger.debug("apiKey does not map to a valid user -- ignoring request, apiKey: " + apiKey);
return false;
}
user = userAcctPair.first();
Account account = userAcctPair.second();
if (!user.getState().equals(Account.State.enabled) || !account.getState().equals(Account.State.enabled)) {
s_logger.debug("disabled or locked user accessing the api, userid = " + user.getId() + "; name = " + user.getUsername() + "; state: " + user.getState() +
"; accountState: " + account.getState());
return false;
}
// verify secret key exists
secretKey = user.getSecretKey();
if (secretKey == null) {
s_logger.debug("User does not have a secret key associated with the account -- ignoring request, username: " + user.getUsername());
return false;
}
unsignedRequest = unsignedRequest.toLowerCase();
Mac mac = Mac.getInstance("HmacSHA1");
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1");
mac.init(keySpec);
mac.update(unsignedRequest.getBytes());
byte[] encryptedBytes = mac.doFinal();
String computedSignature = Base64.encodeBase64String(encryptedBytes);
boolean equalSig = ConstantTimeComparator.compareStrings(signature, computedSignature);
if (!equalSig) {
s_logger.debug("User signature: " + signature + " is not equaled to computed signature: " + computedSignature);
}
if (equalSig) {
requestParameters.put("userid", new Object[] {String.valueOf(user.getId())});
requestParameters.put("account", new Object[] {account.getAccountName()});
requestParameters.put("accountobj", new Object[] {account});
}
return equalSig;
} catch (Exception ex) {
s_logger.error("unable to verifty request signature", ex);
}
return false;
}
public static final String escapeHTML(String content) {
if (content == null || content.isEmpty())
return content;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < content.length(); i++) {
char c = content.charAt(i);
switch (c) {
case '<':
sb.append("&lt;");
break;
case '>':
sb.append("&gt;");
break;
case '&':
sb.append("&amp;");
break;
case '"':
sb.append("&quot;");
break;
case ' ':
sb.append("&nbsp;");
break;
default:
sb.append(c);
break;
}
}
return sb.toString();
}
}