module Snap.Internal.Util.FileUploads
(
handleFormUploads
, foldMultipart
, PartFold
, FormParam
, FormFile (..)
, storeAsLazyByteString
, withTemporaryStore
, handleFileUploads
, handleMultipart
, PartProcessor
, PartInfo(..)
, PartDisposition(..)
, toPartDisposition
, UploadPolicy(..)
, defaultUploadPolicy
, doProcessFormInputs
, setProcessFormInputs
, getMaximumFormInputSize
, setMaximumFormInputSize
, getMaximumNumberOfFormInputs
, setMaximumNumberOfFormInputs
, getMinimumUploadRate
, setMinimumUploadRate
, getMinimumUploadSeconds
, setMinimumUploadSeconds
, getUploadTimeout
, setUploadTimeout
, FileUploadPolicy(..)
, defaultFileUploadPolicy
, setMaximumFileSize
, setMaximumNumberOfFiles
, setSkipFilesWithoutNames
, setMaximumSkippedFileSize
, PartUploadPolicy(..)
, disallow
, allowWithMaximumSize
, FileUploadException(..)
, fileUploadExceptionReason
, BadPartException(..)
, PolicyViolationException(..)
) where
import Control.Applicative (Alternative ((<|>)), Applicative (pure, (*>), (<*)))
import Control.Arrow (Arrow (first))
import Control.Exception.Lifted (Exception, SomeException (..), bracket, catch, finally, fromException, mask, throwIO, toException)
import qualified Control.Exception.Lifted as E (try)
import Control.Monad (Functor (fmap), Monad (return, (>>=)), MonadPlus (mzero), forM_, guard, liftM, sequence, unless, void, when, (>=>))
import Control.Monad.IO.Class (liftIO)
import Data.Attoparsec.ByteString.Char8 (Parser, isEndOfLine, string, takeWhile)
import qualified Data.Attoparsec.ByteString.Char8 as Atto (try)
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as S
import Data.ByteString.Internal (c2w)
import qualified Data.ByteString.Lazy.Internal as LB (ByteString (Empty), chunk)
import qualified Data.CaseInsensitive as CI (mk)
import Data.Int (Int, Int64)
import qualified Data.IORef as IORef
import Data.List (find, map, (++))
import qualified Data.Map as Map (insertWith')
import Data.Maybe (Maybe (..), fromMaybe, isJust, maybe)
import Data.Text (Text)
import qualified Data.Text as T (concat, pack, unpack)
import qualified Data.Text.Encoding as TE (decodeUtf8)
import Data.Typeable (Typeable, cast)
import Prelude (Bool (..), Double, Either (..), Eq (..), FilePath, IO, Ord (..), Show (..), String, const, either, foldr, fst, id, max, not, otherwise, seq, snd, succ, ($), ($!), (.), (^), (||))
import Snap.Core (HasHeaders (headers), Headers, MonadSnap, Request (rqParams, rqPostParams), getHeader, getRequest, getTimeoutModifier, putRequest, runRequestBody)
import Snap.Internal.Parsing (crlf, fullyParse, pContentTypeWithParameters, pHeaders, pValueWithParameters')
import qualified Snap.Types.Headers as H (fromList)
import System.Directory (removeFile)
import System.FilePath ((</>))
import System.IO (BufferMode (NoBuffering), Handle, hClose, hSetBuffering, openBinaryTempFile)
import System.IO.Error (isDoesNotExistError)
import System.IO.Streams (InputStream, MatchInfo (..), TooManyBytesReadException, search)
import qualified System.IO.Streams as Streams
import System.IO.Streams.Attoparsec (parseFromStream)
import System.PosixCompat.Temp (mkstemp)
handleFileUploads ::
(MonadSnap m) =>
FilePath
-> UploadPolicy
-> (PartInfo -> PartUploadPolicy)
-> (PartInfo -> Either PolicyViolationException FilePath -> IO a)
-> m [a]
handleFileUploads tmpdir uploadPolicy partPolicy partHandler =
handleMultipart uploadPolicy go
where
go partInfo stream = maybe disallowed takeIt mbFs
where
ctText = partContentType partInfo
fnText = fromMaybe "" $ partFileName partInfo
ct = TE.decodeUtf8 ctText
fn = TE.decodeUtf8 fnText
(PartUploadPolicy mbFs) = partPolicy partInfo
takeIt maxSize = do
str' <- Streams.throwIfProducesMoreThan maxSize stream
fileReader tmpdir partHandler partInfo str' `catch` tooMany maxSize
tooMany maxSize (_ :: TooManyBytesReadException) =
partHandler partInfo
(Left $
PolicyViolationException $
T.concat [ "File \""
, fn
, "\" exceeded maximum allowable size "
, T.pack $ show maxSize ])
disallowed =
partHandler partInfo
(Left $
PolicyViolationException $
T.concat [ "Policy disallowed upload of file \""
, fn
, "\" with content-type \""
, ct
, "\"" ] )
data FormFile a = FormFile
{ formFileName :: !ByteString
, formFileValue :: a
} deriving (Eq, Ord, Show)
data UploadState a = UploadState
{ numUploadedFiles :: !Int
, uploadedFiles :: !([FormFile a] -> [FormFile a])
}
handleFormUploads ::
(MonadSnap m) =>
UploadPolicy
-> FileUploadPolicy
-> (PartInfo -> InputStream ByteString -> IO a)
-> m ([FormParam], [FormFile a])
handleFormUploads uploadPolicy filePolicy partHandler = do
(params, !st) <- foldMultipart uploadPolicy go (UploadState 0 id)
return (params, uploadedFiles st [])
where
go !partInfo stream !st = do
when (numUploads >= maxFiles) throwTooManyFiles
case partFileName partInfo of
Nothing -> onEmptyName
Just _ -> takeIt
where
numUploads = numUploadedFiles st
files = uploadedFiles st
maxFiles = maxNumberOfFiles filePolicy
maxFileSize = maxFileUploadSize filePolicy
fnText = fromMaybe "" $ partFileName partInfo
fn = TE.decodeUtf8 fnText
takeIt = do
str' <- Streams.throwIfProducesMoreThan maxFileSize stream
r <- partHandler partInfo str' `catch` tooMany maxFileSize
let f = FormFile (partFieldName partInfo) r
return $! UploadState (succ numUploads) (files . ([f] ++) )
skipIt maxSize = do
str' <- Streams.throwIfProducesMoreThan maxSize stream
!_ <- Streams.skipToEof str' `catch` tooMany maxSize
return $! UploadState (succ numUploads) files
onEmptyName = if skipEmptyFileName filePolicy
then skipIt (maxEmptyFileNameSize filePolicy)
else takeIt
throwTooManyFiles = throwIO . PolicyViolationException $ T.concat
["number of files exceeded the maximum of "
,T.pack (show maxFiles) ]
tooMany maxSize (_ :: TooManyBytesReadException) =
throwIO . PolicyViolationException $
T.concat [ "File \""
, fn
, "\" exceeded maximum allowable size "
, T.pack $ show maxSize ]
type PartFold a = PartInfo -> InputStream ByteString -> a -> IO a
foldMultipart ::
(MonadSnap m) =>
UploadPolicy
-> PartFold a
-> a
-> m ([FormParam], a)
foldMultipart uploadPolicy origPartHandler zero = do
hdrs <- liftM headers getRequest
let (ct, mbBoundary) = getContentType hdrs
tickleTimeout <- liftM (. max) getTimeoutModifier
let bumpTimeout = tickleTimeout $ uploadTimeout uploadPolicy
let partHandler = if doProcessFormInputs uploadPolicy
then captureVariableOrReadFile
(getMaximumFormInputSize uploadPolicy)
origPartHandler
else \x y acc -> liftM File $ origPartHandler x y acc
guard (ct == "multipart/form-data")
boundary <- maybe (throwIO $ BadPartException
"got multipart/form-data without boundary")
return
mbBoundary
runRequestBody (proc bumpTimeout boundary partHandler)
where
uploadRate = minimumUploadRate uploadPolicy
uploadSecs = minimumUploadSeconds uploadPolicy
maxFormVars = maximumNumberOfFormInputs uploadPolicy
proc bumpTimeout boundary partHandler =
Streams.throwIfTooSlow bumpTimeout uploadRate uploadSecs >=>
internalFoldMultipart maxFormVars boundary partHandler zero
type PartProcessor a = PartInfo -> InputStream ByteString -> IO a
handleMultipart ::
(MonadSnap m) =>
UploadPolicy
-> PartProcessor a
-> m [a]
handleMultipart uploadPolicy origPartHandler = do
(captures, files) <- foldMultipart uploadPolicy partFold id
procCaptures captures
return $! files []
where
partFold info input acc = do
x <- origPartHandler info input
return $ acc . ([x]++)
procCaptures [] = pure ()
procCaptures params = do
rq <- getRequest
putRequest $ modifyParams (\m -> foldr ins m params) rq
ins (!k, !v) = Map.insertWith' (\_ ex -> (v:ex)) k [v]
modifyParams f r = r { rqPostParams = f $ rqPostParams r
, rqParams = f $ rqParams r
}
data PartDisposition =
DispositionAttachment
| DispositionFile
| DispositionFormData
| DispositionOther ByteString
deriving (Eq, Show)
data PartInfo =
PartInfo
{ partFieldName :: !ByteString
, partFileName :: !(Maybe ByteString)
, partContentType :: !ByteString
, partDisposition :: !PartDisposition
, partHeaders :: !Headers
}
deriving (Show)
toPartDisposition :: ByteString -> PartDisposition
toPartDisposition s | s == "attachment" = DispositionAttachment
| s == "file" = DispositionFile
| s == "form-data" = DispositionFormData
| otherwise = DispositionOther s
data FileUploadException = forall e . (ExceptionWithReason e, Show e) =>
WrappedFileUploadException e
deriving (Typeable)
class Exception e => ExceptionWithReason e where
exceptionReason :: e -> Text
instance Show FileUploadException where
show (WrappedFileUploadException e) = show e
instance Exception FileUploadException
fileUploadExceptionReason :: FileUploadException -> Text
fileUploadExceptionReason (WrappedFileUploadException e) = exceptionReason e
uploadExceptionToException :: ExceptionWithReason e => e -> SomeException
uploadExceptionToException = toException . WrappedFileUploadException
uploadExceptionFromException :: ExceptionWithReason e => SomeException -> Maybe e
uploadExceptionFromException x = do
WrappedFileUploadException e <- fromException x
cast e
data BadPartException = BadPartException {
badPartExceptionReason :: Text
}
deriving (Typeable)
instance Exception BadPartException where
toException = uploadExceptionToException
fromException = uploadExceptionFromException
instance ExceptionWithReason BadPartException where
exceptionReason (BadPartException e) = T.concat ["Bad part: ", e]
instance Show BadPartException where
show = T.unpack . exceptionReason
data PolicyViolationException = PolicyViolationException {
policyViolationExceptionReason :: Text
} deriving (Typeable)
instance Exception PolicyViolationException where
toException e@(PolicyViolationException _) =
uploadExceptionToException e
fromException = uploadExceptionFromException
instance ExceptionWithReason PolicyViolationException where
exceptionReason (PolicyViolationException r) =
T.concat ["File upload policy violation: ", r]
instance Show PolicyViolationException where
show (PolicyViolationException s) = "File upload policy violation: "
++ T.unpack s
data UploadPolicy = UploadPolicy {
processFormInputs :: Bool
, maximumFormInputSize :: Int64
, maximumNumberOfFormInputs :: Int
, minimumUploadRate :: Double
, minimumUploadSeconds :: Int
, uploadTimeout :: Int
}
defaultUploadPolicy :: UploadPolicy
defaultUploadPolicy = UploadPolicy True maxSize maxNum minRate minSeconds tout
where
maxSize = 2^(17::Int)
maxNum = 10
minRate = 1000
minSeconds = 10
tout = 20
doProcessFormInputs :: UploadPolicy -> Bool
doProcessFormInputs = processFormInputs
setProcessFormInputs :: Bool -> UploadPolicy -> UploadPolicy
setProcessFormInputs b u = u { processFormInputs = b }
getMaximumFormInputSize :: UploadPolicy -> Int64
getMaximumFormInputSize = maximumFormInputSize
setMaximumFormInputSize :: Int64 -> UploadPolicy -> UploadPolicy
setMaximumFormInputSize s u = u { maximumFormInputSize = s }
getMaximumNumberOfFormInputs :: UploadPolicy -> Int
getMaximumNumberOfFormInputs = maximumNumberOfFormInputs
setMaximumNumberOfFormInputs :: Int -> UploadPolicy -> UploadPolicy
setMaximumNumberOfFormInputs s u = u { maximumNumberOfFormInputs = s }
getMinimumUploadRate :: UploadPolicy -> Double
getMinimumUploadRate = minimumUploadRate
setMinimumUploadRate :: Double -> UploadPolicy -> UploadPolicy
setMinimumUploadRate s u = u { minimumUploadRate = s }
getMinimumUploadSeconds :: UploadPolicy -> Int
getMinimumUploadSeconds = minimumUploadSeconds
setMinimumUploadSeconds :: Int -> UploadPolicy -> UploadPolicy
setMinimumUploadSeconds s u = u { minimumUploadSeconds = s }
getUploadTimeout :: UploadPolicy -> Int
getUploadTimeout = uploadTimeout
setUploadTimeout :: Int -> UploadPolicy -> UploadPolicy
setUploadTimeout s u = u { uploadTimeout = s }
data FileUploadPolicy = FileUploadPolicy
{ maxFileUploadSize :: !Int64
, maxNumberOfFiles :: !Int
, skipEmptyFileName :: !Bool
, maxEmptyFileNameSize :: !Int64
}
defaultFileUploadPolicy :: FileUploadPolicy
defaultFileUploadPolicy = FileUploadPolicy maxFileSize maxFiles
skipEmptyName maxEmptySize
where
maxFileSize = 1048576
maxFiles = 10
skipEmptyName = True
maxEmptySize = 0
setMaximumFileSize :: Int64 -> FileUploadPolicy -> FileUploadPolicy
setMaximumFileSize maxSize s =
s { maxFileUploadSize = maxSize }
setMaximumNumberOfFiles :: Int -> FileUploadPolicy -> FileUploadPolicy
setMaximumNumberOfFiles maxFiles s =
s { maxNumberOfFiles = maxFiles }
setSkipFilesWithoutNames :: Bool -> FileUploadPolicy -> FileUploadPolicy
setSkipFilesWithoutNames shouldSkip s =
s { skipEmptyFileName = shouldSkip }
setMaximumSkippedFileSize :: Int64 -> FileUploadPolicy -> FileUploadPolicy
setMaximumSkippedFileSize maxSize s =
s { maxEmptyFileNameSize = maxSize }
data PartUploadPolicy = PartUploadPolicy (Maybe Int64)
disallow :: PartUploadPolicy
disallow = PartUploadPolicy Nothing
allowWithMaximumSize :: Int64 -> PartUploadPolicy
allowWithMaximumSize = PartUploadPolicy . Just
storeAsLazyByteString :: InputStream ByteString -> IO LB.ByteString
storeAsLazyByteString !str = do
f <- Streams.fold (\f c -> f . LB.chunk c) id str
return $! f LB.Empty
withTemporaryStore ::
MonadSnap m
=> FilePath
-> String
-> ((InputStream ByteString -> IO FilePath) -> m a)
-> m a
withTemporaryStore tempdir pat act = do
ioref <- liftIO $ IORef.newIORef []
let
modifyIORef' ref f = do
x <- IORef.readIORef ref
let x' = f x
x' `seq` IORef.writeIORef ref x'
go input = do
(fn, h) <- openBinaryTempFile tempdir pat
modifyIORef' ioref (fn:)
hSetBuffering h NoBuffering
output <- Streams.handleToOutputStream h
Streams.connect input output
hClose h
pure fn
cleanup = liftIO $ do
files <- IORef.readIORef ioref
forM_ files $ \fn ->
removeFile fn `catch` handleExists
handleExists e = unless (isDoesNotExistError e) $ throwIO e
act go `finally` cleanup
captureVariableOrReadFile ::
Int64
-> PartFold a
-> PartInfo -> InputStream ByteString
-> a
-> IO (Capture a)
captureVariableOrReadFile maxSize fileHandler partInfo stream acc =
if isFile
then liftM File $ fileHandler partInfo stream acc
else variable `catch` handler
where
isFile = isJust (partFileName partInfo) ||
partDisposition partInfo == DispositionFile
variable = do
!x <- liftM S.concat $
Streams.throwIfProducesMoreThan maxSize stream >>= Streams.toList
return $! Capture fieldName x
fieldName = partFieldName partInfo
handler (_ :: TooManyBytesReadException) =
throwIO $ PolicyViolationException $
T.concat [ "form input '"
, TE.decodeUtf8 fieldName
, "' exceeded maximum permissible size ("
, T.pack $ show maxSize
, " bytes)" ]
data Capture a = Capture !ByteString !ByteString
| File a
fileReader :: FilePath
-> (PartInfo -> Either PolicyViolationException FilePath -> IO a)
-> PartProcessor a
fileReader tmpdir partProc partInfo input =
withTempFile tmpdir "snap-upload-" $ \(fn, h) -> do
hSetBuffering h NoBuffering
output <- Streams.handleToOutputStream h
Streams.connect input output
hClose h
partProc partInfo $ Right fn
data MultipartState a = MultipartState
{ numFormVars :: !Int
, numFormFiles :: !Int
, capturedFields :: !([FormParam] -> [FormParam])
, accumulator :: !a
}
type FormParam = (ByteString, ByteString)
addCapture :: ByteString -> ByteString -> MultipartState a -> MultipartState a
addCapture !k !v !ms =
let !kv = (k,v)
f = capturedFields ms . ([kv]++)
!ms' = ms { capturedFields = f
, numFormVars = succ (numFormVars ms) }
in ms'
internalFoldMultipart ::
Int
-> ByteString
-> (PartInfo -> InputStream ByteString -> a -> IO (Capture a))
-> a
-> InputStream ByteString
-> IO ([FormParam], a)
internalFoldMultipart !maxFormVars !boundary clientHandler !zeroAcc !stream = go
where
initialState = MultipartState 0 0 id zeroAcc
go = do
_ <- parseFromStream (parseFirstBoundary boundary) stream
bmstream <- search (fullBoundary boundary) stream
ms <- foldParts goPart bmstream initialState
return $ (capturedFields ms [], accumulator ms)
pBoundary !b = Atto.try $ do
_ <- string "--"
string b
fullBoundary !b = S.concat ["\r\n", "--", b]
pLine = takeWhile (not . isEndOfLine . c2w) <* eol
parseFirstBoundary !b = pBoundary b <|> (pLine *> parseFirstBoundary b)
takeHeaders !str = hdrs `catch` handler
where
hdrs = do
str' <- Streams.throwIfProducesMoreThan mAX_HDRS_SIZE str
liftM toHeaders $ parseFromStream pHeadersWithSeparator str'
handler (_ :: TooManyBytesReadException) =
throwIO $ BadPartException "headers exceeded maximum size"
goPart !str !state = do
hdrs <- takeHeaders str
let (contentType, mboundary) = getContentType hdrs
let (fieldName, fileName, disposition) = getFieldHeaderInfo hdrs
if contentType == "multipart/mixed"
then maybe (throwIO $ BadPartException $
"got multipart/mixed without boundary")
(processMixed fieldName str state)
mboundary
else do
let info = PartInfo fieldName fileName contentType disposition hdrs
handlePart info str state
handlePart !info !str !ms = do
r <- clientHandler info str (accumulator ms)
case r of
Capture !k !v -> do
when (maxFormVars <= numFormVars ms) throwTooMuchVars
return $! addCapture k v ms
File !newAcc -> return $! ms { accumulator = newAcc
, numFormFiles = succ (numFormFiles ms)
}
throwTooMuchVars =
throwIO . PolicyViolationException
$ T.concat [ "number of form inputs exceeded maximum of "
, T.pack $ show maxFormVars ]
processMixed !fieldName !str !state !mixedBoundary = do
_ <- parseFromStream (parseFirstBoundary mixedBoundary) str
bm <- search (fullBoundary mixedBoundary) str
foldParts (mixedStream fieldName) bm state
mixedStream !fieldName !str !acc = do
hdrs <- takeHeaders str
let (contentType, _) = getContentType hdrs
let (_, fileName, disposition) = getFieldHeaderInfo hdrs
let info = PartInfo fieldName fileName contentType disposition hdrs
handlePart info str acc
getContentType :: Headers
-> (ByteString, Maybe ByteString)
getContentType hdrs = (contentType, boundary)
where
contentTypeValue = fromMaybe "text/plain" $
getHeader "content-type" hdrs
eCT = fullyParse contentTypeValue pContentTypeWithParameters
(!contentType, !params) = either (const ("text/plain", [])) id eCT
boundary = findParam "boundary" params
getFieldHeaderInfo :: Headers -> (ByteString, Maybe ByteString, PartDisposition)
getFieldHeaderInfo hdrs = (fieldName, fileName, disposition)
where
contentDispositionValue = fromMaybe "unknown" $
getHeader "content-disposition" hdrs
eDisposition = fullyParse contentDispositionValue $ pValueWithParameters' (const True)
(!dispositionType, dispositionParameters) =
either (const ("unknown", [])) id eDisposition
disposition = toPartDisposition dispositionType
fieldName = fromMaybe "" $ findParam "name" dispositionParameters
fileName = findParam "filename" dispositionParameters
findParam :: (Eq a) => a -> [(a, b)] -> Maybe b
findParam p = fmap snd . find ((== p) . fst)
partStream :: InputStream MatchInfo -> IO (InputStream ByteString)
partStream st = Streams.makeInputStream go
where
go = do
s <- Streams.read st
return $! s >>= f
f (NoMatch s) = return s
f _ = mzero
foldParts :: (InputStream ByteString -> MultipartState a -> IO (MultipartState a))
-> InputStream MatchInfo
-> (MultipartState a)
-> IO (MultipartState a)
foldParts partFunc stream = go
where
part acc pStream = do
isLast <- parseFromStream pBoundaryEnd pStream
if isLast
then return Nothing
else do
!x <- partFunc pStream acc
Streams.skipToEof pStream
return $! Just x
go !acc = do
cap <- partStream stream >>= part acc
maybe (return acc) go cap
pBoundaryEnd = (eol *> pure False) <|> (string "--" *> pure True)
eol :: Parser ByteString
eol = (string "\n") <|> (string "\r\n")
pHeadersWithSeparator :: Parser [(ByteString,ByteString)]
pHeadersWithSeparator = pHeaders <* crlf
toHeaders :: [(ByteString,ByteString)] -> Headers
toHeaders kvps = H.fromList kvps'
where
kvps' = map (first CI.mk) kvps
mAX_HDRS_SIZE :: Int64
mAX_HDRS_SIZE = 32768
withTempFile :: FilePath
-> String
-> ((FilePath, Handle) -> IO a)
-> IO a
withTempFile tmpl temp handler =
mask $ \restore -> bracket make cleanup (restore . handler)
where
make = mkstemp $ tmpl </> (temp ++ "XXXXXXX")
cleanup (fp,h) = sequence $ map gobble [hClose h, removeFile fp]
t :: IO z -> IO (Either SomeException z)
t = E.try
gobble = void . t