zk/src/main/java/org/zkoss/zk/au/http/DHtmlUpdateServlet.java
/* DHtmlUpdateServlet.java
Purpose:
Description:
History:
Mon May 30 21:11:28 2005, Created by tomyeh
Copyright (C) 2005 Potix Corporation. All Rights Reserved.
{{IS_RIGHT
This program is distributed under LGPL Version 2.1 in the hope that
it will be useful, but WITHOUT ANY WARRANTY.
}}IS_RIGHT
*/
package org.zkoss.zk.au.http;
import static org.zkoss.lang.Generics.cast;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zkoss.json.JSONValue;
import org.zkoss.lang.Classes;
import org.zkoss.mesg.Messages;
import org.zkoss.util.resource.Labels;
import org.zkoss.web.servlet.Charsets;
import org.zkoss.web.servlet.Servlets;
import org.zkoss.web.servlet.http.Encodes;
import org.zkoss.web.servlet.http.Https;
import org.zkoss.web.util.resource.ClassWebResource;
import org.zkoss.xml.XMLs;
import org.zkoss.zk.au.AuDecoder;
import org.zkoss.zk.au.AuRequest;
import org.zkoss.zk.au.AuResponse;
import org.zkoss.zk.au.AuWriter;
import org.zkoss.zk.au.AuWriters;
import org.zkoss.zk.au.RequestOutOfSequenceException;
import org.zkoss.zk.au.out.AuConfirmClose;
import org.zkoss.zk.au.out.AuObsolete;
import org.zkoss.zk.au.out.AuSendRedirect;
import org.zkoss.zk.device.Devices;
import org.zkoss.zk.mesg.MZk;
import org.zkoss.zk.ui.ActivationTimeoutException;
import org.zkoss.zk.ui.Desktop;
import org.zkoss.zk.ui.Execution;
import org.zkoss.zk.ui.Session;
import org.zkoss.zk.ui.UiException;
import org.zkoss.zk.ui.WebApp;
import org.zkoss.zk.ui.event.Events;
import org.zkoss.zk.ui.http.ExecutionImpl;
import org.zkoss.zk.ui.http.I18Ns;
import org.zkoss.zk.ui.http.SessionResolverImpl;
import org.zkoss.zk.ui.http.WebManager;
import org.zkoss.zk.ui.sys.DesktopCtrl;
import org.zkoss.zk.ui.sys.ExecutionCtrl;
import org.zkoss.zk.ui.sys.FailoverManager;
import org.zkoss.zk.ui.sys.SessionCtrl;
import org.zkoss.zk.ui.sys.SessionsCtrl;
import org.zkoss.zk.ui.sys.WebAppCtrl;
import org.zkoss.zk.ui.util.Configuration;
import org.zkoss.zk.ui.util.URIInfo;
/**
* Used to receive command from the server and send result back to client.
* Though it is called
* DHtmlUpdateServlet, it is used to serve all kind of HTTP-based clients,
* including ajax (HTML+Ajax), mil (Mobile Interactive Language),
* and others (see {@link Desktop#getDeviceType}.
*
* <p>Init parameters:
*
* <dl>
* <dt>compress</dt>
* <dd>It specifies whether to compress the output if the browser supports the compression (Accept-Encoding).</dd>
* <dt>extension0, extension1...</dt>
* <dd>It specifies an AU extension ({@link AuExtension}).
* The <code>extension0</code> parameter specifies
* the first AU extension, the <code>extension1</code> parameter the second AU extension,
* and so on.<br/>
* The syntax of the value is<br/>
* <code>/prefix=class</code>
* </dd>
* </dl>
*
* <p>By default: there are three extensions are associated with
* "/upload", "/view" and "/dropupload" (see {@link #addAuExtension}.
* Also, "/web" is reserved. Don't associate to any AU extension.
*
* @author tomyeh
*/
public class DHtmlUpdateServlet extends HttpServlet {
private static final Logger log = LoggerFactory.getLogger(DHtmlUpdateServlet.class);
private static final String ATTR_UPDATE_SERVLET = "org.zkoss.zk.au.http.updateServlet";
private static final String ATTR_AU_PROCESSORS = "org.zkoss.zk.au.http.auProcessors";
private final AtomicLong _lastModified = new AtomicLong();
/** (String name, AuExtension). */
private final Map<String, AuExtension> _aues = new ConcurrentHashMap<>(8);
private boolean _compress = true;
/** Returns the update servlet of the specified application, or
* null if not loaded yet.
* Note: if the update servlet is not loaded, it returns null.
* @since 3.0.2
*/
public static DHtmlUpdateServlet getUpdateServlet(WebApp wapp) {
return (DHtmlUpdateServlet) (wapp.getServletContext()).getAttribute(ATTR_UPDATE_SERVLET);
}
//Servlet//
public void init() throws ServletException {
final ServletConfig config = getServletConfig();
final ServletContext ctx = getServletContext();
ctx.setAttribute(ATTR_UPDATE_SERVLET, this);
final WebManager webman = WebManager.getWebManager(ctx);
String param = config.getInitParameter("compress");
_compress = param == null || param.isEmpty() || "true".equals(param);
if (!_compress)
webman.getClassWebResource().setCompress(null); //disable all
//Copies au extensions defined before DHtmlUpdateServlet is started
final WebApp wapp = webman.getWebApp();
final Map aues = (Map) wapp.getAttribute(ATTR_AU_PROCESSORS);
if (aues != null) {
for (Iterator it = aues.entrySet().iterator(); it.hasNext();) {
final Map.Entry me = (Map.Entry) it.next();
addAuExtension((String) me.getKey(), (AuExtension) me.getValue());
}
wapp.removeAttribute(ATTR_AU_PROCESSORS);
}
//ZK 5: extension defined in init-param has the higher priority
for (int j = 0;; ++j) {
param = config.getInitParameter("extension" + j);
if (param == null) {
param = config.getInitParameter("processor" + j); //backward compatible
if (param == null)
break;
}
final int k = param.indexOf('=');
if (k < 0) {
log.warn("Ignore init-param: illegal format, " + param);
continue;
}
final String prefix = param.substring(0, k).trim();
final String clsnm = param.substring(k + 1).trim();
try {
addAuExtension(prefix, (AuExtension) Classes.newInstanceByThread(clsnm));
} catch (ClassNotFoundException ex) {
log.warn("Ignore init-param: class not found, {}", clsnm);
} catch (ClassCastException ex) {
log.warn("Ignore: " + clsnm + " not implement " + AuExtension.class);
} catch (Throwable ex) {
log.warn("Ignore init-param: failed to add an AU extension, " + param, ex);
}
}
if (getAuExtension("/view") == null)
addAuExtension("/view", new AuDynaMediar());
}
@Override
public void destroy() {
for (AuExtension aue : _aues.values()) {
try {
aue.destroy();
} catch (Throwable ex) {
log.warn("Unable to stop " + aue, ex);
}
}
}
/* Returns whether to compress the output.
* @since 5.0.0
*/
public boolean isCompress() {
return _compress;
}
/* Set whether to compress the output or not.
* @since 6.5.2
*/
public void setCompress(boolean compress) {
_compress = compress;
}
/** Returns the AU extension that is associated the specified prefix.
* @since 5.0.0
*/
public static final AuExtension getAuExtension(WebApp wapp, String prefix) {
DHtmlUpdateServlet upsv = DHtmlUpdateServlet.getUpdateServlet(wapp);
if (upsv == null) {
synchronized (DHtmlUpdateServlet.class) {
upsv = DHtmlUpdateServlet.getUpdateServlet(wapp);
if (upsv == null) {
Map aues = (Map) wapp.getAttribute(ATTR_AU_PROCESSORS);
return aues != null ? (AuExtension) aues.get(prefix) : null;
}
}
}
return upsv.getAuExtension(prefix);
}
/** Returns the AU extension associated with the specified prefix,
* or null if no AU extension associated.
* @since 5.0.0
*/
public AuExtension getAuExtension(String prefix) {
return _aues.get(prefix);
}
/** Adds an AU extension and associates it with the specified prefix,
* even before {@link DHtmlUpdateServlet} is started.
* <p>Unlike {@link #addAuExtension(String, AuExtension)}, it can be called
* even if the update servlet is not loaded yet ({@link #getUpdateServlet}
* returns null).
*
* <p>If there was an AU extension associated with the same name, the
* the old AU extension will be replaced.
* @since 5.0.0
*/
public static final AuExtension addAuExtension(WebApp wapp, String prefix, AuExtension extension)
throws ServletException {
DHtmlUpdateServlet upsv = DHtmlUpdateServlet.getUpdateServlet(wapp);
if (upsv == null) {
synchronized (DHtmlUpdateServlet.class) {
upsv = DHtmlUpdateServlet.getUpdateServlet(wapp);
if (upsv == null) {
checkAuExtension(prefix, extension);
Map<String, AuExtension> aues = cast((Map) wapp.getAttribute(ATTR_AU_PROCESSORS));
if (aues == null)
wapp.setAttribute(ATTR_AU_PROCESSORS, aues = new HashMap<>(4));
return aues.put(prefix, extension);
}
}
}
return upsv.addAuExtension(prefix, extension);
}
/** Adds an AU extension and associates it with the specified prefix.
*
* <p>If there was an AU extension associated with the same name, the
* the old AU extension will be replaced.
*
* <p>If you want to add an Au extension, even before DHtmlUpdateServlet
* is started, use {@link #addAuExtension(WebApp, String, AuExtension)}
* instead.
*
* @param prefix the prefix. It must start with "/", but it cannot be
* "/" nor "/web" (which are reserved).
* @param extension the AU extension (never null).
* @return the previous AU extension associated with the specified prefix,
* or null if the prefix was not associated before.
* @see #addAuExtension(WebApp,String,AuExtension)
* @since 5.0.0
*/
public AuExtension addAuExtension(String prefix, AuExtension extension) throws ServletException {
checkAuExtension(prefix, extension);
if (_aues.get(prefix) == extension) //speed up to avoid sync
return extension; //nothing changed
extension.init(this);
//To avoid using sync in doGet(), we make a copy here
final AuExtension old = _aues.put(prefix, extension);
if (old != null)
try {
old.destroy();
} catch (Throwable ex) {
log.warn("Unable to stop " + old, ex);
}
return old;
}
private static void checkAuExtension(String prefix, AuExtension extension) {
if (prefix == null || !prefix.startsWith("/") || prefix.length() < 2 || "/_".equals(prefix)
|| extension == null)
throw new UiException("Ilegal prefix: " + prefix);
if (ClassWebResource.PATH_PREFIX.equalsIgnoreCase(prefix))
throw new IllegalArgumentException(ClassWebResource.PATH_PREFIX + " is reserved");
}
/** Returns the first AU extension matches the specified path,
* or null if not found.
*/
private AuExtension getAuExtensionByPath(String path) {
for (final Map.Entry<String, AuExtension> me : _aues.entrySet()) {
if (path.startsWith(me.getKey()))
return me.getValue();
}
return null;
}
//-- super --//
protected long getLastModified(HttpServletRequest request) {
final String pi = Https.getThisPathInfo(request);
if (pi != null && pi.startsWith(ClassWebResource.PATH_PREFIX) && pi.indexOf('*') < 0 //language independent
&& !Servlets.isIncluded(request)) {
//If a resource extension is registered for the extension,
//we assume the content is dynamic
final String ext = Servlets.getExtension(pi, false);
if (ext == null || getClassWebResource().getExtendlet(ext) == null) {
if (_lastModified.get() == 0)
_lastModified.set(new Date().getTime());
//Hard to know when it is modified, so cheat it..
return _lastModified.get();
}
}
return -1;
}
private ClassWebResource getClassWebResource() {
return WebManager.getWebManager(getServletContext()).getClassWebResource();
}
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
final String pi = Https.getThisPathInfo(request);
final ServletContext ctx = getServletContext();
if (DHtmlResourceServlet.doGet0(request, response, ctx, getClassWebResource()))
return;
final Session sess = WebManager.getSession(ctx, request, false);
if (pi != null && pi.length() != 0 && !(pi.startsWith("/_/") || "/_".equals(pi))) {
final AuExtension aue = getAuExtensionByPath(pi);
if (aue == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
log.debug("Unknown path info: {}", pi);
return;
}
Object oldsess = null;
if (sess == null) {
oldsess = SessionsCtrl.getRawCurrent();
SessionsCtrl.setCurrent(new SessionResolverImpl(ctx, request));
//it might be created later
} else {
SessionsCtrl.setCurrent(sess);
}
final Object old = aue.charsetSetup(sess, request, response);
try {
aue.service(request, response, pi);
} finally {
if (sess != null)
I18Ns.cleanup(request, old);
else {
Charsets.cleanup(request, old);
SessionsCtrl.setRawCurrent(oldsess);
}
}
return; //done
}
// Fix for ZK-5142
if (!("POST".equals(request.getMethod()))) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
log.debug("Request method is not POST: {}", Servlets.getDetail(request));
return; //done
}
//AU
if (sess == null) {
final Object old = Charsets.setup(null, request, response, "UTF-8");
try {
final WebApp wapp = WebManager.getWebAppIfAny(ctx);
denoteSessionTimeout(wapp, request, response, _compress);
return;
} finally {
Charsets.cleanup(request, old);
}
}
// Feature 3285074 add no-cache for security risk.
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Cache-Control", "no-store");
response.setHeader("Expires", "-1");
final Object old = I18Ns.setup(sess, request, response, "UTF-8");
try {
process(sess, request, response, _compress);
} finally {
I18Ns.cleanup(request, old);
}
}
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
/**
* Denote HTTP 410 Session timeout response
* @since 6.5.2
*/
public void denoteSessionTimeout(WebApp wapp, HttpServletRequest request, HttpServletResponse response,
boolean compress) throws ServletException, IOException {
response.setIntHeader("ZK-Error", HttpServletResponse.SC_GONE); // denote timeout
// Bug 1849088: rmDesktop might be sent after invalidate
// Bug 1859776: need send response to client for redirect or others
final String dtid = getAuDecoder(wapp).getDesktopId(request);
if (dtid != null)
sessionTimeout(request, response, wapp, dtid, compress);
}
//-- ASYNC-UPDATE --//
/** Process asynchronous update requests from the client.
* @since 3.0.0
*/
public void process(Session sess, HttpServletRequest request, HttpServletResponse response, boolean compress)
throws ServletException, IOException {
final String errClient = request.getHeader("ZK-Error-Report");
if (errClient != null)
if (log.isDebugEnabled())
log.debug("Error found at client: " + errClient + "\n" + Servlets.getDetail(request));
//parse desktop ID
final WebApp wapp = sess.getWebApp();
final WebAppCtrl wappc = (WebAppCtrl) wapp;
AuDecoder audec = getAuDecoder(wapp);
boolean multipartContent = AuMultipartUploader.isMultipartContent(request);
if (multipartContent) {
audec = AuMultipartUploader.parseRequest(request, audec);
}
final String dtid = audec.getDesktopId(request);
if (dtid == null) {
//Bug 1929139: incomplete request (IE only)
if (log.isDebugEnabled()) {
final String msg = "Incomplete request\n" + Servlets.getDetail(request);
log.debug(msg);
}
response.sendError(467, "Incomplete request");
return;
}
Desktop desktop = getDesktop(sess, dtid);
if (desktop == null) {
final String cmdId = audec.getFirstCommand(request);
if (!"rmDesktop".equals(cmdId))
desktop = recoverDesktop(sess, request, response, wappc, dtid);
if (desktop == null) {
response.setIntHeader("ZK-Error", HttpServletResponse.SC_GONE); //denote timeout
sessionTimeout(request, response, wapp, dtid, compress);
return;
}
}
WebManager.setDesktop(request, desktop);
//reason: a new page might be created (such as include)
final String sid = request.getHeader("ZK-SID");
if (sid != null) { //Some client might not have ZK-SID
//B65-ZK-2464 : Possible XSS Vulnerability in HTTP Header
try {
Integer.parseInt(sid);
} catch (NumberFormatException e) {
log.warn("", e);
responseError(request, response, "Illegal request");
return;
}
response.setHeader("ZK-SID", sid);
}
//parse commands
final Configuration config = wapp.getConfiguration();
final List<AuRequest> aureqs;
boolean keepAlive = false;
try {
final boolean timerKeepAlive = config.isTimerKeepAlive();
aureqs = audec.decode(request, desktop);
for (AuRequest aureq : aureqs) {
final String cmdId = aureq.getCommand();
keepAlive = !(!timerKeepAlive && Events.ON_TIMER.equals(cmdId)) && !"dummy".equals(cmdId);
//dummy is used for PollingServerPush for piggyback
if (keepAlive)
break; //done
}
} catch (Throwable ex) {
log.warn("", ex);
responseError(request, response, "Invalid Request (see: Server logs for details)");
return;
}
if (aureqs.isEmpty()) {
final String errmsg = "Illegal request: cmd required";
log.debug(errmsg);
responseError(request, response, errmsg);
return;
}
((SessionCtrl) sess).notifyClientRequest(keepAlive);
// if (log.isDebugEnabled()) log.debug("AU request: "+aureqs);
final Execution exec = new ExecutionImpl(getServletContext(), request, response, desktop, null);
if (sid != null)
((ExecutionCtrl) exec).setRequestId(sid);
final AuWriter out = AuWriters.newInstance();
out.setCompress(compress);
out.open(request, response);
try {
wappc.getUiEngine().execUpdate(exec, aureqs, out);
} catch (ActivationTimeoutException ex) {
log.warn(ex.getMessage());
response.setHeader("ZK-SID", sid);
response.setIntHeader("ZK-Error", AuResponse.SC_ACTIVATION_TIMEOUT);
} catch (RequestOutOfSequenceException ex) {
log.warn(ex.getMessage());
response.setHeader("ZK-SID", sid);
response.setIntHeader("ZK-Error", AuResponse.SC_OUT_OF_SEQUENCE);
}
out.close(request, response);
}
/** Returns the desktop of the specified ID, or null if not found.
* If null is returned, {@link #recoverDesktop} will be invoked.
* @param sess the session (never null)
* @param dtid the desktop ID to look for
* @since 5.0.3
*/
protected Desktop getDesktop(Session sess, String dtid) {
return ((WebAppCtrl) sess.getWebApp()).getDesktopCache(sess).getDesktopIfAny(dtid);
}
/**
* @param wapp the Web application (or null if not available yet)
*/
private void sessionTimeout(HttpServletRequest request, HttpServletResponse response, WebApp wapp, String dtid,
boolean compress) throws ServletException, IOException {
final String sid = request.getHeader("ZK-SID");
if (sid != null) {
//B65-ZK-2464 : Possible XSS Vulnerability in HTTP Header
try {
Integer.parseInt(sid);
} catch (NumberFormatException e) {
log.warn("", e);
responseError(request, response, "Illegal request");
return;
}
response.setHeader("ZK-SID", sid);
}
final AuWriter out = AuWriters.newInstance();
out.setCompress(compress);
out.open(request, response);
if (!getAuDecoder(wapp).isIgnorable(request, wapp)) {
final String deviceType = getDeviceType(request);
URIInfo ui = wapp != null ? wapp.getConfiguration().getTimeoutURI(deviceType) : null;
String uri = ui != null ? ui.uri : null;
out.write(new AuConfirmClose(null)); // Bug: B50-3147382
final AuResponse resp;
if (uri != null) {
if (!uri.isEmpty())
uri = Encodes.encodeURL(getServletContext(), request, response, uri);
resp = new AuSendRedirect(uri, null);
} else {
String msg = wapp != null ? wapp.getConfiguration().getTimeoutMessage(deviceType) : null;
dtid = XMLs.encodeText(dtid); // Fix ZK-1862 security issue
if (msg != null && msg.startsWith("label:")) {
final String key = msg.substring(6);
msg = Labels.getLabel(key, new Object[] { dtid });
if (msg == null)
log.warn("Label not found, {}", key);
}
if (msg == null)
msg = Messages.get(MZk.UPDATE_OBSOLETE_PAGE, dtid);
resp = new AuObsolete(dtid, msg);
}
out.write(resp);
}
out.close(request, response);
}
private static String getDeviceType(HttpServletRequest request) {
final String agt = request.getHeader("user-agent");
if (agt != null && !agt.isEmpty()) {
try {
return Devices.getDeviceByClient(agt).getType();
} catch (Throwable ex) {
log.warn("Unknown device for {}", agt);
}
}
return "ajax";
}
/** Recovers the desktop if possible.
* It is called if {@link #getDesktop} returns null.
* <p>The default implementation will look for any failover manager ({@link FailoverManager})
* is registered, and forward the invocation to it if found.
* @return the recovered desktop, or null if failed to recover
* @since 5.0.3
*/
protected Desktop recoverDesktop(Session sess, HttpServletRequest request, HttpServletResponse response,
WebAppCtrl wappc, String dtid) {
final FailoverManager failover = wappc.getFailoverManager();
if (failover != null) {
Desktop desktop = null;
final ServletContext ctx = getServletContext();
try {
if (failover.isRecoverable(sess, dtid)) {
desktop = WebManager.getWebManager(ctx).getDesktop(sess, request, response, null, true);
if (desktop == null) //forward or redirect
throw new IllegalStateException("sendRediect or forward not allowed in recovering");
wappc.getUiEngine().execRecover(new ExecutionImpl(ctx, request, response, desktop, null), failover);
return desktop; //success
}
} catch (Throwable ex) {
log.error("Unable to recover " + dtid, ex);
if (desktop != null)
((DesktopCtrl) desktop).recoverDidFail(ex);
}
}
return null;
}
/** Generates a response for an error message.
*/
private static void responseError(HttpServletRequest request, HttpServletResponse response, String errmsg)
throws IOException {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, errmsg);
}
private static final AuDecoder getAuDecoder(WebApp wapp) {
AuDecoder audec = wapp != null ? ((WebAppCtrl) wapp).getAuDecoder() : null;
return audec != null ? audec : _audec;
}
private static final AuDecoder _audec = new AuDecoder() {
public String getDesktopId(Object request) {
return ((HttpServletRequest) request).getParameter("dtid");
}
public String getFirstCommand(Object request) {
return ((HttpServletRequest) request).getParameter("cmd_0");
}
@SuppressWarnings("unchecked")
public List<AuRequest> decode(Object request, Desktop desktop) {
final List<AuRequest> aureqs = new LinkedList<>();
final HttpServletRequest hreq = (HttpServletRequest) request;
for (int j = 0;; ++j) {
final String cmdId = hreq.getParameter("cmd_" + j);
if (cmdId == null)
break;
final String uuid = hreq.getParameter("uuid_" + j);
final String data = hreq.getParameter("data_" + j);
final Map<String, Object> decdata = (Map) JSONValue.parse(data);
aureqs.add(uuid == null || uuid.isEmpty() ? new AuRequest(desktop, cmdId, decdata)
: new AuRequest(desktop, uuid, cmdId, decdata));
}
return aureqs;
}
public boolean isIgnorable(Object request, WebApp wapp) {
final HttpServletRequest hreq = (HttpServletRequest) request;
for (int j = 0;; ++j) {
if (hreq.getParameter("cmd_" + j) == null)
break;
final String opt = hreq.getParameter("opt_" + j);
if (opt == null || !opt.contains("i"))
return false; //not ignorable
}
return true;
}
};
}