diff --git a/apps/i2psnark/java/build.xml b/apps/i2psnark/java/build.xml index fdecd0f46..025c3f1db 100644 --- a/apps/i2psnark/java/build.xml +++ b/apps/i2psnark/java/build.xml @@ -104,9 +104,11 @@ + + - + diff --git a/apps/i2psnark/java/src/org/klomp/snark/web/BasicServlet.java b/apps/i2psnark/java/src/org/klomp/snark/web/BasicServlet.java new file mode 100644 index 000000000..3fea3a0b0 --- /dev/null +++ b/apps/i2psnark/java/src/org/klomp/snark/web/BasicServlet.java @@ -0,0 +1,547 @@ +// ======================================================================== +// Copyright 199-2004 Mort Bay Consulting Pty. Ltd. +// ------------------------------------------------------------------------ +// 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. +// ======================================================================== + +package org.klomp.snark.web; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; + +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.UnavailableException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import net.i2p.I2PAppContext; +import net.i2p.data.ByteArray; +import net.i2p.util.ByteCache; +import net.i2p.util.Log; + + +/* ------------------------------------------------------------ */ +/** + * Based on DefaultServlet from Jetty 6.1.26, heavily simplified + * and modified to remove all dependencies on Jetty libs. + * + * Supports HEAD and GET only, for resources from the .war and local files. + * Supports files and resource only. + * Supports MIME types with local overrides and additions. + * Supports Last-Modified. + * + * Does not support directories or "welcome files". + * Does not support gzip. + * Does not support request ranges. + * Does not cache. + * + * HEAD and POST return 405. + * Directories return 403. + * Jar resources are sent with a long cache directive. + * + * ------------------------------------------------------------ + * + * The default servlet. + * This servlet, normally mapped to /, provides the handling for static + * content, OPTION and TRACE methods for the context. + * The following initParameters are supported, these can be set either + * on the servlet itself or as ServletContext initParameters with a prefix + * of org.mortbay.jetty.servlet.Default. : + *
                                                                      
