package hirondelle.web4j.action;
import static hirondelle.web4j.util.Consts.SPACE;
import hirondelle.web4j.BuildImpl;
import hirondelle.web4j.database.DynamicCriteria;
import hirondelle.web4j.model.AppException;
import hirondelle.web4j.model.Id;
import hirondelle.web4j.model.MessageList;
import hirondelle.web4j.model.MessageListImpl;
import hirondelle.web4j.request.LocaleSource;
import hirondelle.web4j.request.RequestParameter;
import hirondelle.web4j.request.RequestParser;
import hirondelle.web4j.request.TimeZoneSource;
import hirondelle.web4j.security.CsrfFilter;
import hirondelle.web4j.security.SafeText;
import hirondelle.web4j.util.Args;
import hirondelle.web4j.util.Util;
import hirondelle.web4j.util.WebUtil;
import java.security.Principal;
import java.util.Collection;
import java.util.Locale;
import java.util.TimeZone;
import java.util.logging.Logger;
import javax.servlet.ServletException;
import javax.servlet.http.HttpSession;
/**
Abstract Base Class (ABC) for implementations of the {@link Action} interface.
<P>This ABC provides concise methods for common operations, which will make
implementations read more clearly, concisely, and at a higher level of abstraction.
<P>A simple fetch-and-display operation can often be implemented using this class
as a base class. However, operations involving user input and/or edits to the
datastore should very likely use other abstract base classes, such as
{@link ActionTemplateListAndEdit}, {@link ActionTemplateSearch}, and
{@link hirondelle.web4j.action.ActionTemplateShowAndApply}.
<P>This class places success/fail messages in session scope, not request scope.
This is because such messages often need to survive a redirect operation.
For example, when a successful edit to the database occurs, a <em>redirect</em> is
usually performed, to avoid problems with browser reloads.
The only way a success message can survive a redirect is by being placed
in session scope.
<P><em>This class assumes that a session already exists</em>.
If a session does not already exist, then calling such methods will result in an error.
In practice, the user will almost always have already logged in, and this will not
be a problem. As a backup, actions can always explicitly create a session, if needed,
by calling
<PRE>getRequestParser.getRequest().getSession(true);</PRE>
*/
public abstract class ActionImpl implements Action {
/**
Value {@value} - identifies a {@link hirondelle.web4j.model.MessageList}, placed
in session scope, to hold error information for the end user.
These errors are used by both WEB4J and the application programmer.
*/
public static final String ERRORS = "web4j_key_for_errors";
/**
Value {@value} - identifies a {@link hirondelle.web4j.model.MessageList}, placed
in session scope, to hold messages for the end user. These messages are
used by both WEB4J and the application programmer. They typically hold success
and information messages.
*/
public static final String MESSAGES = "web4j_key_for_messages";
/**
Value {@value} - identifies the user's id, placed in session scope.
<P>Many applications will benefit from having <i>both</i> the user id <i>and</i> the user login name
placed in session scope upon login. The Servlet Container will place the user <i>login name</i>
in session scope upon login, but it will not place the corresponding <i>user id</i>
(the database's primary key of the user record) in session scope.
<P>If an application chooses to place the user's underlying database id into session scope under
this USER_ID key, then the user's id will be returned by {@link #getUserId()}.
*/
public static final String USER_ID = "web4j_key_for_user_id";
/**
Value {@value} - generic key for an object placed in scope for a JSP.
<P>Not mandatory to use this generic key. Provided simply as a convenience.
*/
public static final String ITEM_FOR_EDIT = "itemForEdit";
/**
Value {@value} - generic key for a collection of objects placed in scope for a JSP.
<P>Not mandatory to use this generic key. Provided simply as a convenience.
*/
public static final String ITEMS_FOR_LISTING = "itemsForListing";
/**
Constructor.
<P>This constructor will add an attribute named <tt>'Operation'</tt> to the request. Its
value is deduced as specified by {@link #getOperation()}. This attribute is intended for JSPs,
which can use it to access the <tt>Operation</tt> regardless of its original source.
@param aNominalPage simply one of the possible {@link ResponsePage}s,
arbitrarily chosen as a "default". It may be changed after construction
by calling {@link #setResponsePage}. Recommended that the "success" page
be chosen as the nominal page. If not, then selection of any of the
possible {@link ResponsePage}s is acceptable.
@param aRequestParser allows parsing of request parameters into higher level java objects.
*/
protected ActionImpl(ResponsePage aNominalPage, RequestParser aRequestParser) {
fFinalResponsePage = aNominalPage;
fRequestParser = aRequestParser;
fErrors = new AppException();
fMessages = new MessageListImpl();
fLocale = BuildImpl.forLocaleSource().get(fRequestParser.getRequest());
fTimeZone = BuildImpl.forTimeZoneSource().get(fRequestParser.getRequest());
fOperation = parseOperation();
addToRequest("Operation", fOperation);
fLogger.fine("Operation: " + fOperation);
}
public abstract ResponsePage execute() throws AppException;
/** Return the resource which will render the final result. */
public final ResponsePage getResponsePage(){
return fFinalResponsePage;
}
/**
Return the {@link Operation} associated with this <tt>Action</tt>, if any.
<P>The <tt>Operation</tt> is found as follows :
<ol>
<li>if there is a request parameter named <tt>'Operation'</tt>, and it has a value, pass its value to
{@link Operation#valueFor(String)}
<li>if the above style fails, then the 'extension' is examined. For example, a request to <tt>.../MyAction.list?x=1</tt>
would result in {@link Operation#List} being added to request scope, since the extension value <tt>list</tt> is known to
{@link Operation#valueFor(String)}.
This style is useful for implementing fine-grained <tt><security-constraint></tt>
items in <tt>web.xml</tt>. See the User Guide for more information.
<li>if both of the above methods fail, return <tt>null</tt>
</ol>
<P>When using the 'extension' style, please note that <tt>web.xml</tt> contains related <tt>servlet-mapping</tt> settings.
Such settings control which HTTP requests (as defined by a <tt>url-pattern</tt>) are passed from the Servlet Container to
your application in the first place. Thus, <b>any 'extensions' which your application intends to use must have a corresponding
<tt>servlet-mapping</tt> setting in your <tt>web.xml</tt></b>.
*/
protected final Operation getOperation(){
return fOperation;
}
/**
Return the name of the logged in user.
<P>By definition in the servlet specification, a successfully logged in user
will always have a non-<tt>null</tt> return value for
{@link javax.servlet.http.HttpServletRequest#getUserPrincipal()}.
<P>If the user is not logged in, this method will always return <tt>null</tt>.
<P>This method returns {@link SafeText}, not a <tt>String</tt>.
The user name is often rendered in the view. Since in general the user name
may contain special characters, it is appropriate to model it as
<tt>SafeText</tt>.
*/
protected final SafeText getLoggedInUserName(){
Principal principal = fRequestParser.getRequest().getUserPrincipal();
return principal == null ? null : new SafeText(principal.getName());
}
/**
Return the {@link Id} stored in session scope under the key {@link #USER_ID}.
If no such item is found, then return <tt>null</tt>.
<P><style class='highlight'>This internal database identifier should never be served to the client, since that
would be a grave security risk.</style> The user id should only be used in server-side code, and never
presented to the user in a JSP.
*/
protected final Id getUserId(){
Id result = (Id) getFromSession(USER_ID);
return result;
}
/**
Called by subclasses if the final {@link ResponsePage}
differs from the nominal one passed to the constructor.
<P>If an implementation calls this method, it is usually
called in {@link #execute}.
*/
protected final void setResponsePage(ResponsePage aNewResponsePage){
fFinalResponsePage = aNewResponsePage;
}
/**
Add a name-object pair to request scope.
<P>If the pair already exists, it is <em>updated</em> with <tt>aObject</tt>.
@param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
@param aObject if <tt>null</tt> and a corresponding name-object pair exists, then
the pair is <em>removed</em> from request scope.
*/
protected final void addToRequest(String aName, Object aObject){
Args.checkForContent(aName);
fRequestParser.getRequest().setAttribute(aName, aObject);
}
/**
Return the existing session.
<P>If a session does not already exist, then an error will result.
*/
protected final HttpSession getExistingSession(){
HttpSession result = fRequestParser.getRequest().getSession(DO_NOT_CREATE);
if ( result == null ) {
String MESSAGE = "No session currently exists. Either require user to login, or create a session explicitly.";
fLogger.severe(MESSAGE);
throw new UnsupportedOperationException(MESSAGE);
}
return result;
}
/**
Add a name-object pair to an existing session.
<P>If the pair already exists, it is <em>updated</em> with <tt>aObject</tt>.
@param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
@param aObject if <tt>null</tt> and a corresponding name-object pair exists, then
the pair is <em>removed</em> from session scope.
*/
protected final void addToSession(String aName, Object aObject){
Args.checkForContent(aName);
getExistingSession().setAttribute(aName, aObject);
}
/** Synonym for <tt>addToSession(aName, null)</tt>. */
protected final void removeFromSession(String aName){
addToSession(aName, null);
}
/**
Retrieve an object from an existing session, or <tt>null</tt> if no
object is paired with <tt>aName</tt>.
@param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
*/
protected final Object getFromSession(String aName){
Args.checkForContent(aName);
return getExistingSession().getAttribute(aName);
}
/**
Place an object which is in an existing session into request scope
as well.
<P>When serving the last page in a session, some session
items may still be needed for rendering the final page.
<P>For example, a log off page in a mutlilingual application might present a
"goodbye" message in the language that the user was using. Since the
session is being destroyed, the {@link Locale} stored in the session must be
first copied into request scope before the session is killed.
@param aName identifies an <tt>Object</tt> which is currently in session scope, and satisfies
{@link hirondelle.web4j.util.Util#textHasContent(String)}. If no attribute of the given name
is found in the current session, then a <tt>null</tt> is added to the request scope
under this name.
*/
protected final void copyFromSessionToRequest(String aName){
addToRequest( aName, getFromSession(aName) );
}
/**
If a session exists, then it is invalidated.
This method should be called only when the user is logging out.
*/
protected final void endSession(){
if ( hasExistingSession() ) {
fLogger.fine("Session exists, and will now be ended.");
getExistingSession().invalidate();
}
else {
fLogger.fine("Session does not currently exist, so cannot be ended.");
}
}
/**
Add a simple {@link hirondelle.web4j.model.AppResponseMessage} describing a
failed validation of user input, or a failed datastore operation.
<P>One of the <tt>addError</tt> methods must be called when a failure occurs.
*/
protected final void addError(String aMessage){
fErrors.add(aMessage);
placeErrorsInSession();
}
/**
Add a compound {@link hirondelle.web4j.model.AppResponseMessage} describing a
failed validation of user input, or a failed datastore operation.
<P>One of the <tt>addError</tt> methods must be called when a failure occurs.
*/
protected final void addError(String aMessage, Object... aParams){
fErrors.add(aMessage, aParams);
placeErrorsInSession();
}
/**
Add all the error messages attached to <tt>aEx</tt>.
<P>One of the <tt>addError</tt> methods must be called when a failure occurs.
*/
protected final void addError(AppException aEx){
fErrors.add(aEx);
placeErrorsInSession();
}
/**
Return all the errors passed to all <tt>addError</tt> methods.
*/
protected final MessageList getErrors(){
return fErrors;
}
/**
Return <tt>true</tt> only if at least one <tt>addError</tt> method has been called.
*/
protected final boolean hasErrors(){
return fErrors.isNotEmpty();
}
/**
Add a simple {@link hirondelle.web4j.model.AppResponseMessage}, to be displayed
to the end user.
*/
protected final void addMessage(String aMessage){
fMessages.add(aMessage);
placeMessagesInSession();
}
/**
Add a compound {@link hirondelle.web4j.model.AppResponseMessage}, to be displayed
to the end user.
*/
protected final void addMessage(String aMessage, Object... aParams){
fMessages.add(aMessage, aParams);
placeMessagesInSession();
}
/**
Return all messages passed to all <tt>addMessage</tt> methods
*/
protected final MessageList getMessages(){
return fMessages;
}
/**
Return the {@link Locale} associated with the underlying request.
<P>The configured implementation of {@link LocaleSource} defines how
<tt>Locale</tt> is looked up.
*/
protected final Locale getLocale(){
return fLocale;
}
/**
Return the {@link TimeZone} associated with the underlying request.
<P>The configured implementation of {@link TimeZoneSource} defines how
<tt>TimeZone</tt> is looked up.
*/
protected final TimeZone getTimeZone(){
return fTimeZone;
}
/** Return the {@link RequestParser} passed to the constructor. */
protected final RequestParser getRequestParser(){
return fRequestParser;
}
/**
Convenience method for retrieving a parameter as a simple <tt>Id</tt>.
<P>Synonym for <tt>getRequestParser().toId(RequestParameter)</tt>.
*/
protected final Id getIdParam(RequestParameter aReqParam){
return fRequestParser.toId(aReqParam);
}
/**
Convenience method for retrieving a multivalued parameter as a simple {@code Collection<Id>}.
<P>Synonym for <tt>getRequestParser().toIds(RequestParameter)</tt>.
*/
protected final Collection<Id> getIdParams(RequestParameter aReqParam){
return fRequestParser.toIds(aReqParam);
}
/**
Convenience method for retrieving a parameter as {@link SafeText}.
<P>Synonym for <tt>getRequestParser().toSafeText(RequestParameter)</tt>.
*/
protected final SafeText getParam(RequestParameter aReqParam){
return fRequestParser.toSafeText(aReqParam);
}
/**
Convenience method for retrieving a parameter as raw text, with no escaped
characters.
<P>This method call is unsafe in the sense that it returns <tt>String</tt>
instead of {@link SafeText}. It is usually preferable to use {@link SafeText},
since it protects against Cross-Site Scripting attacks.
<P>If, however, the caller needs to use a request parameter
value <em>to perform a computation</em>, as opposed to presenting user
data in markup, then this method is provided as a convenience.
*/
protected final String getParamUnsafe(RequestParameter aReqParam){
SafeText result = fRequestParser.toSafeText(aReqParam);
return result == null ? null : result.getRawString();
}
/**
Return an <tt>ORDER BY</tt> clause for an SQL statement.
<P>Provided as a convenience for the common task of creating an
<tt>ORDER BY</tt> clause from request parameters.
@param aSortColumn carries a <tt>ResultSet</tt> column identifer, either a
numeric column index, or the name of the column itself.
@param aOrder carries the value <tt>ASC</tt> or <tt>DESC</tt> (ignores case).
@param aDefaultOrderBy default text to be used if the request parameters are not
present, or have no content.
*/
protected final DynamicCriteria getOrderBy(RequestParameter aSortColumn, RequestParameter aOrder, String aDefaultOrderBy){
String result = aDefaultOrderBy;
String column = getRequestParser().getRawParamValue(aSortColumn);
String order = getRequestParser().getRawParamValue(aOrder);
if ( Util.textHasContent(column) && Util.textHasContent(order) ) {
if ( ! "ASC".equalsIgnoreCase(order) && ! "DESC".equalsIgnoreCase(order)) {
String message = "Sort Order must take value 'ASC' or 'DESC' (ignoring case). Actual value :" + Util.quote(order);
fLogger.severe(message);
throw new RuntimeException(message);
}
result = DynamicCriteria.ORDER_BY + column + SPACE + order;
}
return new DynamicCriteria(result);
}
/**
Create a new session (if one doesn't already exist) <b>outside of the usual user login</b>,
and add a CSRF token to the new session to defend against Cross-Site Request Forgery (CSRF) attacks.
<P>This method exists to extend the {@link CsrfFilter}, to allow it to apply to a form/action that does not already have a
user logged in.
<P><b>Warning:</b> you can only call this method in Actions for which the
{@link hirondelle.web4j.security.SuppressUnwantedSessions} filter is <i>NOT</i> in effect.
<P><b>Warning:</b> This method should be used with care when using Tomcat.
This method creates an 'anonymous' session, unattached to any user login.
Should the user log in afterwards, a robust web application should assign a <i>new</i>
session id. (See <a href='http://www.owasp.org/'>OWASP</a> for more information.)
The problem is that Tomcat 5 and 6 do <i>not</i> follow this rule, and will retain any existing
session id when the user logs in.
<P><b>This method is needed only when the user has not yet logged in.</b>
An excellent example of operations <i>not</i> requiring a login are operations that deal with
account management on a typical public web site :
<ul>
<li>registering users
<li>regaining lost passwords
</ul>
<P>For such forms, it's strongly recommended that corresponding Actions call this method.
This will allow the {@link CsrfFilter} mechanism to be used to defend such forms against CSRF attack.
As a second benefit, it will also allow information messages sent to the end user to survive <i>redirect</i> operations.
*/
protected final void createSessionAndCsrfToken(){
boolean CREATE_IF_MISSING = true;
HttpSession session = getRequestParser().getRequest().getSession(DO_NOT_CREATE);
if( session == null ) {
fLogger.fine("No session exists. Creating new session, outside of regular login.");
session = getRequestParser().getRequest().getSession(CREATE_IF_MISSING);
fLogger.fine("Adding CSRF token to the new session, to defend against CSRF attacks.");
CsrfFilter csrfFilter = new CsrfFilter();
try {
csrfFilter.addCsrfToken(getRequestParser().getRequest());
}
catch (ServletException ex){
throw new RuntimeException(ex);
}
}
else {
fLogger.fine("Not creating a new session, since one already exists. Assuming the session contains a CSRF token.");
}
}
// PRIVATE //
/*
Design Note :
This abstract base class (ABC) does not use protected fields.
Instead, its fields are private, and subclasses which need to operate on
fields do so indirectly, by calling <tt>final</tt> convenience methods
such as {@link #addToRequest}.
This style was chosen because, in this case, it seems to be simpler.
Subclasses need only a small number of interactions with these fields. If a
a large number of interactions were needed, then changing field scope to
protected would become more attractive.
As well, note how most methods are declared as <tt>final</tt>, except
for the <tt>abstract</tt> ones.
*/
private ResponsePage fFinalResponsePage;
private final RequestParser fRequestParser;
private final Locale fLocale;
private final TimeZone fTimeZone;
private final Operation fOperation;
/* Control the creation of sessions. */
private static final boolean DO_NOT_CREATE = false;
private final MessageList fErrors;
private final MessageList fMessages;
private static final Logger fLogger = Util.getLogger(ActionImpl.class);
/**
Fetch first from request parameter; if not there, use the 'file extension' instead.
If still none, return <tt>null</tt>.
*/
private Operation parseOperation(){
String opValue = getRequestParser().getRequest().getParameter("Operation");
if( ! Util.textHasContent(opValue) ) {
opValue = getFileExtension();
}
return Operation.valueFor(opValue);
}
private String getFileExtension(){
String uri = getRequestParser().getRequest().getRequestURI();
fLogger.finest("URI : " + uri);
return WebUtil.getFileExtension(uri);
}
private boolean hasExistingSession() {
return fRequestParser.getRequest().getSession(DO_NOT_CREATE) != null;
}
/**
If {@link #getErrors} has content, place it in session scope under the name {@value #ERRORS}.
If it is already in the session, then it is updated.
*/
private void placeErrorsInSession(){
if ( getErrors().isNotEmpty() ) {
addToSession(ERRORS, getErrors());
}
}
/**
If {@link #getMessages} has content, place it in session scope under the name {@value #MESSAGES}.
If it is already in the session, then it is updated.
*/
private void placeMessagesInSession(){
if ( getMessages().isNotEmpty() ) {
addToSession(MESSAGES, getMessages());
}
}
}