/*
* Copyright (c) 2013-2014 the original author or authors
*
* 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 io.werval.controllers;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.nio.file.Path;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import io.werval.api.Mode;
import io.werval.api.outcomes.Outcome;
import io.werval.util.Dates;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static java.util.Locale.US;
import static io.werval.api.context.CurrentContext.application;
import static io.werval.api.context.CurrentContext.mimeTypes;
import static io.werval.api.context.CurrentContext.outcomes;
import static io.werval.api.context.CurrentContext.request;
import static io.werval.api.context.CurrentContext.response;
import static io.werval.api.http.Headers.Names.CACHE_CONTROL;
import static io.werval.api.http.Headers.Names.CONTENT_TYPE;
import static io.werval.api.http.Headers.Names.ETAG;
import static io.werval.api.http.Headers.Names.IF_MODIFIED_SINCE;
import static io.werval.api.http.Headers.Names.IF_NONE_MATCH;
import static io.werval.api.http.Headers.Names.LAST_MODIFIED;
import static io.werval.api.mime.MimeTypesNames.APPLICATION_OCTET_STREAM;
import static io.werval.util.Charsets.US_ASCII;
import static io.werval.util.IllegalArguments.ensureNotEmpty;
import static io.werval.util.IllegalArguments.ensureNotNull;
/**
* Serve static files or directory trees.
* <p>
* Cache behaviour can be tweeked with <code>werval.controllers.static</code> config properties.
* <p>
* Always use streamed identity transfer encoding.
* <p>
* MimeType detection done using Application MimeTypes, fallback to <code>application/octet-stream</code>.
* <p>
* Log 404 at DEBUG level.
* <p>
* Log 200 at TRACE level.
* <p>
* <strong>Keep in mind that not all deployment strategies will be compatible with the use of this controller.</strong>
*/
public class Static
{
private static final Logger LOG = LoggerFactory.getLogger( Static.class );
/**
* Serve a filesystem directory as read-only resources.
* <p>
* If a directory is requested, filenames set in the <code>werval.controllers.static.index</code> config property
* are used to find an index file. Default value is <strong>no index file support</strong>.
*
* @param root Root of the file tree to serve
* @param path Path of the requeted file, relative to root
*
* @return The requested file or a 404 Outcome if not found
*/
public Outcome tree( String root, String path )
{
ensureNotEmpty( "Root", root );
return tree( new File( root ), path );
}
/**
* Serve a filesystem directory as read-only resources.
* <p>
* If a directory is requested, filenames set in the <code>werval.controllers.static.index</code> config property
* are used to find an index file. Default value is <strong>no index file support</strong>.
*
* @param root Root of the file tree to serve
* @param path Path of the requeted file, relative to root
*
* @return The requested file or a 404 Outcome if not found
*/
public Outcome tree( Path root, String path )
{
ensureNotNull( "Root", root );
return tree( root.toFile(), path );
}
/**
* Serve a filesystem directory as read-only resources.
* <p>
* If a directory is requested, filenames set in the <code>werval.controllers.static.index</code> config property
* are used to find an index file. Default value is <strong>no index file support</strong>.
*
* @param root Root of the file tree to serve
* @param path Path of the requeted file, relative to root
*
* @return The requested file or a 404 Outcome if not found
*/
public Outcome tree( File root, String path )
{
ensureNotNull( "Root", root );
ensureNotNull( "Path", path );
if( !root.isDirectory() )
{
LOG.warn( "Root '{}' is not a directory, review your routes. Outcome will be 404 Not Found.", root );
return outcomes().notFound().build();
}
File file = new File( root, path );
if( file.isDirectory() )
{
List<String> indexFileNames = application().config().stringList( "werval.controllers.static.index" );
for( String indexFileName : indexFileNames )
{
File indexFile = new File( file, indexFileName );
if( indexFile.isFile() )
{
file = indexFile;
break;
}
}
}
return file( file );
}
/**
* Serve a single file.
*
* @param file Path of the requested file
*
* @return The requested file or a 404 Outcome if not found
*/
public Outcome file( String file )
{
ensureNotEmpty( "File", file );
return serveFile( new File( file ) );
}
/**
* Serve a single file.
*
* @param file Path of the requested file
*
* @return The requested file or a 404 Outcome if not found
*/
public Outcome file( Path file )
{
ensureNotNull( "File", file );
return serveFile( file.toFile() );
}
/**
* Serve a single file.
*
* @param file Path of the requested file
*
* @return The requested file or a 404 Outcome if not found
*/
public Outcome file( File file )
{
ensureNotNull( "File", file );
return serveFile( file );
}
private Outcome serveFile( File file )
{
if( file.getPath().contains( ".." ) )
{
LOG.warn( "Directory traversal attempt: '{}'", file.getPath() );
return outcomes().
badRequest().
as( "text/plain" ).
withBody( "You just attempted a directory traversal attack, did you?" ).
build();
}
if( !file.isFile() )
{
LOG.debug( "Requested file '{}' not found", file );
return outcomes().notFound().build();
}
// Cache-Control
if( application().mode() == Mode.DEV )
{
response().headers().with( CACHE_CONTROL, "no-cache" );
}
else
{
Long maxAge = application().config().seconds( "werval.controllers.static.cache.maxage" );
if( maxAge.equals( 0L ) )
{
response().headers().with( CACHE_CONTROL, "no-cache" );
}
else
{
response().headers().with( CACHE_CONTROL, "max-age=" + maxAge );
}
}
// ETag
long lastModified = file.lastModified();
final String etag = "\"" + lastModified + "-" + file.hashCode() + "\"";
if( application().config().bool( "werval.controllers.static.cache.etag" ) )
{
response().headers().with( ETAG, etag );
}
// If-None-Match, If-Modified-Since & Last-Modified
boolean notModified = false;
Optional<String> ifNoneMatch = request().headers().singleValueOptional( IF_NONE_MATCH );
if( ifNoneMatch.isPresent() )
{
notModified = ifNoneMatch.get().equals( etag );
}
Optional<String> ifModifiedSince = request().headers().singleValueOptional( IF_MODIFIED_SINCE );
if( ifModifiedSince.isPresent() )
{
try
{
if( Dates.HTTP.parse( ifModifiedSince.get() ).getTime() >= lastModified )
{
notModified = true;
}
}
catch( ParseException ex )
{
LOG.warn( "Unable to parse HTTP date: " + ifModifiedSince.get(), ex );
}
}
// 304 Not-Modified or 200 Last-Modified
if( notModified )
{
return outcomes().notModified().build();
}
response().headers().with( LAST_MODIFIED, Dates.HTTP.format( new Date( lastModified ) ) );
// MimeType
String mimetype = mimeTypes().ofFileWithCharset( file );
response().headers().with( CONTENT_TYPE, mimetype );
// Disposition and filename
String filename = US_ASCII.newEncoder().canEncode( file.getName() )
? "; filename=\"" + file.getName() + "\""
: "; filename*=" + application().defaultCharset().name().toLowerCase( US )
+ "; filename=\"" + file.getName() + "\"";
if( APPLICATION_OCTET_STREAM.equals( mimetype ) )
{
// Browser will prompt the user, we should provide a filename
response().headers().with( "Content-Disposition", "attachment" + filename );
}
else
{
// Tell the browser to attempt to display the file and provide a filename in case it cannot
response().headers().with( "Content-Disposition", "inline" + filename );
}
// Service
try
{
LOG.trace( "Outcome will stream '{}' as '{}'", file, mimetype );
return outcomes().
ok().
withBody( new FileInputStream( file ), file.length() ).
build();
}
catch( FileNotFoundException ex )
{
// File removed?
LOG.debug( "Requested file '{}' not found", file );
return outcomes().notFound().build();
}
}
}