package act.app;
/*-
* #%L
* ACT Framework
* %%
* Copyright (C) 2014 - 2017 ActFramework
* %%
* 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.
* #L%
*/
import act.Act;
import act.Destroyable;
import act.ResponseImplBase;
import act.conf.AppConfig;
import act.controller.ResponseCache;
import act.data.MapUtil;
import act.data.RequestBodyParser;
import act.event.ActEvent;
import act.event.EventBus;
import act.event.SystemEvent;
import act.handler.RequestHandler;
import act.i18n.LocaleResolver;
import act.route.Router;
import act.security.CORS;
import act.util.ActContext;
import act.util.MissingAuthenticationHandler;
import act.util.PropertySpec;
import org.osgl.$;
import org.osgl.concurrent.ContextLocal;
import org.osgl.http.H;
import org.osgl.http.H.Cookie;
import org.osgl.mvc.result.Result;
import org.osgl.storage.ISObject;
import org.osgl.util.C;
import org.osgl.util.E;
import org.osgl.util.S;
import org.osgl.web.util.UserAgent;
import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.validation.ConstraintViolation;
import java.util.*;
import static act.controller.Controller.Util.*;
import static org.osgl.http.H.Header.Names.*;
/**
* {@code AppContext} encapsulate contextual properties needed by
* an application session
*/
@RequestScoped
public class ActionContext extends ActContext.Base<ActionContext> implements Destroyable {
public static final String ATTR_CSRF_TOKEN = "__csrf__";
public static final String ATTR_CSR_TOKEN_PREFETCH = "__csrf_prefetch__";
public static final String ATTR_WAS_UNAUTHENTICATED = "__was_unauthenticated__";
public static final String ATTR_HANDLER = "__act_handler__";
public static final String ATTR_PATH_VARS = "__path_vars__";
public static final String ATTR_RESULT = "__result__";
public static final String ATTR_EXCEPTION = "__exception__";
public static final String ATTR_CURRENT_FILE_INDEX = "__file_id__";
public static final String REQ_BODY = "_body";
private H.Request request;
private H.Response response;
private H.Session session;
private H.Flash flash;
private Set<Map.Entry<String, String[]>> requestParamCache;
private Map<String, String> extraParams;
private volatile Map<String, String[]> bodyParams;
private Map<String, String[]> allParams;
private String actionPath; // e.g. com.mycorp.myapp.controller.AbcController.foo
private State state;
private Map<String, Object> controllerInstances;
private Map<String, ISObject[]> uploads;
private Router router;
private RequestHandler handler;
private UserAgent ua;
private String sessionKeyUsername;
private LocaleResolver localeResolver;
private boolean disableCors;
private boolean disableCsrf;
private Boolean hasTemplate;
private H.Status forceResponseStatus;
private boolean cacheEnabled;
private MissingAuthenticationHandler forceMissingAuthenticationHandler;
private MissingAuthenticationHandler forceCsrfCheckingFailureHandler;
private String urlContext;
@Inject
private ActionContext(App app, H.Request request, H.Response response) {
super(app);
E.NPE(app, request, response);
request.context(this);
response.context(this);
this.request = request;
this.response = response;
this._init();
this.state = State.CREATED;
AppConfig config = app.config();
this.disableCors = !config.corsEnabled();
this.disableCsrf = req().method().safe();
this.sessionKeyUsername = config.sessionKeyUsername();
this.localeResolver = new LocaleResolver(this);
}
public State state() {
return state;
}
public boolean isSessionDissolved() {
return state == State.SESSION_DISSOLVED;
}
public boolean isSessionResolved() {
return state == State.SESSION_RESOLVED;
}
public H.Request req() {
return request;
}
public H.Response resp() {
return response;
}
public H.Cookie cookie(String name) {
return req().cookie(name);
}
public H.Session session() {
return session;
}
public String session(String key) {
return session.get(key);
}
public H.Session session(String key, String value) {
return session.put(key, value);
}
public H.Flash flash() {
return flash;
}
public String flash(String key) {
return flash.get(key);
}
public H.Flash flash(String key, String value) {
return flash.put(key, value);
}
public Router router() {
return router;
}
public ActionContext router(Router router) {
this.router = $.notNull(router);
return this;
}
public MissingAuthenticationHandler missingAuthenticationHandler() {
if (null != forceMissingAuthenticationHandler) {
return forceMissingAuthenticationHandler;
}
return isAjax() ? config().ajaxMissingAuthenticationHandler() : config().missingAuthenticationHandler();
}
public MissingAuthenticationHandler csrfFailureHandler() {
if (null != forceCsrfCheckingFailureHandler) {
return forceCsrfCheckingFailureHandler;
}
return isAjax() ? config().ajaxCsrfCheckFailureHandler() : config().csrfCheckFailureHandler();
}
public ActionContext forceMissingAuthenticationHandler(MissingAuthenticationHandler handler) {
this.forceMissingAuthenticationHandler = handler;
return this;
}
public ActionContext forceCsrfCheckingFailureHandler(MissingAuthenticationHandler handler) {
this.forceCsrfCheckingFailureHandler = handler;
return this;
}
public ActionContext urlContext(String context) {
this.urlContext = context;
return this;
}
public String urlContext() {
return urlContext;
}
// !!!IMPORTANT! the following methods needs to be kept to allow enhancer work correctly
@Override
public <T> T renderArg(String name) {
return super.renderArg(name);
}
@Override
public ActionContext renderArg(String name, Object val) {
return super.renderArg(name, val);
}
@Override
public Map<String, Object> renderArgs() {
return super.renderArgs();
}
@Override
public ActionContext templatePath(String templatePath) {
return super.templatePath(templatePath);
}
public RequestHandler handler() {
return handler;
}
public ActionContext handler(RequestHandler handler) {
E.NPE(handler);
this.handler = handler;
return this;
}
public H.Format accept() {
return req().accept();
}
public ActionContext accept(H.Format fmt) {
req().accept(fmt);
return this;
}
public Boolean hasTemplate() {
return hasTemplate;
}
public ActionContext hasTemplate(boolean b) {
hasTemplate = b;
return this;
}
public ActionContext enableCache() {
E.illegalArgumentIf(this.cacheEnabled, "cache already enabled in the action context");
this.cacheEnabled = true;
this.response = new ResponseCache(response);
return this;
}
public String portId() {
return router().portId();
}
public int port() {return router().port(); }
public UserAgent userAgent() {
if (null == ua) {
ua = UserAgent.parse(req().header(H.Header.Names.USER_AGENT));
}
return ua;
}
public boolean jsonEncoded() {
return req().contentType() == H.Format.JSON;
}
public boolean acceptJson() {
return accept() == H.Format.JSON;
}
public boolean acceptXML() {
return accept() == H.Format.XML;
}
public boolean isAjax() {
return req().isAjax();
}
public boolean isOptionsMethod() {
return req().method() == H.Method.OPTIONS;
}
public String username() {
return session().get(sessionKeyUsername);
}
public boolean isLoggedIn() {
return S.notBlank(username());
}
public String body() {
return paramVal(REQ_BODY);
}
public ActionContext param(String name, String value) {
extraParams.put(name, value);
return this;
}
@Override
public Set<String> paramKeys() {
Set<String> set = new HashSet<String>();
set.addAll(C.<String>list(request.paramNames()));
set.addAll(extraParams.keySet());
set.addAll(bodyParams().keySet());
return set;
}
@Override
public String paramVal(String name) {
String val = extraParams.get(name);
if (null != val) {
return val;
}
val = request.paramVal(name);
if (null == val) {
String[] sa = getBody(name);
if (null != sa && sa.length > 0) {
val = sa[0];
}
}
return val;
}
public String[] paramVals(String name) {
String val = extraParams.get(name);
if (null != val) {
return new String[]{val};
}
String[] sa = request.paramVals(name);
if (null == sa) {
sa = getBody(name);
}
return sa;
}
private String[] getBody(String name) {
Map<String, String[]> body = bodyParams();
return body.get(name);
}
private Map<String, String[]> bodyParams() {
if (null == bodyParams) {
synchronized (this) {
if (null == bodyParams) {
Map<String, String[]> map = C.newMap();
H.Method method = request.method();
if (H.Method.POST == method || H.Method.PUT == method || H.Method.PATCH == method) {
RequestBodyParser parser = RequestBodyParser.get(request);
map = parser.parse(this);
}
bodyParams = map;
}
}
}
return bodyParams;
}
public Map<String, String[]> allParams() {
return allParams;
}
public ISObject upload(String name) {
Integer index = attribute(ATTR_CURRENT_FILE_INDEX);
if (null == index) {
index = 0;
}
return upload(name, index);
}
public ISObject upload(String name, int index) {
body();
ISObject[] a = uploads.get(name);
return null != a && a.length > index ? a[index] : null;
}
public ActionContext addUpload(String name, ISObject sobj) {
ISObject[] a = uploads.get(name);
if (null == a) {
a = new ISObject[1];
a[0] = sobj;
} else {
ISObject[] newA = new ISObject[a.length + 1];
System.arraycopy(a, 0, newA, 0, a.length);
newA[a.length] = sobj;
a = newA;
}
uploads.put(name, a);
return this;
}
public H.Status successStatus() {
if (null != forceResponseStatus) {
return forceResponseStatus;
}
return H.Method.POST == req().method() ? H.Status.CREATED : H.Status.OK;
}
public ActionContext forceResponseStatus(H.Status status) {
this.forceResponseStatus = $.notNull(status);
return this;
}
public Result nullValueResult() {
if (null != forceResponseStatus) {
return new Result(forceResponseStatus){};
} else {
if (req().method() == H.Method.POST) {
H.Format accept = accept();
if (H.Format.JSON == accept) {
return CREATED_JSON;
} else if (H.Format.XML == accept) {
return CREATED_XML;
} else {
return CREATED;
}
} else {
return NO_CONTENT;
}
}
}
public void preCheckCsrf() {
if (!disableCsrf) {
handler().csrfSpec().preCheck(this);
}
}
public void checkCsrf(H.Session session) {
if (!disableCsrf) {
handler().csrfSpec().check(this, session);
}
}
public void setCsrfCookieAndRenderArgs() {
handler().csrfSpec().setCookieAndRenderArgs(this);
}
public void disableCORS() {
this.disableCors = true;
}
public ActionContext applyContentType
() {
H.Request req = req();
H.Format fmt = req.accept();
if (H.Format.UNKNOWN == fmt) {
fmt = req.contentType();
}
ResponseImplBase resp = $.cast(resp());
if (null != fmt) {
resp.initContentType(fmt.contentType());
}
resp.commitContentType();
return this;
}
public ActionContext applyCorsSpec() {
RequestHandler handler = handler();
if (null != handler) {
CORS.Spec spec = handler.corsSpec();
spec.applyTo(this);
}
applyGlobalCorsSetting();
return this;
}
private void applyGlobalCorsSetting() {
if (this.disableCors) {
return;
}
AppConfig conf = config();
if (!conf.corsEnabled()) {
return;
}
H.Response r = resp();
r.addHeaderIfNotAdded(ACCESS_CONTROL_ALLOW_ORIGIN, conf.corsAllowOrigin());
if (request.method() == H.Method.OPTIONS || !conf.corsOptionCheck()) {
r.addHeaderIfNotAdded(ACCESS_CONTROL_ALLOW_HEADERS, conf.corsAllowHeaders());
r.addHeaderIfNotAdded(ACCESS_CONTROL_ALLOW_CREDENTIALS, S.string(conf.corsAllowCredentials()));
r.addHeaderIfNotAdded(ACCESS_CONTROL_EXPOSE_HEADERS, conf.corsExposeHeaders());
r.addHeaderIfNotAdded(ACCESS_CONTROL_MAX_AGE, S.string(conf.corsMaxAge()));
}
}
/**
* Called by bytecode enhancer to set the name list of the render arguments that is update
* by the enhancer
*
* @param names the render argument names separated by ","
* @return this AppContext
*/
public ActionContext __appRenderArgNames(String names) {
return renderArg("__arg_names__", C.listOf(names.split(",")));
}
public List<String> __appRenderArgNames() {
return renderArg("__arg_names__");
}
public ActionContext __controllerInstance(String className, Object instance) {
if (null == controllerInstances) {
controllerInstances = C.newMap();
}
controllerInstances.put(className, instance);
return this;
}
public Object __controllerInstance(String className) {
return null == controllerInstances ? null : controllerInstances.get(className);
}
/**
* Return cached object by key. The key will be concatenated with
* current session id when fetching the cached object
*
* @param key
* @param <T> the object type
* @return the cached object
*/
public <T> T cached(String key) {
H.Session sess = session();
if (null != sess) {
return sess.cached(key);
} else {
return app().cache().get(key);
}
}
/**
* Add an object into cache by key. The key will be used in conjunction with session id if
* there is a session instance
*
* @param key the key to index the object within the cache
* @param obj the object to be cached
*/
public void cache(String key, Object obj) {
H.Session sess = session();
if (null != sess) {
sess.cache(key, obj);
} else {
app().cache().put(key, obj);
}
}
/**
* Add an object into cache by key with expiration time specified
*
* @param key the key to index the object within the cache
* @param obj the object to be cached
* @param expiration the seconds after which the object will be evicted from the cache
*/
public void cache(String key, Object obj, int expiration) {
H.Session session = this.session;
if (null != session) {
session.cache(key, obj, expiration);
} else {
app().cache().put(key, obj, expiration);
}
}
/**
* Add an object into cache by key and expired after one hour
*
* @param key the key to index the object within the cache
* @param obj the object to be cached
*/
public void cacheForOneHour(String key, Object obj) {
cache(key, obj, 60 * 60);
}
/**
* Add an object into cache by key and expired after half hour
*
* @param key the key to index the object within the cache
* @param obj the object to be cached
*/
public void cacheForHalfHour(String key, Object obj) {
cache(key, obj, 30 * 60);
}
/**
* Add an object into cache by key and expired after 10 minutes
*
* @param key the key to index the object within the cache
* @param obj the object to be cached
*/
public void cacheForTenMinutes(String key, Object obj) {
cache(key, obj, 10 * 60);
}
/**
* Add an object into cache by key and expired after one minute
*
* @param key the key to index the object within the cache+
* @param obj the object to be cached
*/
public void cacheForOneMinute(String key, Object obj) {
cache(key, obj, 60);
}
/**
* Evict cached object
*
* @param key the key indexed the cached object to be evicted
*/
public void evictCache(String key) {
H.Session sess = session();
if (null != sess) {
sess.evict(key);
} else {
app().cache().evict(key);
}
}
public S.Buffer buildViolationMessage(S.Buffer builder) {
return buildViolationMessage(builder, "\n");
}
public S.Buffer buildViolationMessage(S.Buffer builder, String separator) {
Map<String, ConstraintViolation> violations = violations();
if (violations.isEmpty()) return builder;
for (Map.Entry<String, ConstraintViolation> entry : violations.entrySet()) {
builder.append(entry.getKey()).append(": ").append(entry.getValue().getMessage()).append(separator);
}
int n = builder.length();
builder.delete(n - separator.length(), n);
return builder;
}
public String violationMessage(String separator) {
return buildViolationMessage(S.newBuffer(), separator).toString();
}
public String violationMessage() {
return violationMessage("\n");
}
public ActionContext flashViolationMessage() {
return flashViolationMessage("\n");
}
public ActionContext flashViolationMessage(String separator) {
if (violations().isEmpty()) return this;
flash().error(violationMessage(separator));
return this;
}
public String actionPath() {
return actionPath;
}
public ActionContext actionPath(String path) {
actionPath = path;
return this;
}
@Override
public String methodPath() {
return actionPath;
}
public void startIntercepting() {
state = State.INTERCEPTING;
}
public void startHandling() {
state = State.HANDLING;
}
/**
* Update the context session to mark a user logged in
* @param username the username
*/
public void login(String username) {
session().put(config().sessionKeyUsername(), username);
}
/**
* Logout the current session. After calling this method,
* the session will be cleared
*/
public void logout() {
session().clear();
}
/**
* Initialize params/renderArgs/attributes and then
* resolve session and flash from cookies
*/
public void resolve() {
E.illegalStateIf(state != State.CREATED);
boolean sessionFree = handler.sessionFree();
attribute(ATTR_WAS_UNAUTHENTICATED, true);
if (!sessionFree) {
resolveSession();
resolveFlash();
}
localeResolver.resolve();
state = State.SESSION_RESOLVED;
if (!sessionFree) {
handler.prepareAuthentication(this);
EventBus eventBus = app().eventBus();
eventBus.emit(new PreFireSessionResolvedEvent(session, this));
Act.sessionManager().fireSessionResolved(this);
eventBus.emit(new SessionResolvedEvent(session, this));
if (isLoggedIn()) {
attribute(ATTR_WAS_UNAUTHENTICATED, false);
}
}
}
@Override
public Locale locale(boolean required) {
if (required) {
if (null == locale()) {
localeResolver.resolve();
}
}
return super.locale(required);
}
/**
* Dissolve session and flash into cookies.
* <p><b>Note</b> this method must be called
* before any content has been committed to
* response output stream/writer</p>
*/
public void dissolve() {
if (state == State.SESSION_DISSOLVED) {
return;
}
if (handler.sessionFree()) {
return;
}
localeResolver.dissolve();
app().eventBus().emit(new SessionWillDissolveEvent(this));
try {
dissolveFlash();
dissolveSession();
state = State.SESSION_DISSOLVED;
} finally {
app().eventBus().emit(new SessionDissolvedEvent(this));
}
}
/**
* Clear all internal data store/cache and then
* remove this context from thread local
*/
@Override
protected void releaseResources() {
super.releaseResources();
PropertySpec.current.remove();
if (this.state != State.DESTROYED) {
this.allParams = null;
this.extraParams = null;
this.requestParamCache = null;
this.router = null;
this.handler = null;
// xio impl might need this this.request = null;
// xio impl might need this this.response = null;
this.flash = null;
this.session = null;
this.controllerInstances = null;
clearLocal();
this.uploads.clear();
}
this.state = State.DESTROYED;
}
public void saveLocal() {
_local.set(this);
}
public static void clearLocal() {
clearCurrent();
}
private Set<Map.Entry<String, String[]>> requestParamCache() {
if (null != requestParamCache) {
return requestParamCache;
}
requestParamCache = new HashSet<>();
Map<String, String[]> map = new HashMap<>();
// url queries
Iterator<String> paramNames = request.paramNames().iterator();
while (paramNames.hasNext()) {
final String key = paramNames.next();
final String[] val = request.paramVals(key);
MapUtil.mergeValueInMap(map, key, val);
}
// post bodies
Map<String, String[]> map2 = bodyParams();
for (String key : map2.keySet()) {
String[] val = map2.get(key);
if (null != val) {
MapUtil.mergeValueInMap(map, key, val);
}
}
requestParamCache.addAll(map.entrySet());
return requestParamCache;
}
private void _init() {
uploads = new HashMap<>();
extraParams = new HashMap<>();
final Set<Map.Entry<String, String[]>> paramEntrySet = new AbstractSet<Map.Entry<String, String[]>>() {
@Override
public Iterator<Map.Entry<String, String[]>> iterator() {
final Iterator<Map.Entry<String, String[]>> extraItr = new Iterator<Map.Entry<String, String[]>>() {
Iterator<Map.Entry<String, String>> parent = extraParams.entrySet().iterator();
@Override
public boolean hasNext() {
return parent.hasNext();
}
@Override
public Map.Entry<String, String[]> next() {
final Map.Entry<String, String> parentEntry = parent.next();
return new Map.Entry<String, String[]>() {
@Override
public String getKey() {
return parentEntry.getKey();
}
@Override
public String[] getValue() {
return new String[]{parentEntry.getValue()};
}
@Override
public String[] setValue(String[] value) {
throw E.unsupport();
}
};
}
@Override
public void remove() {
throw E.unsupport();
}
};
final Iterator<Map.Entry<String, String[]>> reqParamItr = requestParamCache().iterator();
return new Iterator<Map.Entry<String, String[]>>() {
@Override
public boolean hasNext() {
return extraItr.hasNext() || reqParamItr.hasNext();
}
@Override
public Map.Entry<String, String[]> next() {
if (extraItr.hasNext()) return extraItr.next();
return reqParamItr.next();
}
@Override
public void remove() {
throw E.unsupport();
}
};
}
@Override
public int size() {
int size = extraParams.size();
if (null != request) {
size += requestParamCache().size();
}
return size;
}
};
allParams = new AbstractMap<String, String[]>() {
@Override
public Set<Entry<String, String[]>> entrySet() {
return paramEntrySet;
}
};
}
private void resolveSession() {
this.session = Act.sessionManager().resolveSession(this);
}
private void resolveFlash() {
this.flash = Act.sessionManager().resolveFlash(this);
}
private void dissolveSession() {
Cookie c = Act.sessionManager().dissolveSession(this);
if (null != c) {
config().sessionMapper().serializeSession(c, this);
}
}
private void dissolveFlash() {
Cookie c = Act.sessionManager().dissolveFlash(this);
if (null != c) {
config().sessionMapper().serializeFlash(c, this);
}
}
private static ContextLocal<ActionContext> _local = $.contextLocal();
public static final String METHOD_GET_CURRENT = "current";
public static ActionContext current() {
return _local.get();
}
public static void clearCurrent() {
_local.remove();
}
/**
* Create an new {@code AppContext} and return the new instance
*/
public static ActionContext create(App app, H.Request request, H.Response resp) {
return new ActionContext(app, request, resp);
}
public enum State {
CREATED,
SESSION_RESOLVED,
SESSION_DISSOLVED,
INTERCEPTING,
HANDLING,
DESTROYED;
public boolean isHandling() {
return this == HANDLING;
}
public boolean isIntercepting() {
return this == INTERCEPTING;
}
}
public static class ActionContextEvent extends ActEvent<ActionContext> implements SystemEvent {
public ActionContextEvent(ActionContext source) {
super(source);
}
public ActionContext context() {
return source();
}
}
private static class SessionEvent extends ActionContextEvent {
private H.Session session;
public SessionEvent(H.Session session, ActionContext source) {
super(source);
this.session = session;
}
public H.Session session() {
return session;
}
}
/**
* This event is fired after session resolved and before any
* {@link act.util.SessionManager.Listener} get called
*/
public static class PreFireSessionResolvedEvent extends SessionEvent {
public PreFireSessionResolvedEvent(H.Session session, ActionContext context) {
super(session, context);
}
}
/**
* This event is fired after session resolved and after all
* {@link act.util.SessionManager.Listener} get notified and
* in turn after all event listeners that listen to the
* {@link PreFireSessionResolvedEvent} get notified
*/
public static class SessionResolvedEvent extends SessionEvent {
public SessionResolvedEvent(H.Session session, ActionContext context) {
super(session, context);
}
}
public static class SessionWillDissolveEvent extends ActionContextEvent {
public SessionWillDissolveEvent(ActionContext source) {
super(source);
}
}
public static class SessionDissolvedEvent extends ActionContextEvent {
public SessionDissolvedEvent(ActionContext source) {
super(source);
}
}
}