-- FIXME: UTF-8 filenames. module Text.XHtml.FCKeditor.FileBrowser ( SSIConfig(..), SSIException(..) , handleFileBrowser ) where import Control.Exception ( Exception(DynException) ) import Control.Monad ( foldM, unless, when ) import qualified Data.ByteString.Lazy as BS import Data.Char ( ord, toLower ) import Data.Dynamic ( Typeable, toDyn ) import Data.List ( foldl', sortBy ) import System.Directory ( getDirectoryContents, createDirectory, doesDirectoryExist, doesFileExist ) import System.IO ( IOMode(ReadMode, WriteMode), hClose, hFileSize, openFile ) import System.IO.Error import Network.CGI ( MonadCGI, MonadIO, getInput, getInputFilename, getInputFPS, liftIO, logCGI, throwCGI ) import Text.XML.HaXml.Combinators ( CFilter, mkElem, mkElemAttr, literal, cdata ) import Text.XML.HaXml.Escape ( xmlEscape, stdXmlEscaper ) import Text.XML.HaXml.Types ( Element, Content(..) ) import Text.XML.HaXml.Verbatim ( verbatim ) ---------------------------------------- splitBy :: (a -> Bool) -> [a] -> [[a]] splitBy _ [] = [[]] splitBy f list = case break f list of (first,[]) -> [first] (first,_:rest) -> first : splitBy f rest -- | Render XML as a string. FIXME terrible string->word8 hack. showXML :: CFilter -> (String, BS.ByteString) showXML xml = ("text/xml; charset=utf-8", BS.pack (map (fromInteger . toInteger . ord) (verbatim (cfilterToElem xml)))) cfilterToElem :: CFilter -> Element cfilterToElem f = case f (CString False "") of [CElem e] -> xmlEscape stdXmlEscaper e [] -> error "FIXME RSS produced no output" _ -> error "FIXME RSS produced more than one output" -- | Verify the path is not dodgy: -- -- * If it contains \'..\', then ensure it isn't talking about a path -- * higher than the root. verifyPath :: FilePath -> Bool verifyPath p = foldl' countParent 0 (splitBy (== '/') p) >= 0 where countParent acc ".." = acc - (1 :: Int) countParent acc _ = acc + 1 ---------------------------------------- data SSIConfig = SSIConfig { ssiPathPrefix :: FilePath -- ^ The prefix of the file area on the filesystem. , ssiURLPrefix :: FilePath -- ^ The URL prefix from which these files can be reached. , ssiCanUpload :: Bool -- ^ Enable the \'Upload\' command. } data SSIConfigI = SSIConfigI { ssiConfig :: SSIConfig , ssiCurrentFolder :: FilePath , ssiType :: String } -- | Assemble the current directory on the filesystem from the user -- configuration and the request. Verify that the request path is not -- suspect, but trust the configuration path. assembleDir :: MonadCGI m => SSIConfigI -> m FilePath assembleDir config = do unless (verifyPath (ssiCurrentFolder config)) $ error "FIXME exception" return $ ssiPathPrefix (ssiConfig config) ++ "/" ++ ssiCurrentFolder config ++ "/" -- FIXME '/' -- | As most errors are unrecoverable, 'handleFileBrowser' throws -- fairly opaque exceptions using @CGI.throwCGI@. newtype SSIException = SSIException String deriving ( Typeable ) -- | Responds to requests from FCKeditor's file browser. Returns the -- @content-type@ and a @ByteString@-encoded XML document that should -- be sent back to the webserver. Throws exceptions using -- @CGI.throwCGI@. The caller needs to take care of the HTTP -- @cache-control@ header. handleFileBrowser :: (MonadCGI m, MonadIO m) => SSIConfig -> m (String, BS.ByteString) handleFileBrowser config = do fbc <- getInput "Command" fbt <- getInput "Type" fbcf <- getInput "CurrentFolder" case (fbc, fbt, fbcf) of (Just fbCommand, Just fbType, Just fbCurrentFolder) -> do let configI = SSIConfigI { ssiConfig = config , ssiCurrentFolder = fbCurrentFolder , ssiType = fbType } case fbCommand of "GetFolders" -> getFolders configI "GetFoldersAndFiles" -> getFoldersAndFiles configI "CreateFolder" -> createFolder configI "FileUpload" | ssiCanUpload config -> fileUpload configI _ -> throwCGI $ DynException $ toDyn $ SSIException "handleFileBrowser: unknown command." _ -> throwCGI $ DynException $ toDyn $ SSIException "handleFileBrowser: missing parameters." getFolders :: (MonadCGI m, MonadIO m) => SSIConfigI -> m (String, BS.ByteString) getFolders config = do (folders, _files) <- splitIntoFoldersAndFiles config return (showXML (xmlFileList config "GetFolders" (folders, []))) getFoldersAndFiles :: (MonadCGI m, MonadIO m) => SSIConfigI -> m (String, BS.ByteString) getFoldersAndFiles config = splitIntoFoldersAndFiles config >>= return . showXML . xmlFileList config "GetFoldersAndFiles" splitIntoFoldersAndFiles :: (MonadCGI m, MonadIO m) => SSIConfigI -> m ([FilePath], [(FilePath, Integer)]) splitIntoFoldersAndFiles config = do dir <- assembleDir config liftIO $ getDirectoryContents dir >>= return . sortBy reverseCaseInsensitive >>= foldM (split dir) ([], []) where reverseCaseInsensitive s s' = case compare (map toLower s) (map toLower s') of LT -> GT EQ -> EQ GT -> LT split dir ff@(folders, files) f = if f == "." || f == ".." -- FIXME skip special dirs then return ff else do let dirf = dir ++ f b <- doesDirectoryExist dirf if b then return (f:folders, files) else do h <- openFile dirf ReadMode size <- hFileSize h hClose h return (folders, (f, size):files) -- FIXME perhaps abstract the path assembling here. xmlFileList :: SSIConfigI -> String -> ([FilePath], [(FilePath, Integer)]) -> CFilter xmlFileList config fbCommand (folders, files) = mkElemAttr "Connector" [ ("command", literal fbCommand) , ("resourceType", literal (ssiType config)) ] ([ mkElemAttr "CurrentFolder" [ ("path", literal (ssiCurrentFolder config)) , ("url", literal (ssiURLPrefix (ssiConfig config) ++ ssiCurrentFolder config)) ] [] ] ++ foldersElem ++ filesElem) where foldersChildren = [ mkElemAttr "Folder" [("name", literal f)] [] | f <- folders ] foldersElem = case foldersChildren of [] -> [] _ -> [mkElem "Folders" foldersChildren] -- NOTE: FCKeditor wants size in kb filesChildren = [ mkElemAttr "File" [("name", literal f), ("size", literal (show (size `div` 1024)))] [] | (f, size) <- files ] filesElem = case filesChildren of [] -> [] _ -> [mkElem "Files" filesChildren] ---------------------------------------- createFolder :: (MonadIO m, MonadCGI m) => SSIConfigI -> m (String, BS.ByteString) createFolder config = do when (ssiType config == "File") $ fail "FIXME createFolder expected Type == File" Just fbNewFolderName <- getInput "NewFolderName" -- FIXME dirf <- assembleDir config >>= return . (++ fbNewFolderName) liftIO (try (createDirectory dirf)) >>= createFolder_error >>= return . showXML . xmlCreateFolderResponse config createFolder_error :: (MonadIO m, MonadCGI m) => Either IOError () -> m Int createFolder_error (Right ()) = return 0 -- createFolder_noError createFolder_error (Left e) = do logCGI $ "FCKeditor.FileBrowser.createFolder: " ++ show e let errorCode | isAlreadyExistsError e = 101 -- | isInvalidFolderName e = 102 -- FIXME | isPermissionError e = 103 | otherwise = 110 return errorCode -- FIXME perhaps abstract the path assembling here. xmlCreateFolderResponse :: SSIConfigI -> Int -> CFilter xmlCreateFolderResponse config errorCode = mkElemAttr "Connector" [ ("command", literal "CreateFolder") , ("resourceType", literal "File") ] [ mkElemAttr "CurrentFolder" [ ("path", literal (ssiPathPrefix (ssiConfig config) ++ ssiCurrentFolder config)) , ("url", literal (ssiURLPrefix (ssiConfig config) ++ ssiCurrentFolder config)) ] [] , mkElemAttr "Error" [ ("number", literal (show errorCode)) ] [] ] ---------------------------------------- -- | FIXME Argh, this is racey. Also if they upload text we're a bit -- screwed. Probably should check the input encoding. -- Check the filetype being uploaded (extension, MIME type) against a list -- of allowed types. Limit maximum upload size. Use a temp file + move? fileUpload :: (MonadIO m, MonadCGI m) => SSIConfigI -> m (String, BS.ByteString) fileUpload config = do mFileName <- getInputFilename "NewFile" case mFileName of Nothing -> throwCGI $ DynException $ toDyn "FIXME fileUpload don't know the filename" Just filename -> do let fbits = splitBy (== '.') filename base = concat (init fbits) ext = last fbits saveFile config base ext 0 >>= fileUpload_error config >>= return . htmlFileUpload saveFile :: (MonadIO m, MonadCGI m) => SSIConfigI -> String -> String -> Int -> m (Either IOError (String, Bool)) saveFile config base ext x = do let fname = base ++ (if x == 0 then "" else "(" ++ show x ++ ")") ++ (if null ext then "" else '.':ext) dirf <- assembleDir config >>= (return . (++ fname)) b <- liftIO (doesFileExist dirf) if b then saveFile config base ext (x + 1) else do mInput <- getInputFPS "NewFile" case mInput of Nothing -> throwCGI $ DynException $ toDyn "FIXME fileUpload no input called NewFile" Just input -> liftIO $ try $ do h <- openFile dirf WriteMode BS.hPut h input hClose h return ( ssiCurrentFolder config ++ "/" ++ fname , x /= 0) -- FIXME is this path right? fileUpload_error :: (MonadIO m, MonadCGI m) => SSIConfigI -> Either IOError (String, Bool) -> m (Int, String, FilePath, String) fileUpload_error config (Right (fname, changed)) = return ( if changed then 201 else 0 , ssiURLPrefix (ssiConfig config) ++ fname , fname , "") fileUpload_error _config (Left e) = do logCGI $ "FCKeditor.FileBrowser.fileUpload: " ++ show e return (1, "", "", show e) -- FIXME -- FIXME terrible string->bs hack. -- FIXME what's this URL parameter for? -- FIXME get the error handling right htmlFileUpload :: (Int, String, FilePath, String) -> (String, BS.ByteString) htmlFileUpload (errorCode, url, fname, reason) = ("text/html; charset=utf-8", BS.pack (map (fromInteger . toInteger . ord) (verbatim (cfilterToElem xml)))) where xml = mkElemAttr "script" [ ("type", literal "text/javascript") ] [ cdata $ "window.parent.OnUploadCompleted( " ++ args ++ " ) ;" ] args = show errorCode ++ ",'" ++ url ++ "','" ++ fname ++ "','" ++ reason ++ "'" -- args | errorCode == 0 = "0" -- no errors found on the upload process. -- | errorCode == 1 = "1,,,'" ++ reason ++ "'" -- the upload filed because of "Reason". -- | errorCode == 201 = "201,, '" ++ fname ++ "'" -- the file has been uploaded successfully, but its name has been changed to "fname". -- | otherwise = "202" -- invalid file. {- # upload Content-Type list my %UPLOAD_CONTENT_TYPE_LIST = ( 'image/(x-)?png' => 'png', # PNG image 'image/p?jpe?g' => 'jpg', # JPEG image 'image/gif' => 'gif', # GIF image 'image/x-xbitmap' => 'xbm', # XBM image 'image/(x-(MS-)?)?bmp' => 'bmp', # Windows BMP image 'image/pict' => 'pict', # Macintosh PICT image 'image/tiff' => 'tif', # TIFF image 'application/pdf' => 'pdf', # PDF image 'application/x-shockwave-flash' => 'swf', # Shockwave Flash 'video/(x-)?msvideo' => 'avi', # Microsoft Video 'video/quicktime' => 'mov', # QuickTime Video 'video/mpeg' => 'mpeg', # MPEG Video 'video/x-mpeg2' => 'mpv2', # MPEG2 Video 'audio/(x-)?midi?' => 'mid', # MIDI Audio 'audio/(x-)?wav' => 'wav', # WAV Audio 'audio/basic' => 'au', # ULAW Audio 'audio/mpeg' => 'mpga', # MPEG Audio 'application/(x-)?zip(-compressed)?' => 'zip', # ZIP Compress 'text/html' => 'html', # HTML 'text/plain' => 'txt', # TEXT '(?:application|text)/(?:rtf|richtext)' => 'rtf', # RichText 'application/msword' => 'doc', # Microsoft Word 'application/vnd.ms-excel' => 'xls', # Microsoft Excel '' ); # Upload is permitted. # A regular expression is possible. my %UPLOAD_EXT_LIST = ( 'png' => 'PNG image', 'p?jpe?g|jpe|jfif|pjp' => 'JPEG image', 'gif' => 'GIF image', 'xbm' => 'XBM image', 'bmp|dib|rle' => 'Windows BMP image', 'pi?ct' => 'Macintosh PICT image', 'tiff?' => 'TIFF image', 'pdf' => 'PDF image', 'swf' => 'Shockwave Flash', 'avi' => 'Microsoft Video', 'moo?v|qt' => 'QuickTime Video', 'm(p(e?gv?|e|v)|1v)' => 'MPEG Video', 'mp(v2|2v)' => 'MPEG2 Video', 'midi?|kar|smf|rmi|mff' => 'MIDI Audio', 'wav' => 'WAVE Audio', 'au|snd' => 'ULAW Audio', 'mp(e?ga|2|a|3)|abs' => 'MPEG Audio', 'zip' => 'ZIP Compress', 'lzh' => 'LZH Compress', 'cab' => 'CAB Compress', 'd?html?' => 'HTML', 'rtf|rtx' => 'RichText', 'txt|text' => 'Text', '' ); -}