+ *
+ *  resourceBase      Set to replace the context resource base
+
+ *  warBase      Path allowed for resource in war
+ * 
+ * 
+ * + * + * @author Greg Wilkins (gregw) + * @author Nigel Canonizado + * + * @since Jetty 7 + */ +class BasicServlet extends HttpServlet +{ + protected final I2PAppContext _context; + protected final Log _log; + protected File _resourceBase; + private String _warBase; + + private final MimeTypes _mimeTypes; + + /** same as PeerState.PARTSIZE */ + private static final int BUFSIZE = 16*1024; + private ByteCache _cache = ByteCache.getInstance(16, BUFSIZE); + + private static final int WAR_CACHE_CONTROL_SECS = 24*60*60; + private static final int FILE_CACHE_CONTROL_SECS = 24*60*60; + + public BasicServlet() { + super(); + _context = I2PAppContext.getGlobalContext(); + _log = _context.logManager().getLog(getClass()); + _mimeTypes = new MimeTypes(); + } + + /* ------------------------------------------------------------ */ + public void init(ServletConfig cfg) throws ServletException { + super.init(cfg); + String rb=getInitParameter("resourceBase"); + if (rb!=null) + { + File f = new File(rb); + setResourceBase(f); + } + String wb = getInitParameter("warBase"); + if (wb != null) + setWarBase(wb); + } + + /** + * Files are served from here + */ + protected void setResourceBase(File base) throws UnavailableException { + if (!base.isDirectory()) + throw new UnavailableException("Resource base does not exist: " + base); + _resourceBase = base; + if (_log.shouldLog(Log.INFO)) + _log.info("Resource base is " + _resourceBase); + } + + /** + * Only paths starting with this in the path are served + */ + protected void setWarBase(String base) { + if (!base.startsWith("/")) + base = '/' + base; + if (!base.endsWith("/")) + base = base + '/'; + _warBase = base; + if (_log.shouldLog(Log.INFO)) + _log.info("War base is " + _warBase); + } + + /** get Resource to serve. + * Map a path to a resource. The default implementation calls + * HttpContext.getResource but derived servlets may provide + * their own mapping. + * @param pathInContext The path to find a resource for. + * @return The resource to serve or null if not existing + */ + public File getResource(String pathInContext) + { + if (_resourceBase==null) + return null; + File r = null; + if (!pathInContext.contains("..") && + !pathInContext.endsWith("/")) { + File f = new File(_resourceBase, pathInContext); + if (f.exists()) + r = f; + } + return r; + } + + /** get Resource to serve. + * Map a path to a resource. The default implementation calls + * HttpContext.getResource but derived servlets may provide + * their own mapping. + * @param pathInContext The path to find a resource for. + * @return The resource to serve or null. Returns null for directories + */ + public HttpContent getContent(String pathInContext) + { + if (_resourceBase==null) + return null; + HttpContent r = null; + if (_warBase != null && pathInContext.startsWith(_warBase)) { + r = new JarContent(pathInContext); + } else if (!pathInContext.contains("..") && + !pathInContext.endsWith("/")) { + File f = new File(_resourceBase, pathInContext); + // exists && !directory + if (f.isFile()) + r = new FileContent(f); + } + return r; + } + + /* ------------------------------------------------------------ */ + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + // always starts with a '/' + String servletpath = request.getServletPath(); + String pathInfo=request.getPathInfo(); + // ??? right?? + String pathInContext = addPaths(servletpath, pathInfo); + + // Find the resource and content + try { + HttpContent content = getContent(pathInContext); + + // Handle resource + if (content == null) { + if (_log.shouldLog(Log.WARN)) + _log.warn("Not found: " + pathInContext); + response.sendError(404); + } else { + if (passConditionalHeaders(request, response, content)) { + if (_log.shouldLog(Log.INFO)) + _log.info("Sending: " + content); + sendData(request, response, content); + } else { + if (_log.shouldLog(Log.INFO)) + _log.info("Not modified: " + content); + } + } + } + catch(IllegalArgumentException e) + { + if (_log.shouldLog(Log.WARN)) + _log.warn("Error sending " + pathInContext, e); + if(!response.isCommitted()) + response.sendError(500, e.getMessage()); + } + catch(IOException e) + { + if (_log.shouldLog(Log.WARN)) + // typical browser abort + //_log.warn("Error sending", e); + _log.warn("Error sending " + pathInContext + ": " + e); + throw e; + } + } + + /* ------------------------------------------------------------ */ + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + response.sendError(405); + } + + /* ------------------------------------------------------------ */ + /* (non-Javadoc) + * @see javax.servlet.http.HttpServlet#doTrace(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse) + */ + protected void doTrace(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + response.sendError(405); + } + + protected void doOptions(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + response.sendError(405); + } + + protected void doDelete(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + response.sendError(405); + } + + + /* ------------------------------------------------------------ */ + /** Check modification date headers. + * @return true to keep going, false if handled here + */ + protected boolean passConditionalHeaders(HttpServletRequest request,HttpServletResponse response, HttpContent content) + throws IOException + { + try + { + if (!request.getMethod().equals("HEAD") ) { + long ifmsl=request.getDateHeader("If-Modified-Since"); + if (ifmsl!=-1) + { + if (content.getLastModified()/1000 <= ifmsl/1000) + { + response.reset(); + response.setStatus(304); + response.flushBuffer(); + return false; + } + } + } + } + catch(IllegalArgumentException iae) + { + if(!response.isCommitted()) + response.sendError(400, iae.getMessage()); + throw iae; + } + return true; + } + + + /* ------------------------------------------------------------ */ + protected void sendData(HttpServletRequest request, + HttpServletResponse response, + HttpContent content) + throws IOException + { + InputStream in =null; + try { + in = content.getInputStream(); + } catch (IOException e) { + if (_log.shouldLog(Log.WARN)) + _log.warn("Not found: " + content); + response.sendError(404); + return; + } + + OutputStream out =null; + try { + out = response.getOutputStream(); + } catch (IllegalStateException e) { + out = new WriterOutputStream(response.getWriter()); + } + + // Write content normally + long content_length = content.getContentLength(); + writeHeaders(response,content,content_length); + if (content_length >= 0 && request.getMethod().equals("HEAD")) { + // if we know the content length, don't send it to be counted + if (_log.shouldLog(Log.INFO)) + _log.info("HEAD: " + content); + } else { + // GET or unknown size for HEAD + copy(in, out); + } + + return; + } + + /* ------------------------------------------------------------ */ + protected void writeHeaders(HttpServletResponse response,HttpContent content,long count) + throws IOException + { + if (content.getContentType()!=null && response.getContentType()==null) + response.setContentType(content.getContentType()); + + long lml = content.getLastModified(); + if (lml>=0) + response.setDateHeader("Last-Modified",lml); + + if (count != -1) + { + if (count=0) + response.setHeader("Cache-Control", "public, max-age=" + ct); + + } + + /* ------------------------------------------------------------ */ + /* ------------------------------------------------------------ */ + /* ------------------------------------------------------------ */ + /* I2P additions below here */ + + /** from Jetty HttpContent.java */ + public interface HttpContent + { + String getContentType(); + long getLastModified(); + /** in seconds */ + int getCacheTime(); + long getContentLength(); + InputStream getInputStream() throws IOException; + } + + private class FileContent implements HttpContent + { + private final File _file; + + public FileContent(File file) + { + _file = file; + } + + /* ------------------------------------------------------------ */ + public String getContentType() + { + //return _mimeTypes.getMimeByExtension(_file.toString()); + return getMimeType(_file.toString()); + } + + /* ------------------------------------------------------------ */ + public long getLastModified() + { + return _file.lastModified(); + } + + public int getCacheTime() + { + return FILE_CACHE_CONTROL_SECS; + } + + /* ------------------------------------------------------------ */ + public long getContentLength() + { + return _file.length(); + } + + /* ------------------------------------------------------------ */ + public InputStream getInputStream() throws IOException + { + return new BufferedInputStream(new FileInputStream(_file)); + } + + @Override + public String toString() { return "File \"" + _file + '"'; } + } + + private class JarContent implements HttpContent + { + private final String _path; + + public JarContent(String path) + { + _path = path; + } + + /* ------------------------------------------------------------ */ + public String getContentType() + { + return getMimeType(_path); + } + + /* ------------------------------------------------------------ */ + public long getLastModified() + { + return (new File(_context.getBaseDir(), "webapps/i2psnark.war")).lastModified(); + } + + public int getCacheTime() + { + return WAR_CACHE_CONTROL_SECS; + } + + /* ------------------------------------------------------------ */ + public long getContentLength() + { + return -1; + } + + /* ------------------------------------------------------------ */ + public InputStream getInputStream() throws IOException + { + InputStream rv = getServletContext().getResourceAsStream(_path); + if (rv == null) + throw new IOException("Not found"); + return rv; + } + + @Override + public String toString() { return "Jar resource \"" + _path + '"'; } + } + + + /** + * @param resourcePath in the classpath, without ".properties" extension + */ + protected void loadMimeMap(String resourcePath) { + _mimeTypes.loadMimeMap(resourcePath); + } + + /* ------------------------------------------------------------ */ + /** Get the MIME type by filename extension. + * @param filename A file name + * @return MIME type matching the longest dot extension of the + * file name. + */ + protected String getMimeType(String filename) { + String rv = _mimeTypes.getMimeByExtension(filename); + if (rv != null) + return rv; + return getServletContext().getMimeType(filename); + } + + protected void addMimeMapping(String extension, String type) { + _mimeTypes.addMimeMapping(extension, type); + } + + /** + * Simple version of URIUtil.addPaths() + * @param path may be null + */ + protected static String addPaths(String base, String path) { + if (path == null) + return base; + return (new File(base, path)).toString(); + } + + /** + * Simple version of URIUtil.decodePath() + */ + protected static String decodePath(String path) throws MalformedURLException { + if (!path.contains("%")) + return path; + try { + URI uri = new URI(path); + return uri.getPath(); + } catch (URISyntaxException use) { + // for ease of use, since a USE is not an IOE but a MUE is... + throw new MalformedURLException(use.getMessage()); + } + } + + /** + * Simple version of URIUtil.encodePath() + */ + protected static String encodePath(String path) throws MalformedURLException { + try { + URI uri = new URI(null, null, path, null); + return uri.toString(); + } catch (URISyntaxException use) { + // for ease of use, since a USE is not an IOE but a MUE is... + throw new MalformedURLException(use.getMessage()); + } + } + + /** + * Write from in to out + */ + private void copy(InputStream in, OutputStream out) throws IOException { + ByteArray ba = _cache.acquire(); + byte[] buf = ba.getData(); + try { + int read = 0; + while ( (read = in.read(buf)) != -1) { + out.write(buf, 0, read); + } + } finally { + _cache.release(ba, false); + if (in != null) + try { in.close(); } catch (IOException ioe) {} + if (out != null) + try { out.close(); } catch (IOException ioe) {} + } + } +} diff --git a/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java index c8aa8e1c1..ea8875626 100644 --- a/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java +++ b/apps/i2psnark/java/src/org/klomp/snark/web/I2PSnarkServlet.java @@ -24,6 +24,7 @@ import java.util.TreeSet; import javax.servlet.ServletConfig; import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -44,31 +45,28 @@ import org.klomp.snark.Tracker; import org.klomp.snark.TrackerClient; import org.klomp.snark.dht.DHT; -import org.eclipse.jetty.servlet.DefaultServlet; -import org.eclipse.jetty.util.URIUtil; -import org.eclipse.jetty.util.resource.Resource; - /** - * We extend Default instead of HTTPServlet so we can handle - * i2psnark/ file requests with http:// instead of the flaky and - * often-blocked-by-the-browser file:// + * Refactored to eliminate Jetty dependencies */ -public class I2PSnarkServlet extends DefaultServlet { - private I2PAppContext _context; - private Log _log; +public class I2PSnarkServlet extends BasicServlet { + /** generally "/i2psnark" */ + private String _contextPath; private SnarkManager _manager; private static long _nonce; - private Resource _resourceBase; private String _themePath; private String _imgPath; private String _lastAnnounceURL; public static final String PROP_CONFIG_FILE = "i2psnark.configFile"; + public I2PSnarkServlet() { + super(); + } + @Override public void init(ServletConfig cfg) throws ServletException { - _context = I2PAppContext.getGlobalContext(); - _log = _context.logManager().getLog(I2PSnarkServlet.class); + super.init(cfg); + _contextPath = getServletContext().getContextPath(); _nonce = _context.random().nextLong(); _manager = new SnarkManager(_context); String configFile = _context.getProperty(PROP_CONFIG_FILE); @@ -76,10 +74,9 @@ public class I2PSnarkServlet extends DefaultServlet { configFile = "i2psnark.config"; _manager.loadConfig(configFile); _manager.start(); - try { - _resourceBase = Resource.newResource(_manager.getDataDir().getAbsolutePath()); - } catch (IOException ioe) {} - super.init(cfg); + loadMimeMap("org/klomp/snark/web/mime"); + setResourceBase(_manager.getDataDir()); + setWarBase("/.icons/"); } @Override @@ -95,17 +92,13 @@ public class I2PSnarkServlet extends DefaultServlet { * and we can't get any resources (like icons) out of the .war */ @Override - public Resource getResource(String pathInContext) + public File getResource(String pathInContext) { if (pathInContext == null || pathInContext.equals("/") || pathInContext.equals("/index.jsp") || pathInContext.equals("/index.html") || pathInContext.startsWith("/.icons/")) return super.getResource(pathInContext); // files in the i2psnark/ directory - try { - return _resourceBase.addPath(pathInContext); - } catch (IOException ioe) { - throw new RuntimeException(ioe); - } + return new File(_resourceBase, pathInContext); } /** @@ -139,6 +132,7 @@ public class I2PSnarkServlet extends DefaultServlet { */ @Override public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + //_log.error("Service " + req.getMethod() + " \"" + req.getContextPath() + "\" \"" + req.getServletPath() + "\" \"" + req.getPathInfo() + '"'); // since we are not overriding handle*(), do this here String method = req.getMethod(); if (!(method.equals("GET") || method.equals("HEAD") || method.equals("POST"))) { @@ -178,15 +172,15 @@ public class I2PSnarkServlet extends DefaultServlet { if (path.endsWith("/")) { // bypass the horrid Resource.getListHTML() String pathInfo = req.getPathInfo(); - String pathInContext = URIUtil.addPaths(path, pathInfo); + String pathInContext = addPaths(path, pathInfo); req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html; charset=UTF-8"); - Resource resource = getResource(pathInContext); - if (resource == null || (!resource.exists())) { + File resource = getResource(pathInContext); + if (resource == null) { resp.sendError(404); } else { - String base = URIUtil.addPaths(req.getRequestURI(), "/"); + String base = addPaths(req.getRequestURI(), "/"); String listing = getListHTML(resource, base, true, method.equals("POST") ? req.getParameterMap() : null); if (method.equals("POST")) { // P-R-G @@ -2031,7 +2025,7 @@ public class I2PSnarkServlet extends DefaultServlet { * @return String of HTML or null if postParams != null * @since 0.7.14 */ - private String getListHTML(Resource r, String base, boolean parent, Map postParams) + private String getListHTML(File r, String base, boolean parent, Map postParams) throws IOException { String[] ls = null; @@ -2040,7 +2034,7 @@ public class I2PSnarkServlet extends DefaultServlet { Arrays.sort(ls, Collator.getInstance()); } // if r is not a directory, we are only showing torrent info section - String title = URIUtil.decodePath(base); + String title = decodePath(base); if (title.startsWith("/i2psnark/")) title = title.substring("/i2psnark/".length()); @@ -2228,7 +2222,7 @@ public class I2PSnarkServlet extends DefaultServlet { .append("\">\n"); buf.append("\n\n"); buf.append("\"\" ") .append(_("Up to higher level directory")) .append("\n"); @@ -2239,12 +2233,12 @@ public class I2PSnarkServlet extends DefaultServlet { boolean showSaveButton = false; for (int i=0 ; i< ls.length ; i++) { - String encoded=URIUtil.encodePath(ls[i]); + String encoded = encodePath(ls[i]); // bugfix for I2P - Backport from Jetty 6 (zero file lengths and last-modified times) // http://jira.codehaus.org/browse/JETTY-361?page=com.atlassian.jira.plugin.system.issuetabpanels%3Achangehistory-tabpanel#issue-tabs // See resource.diff attachment //Resource item = addPath(encoded); - Resource item = r.addPath(ls[i]); + File item = new File(r, ls[i]); String rowClass = (i % 2 == 0 ? "snarkTorrentEven" : "snarkTorrentOdd"); buf.append(""); @@ -2264,7 +2258,7 @@ public class I2PSnarkServlet extends DefaultServlet { } else { Storage storage = snark.getStorage(); try { - File f = item.getFile(); + File f = item; if (f != null) { long remaining = storage.remaining(f.getCanonicalPath()); if (remaining < 0) { @@ -2294,9 +2288,9 @@ public class I2PSnarkServlet extends DefaultServlet { } } - String path=URIUtil.addPaths(base,encoded); + String path=addPaths(base,encoded); if (item.isDirectory() && !path.endsWith("/")) - path=URIUtil.addPaths(path,"/"); + path=addPaths(path,"/"); String icon = toIcon(item); buf.append(""); if (showPriority) { buf.append(""); - File f = item.getFile(); + File f = item; if ((!complete) && (!item.isDirectory()) && f != null) { int pri = snark.getStorage().getPriority(f.getCanonicalPath()); buf.append(" _mimeMap; + + public MimeTypes() { + _mimeMap = new ConcurrentHashMap(); + } + + /* ------------------------------------------------------------ */ + /** + * @param resourcePath A Map of file extension to mime-type. + */ + public void loadMimeMap(String resourcePath) { + loadMimeMap(_mimeMap, resourcePath); + } + + /** + * Tries both webapp and system class loader, since Jetty blocks + * its classes from the webapp class loader. + */ + private static void loadMimeMap(Map map, String resourcePath) { + try + { + ResourceBundle mime; + try { + mime = ResourceBundle.getBundle(resourcePath); + } catch(MissingResourceException e) { + // Jetty 7 webapp classloader blocks jetty classes + // http://wiki.eclipse.org/Jetty/Reference/Jetty_Classloading + //System.out.println("No mime types loaded from " + resourcePath + ", trying system classloader"); + mime = ResourceBundle.getBundle(resourcePath, Locale.getDefault(), ClassLoader.getSystemClassLoader()); + } + Enumeration i = mime.getKeys(); + while(i.hasMoreElements()) + { + String ext = i.nextElement(); + String m = mime.getString(ext); + map.put(ext.toLowerCase(Locale.US), m); + } + //System.out.println("Loaded " + map.size() + " mime types from " + resourcePath); + } catch(MissingResourceException e) { + //System.out.println("No mime types loaded from " + resourcePath); + } + } + + /* ------------------------------------------------------------ */ + /** Get the MIME type by filename extension. + * + * Returns ONLY local mappings. + * Caller should use getServletContext().getMimeType() if this returns null. + * + * @param filename A file name + * @return MIME type matching the longest dot extension of the + * file name. + */ + public String getMimeByExtension(String filename) + { + String type=null; + + if (filename!=null) + { + int i=-1; + while(type==null) + { + i=filename.indexOf(".",i+1); + + if (i<0 || i>=filename.length()) + break; + + String ext=filename.substring(i+1).toLowerCase(Locale.US); + type = _mimeMap.get(ext); + } + } + return type; + } + + /* ------------------------------------------------------------ */ + /** Set a mime mapping + * @param extension + * @param type + */ + public void addMimeMapping(String extension, String type) + { + _mimeMap.put(extension.toLowerCase(Locale.US), type); + } +} diff --git a/apps/i2psnark/java/src/org/klomp/snark/web/WriterOutputStream.java b/apps/i2psnark/java/src/org/klomp/snark/web/WriterOutputStream.java new file mode 100644 index 000000000..c4aa37bc5 --- /dev/null +++ b/apps/i2psnark/java/src/org/klomp/snark/web/WriterOutputStream.java @@ -0,0 +1,19 @@ +package org.klomp.snark.web; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; + +/** + * Treat a writer as an output stream. Quick 'n dirty, none + * of that "intarnasheeonaleyzayshun" stuff. So we can treat + * the jsp's PrintWriter as an OutputStream + * + * @since Jetty 7 copied from routerconsole + */ +class WriterOutputStream extends OutputStream { + private final Writer _writer; + + public WriterOutputStream(Writer writer) { _writer = writer; } + public void write(int b) throws IOException { _writer.write(b); } +} diff --git a/apps/i2psnark/mime.properties b/apps/i2psnark/mime.properties new file mode 100644 index 000000000..4fc8f7a5e --- /dev/null +++ b/apps/i2psnark/mime.properties @@ -0,0 +1,17 @@ +7z = application/x-7z-compressed +ape = audio/ape +bz2 = application/x-bzip2 +flac = audio/ogg +flv = video/x-flv +m4a = audio/mp4a-latm +m4v = video/x-m4v +mkv = video/x-matroska +mp4 = video/mp4 +nfo = text/plain +ogm = video/ogg +ogv = video/ogg +oga = audio/ogg +rar = application/x-rar-compressed +war = application/java-archive +wma = audio/x-ms-wma +wmv = video/x-ms-wmv