/*
 * Copyright (c) 1998-2012 Caucho Technology -- all rights reserved
 *
 * This file is part of Resin(R) Open Source
 *
 * Each copy or derived work must preserve the copyright notice and this
 * notice unmodified.
 *
 * Resin Open Source is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Resin Open Source is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE, or any warranty
 * of NON-INFRINGEMENT.  See the GNU General Public License for more
 * details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Resin Open Source; if not, write to the
 *
 *   Free Software Foundation, Inc.
 *   59 Temple Place, Suite 330
 *   Boston, MA 02111-1307  USA
 *
 * @author Scott Ferguson
 */

package com.caucho.servlets;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.URL;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.servlet.GenericServlet;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.caucho.env.service.ResinSystem;
import com.caucho.loader.EnvironmentLocal;
import com.caucho.server.http.CauchoRequest;
import com.caucho.server.http.CauchoResponse;
import com.caucho.server.http.HttpServletResponseImpl;
import com.caucho.server.util.CauchoSystem;
import com.caucho.server.webapp.WebApp;
import com.caucho.util.Base64;
import com.caucho.util.CharBuffer;
import com.caucho.util.Crc64;
import com.caucho.util.L10N;
import com.caucho.util.LruCache;
import com.caucho.util.QDate;
import com.caucho.util.RandomUtil;
import com.caucho.vfs.CaseInsensitive;
import com.caucho.vfs.Path;
import com.caucho.vfs.ReadStream;
import com.caucho.vfs.Vfs;

/**
 * Serves static files.  The cache headers are automatically set on these
 * files.
 */
@SuppressWarnings("serial")
public class FileServlet extends GenericServlet {
  private static final L10N L = new L10N(FileServlet.class);
  private static final Logger log
    = Logger.getLogger(FileServlet.class.getName());

  private static final EnvironmentLocal<LruCache<String,Cache>> _pathCacheLocal
    = new EnvironmentLocal<LruCache<String,Cache>>();

  private final LruCache<String,Cache> _pathCache;

  private final LruCache<String,Cache> _localCache
    = new LruCache<String,Cache>(16 * 1024);

  private Path _context;
  private WebApp _app;
  private RequestDispatcher _dir;

  private boolean _isCaseInsensitive;
  private boolean _isEnableRange = true;
  private boolean _isGenerateSession;
  private String _characterEncoding;

  public FileServlet()
  {
    ResinSystem resin = ResinSystem.getCurrent();

    LruCache<String,Cache> pathCache;

    pathCache = _pathCacheLocal.get(resin.getClassLoader());
    if (pathCache == null) {
      pathCache = new LruCache<String,Cache>(256 * 1024);
      _pathCacheLocal.set(pathCache, resin.getClassLoader());
    }

    _pathCache = pathCache;

    _isCaseInsensitive = CaseInsensitive.isCaseInsensitive();
  }

  /**
   * Sets the character encoding.
   */
  public void setCharacterEncoding(String encoding)
  {
    _characterEncoding = encoding;
  }

  /**
   * Flag to disable the "Range" header.
   */
  public void setEnableRange(boolean isEnable)
  {
    _isEnableRange = isEnable;
  }

  /**
   * Flag to generate sessions on requests.
   */
  public void setGenerateSession(boolean isGenerateSession)
  {
    _isGenerateSession = isGenerateSession;
  }

  /**
   * Clears the cache
   */
  /*
  public void clearCache()
  {
    _pathCache.clear();
  }
  */

  /**
   * Removes an entry from the cache
   */
  public void removeCacheEntry(String uri)
  {
    _pathCache.remove(uri);
  }

  @Override
  public void init(ServletConfig conf)
    throws ServletException
  {
    super.init(conf);

    _app = (WebApp) getServletContext();
    _context = _app.getRootDirectory();

    try {
      _dir = _app.getNamedDispatcher("directory");
    } catch (Exception e) {
      log.log(Level.ALL, e.toString(), e);
    }

    String enable = getInitParameter("enable-range");
    if (enable != null && enable.equals("false"))
      _isEnableRange = false;

    String encoding = getInitParameter("character-encoding");
    if (encoding != null && ! "".equals(encoding))
      _characterEncoding = encoding;
  }

