This blog is mainly about Java...

Friday, March 26, 2010

Advanced Seam series part 2 of 3: Seam Filters

Seam Filters.

In this blog post I will be showing two filters. One that hacks in UTF-8 as encoding, and the other, a download filter for downloading files efficiently.

Seam filters can potentially be called outside of the Seam context, thus they are not truly a Seam component, but they act as one. However, they are able to reuse the functionality for component scanning, installation and configuration for filters.

There are two ways to define filters.

1. Through web.xml
<filter>
    <filter-name>UTF8 Filter</filter-name>
    <filter-class>com.something.filter.UTF8Filter</filter-class>
</filter>

<filter-mapping>
      <filter-name>UTF8 Filter</filter-name>
      <url-pattern>*.seam</url-pattern>
</filter-mapping>

And the Filter it self:
This filter is a fix for a bug in Seam encoding filter. More information here.
Remember you must implement Filter.
/**
 * This Filter is a fix for bug in seam encoding filter, see here for more details: https://jira.jboss.org/jira/browse/JBSEAM-3006
 * It might be removed if the version will be upgraded to at least 2.2.1.CR1
 *    
 */
public class UTF8Filter implements Filter {

    public void destroy() {}

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)   throws IOException, ServletException {
        // set encoding to UTF-8
        req.setCharacterEncoding("UTF-8");
        chain.doFilter(req, res);
        return;
    }

    public void init(FilterConfig arg0) throws ServletException {}
}
However, the servlet specification doesn't provide a well defined order if you specify your filters in a web.xml, however Seam provides a way to do that by using the @Filter annotation.

In this next example I will show an efficient and pretty safe download filter which you can use to serve files to the user. First I want to present the way you shouldn't do it.

Say you have a File entity class.
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.security.GeneralSecurityException;

import javax.persistence.Basic;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Lob;
import javax.persistence.ManyToOne;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Transient;

import org.hibernate.validator.NotNull;

@Entity
public class File {

    @Id
    @GeneratedValue
    private Long id;

    @NotNull
    @Column(nullable = false, length = 256)
    private String name;

    @Column
    private String hash;

    // BLOB = L + 2 bytes (max size is 2^16 - 1 or 65,535 bytes, 65KB)
    // MEDIUMBLOB = L + 3 bytes (max size is 2^24 - 1 or 16,777,215 bytes, 16MB)
    // LONGBLOB = L + 4 bytes (max size is 2^32 - 1 or 4,294,967,295 bytes, 4GB)
    @Basic(fetch = FetchType.LAZY)
    // @Basic is used in conjunction with @Lob
    @Lob             
    // Set to MAX 100MB LONGBLOB in MySQL
    @Column(length = 104857600, nullable = false)
    private byte[] data;

    @PrePersist
    @PreUpdate
    public void setHash() {
        try {
            hash = PasswordSupport.generateFileHash(data);
        } catch (GeneralSecurityException e) {
            e.printStackTrace();
            hash = null;
        }
    }

    @Transient
    public InputStream getInputStream() {
        return new ByteArrayInputStream(data);
    }
    //getters and setters
}
For the PasswordSupport class (which basically hashes the input), have a look at my previous post. We always want to generate a new hash if a new file appears, thus the triggers @PreUpdate and @PrePersist

But to save you some time, here is the method:
    /**
     * Hash file
     * @throws GeneralSecurityException 
     */
    public static final String generateFileHash(byte[] data) throws GeneralSecurityException {
        byte[] salt = new byte[1024];
        String theHash;
        try {
            AnnotatedBeanProperty<UserPassword> userPasswordProperty = new AnnotatedBeanProperty<UserPassword>(ProcessUser.class, UserPassword.class);
            // Will get the hash value from annotation UserPassword in User.class
            PasswordHash.instance().setHashAlgorithm(userPasswordProperty.getAnnotation().hash().toUpperCase());
            theHash = PasswordHash.instance().createPasswordKey(String.valueOf(Arrays.hashCode(data)).toCharArray(), salt, userPasswordProperty.getAnnotation().iterations());
            return theHash;
        } finally {
            salt = null;
            theHash = null;
        }
    }
Typically, you would do the following to serve the user a file:

import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.FileNameMap;
import java.net.URLConnection;

import javax.annotation.PostConstruct;
import javax.ejb.Local;
import javax.ejb.Stateless;
import javax.faces.context.FacesContext;
import javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.servlet.http.HttpServletResponse;

import org.jboss.seam.annotations.AutoCreate;
import org.jboss.seam.annotations.In;
import org.jboss.seam.annotations.Name;

@Name("fileService")
@AutoCreate
@Stateless
@Local(FileService.class)
public class FileServiceImpl implements FileService {

    @In
    private EntityManager entityManager;

    private FileNameMap fileNameMap;
    
    @PostConstruct
    public void contruct() {
        fileNameMap = URLConnection.getFileNameMap();
    }
    
