/* * Copyright (C) 2011 Laurent Caillette * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation, either * version 3 of the License, or (at your option) any later version. * * This program 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. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.novelang.daemon; import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; import java.util.List; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import com.google.common.collect.Lists; import com.google.common.collect.Ordering; import org.apache.commons.io.FilenameUtils; import org.eclipse.jetty.server.Request; import org.novelang.common.FileTools; import org.novelang.common.StructureKind; import org.novelang.configuration.ContentConfiguration; import org.novelang.logger.Logger; import org.novelang.logger.LoggerFactory; import org.novelang.rendering.RenditionMimeType; /** * Displays directory content. * <p> * Security concerns: * <ul> * <li>All displayed paths are relative. * <li>All generated links are relative. * <li>If target directory contains two dots ("{@code ..}") then access is not authorized. * This should just happen in case of a deliberate attack since Web browsers * (Safari 3.1.2, Camino 1.6.1) resolve ("{@code ..}") on their side. * </ul> * Because Web browsers calculate absolute links from relative links and user-typed location, * there must be a trailing solidus ("{@code /}") at the end of request target. If there isn't, * redirection occurs to correct location. * <p> * Known problem with Safari: Content-type in response not taken in account. * Yet Safari deduces MIME type from file extension (we hit this when generating error reports). * In case of a target ending by "{@code /}" Safari believes it's a file to download. * So for Safari we handle a fake "{@value #MIME_HINT}" resource which doesn't conflict with other * resources. Redirection also occurs to this fake resource is Safari browser is detected. * * @author Laurent Caillette */ public class DirectoryScanHandler extends GenericHandler { private static final Logger LOGGER = LoggerFactory.getLogger( DirectoryScanHandler.class ) ; private final File contentRoot ; private static final String ACCESS_DENIED_MESSAGE = "Target may contain reference to parent directory, denying access ." ; private static final String HTML_CONTENT_TYPE = RenditionMimeType.HTML.getFileExtension() ; /** * Fake resource name indicating that directory listing is requested for browsers * which know MIME type only from resource extension, not Content-Type. */ public static final String MIME_HINT = "-.html" ; public DirectoryScanHandler( final ContentConfiguration contentConfiguration ) { this.contentRoot = contentConfiguration.getContentRoot() ; } @Override protected void doHandle( final String target, final HttpServletRequest request, final HttpServletResponse response ) throws IOException, ServletException { LOGGER.debug( "Handling request for user agent ", request.getHeader( "User-Agent" ) ) ; if( target.contains( ".." ) ) { sendUnauthorizedResponse( response ) ; ( ( Request ) request ).setHandled( true ) ; LOGGER.debug( "Concluded by unauthorized message for original request '", request.getRequestURI(), "'" ) ; } else { final boolean needsMimeHint = doesBrowserNeedMimeHint( request ) ; final boolean mimeHintPresent = target.endsWith( "/" + MIME_HINT ) ; final String targetWithoutMimeHint ; if( mimeHintPresent ) { targetWithoutMimeHint = target.substring( 0, target.length() - MIME_HINT.length() ) ; } else { targetWithoutMimeHint = target ; } final String normalizedTarget ; final boolean needsRedirection ; if( targetWithoutMimeHint.endsWith( "/" ) ) { normalizedTarget = target + ( needsMimeHint ? MIME_HINT : "" ) ; needsRedirection = needsMimeHint & ! mimeHintPresent ; } else { normalizedTarget = target + "/" + ( needsMimeHint ? MIME_HINT : "" ) ; needsRedirection = true ; } final File scanned = new File( contentRoot, targetWithoutMimeHint ) ; final boolean directoryExists = scanned.exists() && scanned.isDirectory() ; if( directoryExists ) { if( needsRedirection ) { redirectTo( response, normalizedTarget ) ; LOGGER.debug( "Concluded by redirection for original request '", request.getRequestURI(), "'." ) ; } else { listFilesAndDirectories( response, scanned ) ; LOGGER.debug( "Concluded by directory listing for original request '", request.getRequestURI(), "'." ) ; } ( ( Request ) request ).setHandled( true ) ; } } } private static void redirectTo( final HttpServletResponse response, final String redirectionTarget ) throws IOException { response.sendRedirect( redirectionTarget ) ; response.setStatus( HttpServletResponse.SC_FOUND ) ; response.setContentType( HTML_CONTENT_TYPE ) ; LOGGER.debug( "Redirected to '", redirectionTarget, "'." ) ; } /** * Currently returns true if Apple's Safari browser is detected. */ private static boolean doesBrowserNeedMimeHint( final HttpServletRequest request ) { final String userAgent = request.getHeader( "User-Agent" ) ; if( null == userAgent ) { LOGGER.warn( "Got no User-Agent in ", request ) ; return false ; } else { return userAgent.contains( "Safari" ) ; // return userAgent.contains( "Mozilla" ) ; } } private static void sendUnauthorizedResponse( final HttpServletResponse response ) throws IOException { LOGGER.warn( ACCESS_DENIED_MESSAGE ) ; response.setStatus( HttpServletResponse.SC_UNAUTHORIZED ) ; response.setContentType( HTML_CONTENT_TYPE ) ; final PrintWriter writer = new PrintWriter( response.getOutputStream() ) ; writer.println( "<html>" ) ; writer.println( "<body>" ) ; writer.println( ACCESS_DENIED_MESSAGE ) ; writer.println( "</body>" ) ; writer.println( "</html>" ) ; writer.flush() ; } private void listFilesAndDirectories( final HttpServletResponse response, final File scanned ) throws IOException { final List< File > filesAndDirectories = Lists.newArrayList() ; filesAndDirectories.addAll( FileTools.scanFiles( scanned, StructureKind.getAllFileExtensions(), true ) ) ; filesAndDirectories.addAll( FileTools.scanDirectories( scanned ) ) ; final List< File > files = Ordering.from( FileTools.ABSOLUTEPATH_COMPARATOR ).sortedCopy( filesAndDirectories ) ; response.setStatus( HttpServletResponse.SC_OK ) ; generateHtml( response.getOutputStream(), scanned, files ) ; // Must be set last or Safari gets confused and cannot render the document as a Web page. response.setContentType( HTML_CONTENT_TYPE ) ; } private void generateHtml( final OutputStream outputStream, final File scannedDirectory, final Iterable< File > sortedFiles ) { final PrintWriter writer = new PrintWriter( outputStream ) ; writer.println( "<html>" ) ; writer.println( "<body>" ) ; writer.println( "<tt>" ) ; LOGGER.debug( "Relativizing files from '", scannedDirectory.getAbsolutePath(), "'" ) ; if( FileTools.isParentOf( contentRoot, scannedDirectory ) ) { writer.println( "<a href=\"..\">..</a><br/>" ) ; } for( final File file : sortedFiles ) { final String documentNameRelativeToContentRoot = createLinkablePath( contentRoot, file ) ; final String documentNameRelativeToScannedDir = createLinkablePath( scannedDirectory, file ) ; writer.println( ( file.isDirectory() ? "" : "  " ) + "<a href=\"" + documentNameRelativeToScannedDir + "\">" + documentNameRelativeToContentRoot + "</a>" + "<br/>" ) ; } writer.println( "</tt>" ) ; writer.println( "</body>" ) ; writer.println( "</html>" ) ; writer.flush() ; } private static String createLinkablePath( final File scannedDirectory, final File file ) { final String fileNameRelativeToScannedDir = FileTools.urlifyPath( FileTools.relativizePath( scannedDirectory, file ) ) ; return file.isDirectory() ? fileNameRelativeToScannedDir : htmlizeExtension( fileNameRelativeToScannedDir ) ; } private static String htmlizeExtension( final String relativeFileName ) { return FilenameUtils.removeExtension( relativeFileName ) + ".html"; } }