  @Override
  public void service(ServletRequest request, ServletResponse response)
    throws ServletException, IOException
  {
    CauchoRequest cauchoReq = null;
    HttpServletRequest req;
    HttpServletResponse res;

    if (request instanceof CauchoRequest) {
      cauchoReq = (CauchoRequest) request;
      req = cauchoReq;
    }
    else
      req = (HttpServletRequest) request;

    res = (HttpServletResponse) response;

    boolean isInclude = false;
    String uri;

    uri = (String) req.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI);
    if (uri != null)
      isInclude = true;
    else
      uri = req.getRequestURI();

    Cache cache = _localCache.get(uri);

    String filename = null;

    String cacheUrl = null;

    if (cache == null) {
      cacheUrl = getCacheUrl(req, uri);

      cache = _pathCache.get(cacheUrl);

      if (cache != null) {
        _localCache.put(uri, cache);
      }
    }

    if (cache == null) {
      CharBuffer cb = new CharBuffer();
      String servletPath;

      if (cauchoReq != null)
        servletPath = cauchoReq.getPageServletPath();
      else if (isInclude)
        servletPath = (String) req.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH);
      else
        servletPath = req.getServletPath();

      if (servletPath != null)
        cb.append(servletPath);

      String pathInfo;
      if (cauchoReq != null)
        pathInfo = cauchoReq.getPagePathInfo();
      else if (isInclude)
        pathInfo
          = (String) req.getAttribute(RequestDispatcher.INCLUDE_PATH_INFO);
      else
        pathInfo = req.getPathInfo();

      if (pathInfo != null)
        cb.append(pathInfo);

      String relPath = cb.toString();

      if (_isCaseInsensitive) {
        relPath = relPath.toLowerCase(Locale.ENGLISH);
      }
      
      filename = getServletContext().getRealPath(relPath);
      Path path = _context.lookupNative(filename);

      // only top-level requests are checked
      if (cauchoReq == null || cauchoReq.getRequestDepth(0) != 0) {
      }
      else if (relPath.regionMatches(true, 0, "/web-inf", 0, 8)
               && (relPath.length() == 8
                   || ! Character.isLetterOrDigit(relPath.charAt(8)))) {
        res.sendError(HttpServletResponse.SC_NOT_FOUND);
        return;
      }
      else if (relPath.regionMatches(true, 0, "/meta-inf", 0, 9)
               && (relPath.length() == 9
                   || ! Character.isLetterOrDigit(relPath.charAt(9)))) {
        res.sendError(HttpServletResponse.SC_NOT_FOUND);
        return;
      }

      if (relPath.endsWith(".DS_store")) {
        // MacOS-X security hole with trailing '.'
        res.sendError(HttpServletResponse.SC_NOT_FOUND);
        return;
      }
      else if (! CauchoSystem.isWindows() || relPath.length() == 0) {
      }
      else if (path.isDirectory()) {
      }
      else if (path.isWindowsInsecure()) {
        // Windows security issues with trailing '.'
        res.sendError(HttpServletResponse.SC_NOT_FOUND);
        return;
      }

      // A null will cause problems.
      for (int i = relPath.length() - 1; i >= 0; i--) {
        char ch = relPath.charAt(i);

        if (ch == 0) {
          res.sendError(HttpServletResponse.SC_NOT_FOUND);
          return;
        }
      }

      ServletContext webApp = getServletContext();

      String mimeType = webApp.getMimeType(relPath);

      boolean isPathReadable = path.canRead();
      Path jarPath = null;

      if (! isPathReadable) {
        String resource = "META-INF/resources" + relPath;
        URL url = webApp.getClassLoader().getResource(resource);

        if (url != null)
          jarPath = Vfs.lookup(url);
      }

      cache = new Cache(path, jarPath, relPath, mimeType);
      _localCache.put(uri, cache);

