001: /*
002: * Copyright (c) 1998-2008 Caucho Technology -- all rights reserved
003: *
004: * This file is part of Resin(R) Open Source
005: *
006: * Each copy or derived work must preserve the copyright notice and this
007: * notice unmodified.
008: *
009: * Resin Open Source is free software; you can redistribute it and/or modify
010: * it under the terms of the GNU General Public License as published by
011: * the Free Software Foundation; either version 2 of the License, or
012: * (at your option) any later version.
013: *
014: * Resin Open Source is distributed in the hope that it will be useful,
015: * but WITHOUT ANY WARRANTY; without even the implied warranty of
016: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE, or any warranty
017: * of NON-INFRINGEMENT. See the GNU General Public License for more
018: * details.
019: *
020: * You should have received a copy of the GNU General Public License
021: * along with Resin Open Source; if not, write to the
022: *
023: * Free Software Foundation, Inc.
024: * 59 Temple Place, Suite 330
025: * Boston, MA 02111-1307 USA
026: *
027: * @author Scott Ferguson
028: */
029:
030: package com.caucho.servlets;
031:
032: import com.caucho.server.connection.CauchoRequest;
033: import com.caucho.server.connection.CauchoResponse;
034: import com.caucho.server.util.CauchoSystem;
035: import com.caucho.server.webapp.Application;
036: import com.caucho.util.Alarm;
037: import com.caucho.util.Base64;
038: import com.caucho.util.CharBuffer;
039: import com.caucho.util.LruCache;
040: import com.caucho.util.QDate;
041: import com.caucho.util.RandomUtil;
042: import com.caucho.vfs.CaseInsensitive;
043: import com.caucho.vfs.Path;
044: import com.caucho.vfs.ReadStream;
045:
046: import javax.servlet.*;
047: import javax.servlet.http.HttpServletRequest;
048: import javax.servlet.http.HttpServletResponse;
049: import java.io.FileNotFoundException;
050: import java.io.IOException;
051: import java.io.OutputStream;
052:
053: /**
054: * Serves static files. The cache headers are automatically set on these
055: * files.
056: */
057: public class FileServlet extends GenericServlet {
058: private Path _context;
059: private byte[] _buffer = new byte[1024];
060: private Application _app;
061: private RequestDispatcher _dir;
062: private LruCache<String, Cache> _pathCache;
063: private QDate _calendar = new QDate();
064: private boolean _isCaseInsensitive;
065: private boolean _isEnableRange = true;
066: private String _characterEncoding;
067:
068: public FileServlet() {
069: _isCaseInsensitive = CaseInsensitive.isCaseInsensitive();
070: }
071:
072: /**
073: * Flag to disable the "Range" header.
074: */
075: public void setEnableRange(boolean isEnable) {
076: _isEnableRange = isEnable;
077: }
078:
079: /**
080: * Sets the character encoding.
081: */
082: public void setCharacterEncoding(String encoding) {
083: _characterEncoding = encoding;
084: }
085:
086: public void init(ServletConfig conf) throws ServletException {
087: super .init(conf);
088:
089: _app = (Application) getServletContext();
090: _context = _app.getAppDir();
091:
092: try {
093: _dir = _app.getNamedDispatcher("directory");
094: } catch (Throwable e) {
095: }
096:
097: _pathCache = new LruCache<String, Cache>(1024);
098:
099: String enable = getInitParameter("enable-range");
100: if (enable != null && enable.equals("false"))
101: _isEnableRange = false;
102:
103: String encoding = getInitParameter("character-encoding");
104: if (encoding != null && !"".equals(encoding))
105: _characterEncoding = encoding;
106: }
107:
108: private RequestDispatcher getDirectoryServlet() {
109: if (_dir == null)
110: _dir = _app.getNamedDispatcher("directory");
111:
112: return _dir;
113: }
114:
115: public void service(ServletRequest request, ServletResponse response)
116: throws ServletException, IOException {
117: CauchoRequest cauchoReq = null;
118: HttpServletRequest req;
119: HttpServletResponse res;
120:
121: if (request instanceof CauchoRequest) {
122: cauchoReq = (CauchoRequest) request;
123: req = cauchoReq;
124: } else
125: req = (HttpServletRequest) request;
126:
127: res = (HttpServletResponse) response;
128:
129: String method = req.getMethod();
130: if (!method.equalsIgnoreCase("GET")
131: && !method.equalsIgnoreCase("HEAD")
132: && !method.equalsIgnoreCase("POST")) {
133: res.sendError(res.SC_NOT_IMPLEMENTED,
134: "Method not implemented");
135: return;
136: }
137:
138: boolean isInclude = false;
139: String uri;
140:
141: uri = (String) req
142: .getAttribute("javax.servlet.include.request_uri");
143: if (uri != null)
144: isInclude = true;
145: else
146: uri = req.getRequestURI();
147:
148: Cache cache = _pathCache.get(uri);
149:
150: String filename = null;
151:
152: if (cache == null) {
153: CharBuffer cb = new CharBuffer();
154: String servletPath;
155:
156: if (cauchoReq != null)
157: servletPath = cauchoReq.getPageServletPath();
158: else if (isInclude)
159: servletPath = (String) req
160: .getAttribute("javax.servlet.include.servlet_path");
161: else
162: servletPath = req.getServletPath();
163:
164: if (servletPath != null)
165: cb.append(servletPath);
166:
167: String pathInfo;
168: if (cauchoReq != null)
169: pathInfo = cauchoReq.getPagePathInfo();
170: else if (isInclude)
171: pathInfo = (String) req
172: .getAttribute("javax.servlet.include.path_info");
173: else
174: pathInfo = req.getPathInfo();
175:
176: if (pathInfo != null)
177: cb.append(pathInfo);
178:
179: String relPath = cb.toString();
180:
181: if (_isCaseInsensitive)
182: relPath = relPath.toLowerCase();
183:
184: filename = getServletContext().getRealPath(relPath);
185: Path path = _context.lookupNative(filename);
186: int lastCh;
187:
188: // only top-level requests are checked
189: if (cauchoReq == null || cauchoReq.getRequestDepth(0) != 0) {
190: } else if (relPath.regionMatches(true, 0, "/web-inf", 0, 8)
191: && (relPath.length() == 8 || !Character
192: .isLetterOrDigit(relPath.charAt(8)))) {
193: res.sendError(res.SC_NOT_FOUND);
194: return;
195: } else if (relPath
196: .regionMatches(true, 0, "/meta-inf", 0, 9)
197: && (relPath.length() == 9 || !Character
198: .isLetterOrDigit(relPath.charAt(9)))) {
199: res.sendError(res.SC_NOT_FOUND);
200: return;
201: }
202:
203: if (relPath.endsWith(".DS_store")) {
204: // MacOS-X security hole with trailing '.'
205: res.sendError(res.SC_NOT_FOUND);
206: return;
207: } else if (!CauchoSystem.isWindows()
208: || relPath.length() == 0) {
209: } else if (path.isDirectory()) {
210: } else if (path.isWindowsInsecure()) {
211: // Windows security issues with trailing '.'
212: res.sendError(res.SC_NOT_FOUND);
213: return;
214: }
215:
216: // A null will cause problems.
217: for (int i = relPath.length() - 1; i >= 0; i--) {
218: char ch = relPath.charAt(i);
219:
220: if (ch == 0) {
221: res.sendError(res.SC_NOT_FOUND);
222: return;
223: }
224: }
225:
226: ServletContext app = getServletContext();
227:
228: cache = new Cache(_calendar, path, relPath, app
229: .getMimeType(relPath));
230:
231: _pathCache.put(uri, cache);
232: }
233:
234: cache.update();
235:
236: if (cache.isDirectory()) {
237: if (_dir != null)
238: _dir.forward(req, res);
239: else
240: res.sendError(res.SC_NOT_FOUND);
241: return;
242: }
243:
244: if (!cache.canRead()) {
245: if (isInclude)
246: throw new FileNotFoundException(uri);
247: else
248: res.sendError(res.SC_NOT_FOUND);
249: return;
250: }
251:
252: String ifMatch = req.getHeader("If-None-Match");
253: String etag = cache.getEtag();
254: if (ifMatch != null && ifMatch.equals(etag)) {
255: res.addHeader("ETag", etag);
256: res.sendError(res.SC_NOT_MODIFIED);
257: return;
258: }
259:
260: String lastModified = cache.getLastModifiedString();
261:
262: if (ifMatch == null) {
263: String ifModified = req.getHeader("If-Modified-Since");
264:
265: boolean isModified = true;
266:
267: if (ifModified == null) {
268: } else if (ifModified.equals(lastModified)) {
269: isModified = false;
270: } else {
271: long ifModifiedTime;
272:
273: synchronized (_calendar) {
274: try {
275: ifModifiedTime = _calendar
276: .parseDate(ifModified);
277: } catch (Throwable e) {
278: ifModifiedTime = 0;
279: }
280: }
281:
282: isModified = ifModifiedTime != cache.getLastModified();
283: }
284:
285: if (!isModified) {
286: if (etag != null)
287: res.addHeader("ETag", etag);
288: res.sendError(res.SC_NOT_MODIFIED);
289: return;
290: }
291: }
292:
293: res.addHeader("ETag", etag);
294: res.addHeader("Last-Modified", lastModified);
295: if (_isEnableRange && cauchoReq != null && cauchoReq.isTop())
296: res.addHeader("Accept-Ranges", "bytes");
297:
298: if (_characterEncoding != null)
299: res.setCharacterEncoding(_characterEncoding);
300:
301: String mime = cache.getMimeType();
302: if (mime != null)
303: res.setContentType(mime);
304:
305: if (method.equalsIgnoreCase("HEAD")) {
306: res.setContentLength((int) cache.getLength());
307: return;
308: }
309:
310: if (_isEnableRange) {
311: String range = req.getHeader("Range");
312:
313: if (range != null) {
314: String ifRange = req.getHeader("If-Range");
315:
316: if (ifRange != null && !ifRange.equals(etag)) {
317: } else if (handleRange(req, res, cache, range, mime))
318: return;
319: }
320: }
321:
322: res.setContentLength((int) cache.getLength());
323:
324: if (res instanceof CauchoResponse) {
325: CauchoResponse cRes = (CauchoResponse) res;
326:
327: cRes.getResponseStream().sendFile(cache.getPath(),
328: cache.getLength());
329: } else {
330: OutputStream os = res.getOutputStream();
331: cache.getPath().writeToStream(os);
332: }
333: }
334:
335: private boolean isWindowsSpecial(String lower, String test) {
336: int p = lower.indexOf(test);
337:
338: if (p < 0)
339: return false;
340:
341: int lowerLen = lower.length();
342: int testLen = test.length();
343: char ch;
344:
345: if (lowerLen == p + testLen
346: || (ch = lower.charAt(p + testLen)) == '/' || ch == '.')
347: return true;
348: else
349: return false;
350: }
351:
352: private boolean handleRange(HttpServletRequest req,
353: HttpServletResponse res, Cache cache, String range,
354: String mime) throws IOException {
355: // This is duplicated in CacheInvocation. Possibly, it should be
356: // completely removed although it's useful even without caching.
357: int length = range.length();
358:
359: boolean hasMore = range.indexOf(',') > 0;
360:
361: int head = 0;
362: ServletOutputStream os = res.getOutputStream();
363: boolean isFirstChunk = true;
364: String boundary = null;
365: int off = range.indexOf("bytes=", head);
366:
367: if (off < 0)
368: return false;
369:
370: off += 6;
371:
372: while (off > 0 && off < length) {
373: boolean hasFirst = false;
374: long first = 0;
375: boolean hasLast = false;
376: long last = 0;
377: int ch = -1;
378: ;
379:
380: // Skip whitespace
381: for (; off < length && (ch = range.charAt(off)) == ' '; off++) {
382: }
383:
384: // read range start (before '-')
385: for (; off < length && (ch = range.charAt(off)) >= '0'
386: && ch <= '9'; off++) {
387: first = 10 * first + ch - '0';
388: hasFirst = true;
389: }
390:
391: if (length <= off && !isFirstChunk)
392: break;
393: else if (ch != '-')
394: return false;
395:
396: // read range end (before '-')
397: for (off++; off < length && (ch = range.charAt(off)) >= '0'
398: && ch <= '9'; off++) {
399: last = 10 * last + ch - '0';
400: hasLast = true;
401: }
402:
403: // Skip whitespace
404: for (; off < length && (ch = range.charAt(off)) == ' '; off++) {
405: }
406:
407: head = off;
408:
409: long cacheLength = cache.getLength();
410:
411: if (!hasLast) {
412: if (first == 0)
413: return false;
414:
415: last = cacheLength - 1;
416: }
417:
418: // suffix
419: if (!hasFirst) {
420: first = cacheLength - last;
421: last = cacheLength - 1;
422: }
423:
424: if (last < first)
425: break;
426:
427: if (cacheLength <= last) {
428: // XXX: actually, an error
429: break;
430: }
431:
432: res.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
433:
434: CharBuffer cb = new CharBuffer();
435: cb.append("bytes ");
436: cb.append(first);
437: cb.append('-');
438: cb.append(last);
439: cb.append('/');
440: cb.append(cacheLength);
441: String chunkRange = cb.toString();
442:
443: if (hasMore) {
444: if (isFirstChunk) {
445: CharBuffer cb1 = new CharBuffer();
446:
447: cb1.append("--");
448: Base64.encode(cb1, RandomUtil.getRandomLong());
449: boundary = cb1.toString();
450:
451: res
452: .setContentType("multipart/byteranges; boundary="
453: + boundary);
454: } else {
455: os.write('\r');
456: os.write('\n');
457: }
458:
459: isFirstChunk = false;
460:
461: os.write('-');
462: os.write('-');
463: os.print(boundary);
464: os.print("\r\nContent-Type: ");
465: os.print(mime);
466: os.print("\r\nContent-Range: ");
467: os.print(chunkRange);
468: os.write('\r');
469: os.write('\n');
470: os.write('\r');
471: os.write('\n');
472: } else {
473: res.setContentLength((int) (last - first + 1));
474:
475: res.addHeader("Content-Range", chunkRange);
476: }
477:
478: ReadStream is = null;
479: try {
480: is = cache.getPath().openRead();
481: is.skip(first);
482:
483: os = res.getOutputStream();
484: is.writeToStream(os, (int) (last - first + 1));
485: } finally {
486: if (is != null)
487: is.close();
488: }
489:
490: for (off--; off < length && range.charAt(off) != ','; off++) {
491: }
492:
493: off++;
494: }
495:
496: if (hasMore) {
497: os.write('\r');
498: os.write('\n');
499: os.write('-');
500: os.write('-');
501: os.print(boundary);
502: os.write('-');
503: os.write('-');
504: os.write('\r');
505: os.write('\n');
506: }
507:
508: return true;
509: }
510:
511: static class Cache {
512: private final static long UPDATE_INTERVAL = 2000L;
513:
514: QDate _calendar;
515: Path _path;
516: boolean _isDirectory;
517: boolean _canRead;
518: long _length;
519: long _lastCheck;
520: long _lastModified = 0xdeadbabe1ee7d00dL;
521: String _relPath;
522: String _etag;
523: String _lastModifiedString;
524: String _mimeType;
525:
526: Cache(QDate calendar, Path path, String relPath, String mimeType) {
527: _calendar = calendar;
528: _path = path;
529: _relPath = relPath;
530: _mimeType = mimeType;
531:
532: update();
533: }
534:
535: Path getPath() {
536: return _path;
537: }
538:
539: boolean canRead() {
540: return _canRead;
541: }
542:
543: boolean isDirectory() {
544: return _isDirectory;
545: }
546:
547: long getLength() {
548: return _length;
549: }
550:
551: String getRelPath() {
552: return _relPath;
553: }
554:
555: String getEtag() {
556: return _etag;
557: }
558:
559: long getLastModified() {
560: return _lastModified;
561: }
562:
563: String getLastModifiedString() {
564: return _lastModifiedString;
565: }
566:
567: String getMimeType() {
568: return _mimeType;
569: }
570:
571: void update() {
572: long now = Alarm.getCurrentTime();
573: if (_lastCheck + UPDATE_INTERVAL < now) {
574: synchronized (this ) {
575: if (now <= _lastCheck + UPDATE_INTERVAL)
576: return;
577:
578: if (_lastCheck == 0) {
579: updateData();
580: _lastCheck = now;
581: return;
582: }
583:
584: _lastCheck = now;
585: }
586:
587: updateData();
588: }
589: }
590:
591: private void updateData() {
592: long lastModified = _path.getLastModified();
593: long length = _path.getLength();
594:
595: if (lastModified != _lastModified || length != _length) {
596: _lastModified = lastModified;
597: _length = length;
598: _canRead = _path.canRead();
599: _isDirectory = _path.isDirectory();
600:
601: CharBuffer cb = new CharBuffer();
602: cb.append('"');
603: Base64.encode(cb, _path.getCrc64());
604: cb.append('"');
605: _etag = cb.close();
606:
607: synchronized (_calendar) {
608: _calendar.setGMTTime(lastModified);
609: _lastModifiedString = _calendar.printDate();
610: }
611: }
612:
613: if (lastModified == 0) {
614: _canRead = false;
615: _isDirectory = false;
616: }
617: }
618: }
619: }
|