2016-11-23 17:22:23 +00:00
import ' dart:async ' ;
2021-05-15 13:28:26 +00:00
import ' package:angel3_framework/angel3_framework.dart ' ;
2017-09-23 21:57:54 +00:00
import ' package:file/file.dart ' ;
2018-08-28 14:58:28 +00:00
import ' package:http_parser/http_parser.dart ' ;
2017-11-18 06:12:59 +00:00
import ' package:path/path.dart ' as p ;
2021-07-04 03:22:19 +00:00
import ' package:logging/logging.dart ' ;
2021-09-12 05:22:01 +00:00
import ' package:belatuk_range_header/belatuk_range_header.dart ' ;
2017-01-25 22:40:41 +00:00
2019-05-02 23:29:09 +00:00
final RegExp _param = RegExp ( r':([A-Za-z0-9_]+)(\((.+)\))?' ) ;
final RegExp _straySlashes = RegExp ( r'(^/+)|(/+$)' ) ;
2016-11-23 17:22:23 +00:00
String _pathify ( String path ) {
var p = path . replaceAll ( _straySlashes , ' ' ) ;
2021-02-16 02:10:09 +00:00
var replace = { } ;
2016-11-23 17:22:23 +00:00
for ( Match match in _param . allMatches ( p ) ) {
if ( match [ 3 ] ! = null ) replace [ match [ 0 ] ] = ' : ${ match [ 1 ] } ' ;
}
replace . forEach ( ( k , v ) {
2021-02-16 02:10:09 +00:00
if ( k is String & & v is String ) {
p = p . replaceAll ( k , v ) ;
}
2016-11-23 17:22:23 +00:00
} ) ;
return p ;
}
2017-02-27 00:19:34 +00:00
/// A static server plug-in.
2017-09-23 21:57:54 +00:00
class VirtualDirectory {
2021-07-04 03:22:19 +00:00
final _log = Logger ( ' VirtualDirectory ' ) ;
late String _prefix ;
late Directory _source ;
2017-02-27 00:19:34 +00:00
/// The directory to serve files from.
2021-07-04 03:22:19 +00:00
Directory get source = > _source ;
2017-02-27 00:19:34 +00:00
/// An optional callback to run before serving files.
2021-05-01 03:53:04 +00:00
final Function ( File file , RequestContext req , ResponseContext res ) ? callback ;
2017-09-23 21:57:54 +00:00
final Angel app ;
final FileSystem fileSystem ;
2017-02-27 00:19:34 +00:00
/// Filenames to be resolved within directories as indices.
final Iterable < String > indexFileNames ;
/// An optional public path to map requests to.
2016-11-23 17:22:23 +00:00
final String publicPath ;
2017-11-18 06:12:59 +00:00
/// If `true` (default: `false`), then if a directory does not contain any of the specific [indexFileNames], a default directory listing will be served.
2021-07-04 03:22:19 +00:00
final bool allowDirectoryListing ;
2017-11-18 06:12:59 +00:00
2018-07-09 17:38:22 +00:00
/// If `true` (default: `true`), then files will be opened as streams and piped into the request.
///
/// If not, the response buffer will be used instead.
2018-08-28 14:58:28 +00:00
final bool useBuffer ;
2018-07-09 17:38:22 +00:00
2017-09-23 21:57:54 +00:00
VirtualDirectory ( this . app , this . fileSystem ,
2021-05-01 03:53:04 +00:00
{ Directory ? source ,
2019-05-02 23:29:09 +00:00
this . indexFileNames = const [ ' index.html ' ] ,
this . publicPath = ' / ' ,
2017-11-18 06:12:59 +00:00
this . callback ,
2019-05-02 23:29:09 +00:00
this . allowDirectoryListing = false ,
this . useBuffer = false } ) {
2016-11-23 20:14:05 +00:00
_prefix = publicPath . replaceAll ( _straySlashes , ' ' ) ;
2016-11-23 17:22:23 +00:00
if ( source ! = null ) {
_source = source ;
} else {
2021-02-16 02:10:09 +00:00
var dirPath = app . environment . isProduction ? ' ./build/web ' : ' ./web ' ;
2017-09-23 21:57:54 +00:00
_source = fileSystem . directory ( dirPath ) ;
2017-06-16 02:05:06 +00:00
}
}
2017-09-23 21:57:54 +00:00
/// Responds to incoming HTTP requests.
Future < bool > handleRequest ( RequestContext req , ResponseContext res ) {
2019-06-06 14:33:40 +00:00
if ( req . method ! = ' GET ' & & req . method ! = ' HEAD ' ) {
2019-05-02 23:29:09 +00:00
return Future < bool > . value ( true ) ;
2019-06-06 14:33:40 +00:00
}
2021-05-01 03:53:04 +00:00
var path = req . uri ! . path . replaceAll ( _straySlashes , ' ' ) ;
2017-06-16 02:05:06 +00:00
2021-07-04 03:22:19 +00:00
if ( _prefix . isNotEmpty = = true & & ! path . startsWith ( _prefix ) ) {
2019-05-02 23:29:09 +00:00
return Future < bool > . value ( true ) ;
2019-06-06 14:33:40 +00:00
}
2017-06-16 02:05:06 +00:00
2017-09-23 21:57:54 +00:00
return servePath ( path , req , res ) ;
2017-06-16 02:05:06 +00:00
}
2017-09-23 21:57:54 +00:00
/// A handler that serves the file at the given path, unless the user has requested that path.
2017-11-18 06:12:59 +00:00
///
/// You can also limit this functionality to specific values of the `Accept` header, ex. `text/html`.
/// If [accepts] is `null`, OR at least one of the content types in [accepts] is present,
/// the view will be served.
2021-05-01 03:53:04 +00:00
RequestHandler pushState ( String path , { Iterable ? accepts } ) {
2017-09-23 21:57:54 +00:00
var vPath = path . replaceAll ( _straySlashes , ' ' ) ;
2021-07-04 03:22:19 +00:00
if ( _prefix . isNotEmpty = = true ) vPath = ' $ _prefix / $ vPath ' ;
2017-06-16 02:05:06 +00:00
2017-09-23 21:57:54 +00:00
return ( RequestContext req , ResponseContext res ) {
var path = req . path . replaceAll ( _straySlashes , ' ' ) ;
2019-05-02 23:29:09 +00:00
if ( path = = vPath ) return Future < bool > . value ( true ) ;
2017-11-18 06:12:59 +00:00
if ( accepts ? . isNotEmpty = = true ) {
2021-05-01 03:53:04 +00:00
if ( ! accepts ! . any ( ( x ) = > req . accepts ( x , strict: true ) ) ) {
2019-05-02 23:29:09 +00:00
return Future < bool > . value ( true ) ;
2019-06-06 14:33:40 +00:00
}
2017-11-18 06:12:59 +00:00
}
2017-09-23 21:57:54 +00:00
return servePath ( vPath , req , res ) ;
} ;
2017-02-22 23:43:27 +00:00
}
2017-09-23 21:57:54 +00:00
/// Writes the file at the given virtual [path] to a response.
Future < bool > servePath (
String path , RequestContext req , ResponseContext res ) async {
2021-07-04 03:22:19 +00:00
if ( _prefix . isNotEmpty ) {
2017-06-20 19:57:03 +00:00
// Only replace the *first* incidence
// Resolve: https://github.com/angel-dart/angel/issues/41
2022-08-17 03:25:45 +00:00
path = path . replaceFirst ( RegExp ( ' ^ ${ _pathify ( _prefix ) } ' ) , ' ' ) ;
2017-02-22 23:43:27 +00:00
}
if ( path . isEmpty ) path = ' . ' ;
2017-06-20 19:57:03 +00:00
path = path . replaceAll ( _straySlashes , ' ' ) ;
2017-02-22 23:43:27 +00:00
2021-07-04 03:22:19 +00:00
var absolute = source . absolute . uri . resolve ( path ) . toFilePath ( ) ;
var parent = source . absolute . uri . toFilePath ( ) ;
2018-11-13 21:25:17 +00:00
2019-06-06 14:33:40 +00:00
if ( ! p . isWithin ( parent , absolute ) & & ! p . equals ( parent , absolute ) ) {
2018-11-13 21:25:17 +00:00
return true ;
2019-06-06 14:33:40 +00:00
}
2018-11-13 21:25:17 +00:00
2021-07-04 05:59:50 +00:00
// Update to the correct file separator based on file system
2021-07-04 03:22:19 +00:00
if ( absolute . contains ( ' \\ ' ) & & fileSystem . path . separator = = ' / ' ) {
absolute = absolute . replaceAll ( ' \\ ' , ' / ' ) ;
2021-07-04 05:59:50 +00:00
_log . warning (
' Incompatible file system type is used. Changed file separator from " \\ " to "/". ' ) ;
} else if ( absolute . contains ( ' / ' ) & & fileSystem . path . separator = = ' \\ ' ) {
absolute = absolute . replaceAll ( ' / ' , ' \\ ' ) ;
_log . warning (
' Incompatible file system type. Changed file separator from "/" to " \\ ". ' ) ;
2021-07-04 03:22:19 +00:00
}
2017-09-23 21:57:54 +00:00
var stat = await fileSystem . stat ( absolute ) ;
2017-11-18 06:12:59 +00:00
return await serveStat ( absolute , path , stat , req , res ) ;
2017-02-22 23:43:27 +00:00
}
2017-09-23 21:57:54 +00:00
/// Writes the file at the path given by the [stat] to a response.
2018-07-09 17:38:22 +00:00
Future < bool > serveStat ( String absolute , String relative , FileStat stat ,
RequestContext req , ResponseContext res ) async {
2019-06-06 14:33:40 +00:00
if ( stat . type = = FileSystemEntityType . directory ) {
2017-09-23 21:57:54 +00:00
return await serveDirectory (
2017-11-18 06:12:59 +00:00
fileSystem . directory ( absolute ) , relative , stat , req , res ) ;
2019-06-06 14:33:40 +00:00
} else if ( stat . type = = FileSystemEntityType . file ) {
2017-09-23 21:57:54 +00:00
return await serveFile ( fileSystem . file ( absolute ) , stat , req , res ) ;
2019-06-06 14:33:40 +00:00
} else if ( stat . type = = FileSystemEntityType . link ) {
2017-09-23 21:57:54 +00:00
var link = fileSystem . link ( absolute ) ;
2017-02-22 23:43:27 +00:00
return await servePath ( await link . resolveSymbolicLinks ( ) , req , res ) ;
2019-06-06 14:33:40 +00:00
} else {
2017-02-22 23:43:27 +00:00
return true ;
2019-06-06 14:33:40 +00:00
}
2017-02-22 23:43:27 +00:00
}
2016-11-23 20:14:05 +00:00
2017-09-23 21:57:54 +00:00
/// Serves the index file of a [directory], if it exists.
2018-07-09 17:38:22 +00:00
Future < bool > serveDirectory ( Directory directory , String relative ,
FileStat stat , RequestContext req , ResponseContext res ) async {
2021-02-16 02:10:09 +00:00
for ( var indexFileName in indexFileNames ) {
2017-06-16 02:05:06 +00:00
final index =
2017-09-23 21:57:54 +00:00
fileSystem . file ( directory . absolute . uri . resolve ( indexFileName ) ) ;
2017-06-16 02:05:06 +00:00
if ( await index . exists ( ) ) {
return await serveFile ( index , stat , req , res ) ;
}
}
2017-11-18 06:12:59 +00:00
if ( allowDirectoryListing = = true ) {
2019-05-02 23:29:09 +00:00
res . contentType = MediaType ( ' text ' , ' html ' ) ;
2017-11-18 06:12:59 +00:00
res
. . write ( ' <!DOCTYPE html> ' )
. . write ( ' <html> ' )
. . write (
' <head><meta name="viewport" content="width=device-width,initial-scale=1"> ' )
. . write ( ' <style>ul { list-style-type: none; }</style> ' )
2018-11-13 23:37:31 +00:00
. . write ( ' </head><body> ' ) ;
2017-11-18 06:12:59 +00:00
res . write ( ' <li><a href="..">..</a></li> ' ) ;
2021-02-16 02:10:09 +00:00
var entities = await directory
2017-11-18 06:12:59 +00:00
. list ( followLinks: false )
. toList ( )
2019-05-02 23:29:09 +00:00
. then ( ( l ) = > List . from ( l ) ) ;
2017-11-18 06:12:59 +00:00
entities . sort ( ( a , b ) {
if ( a is Directory ) {
2021-02-16 02:10:09 +00:00
if ( b is Directory ) {
return a . path . compareTo ( b . path ) ;
}
2017-11-18 06:12:59 +00:00
return - 1 ;
} else if ( a is File ) {
2019-06-06 14:33:40 +00:00
if ( b is Directory ) {
2017-11-18 06:12:59 +00:00
return 1 ;
2021-02-16 02:10:09 +00:00
} else if ( b is File ) {
return a . path . compareTo ( b . path ) ;
}
return - 1 ;
} else if ( a is Link ) {
if ( b is Directory ) {
return 1 ;
} else if ( b is Link ) {
return a . path . compareTo ( b . path ) ;
}
2017-11-18 06:12:59 +00:00
return - 1 ;
2021-02-16 02:10:09 +00:00
}
2017-11-18 06:12:59 +00:00
return 1 ;
} ) ;
for ( var entity in entities ) {
2021-07-04 03:22:19 +00:00
String stub ;
String type ;
2017-11-18 06:12:59 +00:00
2019-06-06 14:33:40 +00:00
if ( entity is File ) {
2017-11-18 06:12:59 +00:00
type = ' [File] ' ;
2021-02-16 02:10:09 +00:00
stub = p . basename ( entity . path ) ;
2019-06-06 14:33:40 +00:00
} else if ( entity is Directory ) {
2017-11-18 06:12:59 +00:00
type = ' [Directory] ' ;
2021-02-16 02:10:09 +00:00
stub = p . basename ( entity . path ) ;
} else if ( entity is Link ) {
type = ' [Link] ' ;
stub = p . basename ( entity . path ) ;
} else {
//TODO: Handle unknown type
2021-07-04 03:22:19 +00:00
_log . severe ( ' Unknown file entity. Not a file, directory or link. ' ) ;
type = ' [] ' ;
stub = ' ' ;
2021-02-16 02:10:09 +00:00
}
var href = stub ;
2017-11-18 06:12:59 +00:00
2021-05-01 03:53:04 +00:00
if ( relative . isNotEmpty ) {
2022-08-17 03:25:45 +00:00
href = ' / $ relative / $ stub ' ;
2021-05-01 03:53:04 +00:00
}
2017-11-18 06:12:59 +00:00
2021-05-01 03:53:04 +00:00
if ( entity is Directory ) {
2021-07-04 03:22:19 +00:00
if ( href = = ' ' ) {
2021-05-01 03:53:04 +00:00
href = ' / ' ;
} else {
href + = ' / ' ;
}
}
2021-07-04 03:22:19 +00:00
href = Uri . encodeFull ( href ) ;
2017-11-18 06:12:59 +00:00
res . write ( ' <li><a href=" $ href "> $ type $ stub </a></li> ' ) ;
}
2021-05-01 02:48:36 +00:00
res . write ( ' </body></html> ' ) ;
2017-11-18 06:12:59 +00:00
return false ;
}
2017-06-16 02:05:06 +00:00
return true ;
}
void _ensureContentTypeAllowed ( String mimeType , RequestContext req ) {
2021-07-04 03:22:19 +00:00
var value = req . headers ? . value ( ' accept ' ) ;
2021-02-16 02:10:09 +00:00
var acceptable = value = = null | |
2021-05-01 03:53:04 +00:00
value . isNotEmpty ! = true | |
( mimeType . isNotEmpty = = true & & value . contains ( mimeType ) = = true ) | |
value . contains ( ' */* ' ) = = true ;
2019-06-06 14:33:40 +00:00
if ( ! acceptable ) {
2021-07-04 03:22:19 +00:00
_log . severe ( ' Mime type [ $ value ] is not supported ' ) ;
2019-05-02 23:29:09 +00:00
throw AngelHttpException (
2022-02-22 00:07:01 +00:00
//UnsupportedError(
// 'Client requested $value, but server wanted to send $mimeType.'),
errors: [
' Client requested $ value , but server wanted to send $ mimeType . '
] , statusCode: 406 , message: ' 406 Not Acceptable ' ) ;
2019-06-06 14:33:40 +00:00
}
2017-06-16 02:05:06 +00:00
}
2017-09-23 21:57:54 +00:00
/// Writes the contents of a file to a response.
2017-06-16 02:05:06 +00:00
Future < bool > serveFile (
File file , FileStat stat , RequestContext req , ResponseContext res ) async {
2019-01-27 22:14:54 +00:00
res . headers [ ' accept-ranges ' ] = ' bytes ' ;
2017-06-16 02:05:06 +00:00
if ( callback ! = null ) {
2021-07-04 03:22:19 +00:00
return await req . app ? . executeHandler (
( RequestContext req , ResponseContext res ) = >
callback ! ( file , req , res ) ,
req ,
res ) ? ?
true ;
2017-06-16 02:05:06 +00:00
}
2018-11-13 21:25:17 +00:00
var type =
app . mimeTypeResolver . lookup ( file . path ) ? ? ' application/octet-stream ' ;
2018-11-14 05:43:47 +00:00
res . headers [ ' accept-ranges ' ] = ' bytes ' ;
2017-06-16 02:05:06 +00:00
_ensureContentTypeAllowed ( type , req ) ;
2018-11-14 05:43:47 +00:00
res . headers [ ' accept-ranges ' ] = ' bytes ' ;
2019-05-02 23:29:09 +00:00
res . contentType = MediaType . parse ( type ) ;
2018-11-13 21:25:17 +00:00
if ( useBuffer = = true ) res . useBuffer ( ) ;
2018-11-14 05:43:47 +00:00
2021-07-04 03:22:19 +00:00
if ( req . headers = = null ) {
_log . severe ( ' Missing headers in the RequestContext ' ) ;
throw ArgumentError ( ' Missing headers in the RequestContext ' ) ;
}
var reqHeaders = req . headers ! ;
if ( reqHeaders . value ( ' range ' ) ? . startsWith ( ' bytes= ' ) ! = true ) {
2018-11-14 05:43:47 +00:00
await res . streamFile ( file ) ;
} else {
2021-07-04 03:22:19 +00:00
var header = RangeHeader . parse ( reqHeaders . value ( ' range ' ) ! ) ;
2018-11-14 05:43:47 +00:00
var items = RangeHeader . foldItems ( header . items ) ;
2019-05-02 23:29:09 +00:00
header = RangeHeader ( items ) ;
2018-11-14 05:43:47 +00:00
2021-07-04 03:22:19 +00:00
var totalFileSize = await file . length ( ) ;
2018-11-14 05:43:47 +00:00
for ( var item in header . items ) {
2021-02-16 02:10:09 +00:00
var invalid = false ;
2018-11-14 05:43:47 +00:00
if ( item . start ! = - 1 ) {
invalid = item . end ! = - 1 & & item . end < item . start ;
2019-06-06 14:33:40 +00:00
} else {
2018-11-14 05:43:47 +00:00
invalid = item . end = = - 1 ;
2019-06-06 14:33:40 +00:00
}
2018-11-14 05:43:47 +00:00
if ( invalid ) {
2019-05-02 23:29:09 +00:00
throw AngelHttpException (
2022-02-22 00:07:01 +00:00
//Exception('Semantically invalid, or unbounded range.'),
errors: [ ' Semantically invalid, or unbounded range. ' ] ,
2018-11-14 05:43:47 +00:00
statusCode: 416 ,
2021-02-16 02:10:09 +00:00
message: ' Semantically invalid, or unbounded range. ' ) ;
2018-11-14 05:43:47 +00:00
}
// Ensure it's within range.
if ( item . start > = totalFileSize | | item . end > = totalFileSize ) {
2019-05-02 23:29:09 +00:00
throw AngelHttpException (
2022-02-22 00:07:01 +00:00
//Exception('Given range $item is out of bounds.'),
errors: [ ' Given range $ item is out of bounds. ' ] ,
2018-11-14 05:43:47 +00:00
statusCode: 416 ,
2021-02-16 02:10:09 +00:00
message: ' Given range $ item is out of bounds. ' ) ;
2018-11-14 05:43:47 +00:00
}
}
if ( header . items . isEmpty ) {
2022-02-22 00:07:01 +00:00
throw AngelHttpException (
2018-11-14 05:43:47 +00:00
statusCode: 416 , message: ' `Range` header may not be empty. ' ) ;
} else if ( header . items . length = = 1 ) {
var item = header . items [ 0 ] ;
2021-02-16 02:10:09 +00:00
Stream < List < int > > stream ;
var len = 0 ;
var total = totalFileSize ;
2018-11-14 05:43:47 +00:00
if ( item . start = = - 1 ) {
if ( item . end = = - 1 ) {
len = total ;
stream = file . openRead ( ) ;
} else {
len = item . end + 1 ;
stream = file . openRead ( 0 , item . end + 1 ) ;
}
} else {
if ( item . end = = - 1 ) {
len = total - item . start ;
stream = file . openRead ( item . start ) ;
} else {
len = item . end - item . start + 1 ;
stream = file . openRead ( item . start , item . end + 1 ) ;
}
}
2019-05-02 23:29:09 +00:00
res . contentType = MediaType . parse (
2018-11-14 05:43:47 +00:00
app . mimeTypeResolver . lookup ( file . path ) ? ?
' application/octet-stream ' ) ;
res . statusCode = 206 ;
res . headers [ ' content-length ' ] = len . toString ( ) ;
2022-08-17 03:25:45 +00:00
res . headers [ ' content-range ' ] = ' bytes ${ item . toContentRange ( total ) } ' ;
2019-06-25 19:59:00 +00:00
await stream . cast < List < int > > ( ) . pipe ( res ) ;
2018-11-14 05:43:47 +00:00
return false ;
} else {
2019-05-02 23:29:09 +00:00
var transformer = RangeHeaderTransformer (
2018-11-14 05:43:47 +00:00
header ,
app . mimeTypeResolver . lookup ( file . path ) ? ?
' application/octet-stream ' ,
await file . length ( ) ) ;
res . statusCode = 206 ;
res . headers [ ' content-length ' ] =
transformer . computeContentLength ( totalFileSize ) . toString ( ) ;
2019-05-02 23:29:09 +00:00
res . contentType = MediaType (
2018-11-14 05:43:47 +00:00
' multipart ' , ' byteranges ' , { ' boundary ' : transformer . boundary } ) ;
2019-06-25 19:59:00 +00:00
await file
. openRead ( )
. cast < List < int > > ( )
. transform ( transformer )
. pipe ( res ) ;
2018-11-14 05:43:47 +00:00
return false ;
}
}
2017-06-16 02:05:06 +00:00
return false ;
}
2016-11-23 17:22:23 +00:00
}