      _pathCache.put(cacheUrl, cache);
    }
    else if (cache.isModified()) {
      cache = new Cache(cache.getFilePath(),
                        cache.getJarPath(),
                        cache.getRelPath(),
                        cache.getMimeType());

      cacheUrl = getCacheUrl(req, uri);
      _pathCache.put(cacheUrl, cache);
      _localCache.put(uri, cache);
    }

    if (_isGenerateSession)
      req.getSession(true);

    if (cache.isDirectory()) {
      if (! uri.endsWith("/")) {
        String queryString = req.getQueryString();

        if (queryString != null)
          sendRedirect(res, uri + "/?" + queryString);
        else
          sendRedirect(res, uri + "/");
      }
      else if (_dir != null)
        _dir.forward(req, res);
      else
        res.sendError(HttpServletResponse.SC_NOT_FOUND);

      return;
    }

    if (! cache.canRead()) {
      if (isInclude)
        throw new FileNotFoundException(uri);
      else
        res.sendError(HttpServletResponse.SC_NOT_FOUND);
      return;
    }

    // server/4500, #4218
    String method = req.getMethod();
    if (! method.equalsIgnoreCase("GET")
        && ! method.equalsIgnoreCase("HEAD")
        && ! method.equalsIgnoreCase("POST")) {
      res.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED,
                    "Method not implemented");
      return;
    }

    String ifMatch = req.getHeader("If-None-Match");
    String etag = cache.getEtag();

    if (ifMatch != null && ifMatch.equals(etag)) {
      res.addHeader("ETag", etag);
      res.sendError(HttpServletResponse.SC_NOT_MODIFIED);
      return;
    }

    String lastModified = cache.getLastModifiedString();

    if (ifMatch == null) {
      String ifModified = req.getHeader("If-Modified-Since");

      boolean isModified = true;

      if (ifModified == null) {
      }
      else if (ifModified.equals(lastModified)) {
        isModified = false;
      }
      else {
        long ifModifiedTime;

        QDate date = QDate.allocateLocalDate();

        try {
          ifModifiedTime = date.parseDate(ifModified);
        } catch (Exception e) {
          log.log(Level.FINER, e.toString(), e);

          ifModifiedTime = 0;
        }

        QDate.freeLocalDate(date);

        isModified = (ifModifiedTime == 0
                      || ifModifiedTime != cache.getLastModified());
      }

      if (! isModified) {
        if (etag != null)
          res.addHeader("ETag", etag);
        res.sendError(HttpServletResponse.SC_NOT_MODIFIED);
        return;
      }
    }

    res.addHeader("ETag", etag);
    res.addHeader("Last-Modified", lastModified);

    if (_isEnableRange && cauchoReq != null && cauchoReq.isTop())
      res.addHeader("Accept-Ranges", "bytes");

    if (_characterEncoding != null)
      res.setCharacterEncoding(_characterEncoding);

    String mime = cache.getMimeType();
    if (mime != null)
      res.setContentType(mime);

    if (method.equalsIgnoreCase("HEAD")) {
      if (res instanceof CauchoResponse) {
        CauchoResponse cRes = (CauchoResponse) res;
        cRes.setContentLength(cache.getLength());
      }
      else if (cache.getLength() <= Integer.MAX_VALUE) {
        res.setContentLength((int) cache.getLength());
      }
      return;
    }

    if (_isEnableRange) {
      String range = req.getHeader("Range");

      if (range != null) {
        String ifRange = req.getHeader("If-Range");

        if (ifRange != null && ! ifRange.equals(etag)) {
        }
        else if (handleRange(req, res, cache, range, mime))
          return;
      }
    }

    if (res instanceof CauchoResponse) {
      CauchoResponse cRes = (CauchoResponse) res;

      cRes.setContentLength(cache.getLength());

      cRes.getResponseStream().sendFile(cache.getPath(),
                                        0,
                                        cache.getLength());
    }
    else {
      long length = cache.getLength();

      if (length >= 0 && length < Integer.MAX_VALUE) {
        res.setContentLength((int) length);
      }

      OutputStream os = res.getOutputStream();
      cache.getPath().writeToStream(os);
    }
  }

  private String getCacheUrl(HttpServletRequest req, String uri)
  {
    WebApp webApp = (WebApp) req.getServletContext();
    return webApp.getId() + "|" + uri;
  }

  private void sendRedirect(HttpServletResponse res, String url)
    throws IOException
  {
    String encUrl;

    HttpServletResponseImpl resImpl = null;

    if (res instanceof HttpServletResponseImpl) {
      resImpl = (HttpServletResponseImpl) res;

      encUrl = resImpl.encodeAbsoluteRedirect(url);
    }
    else
      encUrl = res.encodeRedirectURL(url);

    try {
      res.reset();
    } catch (Exception e) {
      log.log(Level.FINER, e.toString(), e);
    }

    res.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
    res.setHeader("Location", encUrl);
    res.setContentType("text/html; charset=utf-8");

    PrintWriter out = res.getWriter();

    out.println("The URL has moved <a href=\"" + encUrl + "\">here</a>");

    if (resImpl != null)
      resImpl.close();
  }

  private boolean handleRange(HttpServletRequest req,
                              HttpServletResponse res,
                              Cache cache,
                              String range,
                              String mime)
    throws IOException
  {
    // This is duplicated in CacheInvocation.  Possibly, it should be
    // completely removed although it's useful even without caching.
    int length = range.length();

    boolean hasMore = range.indexOf(',') > 0;

    long cacheLength = cache.getLength();
    long bytesMax = 2 * cacheLength;
    long bytesWritten = 0;

    int head = 0;
    boolean isFirstChunk = true;
    String boundary = null;
    int off = range.indexOf("bytes=", head);
    ServletOutputStream os = null;

    if (off < 0)
      return false;

    off += 6;

    while (off > 0 && off < length) {
      boolean hasFirst = false;
      long first = 0;
      boolean hasLast = false;
      long last = 0;
      int ch = -1;

      // Skip whitespace
      for (; off < length && (ch = range.charAt(off)) == ' '; off++) {
      }

      // read range start (before '-')
      for (;
           off < length && (ch = range.charAt(off)) >= '0' && ch <= '9';
           off++) {
        first = 10 * first + ch - '0';
        hasFirst = true;
      }

      if (length <= off && ! isFirstChunk)
        break;
      else if (ch != '-')
        return false;

      // read range end (before '-')
      for (off++;
           off < length && (ch = range.charAt(off)) >= '0' && ch <= '9';
           off++) {
        last = 10 * last + ch - '0';
        hasLast = true;
      }

      // #3766 - browser errors in range
      if (off < length && ch != ' ' && ch != ',')
        return false;

      // Skip whitespace
      for (; off < length && (ch = range.charAt(off)) == ' '; off++) {
      }

      head = off;
      if (! hasLast) {
        if (first == 0)
          return false;

        last = cacheLength - 1;
      }

      // suffix
      if (! hasFirst) {
        first = cacheLength - last;
        last = cacheLength - 1;
      }

      if (last < first)
        break;

      if (cacheLength <= last) {
        // XXX: actually, an error
        break;
      }
      res.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
      StringBuilder cb = new StringBuilder();
      cb.append("bytes ");
      cb.append(first);
      cb.append('-');
      cb.append(last);
      cb.append('/');
      cb.append(cacheLength);
      String chunkRange = cb.toString();

      bytesWritten += last - first + 1;
      if (bytesMax <= bytesWritten) {
        String msg;

        msg = L.l("{0} too many range bytes requested {1} for uri={2} IP={3}",
                  this,
                  bytesWritten,
                  req.getRequestURL(),
                  req.getRemoteAddr());

        log.warning(msg);

        if (msg != null)
          throw new IOException(msg);

      }

      if (hasMore) {
        if (isFirstChunk) {
          StringBuilder cb1 = new StringBuilder();

          cb1.append("--");
          Base64.encode(cb1, RandomUtil.getRandomLong());
          boundary = cb1.toString();

          res.setContentType("multipart/byteranges; boundary=" + boundary);
          os = res.getOutputStream();
        }
        else {
          os.write('\r');
          os.write('\n');
        }

        isFirstChunk = false;

        os.write('-');
        os.write('-');
        os.print(boundary);
        os.print("\r\nContent-Type: ");
        os.print(mime);
        os.print("\r\nContent-Range: ");
        os.print(chunkRange);
        os.write('\r');
        os.write('\n');
        os.write('\r');
        os.write('\n');
      }
      else {
        if (res instanceof CauchoResponse) {
          CauchoResponse cRes = (CauchoResponse) res;

          cRes.setContentLength((last - first + 1));
        }
        else {
          res.setContentLength((int) (last - first + 1));
        }

        res.addHeader("Content-Range", chunkRange);
      }

      ReadStream is = null;
      try {
        is = cache.getPath().openRead();
        is.skip(first);

        os = res.getOutputStream();
        is.writeToStream(os, (int) (last - first + 1));
      } finally {
        if (is != null)
          is.close();
      }

      for (off--; off < length && range.charAt(off) != ','; off++) {
      }

      off++;
    }

    if (hasMore) {
      os = res.getOutputStream();

      os.write('\r');
      os.write('\n');
      os.write('-');
      os.write('-');
      os.print(boundary);
      os.write('-');
      os.write('-');
      os.write('\r');
      os.write('\n');
    }

    return true;
  }

  static class Cache {
    private Path _path;
    private Path _jarPath;
    private Path _pathResolved;
    private boolean _isDirectory;
    private boolean _canRead;
    private long _length;
    private long _lastModified = 0xdeadbabe1ee7d00dL;
    private String _relPath;
    private String _etag;
    private String _lastModifiedString;
    private String _mimeType;

    Cache(Path path, Path jarPath, String relPath, String mimeType)
    {
      _path = path;
      _jarPath = jarPath;
      _relPath = relPath;
      _mimeType = mimeType;

      fillData();
    }

    Path getPath()
    {
      return _pathResolved;
    }

    Path getFilePath()
    {
      return _path;
    }

    Path getJarPath()
    {
      return _jarPath;
    }

    boolean canRead()
    {
      return _canRead;
    }

    boolean isDirectory()
    {
      return _isDirectory;
    }

    long getLength()
    {
      return _length;
    }

    String getRelPath()
    {
      return _relPath;
    }

    String getEtag()
    {
      return _etag;
    }

    long getLastModified()
    {
      return _lastModified;
    }

    String getLastModifiedString()
    {
      return _lastModifiedString;
    }

    String getMimeType()
    {
      return _mimeType;
    }

    boolean isModified()
    {
      long lastModified = _pathResolved.getLastModified();
      long length = _pathResolved.getLength();

      // server/1t06
      if (_path != _pathResolved && _path.canRead())
        return true;

      return (lastModified != _lastModified || length != _length);
    }

    private void fillData()
    {
      _pathResolved = _path;

      if (_jarPath != null && ! _path.canRead())
        _pathResolved = _jarPath;

      long lastModified = _pathResolved.getLastModified();
      long length = _pathResolved.getLength();

      _lastModified = lastModified;
      _length = length;
      _canRead = _pathResolved.canRead();
      _isDirectory = _pathResolved.isDirectory();

      long etagHash = _pathResolved.getCrc64();
      
      if (_mimeType != null) {
        etagHash = Crc64.generate(etagHash, _mimeType);
      }
      
      StringBuilder sb = new StringBuilder();
      sb.append('"');
      Base64.encode(sb, etagHash); 
      sb.append('"');
      _etag = sb.toString();

      QDate cal = QDate.allocateGmtDate();

      cal.setGMTTime(lastModified);
      _lastModifiedString = cal.printDate();

      QDate.freeGmtDate(cal);

      if (lastModified == 0) {
        _canRead = false;
        _isDirectory = false;
      }
    }
  }
}