    @Deprecated
    public void download(File file) throws IOException {
        byte[] data = file.getData();
    
        HttpServletResponse response = (HttpServletResponse) FacesContext.getCurrentInstance().getExternalContext().getResponse();
    
        //Take mime type from filename, I don't know if the null check is necessary, the API doesn't say. 
        String mime = fileNameMap.getContentTypeFor(file.getName());
        if (mime == null || "".equals(mime.trim())) {
            mime = "application/octet-stream";
        }
        response.setContentType(mime);
        response.addHeader("Content-Disposition", "attachment;filename=" + file.getName());
    
        response.setContentLength(data.length);
    
        OutputStream writer = response.getOutputStream();
    
        writer.write(data);
        writer.flush();
        writer.close();
    
        //Skip the rest of JSF phases
        FacesContext.getCurrentInstance().responseComplete();
    }
    
        //rest of ejb not shown
}
This method of doing it is bad because it will load the file in to memory and serve it to the user. Not to mention all the Seam interceptors along the way.

A much better way is to use a filter.
All you need to do is create a link that has the downloadFileId as parameter and the filter will activate, like this http://somedomain.com/myApp/somePage.seam?downloadFileId=1-BAC272728189887A672

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;

import javax.faces.context.FacesContext;
import javax.persistence.EntityManager;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.mydomain.model.File;

import org.jboss.seam.Component;
import org.jboss.seam.ScopeType;
import org.jboss.seam.annotations.Name;
import org.jboss.seam.annotations.Scope;
import org.jboss.seam.annotations.Startup;
import org.jboss.seam.annotations.intercept.BypassInterceptors;
import org.jboss.seam.annotations.web.Filter;
import org.jboss.seam.contexts.Lifecycle;
import org.jboss.seam.web.AbstractFilter;

/**
 * Implemented from suggestion on 
 * {@link http://seamframework.org/Community/LargeFileDownload}
 * @author Shervin Asgari
 * 
 */
@Name("downloadFilter")
@Filter(around = { "org.jboss.seam.web.ajax4jsfFilter" })
@Scope(ScopeType.APPLICATION)
@Startup
@BypassInterceptors
public class DownloadFilter extends AbstractFilter {

    public void doFilter(ServletRequest request, ServletResponse resp, FilterChain arg2) throws IOException, ServletException {
        if (HttpServletRequest.class.isAssignableFrom(request.getClass())) {
            HttpServletRequest req = (HttpServletRequest) request;
            HttpServletResponse response = (HttpServletResponse) resp;
            String downloadFileId = req.getParameter("downloadFileId");
            
            //The param is seperated with the id of the file and the hash eg http://localhost:8080/myapp/somePage.xhtml/?downloadFileId=1-8B94B45466C738FE90EF9CA8D47281EAE821F4E0 
            
            //The two ifs could be one, but for debugging and breakpoints, its easier this way
            if (downloadFileId != null && downloadFileId.matches("^\\d+-.*$")) {
                boolean started = false;
                try {
                    String[] ids = downloadFileId.split("-",0);
                    if(ids.length >= 1) {
                        String fileId = ids[0];
                        String hash = ids.length >= 2 ? ids[1] : null;
                        
                        started = true;
                        Lifecycle.beginCall();
                        File file = null;
                        
                        EntityManager entityManager = (EntityManager) Component.getInstance("entityManager");

                        if(hash != null) {
                            file = (File) entityManager.createQuery("SELECT f FROM " + File.class.getName() + " f " +
                                    "WHERE f.id=:id and f.hash=:hash").setMaxResults(1).setParameter("id", Long.valueOf(fileId)).setParameter("hash", hash).getSingleResult();
                        } else {
                            //This is here for backward compatibility
                            file = (File) entityManager.createQuery("SELECT f FROM " + File.class.getName() + " f " +
                            "WHERE f.id=:id and f IS NULL").setMaxResults(1).setParameter("id", Long.valueOf(fileId)).getSingleResult();
                        }
                        
                        if(file != null) {
                            FacesContext facesContext = FacesContext.getCurrentInstance();
                            InputStream stream = file.getInputStream();
                            response.setContentType("application/octet-stream");
                            response.setContentLength(stream.available());
                            response.setCharacterEncoding("UTF-8");
                            response.setHeader("Content-disposition", "attachment;filename=" + URLEncoder.encode(file.getName(), "UTF-8"));
                            OutputStream out = response.getOutputStream();
                            try {
                                byte[] buf = new byte[response.getBufferSize()];
                                int len;
                                while ((len = stream.read(buf)) > 0) {
                                    out.write(buf, 0, len);
                                }
                            } finally {
                                out.close();
                            }
                                
                            if(facesContext != null)
                                facesContext.responseComplete();
                        }
                    }
                    
                } catch (Exception e) {
                    e.printStackTrace();
                    resp.getOutputStream().write(String.valueOf("Error downloading file" + e.getMessage()).getBytes());
                }

                finally {
                    if(started)
                        Lifecycle.endCall();
                    else 
                        arg2.doFilter(req, response);
                }
            } else {
                arg2.doFilter(req, response);
            }
        }
    }
}
This way you will not load the file into memory, and avoid all the interceptors of Seam, thus it will perform much faster and better. Note that we have included a backward compatibility in case the file entity does not have a hash yet.

No comments:

Labels