sprinkles 0.3.5.0 → 0.6.0.0
raw patch · 56 files changed
+2877/−587 lines, 56 filesdep +SHAdep +asyncdep +base64-bytestringdep −classy-preludedep −memcached-binarydep −pandoc-creoledep ~Cabaldep ~Globdep ~HDBC
Dependencies added: SHA, async, base64-bytestring, bcrypt, css-syntax, extra, file-embed, lens, memcache, nonce, random, split, stm, tagsoup, unix-time, yeshql-hdbc
Dependencies removed: classy-prelude, memcached-binary, pandoc-creole
Dependency ranges changed: Cabal, Glob, HDBC, HDBC-mysql, HDBC-postgresql, HDBC-sqlite3, HTTP, aeson, aeson-pretty, array, bytestring, case-insensitive, cereal, containers, curl, data-default, directory, filepath, ginger, hashable, heredoc, hsyslog, http-types, mime-types, mtl, network-uri, pandoc, pandoc-types, parsec, process, random-shuffle, regex-base, regex-pcre, safe, scientific, system-locale, template-haskell, text, time, transformers, unix-compat, unordered-containers, utf8-string, vector, wai, wai-extra, wai-handler-fastcgi, warp, yaml
Files
- app/Main.hs +53/−21
- embedded/.htaccess +2/−0
- sprinkles.cabal +85/−58
- src/Data/AList.hs +1/−1
- src/Data/EmbedVersion.hs +3/−3
- src/Data/Expandable.hs +48/−0
- src/Data/RandomString.hs +21/−0
- src/Data/Walk.hs +0/−35
- src/Text/Pandoc/Readers/CustomCreole.hs +284/−0
- src/Web/Sprinkles.hs +3/−1
- src/Web/Sprinkles/Backends.hs +16/−12
- src/Web/Sprinkles/Backends/Data.hs +87/−13
- src/Web/Sprinkles/Backends/Loader.hs +4/−2
- src/Web/Sprinkles/Backends/Loader/FileLoader.hs +5/−3
- src/Web/Sprinkles/Backends/Loader/HttpLoader.hs +6/−4
- src/Web/Sprinkles/Backends/Loader/LiteralLoader.hs +3/−2
- src/Web/Sprinkles/Backends/Loader/RequestBodyLoader.hs +47/−4
- src/Web/Sprinkles/Backends/Loader/SqlLoader.hs +27/−14
- src/Web/Sprinkles/Backends/Loader/SubprocessLoader.hs +3/−2
- src/Web/Sprinkles/Backends/Loader/Type.hs +22/−13
- src/Web/Sprinkles/Backends/Parsers.hs +49/−41
- src/Web/Sprinkles/Backends/Spec.hs +122/−41
- src/Web/Sprinkles/Bake.hs +202/−0
- src/Web/Sprinkles/Cache.hs +4/−1
- src/Web/Sprinkles/Cache/Filesystem.hs +5/−6
- src/Web/Sprinkles/Cache/Memcached.hs +15/−8
- src/Web/Sprinkles/Cache/Memory.hs +1/−1
- src/Web/Sprinkles/Databases.hs +25/−7
- src/Web/Sprinkles/Exceptions.hs +12/−5
- src/Web/Sprinkles/Handlers.hs +1/−1
- src/Web/Sprinkles/Handlers/Common.hs +112/−21
- src/Web/Sprinkles/Handlers/JSON.hs +3/−2
- src/Web/Sprinkles/Handlers/Redirect.hs +3/−2
- src/Web/Sprinkles/Handlers/Respond.hs +67/−169
- src/Web/Sprinkles/Handlers/Static.hs +3/−2
- src/Web/Sprinkles/Handlers/Template.hs +5/−2
- src/Web/Sprinkles/Logger.hs +10/−7
- src/Web/Sprinkles/MatchedText.hs +1/−1
- src/Web/Sprinkles/Pandoc.hs +15/−0
- src/Web/Sprinkles/PandocGVal.hs +25/−14
- src/Web/Sprinkles/Pattern.hs +2/−2
- src/Web/Sprinkles/Prelude.hs +486/−0
- src/Web/Sprinkles/Project.hs +28/−11
- src/Web/Sprinkles/ProjectConfig.hs +13/−4
- src/Web/Sprinkles/Replacement.hs +19/−14
- src/Web/Sprinkles/Rule.hs +44/−11
- src/Web/Sprinkles/Serve.hs +48/−16
- src/Web/Sprinkles/ServerConfig.hs +86/−4
- src/Web/Sprinkles/SessionHandle.hs +66/−0
- src/Web/Sprinkles/SessionStore.hs +60/−0
- src/Web/Sprinkles/SessionStore/Database.hs +153/−0
- src/Web/Sprinkles/SessionStore/InProc.hs +81/−0
- src/Web/Sprinkles/Sessions.hs +95/−0
- src/Web/Sprinkles/TemplateContext.hs +289/−0
- test/Web/Sprinkles/ApplicationTest.hs +4/−3
- test/Web/Sprinkles/PatternTest.hs +3/−3
@@ -2,23 +2,29 @@ {-#LANGUAGE OverloadedStrings #-} {-#LANGUAGE TemplateHaskell #-} {-#LANGUAGE ScopedTypeVariables #-}+{-#LANGUAGE LambdaCase #-} -- | CLI program that drives a Sprinkles instance. module Main where -import ClassyPrelude hiding ( (<|>), try )+import Web.Sprinkles.Prelude hiding ( (<|>), try ) import Web.Sprinkles+import Web.Sprinkles.Project (Project) import Web.Sprinkles.Exceptions import Text.Read (read, readMaybe) import Data.Default (def) import Text.Parsec import Data.EmbedVersion+import qualified Data.Text as Text+import Control.Concurrent.Async data CliOptions = ServeProject ServerConfig |+ BakeProject FilePath ServerConfig [FilePath] | DumpVersion+ deriving (Show) -parseArgs :: [Text] -> IO CliOptions+parseArgs :: [Text] -> IO [CliOptions] parseArgs argv = do let result = runParser argsP () "command line arguments" argv either@@ -33,8 +39,8 @@ | Required Text (Text -> Maybe a) | Bare (Text -> Maybe a) -argsP :: ArgsP CliOptions-argsP = versionP <|> serveArgsP+argsP :: ArgsP [CliOptions]+argsP = some (versionP <|> bakeArgsP <|> serveArgsP) <* eof versionP :: ArgsP CliOptions versionP =@@ -44,9 +50,18 @@ serveArgsP :: ArgsP CliOptions serveArgsP = do- pipeline <- Text.Parsec.many $ choice (map (Text.Parsec.try . argP) argSpecs)+ try $ tExactly "-serve"+ pipeline <- Text.Parsec.many $ choice (map (Text.Parsec.try . serveArgP) serveArgSpecs) return . ServeProject $ foldr ($) def pipeline +bakeArgsP :: ArgsP CliOptions+bakeArgsP = do+ try $ tExactly "-bake"+ -- dirname <- try $ serveArgP (Optional "o" $ Just . Text.unpack . fromMaybe "./baked")+ let dirname = "./baked"+ extraEntryPoints <- fmap Text.unpack <$> Text.Parsec.many bareArgP+ return $ BakeProject dirname def extraEntryPoints+ tSatisfy :: (Show t, Stream s m t) => (t -> Bool) -> ParsecT s u m t tSatisfy cond = do actual <- anyToken@@ -63,29 +78,32 @@ isNotFlag :: Text -> Bool isNotFlag = not . isFlag -argP :: ArgSpec a -> ArgsP a-argP (Flag str x) = do+serveArgP :: ArgSpec a -> ArgsP a+serveArgP (Flag str x) = do tExactly ("-" <> str) return x-argP (Optional str f) = do+serveArgP (Optional str f) = do tExactly ("-" <> str)- paramMay <- optionMaybe (tSatisfy isNotFlag)+ paramMay <- optionMaybe bareArgP maybe (fail "invalid parameter") return (f paramMay)-argP (Required str f) = do+serveArgP (Required str f) = do tExactly ("-" <> str)- param <- tSatisfy isNotFlag+ param <- bareArgP maybe (fail "invalid parameter") return (f param)-argP (Bare f) =- (f <$> tSatisfy isNotFlag) >>= maybe (fail "invalid bare argument") return+serveArgP (Bare f) =+ (f <$> bareArgP) >>= maybe (fail "invalid bare argument") return -argSpecs :: [ArgSpec (ServerConfig -> ServerConfig)]-argSpecs =+bareArgP :: ArgsP Text+bareArgP = tSatisfy isNotFlag++serveArgSpecs :: [ArgSpec (ServerConfig -> ServerConfig)]+serveArgSpecs = [ Optional "warp" (maybe@@ -94,6 +112,9 @@ port <- readMaybe . unpack $ str return $ \config -> config { scDriver = WarpDriver (Just port) }) )+ , Required+ "dir"+ (\str -> Just (\config -> config { scRootDir = unpack str })) , Bare (\str -> do port <- readMaybe . unpack $ str return $ \config -> config { scDriver = WarpDriver (Just port) })@@ -102,21 +123,32 @@ , Flag "fcgi" (\config -> config { scDriver = FastCGIDriver }) ] +sprinklesVersion :: Text sprinklesVersion = $(embedPackageVersionStr "sprinkles.cabal") main :: IO () main = runMain `catch` handleUncaughtExceptions +prepareProject :: ServerConfig -> IO (ServerConfig, Project)+prepareProject sconfigA = do+ sconfigF <- loadServerConfig $ scRootDir sconfigA+ let sconfig = sconfigF `mappend` sconfigA++ project <- loadProject sconfig+ return (sconfig, project)+ runMain :: IO () runMain = do args <- getArgs opts <- parseArgs args- case opts of+ forConcurrently_ opts $ \opt -> do+ print opt+ case opt of ServeProject sconfigA -> do- sconfigF <- loadServerConfig "."- let sconfig = sconfigF `mappend` sconfigA-- project <- loadProject sconfig "."- serveProject sconfig project+ prepareProject sconfigA >>= \(sconfig, project) ->+ serveProject sconfig project+ BakeProject path sconfigA extraEntryPoints -> do+ prepareProject sconfigA >>= \(sconfig, project) ->+ bakeProject path project extraEntryPoints DumpVersion -> do putStrLn sprinklesVersion
@@ -0,0 +1,2 @@+ErrorDocument 404 /_errors/404.html+AddCharset UTF-8 .html
@@ -1,8 +1,8 @@ name: sprinkles-version: 0.3.5.0+version: 0.6.0.0 synopsis: JSON API to HTML website wrapper-description: Please see README.md-homepage: https://bitbucket.org/tdammers/sprinkles+description: Please see README.md. More text to please stack. More text to please stack. More text to please stack. More text to please stack. More text to please stack. More text to please stack. More text to please stack. More text to please stack. More text to please stack. More text to please stack.+homepage: https://sprinkles.tobiasdammers.nl/ license: BSD3 license-file: LICENSE author: Author name here@@ -10,12 +10,13 @@ copyright: 2016 Tobias Dammers category: Web build-type: Simple--- extra-source-files:+extra-source-files: embedded/.htaccess cabal-version: >=1.10 library hs-source-dirs: src exposed-modules: Web.Sprinkles+ , Web.Sprinkles.Prelude , Web.Sprinkles.Logger , Web.Sprinkles.Exceptions , Web.Sprinkles.Databases@@ -23,8 +24,15 @@ , Web.Sprinkles.Cache.Filesystem , Web.Sprinkles.Cache.Memory , Web.Sprinkles.Cache.Memcached+ , Web.Sprinkles.Pandoc+ , Web.Sprinkles.Sessions+ , Web.Sprinkles.SessionHandle+ , Web.Sprinkles.SessionStore+ , Web.Sprinkles.SessionStore.Database+ , Web.Sprinkles.SessionStore.InProc , Web.Sprinkles.Pattern , Web.Sprinkles.Replacement+ , Web.Sprinkles.TemplateContext , Web.Sprinkles.Backends , Web.Sprinkles.Backends.Loader , Web.Sprinkles.Backends.Spec@@ -51,63 +59,82 @@ , Web.Sprinkles.ServerConfig , Web.Sprinkles.Project , Web.Sprinkles.Serve+ , Web.Sprinkles.Bake , Control.MaybeEitherMonad , Data.EmbedVersion , Data.AList- , Data.Walk+ , Data.Expandable+ , Data.RandomString+ , Text.Pandoc.Readers.CustomCreole build-depends: base >= 4.7 && < 5- , Cabal- , Glob- , HDBC- , HDBC-postgresql- , HDBC-sqlite3- , HDBC-mysql- , HTTP- , aeson- , aeson-pretty- , array- , bytestring- , case-insensitive- , cereal- , classy-prelude- , containers- , curl- , data-default- , directory- , filepath- , ginger >=0.3.8.0 && <0.4- , hashable- , hsyslog- , http-types- , memcached-binary- , mime-types- , mtl- , network-uri- , pandoc- , pandoc-creole- , pandoc-types- , parsec- , process- , random-shuffle- , regex-base- , regex-pcre- , safe- , scientific- , system-locale- , template-haskell- , text- , time- , transformers- , unix-compat- , unordered-containers- , utf8-string- , vector- , wai- , wai-extra- , wai-handler-fastcgi- , warp- , yaml+ , Cabal >= 2.4.0 && <2.5+ , Glob >=0.9.3 && <0.10+ , HDBC >=2.4.0.2 && <2.5+ , HDBC-postgresql >=2.3.2.5 && <2.4+ , HDBC-sqlite3 >=2.3.3.1 && <2.4+ , HDBC-mysql >=0.7.1.0 && <0.8+ , HTTP >=4000.3.12 && <4000.4+ , SHA >=1.6.4.4 && <1.7+ , aeson >=1.3.1.1 && <1.4+ , aeson-pretty >=0.8.7 && <0.9+ , array >=0.5.0 && <0.6+ , base64-bytestring >=1.0.0.1 && <1.1+ , bcrypt >=0.0.11 && <0.1+ , bytestring >=0.10.8.2 && <0.11+ , case-insensitive >=1.2.0.11 && <1.3+ , cereal >=0.5.7.0 && <0.6+ , containers >=0.5.11.0 && <0.6+ , css-syntax >=0.0.8 && <0.1+ , curl >=1.3.8 && <1.4+ , data-default >=0.7.1.1 && <0.8+ , directory >=1.3.1.5 && <1.4+ , extra >=1.6.12 && <1.7+ , file-embed >=0.0.10.1 && <0.1+ , filepath >=1.4.2 && <1.5+ , ginger >=0.8.1.0 && <0.9+ , hashable >=1.2.7.0 && <1.3+ , heredoc >=0.2.0.0 && <0.3+ , hsyslog >=5.0.1 && <5.1+ , http-types >=0.12.2 && <0.13+ , lens >=4.17 && <4.18+ , memcache >=0.2.0.1 && <0.3+ , mime-types >=0.1.0.8 && <0.2+ , mtl >=2.2.2 && <2.3+ , network-uri >=2.6.1.0 && <2.7+ , nonce >=1.0.7 && <1.1+ , pandoc >=2.2.1 && <2.3+ , pandoc-types >=1.17.5.1 && <1.18+ , parsec >=3.1.13.0 && <3.2+ , process >=1.6.3.0 && <1.7+ , random >=1.1 && <1.2+ , random-shuffle >=0.0.4 && <0.1+ , regex-base >=0.93.2 && <0.94+ , regex-pcre >=0.94.4 && <0.95+ , safe >=0.3.17 && <0.4+ , scientific >=0.3.6.2 && <0.4+ , split >=0.2.3.3 && <0.3+ , stm >=2.4.5.1 && <2.5+ , system-locale >=0.2.0.0 && <0.3+ , tagsoup >=0.14.7 && <0.15+ , template-haskell >=2.2.0.0 && <2.14+ , text >=1.2.3.1 && <1.3+ , time >=1.8.0.2 && <1.9+ , transformers >=0.5.5.0 && <0.6+ , unix-compat >=0.5.1 && <0.6+ , unix-time >=0.3.8 && <0.4+ , unordered-containers >=0.2.9.0 && <0.3+ , unordered-containers >=0.2.9.0 && <0.3+ , utf8-string >=1.0.1.1 && <1.1+ , vector >=0.12.0.1 && <0.13+ , wai >=3.2.1.2 && <3.3+ , wai-extra >=3.0.24.3 && <3.1+ , wai-extra >=3.0.24.3 && <3.1+ , wai-handler-fastcgi >=3.0.0.2 && <3.1+ , warp >=3.2.25 && <3.3+ , yaml >=0.8.32 && <0.12+ , yeshql-hdbc >=4.1.0.1 && <4.2 default-language: Haskell2010+ ghc-options: -fwarn-incomplete-patterns executable sprinkles hs-source-dirs: app@@ -115,9 +142,10 @@ ghc-options: -threaded -rtsopts -with-rtsopts=-N build-depends: base , sprinkles- , classy-prelude+ , async , data-default , safe+ , text , parsec default-language: Haskell2010 @@ -129,7 +157,6 @@ , Web.Sprinkles.ApplicationTest build-depends: base , sprinkles- , classy-prelude , data-default , directory , filepath
@@ -12,7 +12,7 @@ ) where -import ClassyPrelude hiding (fromList, toList, singleton, empty)+import Web.Sprinkles.Prelude hiding (fromList, toList, singleton, empty) import Data.Aeson (ToJSON (..), FromJSON (..), Value (..)) import qualified Data.HashMap.Strict as HashMap import Data.Monoid
@@ -7,7 +7,7 @@ import Distribution.Verbosity import Distribution.Package import Distribution.PackageDescription-import Distribution.PackageDescription.Parse+import Distribution.PackageDescription.Parsec import Language.Haskell.TH import Language.Haskell.TH.Syntax import Data.List (intercalate, isPrefixOf)@@ -21,7 +21,7 @@ getPackageVersion :: FilePath -> IO Version getPackageVersion cabalFileName = do- gpd <- readPackageDescription silent cabalFileName+ gpd <- readGenericPackageDescription silent cabalFileName return . pkgVersion . package. packageDescription $ gpd getPackageVersionStr :: FilePath -> IO String@@ -60,7 +60,7 @@ formatPackageVersion :: Version -> String formatPackageVersion =- intercalate "." . map show . versionBranch+ intercalate "." . map show . versionNumbers embedPackageVersionStr :: FilePath -> Q Exp embedPackageVersionStr fp = do
@@ -0,0 +1,48 @@+{-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE FlexibleInstances #-}+{-#LANGUAGE RankNTypes #-}++module Data.Expandable+( ExpandableM (..)+, expand+)+where++import qualified Data.Aeson as JSON+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Vector (Vector)+import qualified Data.Vector as Vector+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HashMap+import Control.Monad.Identity++class ExpandableM t a where+ expandM :: forall m. Monad m => (t -> m t) -> a -> m a++expand :: ExpandableM t a => (t -> t) -> a -> a+expand f x = runIdentity $ expandM (return . f) x++instance ExpandableM t t where+ expandM f x = f x++instance ExpandableM t a => ExpandableM t (Maybe a) where+ expandM _ Nothing = return Nothing+ expandM f (Just x) = Just <$> expandM f x++instance ExpandableM t a => ExpandableM t [a] where+ expandM f xs = mapM (expandM f) xs++instance ExpandableM Text JSON.Value where+ expandM = jsonTextWalk++instance (ExpandableM t a, ExpandableM t b) => ExpandableM t (a, b) where+ expandM f (x, y) = (,) <$> expandM f x <*> expandM f y++jsonTextWalk :: Monad m => (Text -> m Text) -> (JSON.Value -> m JSON.Value)+jsonTextWalk f (JSON.String t) = JSON.String <$> f t+jsonTextWalk f (JSON.Array v) = JSON.Array <$> sequence (fmap (expandM f) v)+jsonTextWalk f (JSON.Object o) =+ JSON.Object . HashMap.fromList <$> (expandM f . HashMap.toList) o+jsonTextWalk _ x = return x+
@@ -0,0 +1,21 @@+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE ScopedTypeVariables #-}++module Data.RandomString+( randomStr+)+where++import Web.Sprinkles.Prelude+import System.Random++randomStr :: [Char] -> Int -> IO String+randomStr alphabet desiredLength = do+ let alphabetSize :: Int+ alphabetSize = Web.Sprinkles.Prelude.length alphabet+ when (alphabetSize < 1) $+ error "randomStr: empty list of allowed characters"+ items :: [String] <- forM [0..desiredLength] $ \_ -> do+ i :: Int <- randomRIO (0, pred alphabetSize)+ return $ take 1 . drop i $ alphabet+ return $ concat items
@@ -1,35 +0,0 @@-{-#LANGUAGE MultiParamTypeClasses #-}-{-#LANGUAGE FlexibleInstances #-}--module Data.Walk-( Walk (..)-)-where--import qualified Data.Aeson as JSON-import Data.Text (Text)-import qualified Data.Text as Text-import Data.Vector (Vector)-import qualified Data.Vector as Vector-import Data.HashMap.Strict (HashMap)-import qualified Data.HashMap.Strict as HashMap--class Walk c e where- walk :: (e -> e) -> (c -> c)--instance Walk JSON.Value Text where- walk = jsonTextWalk--jsonTextWalk :: (Text -> Text) -> (JSON.Value -> JSON.Value)-jsonTextWalk f (JSON.String t) = JSON.String (f t)-jsonTextWalk f (JSON.Array v) = JSON.Array (fmap (walk f) v)-jsonTextWalk f (JSON.Object m) =- JSON.Object . HashMap.fromList $- [ (f k, walk f v) | (k, v) <- HashMap.toList m ]-jsonTextWalk _ x = x--instance Walk [a] a where- walk = map--instance Walk (Maybe a) a where- walk = fmap
@@ -0,0 +1,284 @@+module Text.Pandoc.Readers.CustomCreole+( readCustomCreole+, onLeft+, onRight+)+where++import Text.Pandoc+import Text.Pandoc.Error+import Text.Parsec+import Data.List (intersperse, intercalate, lookup)+import Data.Maybe (fromMaybe)++onLeft :: (a -> b) -> Either a c -> Either b c+onLeft f (Left x) = Left (f x)+onLeft _ (Right r) = Right r++onRight :: (b -> c) -> Either a b -> Either a c+onRight f (Right x) = Right (f x)+onRight _ (Left r) = Left r++readCustomCreole :: ReaderOptions -> String -> Either PandocError Pandoc+readCustomCreole options input =+ onLeft (PandocParsecError input) $+ runParser wikipage () "creole source" input++wikipage = Pandoc nullMeta <$> (spaces *> manyTill paragraph eof)++paragraph = nowikiBlock+ <|> division nullAttr+ <|> horizontalLine+ <|> heading nullAttr+ <|> annotatedParagraph+ <|> emptyParagraph+ <|> unorderedList 1+ -- <|> orderedList+ <|> textParagraph++unorderedList level = BulletList <$> many1 (unorderedListItem level)++unorderedListItem level = do+ unorderedListBullet level+ unorderedListItemContent level++unorderedListBullet level = do+ try (lookAhead $ char '*')+ try (count level (char '*') *> whitespace1)++unorderedListItemContent :: Int -> Parsec String () [Block]+unorderedListItemContent level =+ (:) <$> textItem <*> many anyItem+ where+ textItem = Para . concat <$> many1 (unorderedListLine level)+ anyItem = unorderedList (succ level) <|> textItem++unorderedListLine level = do+ notFollowedBy $+ (ignore . try $ many1 (char '*') *> whitespace1) <|>+ (ignore . try $ whitespace *> string "----") <|>+ (ignore . try $ whitespace *> char '=') <|>+ (ignore . try $ whitespace *> string "]]]" *> notFollowedBy (char ']')) <|>+ (ignore eol) <|>+ (ignore eof)+ many1 (notFollowedBy eol *> textItem) <* (eol <|> eof)++annotatedParagraph = do+ attr <- try preAnnotation+ division attr <|> heading attr++annotation = do+ string "@("+ whitespace+ kvp <- sepBy attribute (char ',')+ let idVal = fromMaybe "" . lookup "id" $ kvp+ classes = words . fromMaybe "" . lookup "class" $ kvp+ kvp' = [ (k,v) | (k,v) <- kvp, k /= "id" && k /= "class" ]+ char ')'+ return (idVal, classes, kvp')+ where+ attribute = do+ k <- many (noneOf ")= \t\r\n")+ whitespace+ char '='+ whitespace+ v <- many (noneOf "),\r\n")+ whitespace+ return (k,v)++preAnnotation = annotation <* char ':' <* (eol <|> eof)++postAnnotation = optional eol *> annotation <* notFollowedBy (char ':')++heading attr = do+ leader <- many1 (char '=')+ whitespace+ inner <- manyTill textItem endOfHeading+ attr' <- option attr (try postAnnotation)+ return $ Header (length leader) attr' inner++endOfHeading = eof+ <|> try+ (do+ whitespace+ many (char '=')+ whitespace+ ignore eol <|> eof+ )++division attr = do+ try (string "[[[" *> (eol <|> eof))+ inner <- manyTill paragraph endOfDiv+ return $ Div attr inner++endOfDiv = eof+ <|> try+ (do+ whitespace+ string "]]]"+ notFollowedBy (char ']')+ whitespace+ ignore eol <|> eof+ )++nowikiBlock = do+ try (string "{{{" *> (eol <|> eof))+ lns <- many nowikiLine+ string "}}}" *> (eol <|> eof)+ return $ CodeBlock nullAttr (intercalate "\n" lns)++nowikiLine = nowikiEscapedEndMarker <|> nowikiRegularLine++nowikiEscapedEndMarker = try $ do+ oneOf " \t"+ many (noneOf "\r\n") <* (eol <|> eof)++nowikiRegularLine = do+ notFollowedBy . try $ string "}}}" *> (eol <|> eof)+ many (noneOf "\r\n") <* (eol <|> eof)+++emptyParagraph = try (endOfParagraph *> return Null)++horizontalLine = try (string "----") *> (eol <|> eof) *> return HorizontalRule++textParagraph = Para . concat . intersperse [Space] <$>+ many1 textLine <* endOfParagraph++textLine = do+ notFollowedBy $+ (ignore . try $ unorderedListBullet 1) <|>+ (ignore . try $ whitespace *> string "----") <|>+ (ignore . try $ whitespace *> char '=') <|>+ (ignore . try $ whitespace *> string "]]]" *> notFollowedBy (char ']')) <|>+ (ignore eol) <|>+ (ignore eof)+ many1 (notFollowedBy eol *> textItem) <* (eol <|> eof)++textItem =+ nowikiTextItem <|>+ newlineTextItem <|>+ link <|>+ image <|>+ boldTextItem <|>+ italTextItem <|>+ whitespaceTextItem <|>+ rawTextItem++link = do+ try $ string "[[" *> notFollowedBy (char '[')+ url <- many $ noneOf "|]"+ label <- option url $ do+ char '|'+ many $ noneOf "]"+ string "]]"+ attr <- option nullAttr (try postAnnotation)+ return $ Link attr [ Str label ] (url, label)++image = do+ try $ string "{{" *> notFollowedBy (char '{')+ url <- many $ noneOf "|}"+ label <- option url $ do+ char '|'+ many $ noneOf "}"+ string "}}"+ attr <- option nullAttr (try postAnnotation)+ return $ Image attr [ Str label ] (url, label)++newlineTextItem = do+ try $ whitespace *> string "\\\\"+ whitespace+ optional eol+ whitespace+ return LineBreak++boldTextItem = Strong <$>+ (+ try (string "**" *> notFollowedBy whitespace1) *>+ many1 boldItalTextItem <*+ (+ eof <|>+ (lookAhead . try $ eol *> endOfParagraph) <|>+ (ignore . try . string $ "**")+ )+ )++italTextItem = Emph <$>+ (+ try (string "//") *>+ many1 italBoldTextItem <*+ (+ eof <|>+ (lookAhead . try $ eol *> endOfParagraph) <|>+ (ignore . try . string $ "//")+ )+ )+++boldItalTextItem =+ nowikiTextItem <|>+ newlineTextItem <|>+ link <|>+ image <|>+ italTextItem <|>+ whitespaceTextItem <|>+ rawTextItem++italBoldTextItem =+ nowikiTextItem <|>+ newlineTextItem <|>+ link <|>+ image <|>+ boldTextItem <|>+ whitespaceTextItem <|>+ rawTextItem++rawTextItem = Str <$> many1 textChar++whitespaceTextItem = Space <$ many1 irrelevantWhitespace++irrelevantWhitespace =+ (ignore . many1) (oneOf " \t") <|> try (eol *> notFollowedBy eol)++nowikiTextItem =+ Code nullAttr <$>+ (inlineNowikiStart *> manyTill inlineNowikiItem inlineNowikiEnd)++inlineNowikiEnd = try (string "}}}" *> notFollowedBy (char '}'))++inlineNowikiStart = try (string "{{{")++inlineNowikiItem = anyChar++textChar = escapedChar <|> safeChar <|> allowedSpecialChar++escapedChar = char '~' *> anyChar++safeChar = noneOf " \n\r\t~*/[]\\{}@"++allowedSpecialChar =+ try (char '/' <* notFollowedBy (char '/')) <|>+ try (char '*' <* notFollowedBy (char '*')) <|>+ try (char '[' <* notFollowedBy (char '[')) <|>+ try (char '{' <* notFollowedBy (char '{')) <|>+ try (char '\\' <* notFollowedBy (char '\\')) <|>+ try (char ']' <* notFollowedBy (char ']')) <|>+ try (char '}' <* notFollowedBy (char '}')) <|>+ try (char '@' <* notFollowedBy (char '('))++eol = (try (string "\r\n") <|> string "\n") *> return ()++whitespaceChar = oneOf " \t"++whitespace1 = many1 whitespaceChar++whitespace = many whitespaceChar++ignore :: Parsec s u a -> Parsec s u ()+ignore = (*> return ())++endOfParagraph = (ignore . lookAhead . try $ whitespace *> string "----")+ <|> (ignore . lookAhead . try $ whitespace *> char '=')+ <|> (ignore . lookAhead . try $ whitespace *> string "]]]" *> notFollowedBy (char ']'))+ <|> ignore eof+ <|> ignore eol
@@ -5,11 +5,13 @@ , ServerConfig (..) , loadServerConfig , ServerDriver (..)+, bakeProject ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Project import Web.Sprinkles.ServerConfig import Web.Sprinkles.Serve+import Web.Sprinkles.Bake
@@ -10,7 +10,8 @@ module Web.Sprinkles.Backends ( -- * Defining backends- BackendSpec+ BackendSpec (..)+, makeBackendSpecPathsAbsolute , parseBackendURI -- * Fetching backend data , BackendData (..)@@ -23,7 +24,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import System.Random.Shuffle (shuffleM) import Web.Sprinkles.Cache import qualified Data.Serialize as Cereal@@ -33,6 +34,7 @@ import Web.Sprinkles.Backends.Spec ( BackendSpec (..)+ , makeBackendSpecPathsAbsolute , BackendType (..) , AscDesc (..) , FetchMode (..)@@ -57,7 +59,8 @@ , rawToLBS ) import Web.Sprinkles.Backends.Loader-import Web.Sprinkles.Backends.Loader.Type (PostBodySource)+import Web.Sprinkles.Backends.Loader.Type (RequestContext (..))+import Data.Expandable -- | Cache for raw backend data, stored as bytestrings. type RawBackendCache = Cache ByteString ByteString@@ -68,23 +71,24 @@ -- | Execute a backend query, with caching. loadBackendData :: Monad m => (LogLevel -> Text -> IO ())- -> PostBodySource+ -> RequestContext -> RawBackendCache -> BackendSpec- -> IO (Items (BackendData m h))-loadBackendData writeLog cache loadPost bspec =+ -> IO (Items (BackendData p m h))+loadBackendData writeLog context cache bspec = fmap (reduceItems (bsFetchMode bspec)) $- fetchBackendData writeLog cache loadPost bspec >>=+ fetchBackendData writeLog context cache' bspec >>= mapM parseBackendData >>= sorter where- sorter :: [BackendData m h] -> IO [BackendData m h]+ cache' = if bsCacheEnabled bspec then cache else mempty+ sorter :: [BackendData p m h] -> IO [BackendData p m h] sorter = fmap reverter . baseSorter reverter :: [a] -> [a] reverter = case fetchAscDesc (bsOrder bspec) of Ascending -> id Descending -> reverse- baseSorter :: [BackendData m h] -> IO [BackendData m h]+ baseSorter :: [BackendData p m h] -> IO [BackendData p m h] baseSorter = case fetchField (bsOrder bspec) of ArbitraryOrder -> return RandomOrder -> shuffleM@@ -102,7 +106,7 @@ (fmap Just . fmap (map deserializeBackendSource) . eitherFailS . Cereal.decode) -- | Fetch raw backend data from a backend source, with caching.-fetchBackendData :: (LogLevel -> Text -> IO ()) -> PostBodySource -> RawBackendCache -> BackendSpec -> IO [BackendSource]+fetchBackendData :: (LogLevel -> Text -> IO ()) -> RequestContext -> RawBackendCache -> BackendSpec -> IO [BackendSource] fetchBackendData writeLog loadPost rawCache spec = cacheWrap (fetchBackendData' writeLog loadPost) spec where@@ -113,11 +117,11 @@ cache = wrapBackendCache rawCache -- | Fetch raw backend data from a backend source, without caching.-fetchBackendData' :: (LogLevel -> Text -> IO ()) -> PostBodySource -> BackendSpec -> IO [BackendSource]+fetchBackendData' :: (LogLevel -> Text -> IO ()) -> RequestContext -> BackendSpec -> IO [BackendSource] fetchBackendData' writeLog loadPost- (BackendSpec backendType fetchMode fetchOrder mimeOverride) =+ (BackendSpec backendType fetchMode fetchOrder mimeOverride _) = map (overrideMime mimeOverride) <$> loader backendType writeLog loadPost fetchMode fetchOrder overrideMime :: Maybe MimeType -> BackendSource -> BackendSource
@@ -6,12 +6,15 @@ {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE LambdaCase #-} {-#LANGUAGE DeriveGeneric #-}+{-#LANGUAGE DeriveFunctor #-}+{-#LANGUAGE TypeApplications #-} -- | Types for and operations on backend data. module Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..)+, Verification (..) , RawBytes (..) , toBackendData , Items (..)@@ -24,7 +27,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Text.Ginger (ToGVal (..), GVal, Run (..), dict, (~>)) import qualified Text.Ginger as Ginger import Data.Aeson as JSON@@ -38,6 +41,10 @@ import Data.Time.Clock.POSIX (POSIXTime, posixSecondsToUTCTime) import Data.Time (UTCTime, LocalTime, utc, utcToLocalTime) import Data.Scientific (Scientific)+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy.Char8 as LBS8+import Text.Printf (printf)+import Data.Foldable (Foldable (foldMap)) import Web.Sprinkles.Backends.Spec @@ -49,7 +56,13 @@ data Items a = NotFound -- ^ Nothing was found | SingleItem a -- ^ A single item was requested, and this is it | MultiItem [a] -- ^ Multiple items were requested, here they are+ deriving (Functor) +instance Foldable Items where+ foldMap f NotFound = mempty+ foldMap f (SingleItem x) = f x+ foldMap f (MultiItem xs) = mconcat $ map f xs+ -- | Transform a raw list of results into an 'Items' value. This allows us -- later to distinguish between Nothing Found vs. Empty List, and between -- Single Item Requested And Found vs. Many Items Requested, One Found. This@@ -94,21 +107,79 @@ len <- rbLength r rbGetRange r 0 len +rawToGVal :: MonadIO m => RawBytes -> GVal (Run p m h)+rawToGVal raw =+ dict+ [ ("length", Ginger.fromFunction (gfnLength raw))+ , ("read", Ginger.fromFunction (gfnRead raw))+ , ("store", Ginger.fromFunction (gfnStore raw))+ ]+ where+ gfnLength :: MonadIO m => RawBytes -> [(Maybe Text, GVal (Run p m h))] -> Run p m h (GVal (Run p m h))+ gfnLength raw args = do+ length <- liftIO (rbLength raw)+ return . toGVal $ length+ gfnRead :: MonadIO m => RawBytes -> [(Maybe Text, GVal (Run p m h))] -> Run p m h (GVal (Run p m h))+ gfnRead raw args = do+ inputLength <- liftIO (rbLength raw)+ let extracted =+ Ginger.extractArgsDefL+ [ ("start", def)+ , ("length", toGVal inputLength)+ ]+ args+ case extracted of+ Right [startG, lengthG] -> do+ let start = fromMaybe 0 $ asInteger startG+ length = fromMaybe inputLength $ asInteger lengthG+ bytes <- liftIO (rbGetRange raw start length)+ return . toGVal . LBS8.unpack $ bytes+ _ -> fail "Invalid arguments to RawBytes.read"++ gfnStore :: MonadIO m => RawBytes -> [(Maybe Text, GVal (Run p m h))] -> Run p m h (GVal (Run p m h))+ gfnStore raw args = do+ let extracted =+ Ginger.extractArgsDefL+ [ ("filename", "stored")+ ]+ args+ case extracted of+ Right [filenameG] -> liftIO $ do+ let filename = unpack . Ginger.asText $ filenameG+ len <- rbLength raw+ bytes <- rbGetRange raw 0 len+ LBS8.writeFile filename bytes+ return . toGVal $ True+ _ -> fail "Invalid arguments to RawBytes.store"++ asInteger :: GVal m -> Maybe Integer+ asInteger = fmap round . Ginger.asNumber++instance MonadIO m => ToGVal (Run p m h) RawBytes where+ toGVal = rawToGVal+ -- | A parsed record from a query result.-data BackendData m h =+data BackendData p m h = BackendData { bdJSON :: JSON.Value -- ^ Result body as JSON- , bdGVal :: GVal (Run m h) -- ^ Result body as GVal+ , bdGVal :: GVal (Run p m h) -- ^ Result body as GVal , bdRaw :: RawBytes -- ^ Raw result body source , bdMeta :: BackendMeta -- ^ Meta-information- , bdChildren :: HashMap Text (BackendData m h) -- ^ Child documents+ , bdChildren :: HashMap Text (BackendData p m h) -- ^ Child documents+ , bdVerification :: Verification } +data Verification+ = Trusted+ | VerifyCSRF+ deriving (Show, Eq, Enum, Ord, Bounded)+ -- | A raw (unparsed) record from a query result. data BackendSource = BackendSource { bsMeta :: BackendMeta , bsSource :: RawBytes+ , bsVerification :: Verification } deriving (Generic) @@ -128,13 +199,13 @@ deserializeBackendSource :: SerializableBackendSource -> BackendSource deserializeBackendSource sbs =- BackendSource (sbsMeta sbs) (rawFromLBS $ sbsSource sbs)+ BackendSource (sbsMeta sbs) (rawFromLBS $ sbsSource sbs) Trusted -- | Wrap a parsed backend value in a 'BackendData' structure. The original -- raw 'BackendSource' value is needed alongside the parsed value, because the -- resulting structure contains both the 'BackendMeta' and the raw (unparsed) -- data from it.-toBackendData :: (ToJSON a, ToGVal (Run m h) a) => BackendSource -> a -> BackendData m h+toBackendData :: (ToJSON a, ToGVal (Run p m h) a) => BackendSource -> a -> BackendData p m h toBackendData src val = BackendData { bdJSON = toJSON val@@ -142,18 +213,19 @@ , bdRaw = bsSource src , bdMeta = bsMeta src , bdChildren = mapFromList []+ , bdVerification = bsVerification src } -addBackendDataChildren :: HashMap Text (BackendData m h)- -> BackendData m h- -> BackendData m h+addBackendDataChildren :: HashMap Text (BackendData p m h)+ -> BackendData p m h+ -> BackendData p m h addBackendDataChildren children bd = bd { bdChildren = children <> bdChildren bd } -instance ToJSON (BackendData m h) where+instance ToJSON (BackendData p m h) where toJSON = bdJSON -instance ToGVal (Run m h) (BackendData m h) where+instance MonadIO m => ToGVal (Run p m h) (BackendData p m h) where toGVal bd = let baseVal = bdGVal bd baseLookup = fromMaybe (const def) $ Ginger.asLookup baseVal@@ -164,9 +236,11 @@ { Ginger.asLookup = Just $ \case "props" -> return . toGVal . bdMeta $ bd "children" -> return childrenG+ "bytes" -> return . toGVal . bdRaw $ bd k -> baseLookup k , Ginger.asDictItems = (("props" ~> bdMeta bd):) .+ (("bytes" ~> bdRaw bd):) . (("children", childrenG):) <$> baseDictItems } @@ -207,7 +281,7 @@ toJSON bm = let (mtime, mtimeSci, mtimeUTC) = mtimeFlavors bm in JSON.object- [ "mimeType" .= decodeUtf8 (bmMimeType bm)+ [ "mimeType" .= decodeUtf8 @Text (bmMimeType bm) , "mtime" .= mtimeSci , "mtimeUTC" .= mtimeUTC , "name" .= bmName bm@@ -219,7 +293,7 @@ toGVal bm = let (mtime, mtimeSci, mtimeUTC) = mtimeFlavors bm in Ginger.dict- [ "type" ~> decodeUtf8 (bmMimeType bm)+ [ "type" ~> decodeUtf8 @Text (bmMimeType bm) , "mtime" ~> mtimeSci , "mtimeUTC" ~> mtimeUTC , "name" ~> bmName bm
@@ -12,7 +12,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Loader.Type import Web.Sprinkles.Backends.Loader.SqlLoader import Web.Sprinkles.Backends.Loader.SubprocessLoader@@ -21,9 +21,11 @@ import Web.Sprinkles.Backends.Loader.RequestBodyLoader import Web.Sprinkles.Backends.Loader.LiteralLoader import Web.Sprinkles.Backends.Spec+import Web.Sprinkles.Databases (ResultSetMode (..)) loader :: BackendType -> Loader-loader (SqlBackend dsn query params) = sqlLoader dsn query params+loader (SqlBackend dsn query params) = sqlLoader dsn ResultsLast [(query, params)]+loader (SqlMultiBackend dsn mode queries) = sqlLoader dsn mode queries loader (FileBackend filepath) = fileLoader filepath loader (HttpBackend uriText credentials) = curlLoader uriText credentials loader (SubprocessBackend cmd args mimeType) = subprocessLoader cmd args mimeType
@@ -5,6 +5,7 @@ {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE LambdaCase #-}+{-#LANGUAGE TypeApplications #-} -- | File backend loader module Web.Sprinkles.Backends.Loader.FileLoader@@ -12,11 +13,12 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..)+ , Verification (..) , Items (..) , reduceItems , RawBytes (..)@@ -64,7 +66,7 @@ else fetchAsFile candidate fetchAsDir candidate = do let mimeType = "application/x-directory"- contents <- (encodeUtf8 . unlines . fmap pack <$> getDirectoryContents candidate)+ contents <- (encodeUtf8 @LText . unlines . fmap pack <$> getDirectoryContents candidate) `catchIOError` \err -> do writeLog Notice $ tshow err return ""@@ -102,7 +104,7 @@ , bmPath = pack candidate , bmSize = Just . fromIntegral $ fileSize status :: Maybe Integer }- return $ BackendSource meta contents+ return $ BackendSource meta contents Trusted -- | Our own extended MIME type dictionary. The default one lacks appropriate -- entries for some of the important types we use, so we add them here.
@@ -5,6 +5,7 @@ {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE LambdaCase #-}+{-#LANGUAGE TypeApplications #-} -- | HTTP backend loader module Web.Sprinkles.Backends.Loader.HttpLoader@@ -12,7 +13,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Spec ( HttpBackendOptions (..) )@@ -20,6 +21,7 @@ ( BackendData (..) , BackendMeta (..) , BackendSource (..)+ , Verification (..) , Items (..) , reduceItems , rawFromLBS@@ -39,7 +41,7 @@ curlLoader :: Text -> HttpBackendOptions -> Loader curlLoader uriText options writeLog _ fetchMode fetchOrder = do- let accepts = intercalate "," . map (unpack . decodeUtf8) $ httpAcceptedContentTypes options+ let accepts = intercalate "," . map (unpack . decodeUtf8 @Text) $ httpAcceptedContentTypes options writeLog Debug $ "cURL " <> uriText Curl.initialize >>= \curl -> do response <- Curl.curlGetResponse_@@ -59,7 +61,7 @@ getHeader hname = lookup hname headers getHeaderDef def = fromMaybe def . getHeader mimeType = encodeUtf8 $ getHeaderDef "text/plain" "content-type"- contentLength = readMay . unpack =<< getHeader "content-length"+ contentLength = readMay =<< getHeader "content-length" writeLog Debug $ (pack . show) (Curl.respCurlCode response) writeLog Debug $ pack (Curl.respStatusLine response) writeLog Debug $ "Content-type: " <> decodeUtf8 mimeType@@ -78,4 +80,4 @@ , bmPath = uriText , bmSize = contentLength }- return [BackendSource meta body]+ return [BackendSource meta body Trusted]
@@ -12,11 +12,12 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..)+ , Verification (..) , Items (..) , reduceItems , RawBytes (..)@@ -45,4 +46,4 @@ , bmPath = "literal" , bmSize = Just . fromIntegral $ length rawBodyBytes }- return [ BackendSource meta rawBody ]+ return [ BackendSource meta rawBody Trusted ]
@@ -4,7 +4,7 @@ {-#LANGUAGE MultiParamTypeClasses #-} {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-}-{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE DeriveGeneric #-} -- | File backend loader module Web.Sprinkles.Backends.Loader.RequestBodyLoader@@ -12,11 +12,12 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..)+ , Verification (..) , Items (..) , reduceItems , rawFromLBS@@ -32,15 +33,57 @@ , defaultMimeType , FileName )+import qualified Data.ByteString as BS+import Data.Char (ord) +data Disposition =+ Disposition+ { baseDisposition :: ByteString+ , dispositionAttribs :: [(ByteString, ByteString)]+ }+ deriving (Show, Eq, Generic)++trimL :: ByteString -> ByteString+trimL = BS.dropWhile (<= 32)++trimR :: ByteString -> ByteString+trimR = fst . BS.spanEnd (<= 32)++trim :: ByteString -> ByteString+trim = trimL . trimR++stripQuotes :: ByteString -> ByteString+stripQuotes =+ BS.takeWhile (/= fromIntegral (ord '"')) .+ BS.dropWhile (== fromIntegral (ord '"'))++parseDisposition :: ByteString -> Disposition+parseDisposition str =+ let base:attribStrs = map trim $ BS.split (fromIntegral . ord $ ';') str+ attribs = concatMap parseAttrib attribStrs+ in Disposition base attribs+ where+ parseAttrib :: ByteString -> [(ByteString, ByteString)]+ parseAttrib str =+ case BS.split (fromIntegral . ord $ '=') str of+ [name, value] ->+ [(trim name, stripQuotes . trim $ value)]+ _ -> []+ requestBodyLoader :: Loader requestBodyLoader writeLog pbs fetchMode fetchOrder = do contents <- rawFromLBS <$> loadPost pbs+ let disposition =+ fromMaybe (Disposition "attachment" []) $+ parseDisposition <$> lookupHeader pbs "Content-Disposition"+ filename = fromMaybe "POST" $+ lookup "filename" (dispositionAttribs disposition)+ writeLog Debug . pack . show $ disposition let meta = BackendMeta { bmMimeType = contentType pbs , bmMTime = Nothing , bmName = "POST"- , bmPath = "POST"+ , bmPath = decodeUtf8 filename , bmSize = Nothing }- return [BackendSource meta contents]+ return [BackendSource meta contents VerifyCSRF]
@@ -12,11 +12,12 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..)+ , Verification (..) , Items (..) , reduceItems , rawFromLBS@@ -29,23 +30,35 @@ import Data.Aeson.TH as JSON import Data.Yaml as YAML import Web.Sprinkles.Backends.Loader.Type+import Data.List.Extra (takeEnd) -sqlLoader :: DSN -> Text -> [Text] -> Loader-sqlLoader dsn query params writeLog _ fetchMode fetchOrder = do- rows <- DB.withConnection dsn $ \conn -> do+sqlLoader :: DSN -> DB.ResultSetMode -> [(Text, [Text])] -> Loader+sqlLoader dsn mode queries writeLog _ fetchMode fetchOrder = do+ resultSets <- DB.withConnection dsn $ \conn -> do HDBC.withTransaction conn $ \conn -> do- writeLog Debug $- "SQL: QUERY: " <> tshow query <>- " ON " <> DB.dsnToText dsn <>- " WITH: " <> tshow params- stmt <- HDBC.prepare conn (unpack query)- HDBC.execute stmt (map HDBC.toSql params)- HDBC.fetchAllRowsMap stmt- return $ map mapRow rows+ forM queries $ \(query, params) -> do+ writeLog Debug $+ "SQL: QUERY: " <> tshow query <>+ " ON " <> DB.dsnToText dsn <>+ " WITH: " <> tshow params+ stmt <- HDBC.prepare conn (unpack query)+ HDBC.execute stmt (map HDBC.toSql params)+ HDBC.fetchAllRowsMap stmt+ return $ mergeResultSets resultSets where+ mergeResultSets :: [[Map String HDBC.SqlValue]] -> [BackendSource]+ mergeResultSets [] = []+ mergeResultSets rawRows = case mode of+ DB.ResultsMerge ->+ map mapRow . mconcat $ rawRows+ DB.ResultsNth i ->+ map mapRow . mconcat . drop i . take 1 $ rawRows+ DB.ResultsLast ->+ map mapRow . mconcat . takeEnd 1 $ rawRows+ mapRow :: Map String HDBC.SqlValue -> BackendSource mapRow row =- let json = JSON.encode (fmap (HDBC.fromSql :: HDBC.SqlValue -> Text) row)+ let json = JSON.encode (fmap (HDBC.fromSql :: HDBC.SqlValue -> Maybe Text) row) name = maybe "SQL" HDBC.fromSql $ lookup "name" row <|> lookup "title" row <|>@@ -57,4 +70,4 @@ , bmPath = "SQL" , bmSize = Just . fromIntegral $ length json }- in BackendSource meta (rawFromLBS json)+ in BackendSource meta (rawFromLBS json) Trusted
@@ -12,11 +12,12 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..)+ , Verification (..) , Items (..) , reduceItems , rawFromLBS@@ -40,4 +41,4 @@ , bmPath = unwords $ cmd:args , bmSize = Just contentLength }- return [BackendSource meta (rawFromLBS body)]+ return [BackendSource meta (rawFromLBS . fromStrict $ body) Trusted]
@@ -10,8 +10,11 @@ module Web.Sprinkles.Backends.Loader.Type where -import ClassyPrelude+import Web.Sprinkles.Prelude import qualified Network.Wai as Wai+import Network.HTTP.Types (HeaderName)+import Web.Sprinkles.SessionHandle (SessionHandle)+import Debug.Trace (trace) import Web.Sprinkles.Backends.Data ( BackendData (..)@@ -26,30 +29,36 @@ ) import Web.Sprinkles.Logger (LogLevel) -data PostBodySource =- PostBodySource+data RequestContext =+ RequestContext { loadPost :: IO LByteString- , contentType :: ByteString+ , sessionHandle :: Maybe SessionHandle+ , lookupHeader :: HeaderName -> Maybe ByteString } +contentType :: RequestContext -> ByteString+contentType rqc = fromMaybe "text/plain" $ lookupHeader rqc "Content-type"+ type Loader = (LogLevel -> Text -> IO ())- -> PostBodySource+ -> RequestContext -> FetchMode -> FetchOrder -> IO [BackendSource] -pbsFromRequest :: Wai.Request -> PostBodySource-pbsFromRequest request =- PostBodySource+pbsFromRequest :: Wai.Request -> Maybe SessionHandle -> RequestContext+pbsFromRequest request session =+ RequestContext { loadPost = Wai.lazyRequestBody request- , contentType = fromMaybe "text/plain" $- lookup "Content-type" (Wai.requestHeaders request)+ , sessionHandle = session+ , lookupHeader = \name ->+ lookup name (Wai.requestHeaders request) } -pbsInvalid :: PostBodySource+pbsInvalid :: RequestContext pbsInvalid =- PostBodySource+ RequestContext { loadPost = fail "POST body not available"- , contentType = "text/plain"+ , sessionHandle = Nothing+ , lookupHeader = const Nothing }
@@ -6,6 +6,7 @@ {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE LambdaCase #-} {-#LANGUAGE TupleSections #-}+{-#LANGUAGE TypeApplications #-} -- | Parse raw backend data into useful data structures. module Web.Sprinkles.Backends.Parsers@@ -13,12 +14,13 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..)+ , Verification (..) , toBackendData , addBackendDataChildren , rawToLBS, rawFromLBS@@ -27,7 +29,8 @@ ( parserTypes , ParserType (..) )-import Text.Pandoc (Pandoc)+import Web.Sprinkles.Pandoc (pandocReaderOptions)+import Text.Pandoc (Pandoc, PandocPure) import qualified Text.Pandoc as Pandoc import qualified Text.Pandoc.MediaBag as Pandoc import qualified Text.Pandoc.Readers.Creole as Pandoc@@ -44,38 +47,38 @@ -- | Parse raw backend data source into a structured backend data record. parseBackendData :: (MonadIO m, Monad m, Monad n) => BackendSource- -> m (BackendData n h)-parseBackendData item@(BackendSource meta body) = do+ -> m (BackendData p n h)+parseBackendData item@(BackendSource meta body _) = do let t = takeWhile (/= fromIntegral (ord ';')) (bmMimeType meta) parse = fromMaybe parseRawData $ lookup t parsersTable parse item -- | Lookup table of mime types to parsers. parsersTable :: (MonadIO m, Monad m, Monad n)- => HashMap MimeType (BackendSource -> m (BackendData n h))+ => HashMap MimeType (BackendSource -> m (BackendData p n h)) parsersTable = mapFromList . mconcat $ [ zip mimeTypes (repeat parser) | (mimeTypes, parser) <- parsers ] -- | The parsers we know, by mime types. parsers :: (MonadIO m, Monad m, Monad n)- => [([MimeType], BackendSource -> m (BackendData n h))]+ => [([MimeType], BackendSource -> m (BackendData p n h))] parsers = [ (types, getParser p) | (types, p) <- parserTypes ] getParser :: (MonadIO m, Monad m, Monad n) => ParserType- -> (BackendSource -> m (BackendData n h))+ -> (BackendSource -> m (BackendData p n h)) getParser ParserJSON = json getParser ParserYAML = yaml getParser ParserFormUrlencoded = urlencodedForm-getParser ParserMarkdown = pandoc (Pandoc.readMarkdown Pandoc.def)-getParser ParserCreole = pandoc (Pandoc.readCreole Pandoc.def)-getParser ParserTextile = pandoc (Pandoc.readTextile Pandoc.def)-getParser ParserRST = pandoc (Pandoc.readRST Pandoc.def)-getParser ParserLaTeX = pandoc (Pandoc.readLaTeX Pandoc.def)-getParser ParserDocX = pandocWithMedia (Pandoc.readDocx Pandoc.def)+getParser ParserMarkdown = pandoc (Pandoc.readMarkdown pandocReaderOptions)+getParser ParserCreole = pandoc (Pandoc.readCreole pandocReaderOptions)+getParser ParserTextile = pandoc (Pandoc.readTextile pandocReaderOptions)+getParser ParserRST = pandoc (Pandoc.readRST pandocReaderOptions)+getParser ParserLaTeX = pandoc (Pandoc.readLaTeX pandocReaderOptions)+getParser ParserDocX = pandocWithMedia (Pandoc.readDocx pandocReaderOptions) getParser ParserText = plainText-getParser ParserHtml = pandoc (Pandoc.readHtml Pandoc.def)+getParser ParserHtml = pandoc (Pandoc.readHtml pandocReaderOptions) -- | All the content types we know how to parse knownContentTypes :: [MimeType]@@ -83,40 +86,41 @@ -- | Parser for raw data (used for static files); this is also the default -- fallback for otherwise unsupported file types.-parseRawData :: Monad m => BackendSource -> m (BackendData n h)-parseRawData (BackendSource meta body) =+parseRawData :: Monad m => BackendSource -> m (BackendData p n h)+parseRawData (BackendSource meta body veri) = return BackendData { bdJSON = JSON.Null , bdGVal = toGVal JSON.Null , bdMeta = meta , bdRaw = body , bdChildren = mapFromList []+ , bdVerification = veri } -- | Parser for (utf-8) plaintext documents.-plainText :: (MonadIO m, Monad m) => BackendSource -> m (BackendData n h)-plainText item@(BackendSource meta body) = do- textBody <- liftIO $ toStrict . decodeUtf8 <$> rawToLBS body+plainText :: (MonadIO m, Monad m) => BackendSource -> m (BackendData p n h)+plainText item@(BackendSource meta body _) = do+ textBody <- liftIO $ toStrict . decodeUtf8 @LText <$> rawToLBS body return $ toBackendData item textBody -- | Parser for JSON source data.-json :: (MonadIO m, Monad m) => BackendSource -> m (BackendData n h)-json item@(BackendSource meta body) = do+json :: (MonadIO m, Monad m) => BackendSource -> m (BackendData p n h)+json item@(BackendSource meta body _) = do bodyBytes <- liftIO $ rawToLBS body case JSON.eitherDecode bodyBytes of Left err -> fail $ err ++ "\n" ++ show bodyBytes Right json -> return . toBackendData item $ (json :: JSON.Value) -- | Parser for YAML source data.-yaml :: (MonadIO m, Monad m) => BackendSource -> m (BackendData n h)-yaml item@(BackendSource meta body) = do+yaml :: (MonadIO m, Monad m) => BackendSource -> m (BackendData p n h)+yaml item@(BackendSource meta body _) = do bodyBytes <- liftIO $ rawToLBS body- case YAML.decodeEither (toStrict bodyBytes) of- Left err -> fail $ err ++ "\n" ++ show bodyBytes+ case YAML.decodeEither' (toStrict bodyBytes) of+ Left err -> fail $ YAML.prettyPrintParseException err ++ "\n" ++ show bodyBytes Right json -> return . toBackendData item $ (json :: JSON.Value) -urlencodedForm :: (MonadIO m, Monad m) => BackendSource -> m (BackendData n h)-urlencodedForm item@(BackendSource meta body) = do+urlencodedForm :: (MonadIO m, Monad m) => BackendSource -> m (BackendData p n h)+urlencodedForm item@(BackendSource meta body _) = do bodyBytes <- liftIO $ rawToLBS body return . toBackendData item .@@ -131,24 +135,28 @@ -- | Parser for Pandoc-supported formats that are read from 'LByteString's. pandocBS :: (MonadIO m, Monad m, Monad n)- => (LByteString -> Either PandocError Pandoc)+ => (LByteString -> PandocPure Pandoc) -> BackendSource- -> m (BackendData n h)-pandocBS reader input@(BackendSource meta body) = do+ -> m (BackendData p n h)+pandocBS reader input@(BackendSource meta body _) = do bodyBytes <- liftIO $ rawToLBS body- case reader bodyBytes of+ case Pandoc.runPure $ reader bodyBytes of Left err -> fail . show $ err Right pandoc -> return $ toBackendData input pandoc -- | Parser for Pandoc-supported formats that are read from 'LByteString's, and -- return a 'Pandoc' document plus a 'MediaBag'. pandocWithMedia :: (MonadIO m, Monad m, Monad n)- => (LByteString -> Either PandocError (Pandoc, Pandoc.MediaBag))+ => (LByteString -> PandocPure Pandoc) -> BackendSource- -> m (BackendData n h)-pandocWithMedia reader input@(BackendSource meta body) = do+ -> m (BackendData p n h)+pandocWithMedia reader input@(BackendSource meta body _) = do bodyBytes <- liftIO $ rawToLBS body- case reader bodyBytes of+ let reader' = do+ pandoc <- reader bodyBytes+ mediaBag <- Pandoc.getMediaBag+ return (pandoc, mediaBag)+ case Pandoc.runPure reader' of Left err -> fail . show $ err Right (pandoc, mediaBag) -> do -- TODO: marshal mediaBag to backend data item children@@ -158,7 +166,7 @@ mediaBagToBackendData :: (MonadIO m, Monad m, Monad n) => Pandoc.MediaBag- -> m [(Text, BackendData n h)]+ -> m [(Text, BackendData p n h)] mediaBagToBackendData bag = do let metas = Pandoc.mediaDirectory bag forM metas $ \(path, mimeType, contentLength) -> do@@ -168,19 +176,19 @@ (Pandoc.lookupMedia path bag) let meta = BackendMeta- { bmMimeType = encodeUtf8 . pack $ mimeType+ { bmMimeType = encodeUtf8 . pack @Text $ mimeType , bmMTime = Nothing , bmName = pack path , bmPath = pack path , bmSize = Just $ fromIntegral contentLength }- (pack path,) <$> parseBackendData (BackendSource meta (rawFromLBS body))+ (pack path,) <$> parseBackendData (BackendSource meta (rawFromLBS body) Trusted) -- | Parser for Pandoc-supported formats that are read from 'String's. pandoc :: (MonadIO m, Monad m, Monad n)- => (String -> Either PandocError Pandoc)+ => (Text -> PandocPure Pandoc) -> BackendSource- -> m (BackendData n h)+ -> m (BackendData p n h) pandoc reader =- pandocBS (reader . unpack . decodeUtf8)+ pandocBS (reader . toStrict . decodeUtf8)
@@ -1,17 +1,23 @@ {-#LANGUAGE NoImplicitPrelude #-} {-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-} {-#LANGUAGE TypeFamilies #-} {-#LANGUAGE MultiParamTypeClasses #-} {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE LambdaCase #-}+{-#LANGUAGE TupleSections #-} {-#LANGUAGE DeriveGeneric #-}+{-#LANGUAGE TypeApplications #-} -- | Backend spec types and parser module Web.Sprinkles.Backends.Spec ( -- * Defining backends BackendSpec (..)+, backendSpecFromJSON+, makeBackendTypePathsAbsolute+, makeBackendSpecPathsAbsolute , BackendType (..) , FetchOrderField (..) , FetchMode (..)@@ -28,77 +34,111 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Network.Mime (MimeType) import Network.HTTP.Types () import Data.Aeson (FromJSON (..), Value (..), (.=), (.!=), (.:?), (.:)) import qualified Data.Aeson as JSON+import qualified Data.Aeson.Types as JSON import System.PosixCompat.Files import Data.Default (Default (..)) import Web.Sprinkles.Cache import qualified Data.Serialize as Cereal import Data.Serialize (Serialize)-import Web.Sprinkles.Databases (DSN (..), sqlDriverFromID)+import Web.Sprinkles.Databases (DSN (..), sqlDriverFromID, ResultSetMode (..)) import Web.Sprinkles.Logger (LogLevel (..))-import Data.Walk (walk)+import Data.Expandable (ExpandableM (..), expand) -- | A type of backend. data BackendType = HttpBackend Text HttpBackendOptions -- ^ Fetch data over HTTP(S) | FileBackend Text -- ^ Read local files | SqlBackend DSN Text [Text] -- ^ Query an SQL database+ | SqlMultiBackend DSN ResultSetMode [(Text, [Text])] -- ^ Query an SQL database, multiple queries | SubprocessBackend Text [Text] MimeType -- ^ Run a command in a subprocess | RequestBodyBackend -- ^ Read the incoming request body | LiteralBackend Value -- ^ Return literal data from the spec itself deriving (Show, Generic) +makeBackendTypePathsAbsolute :: FilePath -> BackendType -> BackendType+makeBackendTypePathsAbsolute dir (FileBackend fn) = FileBackend (pack . (dir </>) . unpack $ fn)+makeBackendTypePathsAbsolute dir (SubprocessBackend cmd args ty) = SubprocessBackend (pack . (dir </>) . unpack $ cmd) args ty+makeBackendTypePathsAbsolute _ x = x+ instance Serialize BackendType where put (HttpBackend url options) = do Cereal.put 'h'- Cereal.put (encodeUtf8 . unpack $ url)+ Cereal.put (encodeUtf8 url) Cereal.put options put (FileBackend path) = do Cereal.put 'f'- Cereal.put (encodeUtf8 . unpack $ path)+ Cereal.put (encodeUtf8 path) put (SqlBackend dsn query params) = do Cereal.put 's' Cereal.put dsn- Cereal.put (encodeUtf8 . unpack $ query)- Cereal.put (map (encodeUtf8 . unpack) params)+ Cereal.put (encodeUtf8 query)+ Cereal.put (map encodeUtf8 params)+ put (SqlMultiBackend dsn mode queries) = do+ Cereal.put 'S'+ Cereal.put dsn+ Cereal.put mode+ Cereal.put+ [ (encodeUtf8 $ q, map encodeUtf8 p)+ | (q, p) <- queries+ ] put (SubprocessBackend cmd args t) = do Cereal.put 'p'- Cereal.put (encodeUtf8 . unpack $ cmd)- Cereal.put (map (encodeUtf8 . unpack) args)+ Cereal.put (encodeUtf8 cmd)+ Cereal.put (map encodeUtf8 args) Cereal.put t put RequestBodyBackend = Cereal.put 'b' put (LiteralBackend b) = do Cereal.put 'l' Cereal.put (JSON.encode b) get = Cereal.get >>= \case- 'h' -> HttpBackend <$> (pack . decodeUtf8 <$> Cereal.get) <*> Cereal.get- 'f' -> FileBackend <$> (pack . decodeUtf8 <$> Cereal.get)+ 'h' -> HttpBackend <$> (decodeUtf8 <$> Cereal.get) <*> Cereal.get+ 'f' -> FileBackend <$> (decodeUtf8 <$> Cereal.get) 's' -> SqlBackend <$> Cereal.get <*>- (pack . decodeUtf8 <$> Cereal.get) <*>- (map (pack . decodeUtf8) <$> Cereal.get)+ (decodeUtf8 <$> Cereal.get) <*>+ (map decodeUtf8 <$> Cereal.get)+ 'S' -> SqlMultiBackend <$>+ Cereal.get <*>+ Cereal.get <*>+ (Cereal.get >>= \items -> return+ [ ( decodeUtf8 q+ , map decodeUtf8 p+ )+ | (q, p) <- items+ ]) 'p' -> SubprocessBackend <$>- (pack . decodeUtf8 <$> Cereal.get) <*>- (map (pack . decodeUtf8) <$> Cereal.get) <*>+ (decodeUtf8 <$> Cereal.get) <*>+ (map decodeUtf8 <$> Cereal.get) <*> Cereal.get 'b' -> return RequestBodyBackend 'l' -> LiteralBackend <$> (fromMaybe JSON.Null . JSON.decode <$> Cereal.get) x -> fail $ "Invalid backend type identifier: " <> show x -type instance Element BackendType = Text--instance MonoFunctor BackendType where- omap f (HttpBackend t c) = HttpBackend (f t) c- omap f (FileBackend t) = FileBackend (f t)- omap f (SqlBackend dsn query params) = SqlBackend (omap f dsn) query (map f params)- omap f (SubprocessBackend cmd args t) = SubprocessBackend cmd (map f args) t- omap _ RequestBodyBackend = RequestBodyBackend- omap f (LiteralBackend b) =- LiteralBackend (walk f b)+instance ExpandableM Text BackendType where+ expandM f (HttpBackend t c) =+ HttpBackend <$> f t <*> pure c+ expandM f (FileBackend t) =+ FileBackend <$> f t+ expandM f (SqlBackend dsn query params) =+ SqlBackend <$> expandM f dsn <*> pure query <*> expandM f params+ expandM f (SqlMultiBackend dsn mode queries) =+ SqlMultiBackend+ <$> expandM f dsn+ <*> pure mode+ <*> ( forM queries $ \(query, params) ->+ (query,) <$> expandM f params+ )+ expandM f (SubprocessBackend cmd args t) =+ SubprocessBackend cmd <$> expandM f args <*> pure t+ expandM _ RequestBodyBackend =+ pure RequestBodyBackend+ expandM f (LiteralBackend b) =+ LiteralBackend <$> expandM f b -- | A specification of a backend query. data BackendSpec =@@ -108,15 +148,23 @@ , bsOrder :: FetchOrder -- ^ How to order items -- | If set, ignore reported MIME type and use this one instead. , bsMimeTypeOverride :: Maybe MimeType+ , bsCacheEnabled :: Bool } deriving (Show, Generic) instance Serialize BackendSpec -type instance Element BackendSpec = Text+makeBackendSpecPathsAbsolute :: FilePath -> BackendSpec -> BackendSpec+makeBackendSpecPathsAbsolute dir spec =+ spec { bsType = makeBackendTypePathsAbsolute dir (bsType spec) } -instance MonoFunctor BackendSpec where- omap f (BackendSpec t m o mto) = BackendSpec (omap f t) m o mto+instance ExpandableM Text BackendSpec where+ expandM f (BackendSpec t m o mto ce) =+ BackendSpec <$> expandM f t+ <*> pure m+ <*> pure o+ <*> pure mto+ <*> pure ce -- | The JSON shape of a backend spec is: --@@ -165,6 +213,7 @@ parseJSON = backendSpecFromJSON -- | Read a backend spec from a JSON value.+backendSpecFromJSON :: JSON.Value -> JSON.Parser BackendSpec backendSpecFromJSON (String uri) = parseBackendURI uri backendSpecFromJSON (Object obj) = do@@ -182,23 +231,50 @@ x -> fail $ "Invalid backend specifier: " ++ show x fetchMode <- obj .:? "fetch" .!= defFetchMode fetchOrder <- obj .:? "order" .!= def- mimeOverride <- fmap encodeUtf8 <$> obj .:? "force-mime-type"- return $ BackendSpec t fetchMode fetchOrder mimeOverride+ cacheEnabled <- obj .:? "cache-enabled" .!= True+ mimeOverride <- fmap encodeUtf8 . (id @(Maybe Text)) <$> obj .:? "force-mime-type"+ return $ BackendSpec t fetchMode fetchOrder mimeOverride cacheEnabled where parseHttpBackendSpec = do t <- obj .: "uri" return (HttpBackend t def, FetchOne) parseFileBackendSpec m = do path <- obj .: "path"- return (FileBackend (pack path), m)+ return (FileBackend path, m) parseDirBackendSpec = do path <- obj .: "path" return (FileBackend (pack $ path </> "*"), FetchAll) parseSqlBackendSpec = do dsn <- obj .: "connection"- query <- obj .: "query"- params <- obj .:? "params" .!= []- return (SqlBackend dsn query params, FetchAll)+ case lookup "queries" obj of+ Nothing -> do+ query <- obj .: "query"+ params <- obj .:? "params" .!= []+ return (SqlBackend dsn query params, FetchAll)+ Just (Array queries') -> do+ queries <- forM (toList queries') $ \case+ String queryStr -> do+ return (queryStr, [])+ Object queriesObj -> do+ query <- queriesObj .: "query"+ params <- queriesObj .:? "params" .!= []+ return (query, params)+ Array queriesArr -> do+ case toList queriesArr of+ [] ->+ fail "Invalid query object, empty array is not allowed"+ [String queryStr] ->+ return (queryStr, [])+ [String queryStr, Array params] ->+ (queryStr,) <$> mapM parseJSON (toList params)+ (String queryStr:params) ->+ (queryStr,) <$> mapM parseJSON params+ x ->+ fail "Invalid query object, first array element must be string"+ x -> fail "Invalid query object, must be array, string, or object"+ mode <- obj .:? "results" .!= ResultsMerge+ return (SqlMultiBackend dsn mode queries, FetchAll)+ Just x -> fail "Invalid queries object, must be array" parseSubprocessSpec = do rawCmd <- obj .: "cmd" t <- fromString <$> (obj .:? "mime-type" .!= "text/plain")@@ -206,7 +282,8 @@ String cmd -> return (SubprocessBackend cmd [] t, FetchOne) Array v -> parseJSON rawCmd >>= \case cmd:args -> return (SubprocessBackend cmd args t, FetchOne)- [] -> fail "Expected a command and a list of arguments"+ _ -> fail "Expected a command and a list of arguments"+ x -> fail $ "Expected string or array, but found " ++ show x parseLiteralBackendSpec = do b <- obj .:? "body" .!= JSON.Null return (LiteralBackend b, FetchOne)@@ -226,6 +303,7 @@ FetchOne def Nothing+ True "https" -> return $ BackendSpec@@ -233,25 +311,26 @@ FetchOne def Nothing+ True "dir" -> return $- BackendSpec (FileBackend (pack $ unpack path </> "*")) FetchAll def Nothing+ BackendSpec (FileBackend (pack $ unpack path </> "*")) FetchAll def Nothing True "glob" -> return $- BackendSpec (FileBackend path) FetchAll def Nothing+ BackendSpec (FileBackend path) FetchAll def Nothing True "file" -> return $- BackendSpec (FileBackend path) FetchOne def Nothing+ BackendSpec (FileBackend path) FetchOne def Nothing True "sql" -> do be <- parseSqlBackendURI path- return $ BackendSpec be FetchAll def Nothing+ return $ BackendSpec be FetchAll def Nothing True "post" -> return $ BackendSpec RequestBodyBackend- FetchOne def Nothing+ FetchOne def Nothing True "literal" -> return $ BackendSpec (LiteralBackend $ JSON.String path)- FetchOne def Nothing+ FetchOne def Nothing True _ -> fail $ "Unknown protocol: " <> show protocol where parseSqlBackendURI path = do@@ -371,6 +450,8 @@ <$> (o .:? "credentials" .!= AnonymousCredentials) <*> (o .:? "method" .!= GET) <*> pure knownContentTypes+ parseJSON x =+ fail $ "Expected string or array, but found " ++ show x instance Default HttpBackendOptions where def = HttpBackendOptions
@@ -0,0 +1,202 @@+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-}+{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE ScopedTypeVariables #-}+{-#LANGUAGE FlexibleInstances #-}+{-#LANGUAGE FlexibleContexts #-}+{-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE TemplateHaskell #-}+{-#LANGUAGE TypeApplications #-}++module Web.Sprinkles.Bake+where++import Web.Sprinkles.Prelude+import qualified Data.Text as Text+import Data.Text (Text)+import qualified Data.Set as Set+import Data.Set (Set)+import System.Directory (createDirectoryIfMissing)+import System.FilePath ( (</>), takeDirectory, replaceExtension )+import Control.Monad.State+import Control.Lens+import Control.Lens.TH (makeLenses)+import Text.Printf (printf)+import Network.HTTP.Types (Status (..), status200)+import Network.Wai.Test+import Network.Wai (Application, Request (..))+import qualified Network.Wai as Wai+import Web.Sprinkles.Serve (appFromProject)+import Web.Sprinkles.Project+import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString as BS+import Data.Char (ord)+import Text.HTML.TagSoup (parseTags, Tag (..), Attribute)+import qualified Data.CSS.Syntax.Tokens as CSS+import Data.FileEmbed (embedStringFile)++defHtaccess :: ByteString+defHtaccess = $(embedStringFile "embedded/.htaccess")++data BakeState+ = BakeState+ { _bsTodo :: [FilePath]+ , _bsDone :: Set FilePath+ , _bsBasedir :: FilePath+ , _bsApp :: Application+ }++makeLenses ''BakeState++defBakeState :: BakeState+defBakeState = BakeState [] Set.empty "." defaultApplication++defaultApplication :: Application+defaultApplication rq respond =+ respond $+ Wai.responseLBS+ status200+ [("Content-type", "text/plain;charset=utf8")]+ "Hello, world!"++type Bake = StateT BakeState IO++bakeProject :: FilePath -> Project -> [FilePath] -> IO ()+bakeProject destDir project extraEntryPoints = do+ putStrLn @Text $ "Baking project into " <> pack destDir+ createDirectoryIfMissing True destDir+ let app = appFromProject project+ runBake destDir entryPoints app $ do+ bakeHtaccess+ bake404+ bakeApp+ where+ entryPoints =+ [ "/"+ , "/sitemap.xml"+ , "/favicon.ico"+ , "/robots.txt"+ ]+ ++ extraEntryPoints++runBake :: FilePath -> [FilePath] -> Application -> Bake a -> IO a+runBake baseDir entryPoints app a =+ evalStateT a $ defBakeState+ { _bsTodo = entryPoints+ , _bsBasedir = baseDir+ , _bsApp = app+ }++bakeHtaccess :: Bake ()+bakeHtaccess = do+ basedir <- use bsBasedir+ liftIO $ writeFile (basedir </> ".htaccess") defHtaccess++bakeApp :: Bake ()+bakeApp = do+ use bsTodo >>= \case+ (current:rest) -> do+ bsTodo .= rest+ bakePath current+ bsDone %= Set.insert current+ bakeApp+ _ -> return ()++bakePath :: FilePath -> Bake ()+bakePath fp = do+ done <- use bsDone+ unless (fp `Set.member` done) $+ bakePage CreateIndexHtml [200] fp (dropLeadingSlash fp)++data HtmlMappingMode = MapHtmlDirect | CreateIndexHtml++bake404 :: Bake ()+bake404 = do+ bakePage MapHtmlDirect [404] nonsensicalPath "_errors/404"+ where+ nonsensicalPath = "/123087408972309872109873012984709218371209847123" ++dropLeadingSlash :: FilePath -> FilePath+dropLeadingSlash = \case+ '/':x -> x+ x -> x++bakePage :: HtmlMappingMode -> [Int] -> FilePath -> FilePath -> Bake ()+bakePage htmlMode expectedStatuses fp fn = do+ app <- use bsApp+ basedir <- use bsBasedir+ let dstFile = basedir </> fn+ dstDir = takeDirectory dstFile+ let session = do+ let rq = setPath defaultRequest (fromString fp)+ request rq+ rp <- liftIO $ runSession session app+ let status = simpleStatus rp+ liftIO $ printf "GET %s %i %s\n" ("/" </> fp) (statusCode status) (decodeUtf8 $ statusMessage status)+ if statusCode status `elem` expectedStatuses+ then do+ let ty = fromMaybe "application/octet-stream" $ lookup "content-type" (simpleHeaders rp)+ rawTy = BS.takeWhile (/= fromIntegral (ord ';')) ty+ rawTySplit = BS.split (fromIntegral . ord $ '/') rawTy++ liftIO $ printf "%s\n" (decodeUtf8 ty)+ let (linkUrls, dstDir', dstFile') = case rawTySplit of+ ["text", "html"] ->+ let body = LBS.toStrict $ simpleBody rp+ soup = parseTags (decodeUtf8 body)+ linkUrls = map (fp </>) . map Text.unpack $ extractLinkedUrls soup+ in case htmlMode of+ CreateIndexHtml ->+ (linkUrls, dstFile, dstFile </> "index.html")+ MapHtmlDirect ->+ (linkUrls, dstDir, replaceExtension dstFile "html")+ [_, "css"] ->+ let body = decodeUtf8 . LBS.toStrict $ simpleBody rp+ tokens = either error id $ CSS.tokenize body+ linkUrls = map (takeDirectory fp </>) . map Text.unpack $ extractCssUrls tokens+ in (linkUrls, dstDir, dstFile)+ _ ->+ ([], dstDir, dstFile)+ liftIO $ do+ createDirectoryIfMissing True dstDir'+ LBS.writeFile dstFile' (simpleBody rp)+ bsTodo <>= linkUrls+ else do+ liftIO $ putStrLn @String "skip"++extractLinkedUrls :: [Tag Text] -> [Text]+extractLinkedUrls tags = filter isLocalUrl $ do+ tags >>= \case+ TagOpen "a" attrs -> do+ attrs >>= \case+ ("href", url) -> return url+ _ -> []+ TagOpen "link" attrs -> do+ attrs >>= \case+ ("href", url) -> return url+ _ -> []+ TagOpen "script" attrs -> do+ attrs >>= \case+ ("src", url) -> return url+ _ -> []+ TagOpen "img" attrs -> do+ attrs >>= \case+ ("src", url) -> return url+ _ -> []+ _ -> []++isLocalUrl :: Text -> Bool+isLocalUrl url = not+ ( ("//" `Text.isPrefixOf` url)+ || ("http://" `Text.isPrefixOf` url)+ || ("https://" `Text.isPrefixOf` url)+ )++extractCssUrls :: [CSS.Token] -> [Text]+extractCssUrls tokens = filter isLocalUrl $ go tokens+ where+ go (CSS.Url url:xs) = url:go xs+ go (CSS.Function "url":CSS.String _ url:xs) = url:go xs+ go (x:xs) = go xs+ go _ = []
@@ -6,7 +6,7 @@ module Web.Sprinkles.Cache where -import ClassyPrelude+import Web.Sprinkles.Prelude import Control.MaybeEitherMonad import Data.Time.Clock.POSIX @@ -19,6 +19,9 @@ , cacheDelete :: k -> IO () -- ^ Delete an entry from the cache , cacheVacuum :: IO Int -- ^ Delete all stale keys, return number of keys deleted }++instance Semigroup (Cache k v) where+ (<>) = appendCache instance Monoid (Cache k v) where mempty = nullCache
@@ -4,11 +4,12 @@ module Web.Sprinkles.Cache.Filesystem where -import ClassyPrelude+import Web.Sprinkles.Prelude import Data.Char (isAlphaNum, ord, isDigit, isAlpha, chr) import Prelude (read) import Web.Sprinkles.Cache import System.IO (IOMode (..), withFile)+import System.IO.Error (catchIOError) import System.Directory (removeFile, getDirectoryContents) import System.FilePath (takeFileName) import System.PosixCompat.Files (getFileStatus, modificationTime)@@ -25,26 +26,25 @@ ignoreNonexisting_ = ignoreNonexisting () filesystemCache :: (k -> String) -- ^ Key serializer- -> (String -> k) -- ^ Key deserializer -> (Handle -> v -> IO ()) -- ^ Value serializer -> (Handle -> IO v) -- ^ Value deserializer -> FilePath -- ^ Base directory -> POSIXTime -- ^ Expiration, in seconds -> Cache k v -- ^ Resulting cache-filesystemCache serializeKey deserializeKey writeValue readValue cacheDir maxAge =+filesystemCache serializeKey writeValue readValue cacheDir maxAge = Cache { cacheGet = \key -> do let filename = keyToFilename key catchIOError (do status <- getFileStatus filename- body <- withFile filename ReadMode readValue+ body <- System.IO.withFile filename ReadMode readValue return $ Just body ) (ignoreNonexisting Nothing) , cachePut = \key val -> do let filename = keyToFilename key- withFile filename WriteMode (\h -> writeValue h val)+ System.IO.withFile filename WriteMode (\h -> writeValue h val) , cacheDelete = \key -> do let filename = keyToFilename key removeFile filename `catchIOError` ignoreNonexisting_@@ -64,7 +64,6 @@ } where keyToFilename key = cacheDir </> encodeFilename (serializeKey key) <> ".cache"- keyFromFilename = deserializeKey . decodeFilename . takeFileName encodeFilename :: String -> FilePath encodeFilename =
@@ -1,31 +1,38 @@ {-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE LambdaCase #-} {-#LANGUAGE ScopedTypeVariables #-} {-#LANGUAGE OverloadedStrings #-} module Web.Sprinkles.Cache.Memcached where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Cache import Data.Time.Clock.POSIX import qualified Data.HashMap.Strict as HashMap import Data.Default-import qualified Database.Memcached.Binary.Maybe as Memcached+import qualified Database.Memcache.Client as Memcache memcachedCache :: IO (Cache ByteString ByteString) memcachedCache = do- let connectInfo :: Memcached.ConnectInfo = def- withConnection = Memcached.withConnection connectInfo+ let options :: Memcache.Options = def+ withConnection =+ bracket+ (Memcache.newClient [] options)+ (Memcache.quit) expiry = 60 return Cache { cacheGet = \key -> do- lazyVal <- withConnection $ Memcached.get_ key- return $ toStrict <$> lazyVal+ withConnection $ \client -> Memcache.gat client key expiry >>= \case+ Just (val, _, _) -> return (Just val)+ Nothing -> return Nothing , cachePut = \key val -> do- withConnection $ Memcached.set 0 expiry key (fromStrict val)+ withConnection $ \client ->+ Memcache.set client key val 0 expiry return () , cacheDelete = \key -> do- withConnection $ Memcached.delete key+ withConnection $ \client ->+ Memcache.delete client key 0 return () , cacheVacuum = return 0 }
@@ -3,7 +3,7 @@ module Web.Sprinkles.Cache.Memory where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Cache import Data.Time.Clock.POSIX import qualified Data.HashMap.Strict as HashMap
@@ -10,7 +10,7 @@ module Web.Sprinkles.Databases where -import ClassyPrelude+import Web.Sprinkles.Prelude import Data.Aeson as JSON import Data.Aeson.TH as JSON import qualified Database.HDBC as HDBC@@ -19,15 +19,33 @@ import Database.HDBC.MySQL (connectMySQL, MySQLConnectInfo (..)) import qualified Data.Serialize as Cereal import Data.Serialize (Serialize)+import Data.Expandable data DSN = DSN { dsnDriver :: SqlDriver, dsnDetails :: Text } deriving (Show, Generic) -type instance Element DSN = Text+data ResultSetMode+ = ResultsMerge+ | ResultsNth Int+ | ResultsLast+ deriving (Show, Generic) -instance MonoFunctor DSN where- omap f (DSN driver details) = DSN driver (f details)+instance FromJSON ResultSetMode where+ parseJSON = \case+ String "merge" -> return ResultsMerge+ String "first" -> return $ ResultsNth 0+ String "last" -> return ResultsLast+ String x -> fail $ "Invalid result set mode " ++ show x+ Number i -> if i >= 0+ then return (ResultsNth . round $ i)+ else fail $ "Invalid result set index " ++ show i+ x -> fail $ "Expected integer or string for result set mode, got " ++ show x +instance Serialize ResultSetMode where++instance ExpandableM Text DSN where+ expandM f (DSN driver details) = DSN driver <$> f details+ data SqlDriver = SqliteDriver | PostgreSQLDriver | MySQLDriver@@ -72,8 +90,8 @@ instance Serialize DSN where put (DSN driver details) = do Cereal.put driver- Cereal.put (unpack details)- get = DSN <$> Cereal.get <*> (pack <$> Cereal.get)+ Cereal.put ((unpack :: Text -> String) details)+ get = DSN <$> Cereal.get <*> ((pack :: String -> Text) <$> Cereal.get) dsnToText :: DSN -> Text dsnToText (DSN driver details) = sqlDriverID driver <> ":" <> details@@ -89,7 +107,7 @@ withConnection dsn inner = do conn <- connect dsn inner conn- + connect :: DSN -> IO HDBC.ConnWrapper connect (DSN driver details) = case driver of
@@ -13,7 +13,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Control.Exception import qualified Database.HDBC as HDBC import Database.HDBC (SqlError (..))@@ -44,7 +44,7 @@ throwInvalidReaderException :: String -> String -> IO () throwInvalidReaderException name msg =- throwM $ InvalidReaderException (pack name) (pack msg)+ Control.Exception.throwIO $ InvalidReaderException (pack name) (pack msg) data TemplateNotFoundException = TemplateNotFoundException Text deriving (Show, Eq, Generic)@@ -65,8 +65,15 @@ WithSourceContext source (withoutSourceContext e) instance HasSourceContext Ginger.ParserError where- overrideSourceContext = \s e -> e { peSourceName = Just . unpack $ s }+ overrideSourceContext = setGingerSourceName +setGingerSourceName :: Text -> Ginger.ParserError -> Ginger.ParserError+setGingerSourceName source e@(Ginger.ParserError { peSourcePosition = Just pos }) =+ e { peSourcePosition =+ Just (Ginger.setSourceName pos (unpack source))+ }+setGingerSourceName source e = e+ withSourceContext :: Exception e => Text -> e -> SomeException withSourceContext context = toException . WithSourceContext context . toException@@ -102,8 +109,8 @@ formatGingerParserError :: Ginger.ParserError -> Text formatGingerParserError e =- "line " <> (fromMaybe "?" . fmap tshow . Ginger.peSourceLine $ e)- <> ", column " <> (fromMaybe "?" . fmap tshow . Ginger.peSourceColumn $ e)+ "line " <> (fromMaybe "?" . fmap (tshow . Ginger.sourceLine) . Ginger.peSourcePosition $ e)+ <> ", column " <> (fromMaybe "?" . fmap (tshow . Ginger.sourceColumn) . Ginger.peSourcePosition $ e) <> ": " <> pack (Ginger.peErrorMessage e) formatYamlParseException :: YAML.ParseException -> Text
@@ -12,7 +12,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Handlers.Common import Web.Sprinkles.Handlers.Static import Web.Sprinkles.Handlers.Redirect
@@ -6,10 +6,11 @@ {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE DeriveGeneric #-} module Web.Sprinkles.Handlers.Common where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Exceptions import Web.Sprinkles.Backends import qualified Network.Wai as Wai@@ -19,28 +20,49 @@ import Network.HTTP.Types (Status, status200, status302, status400, status404, status405, status500) import Web.Sprinkles.Handlers.Respond-import Text.Ginger.Html (Html, htmlSource)+import Text.Ginger.Html (Html, htmlSource, unsafeRawHtml)+import qualified Text.Ginger as Ginger import Web.Sprinkles.Backends.Loader.Type- (PostBodySource (..), pbsFromRequest, pbsInvalid)+ (RequestContext (..), pbsFromRequest, pbsInvalid)+import Web.Sprinkles.Backends.Data+ (BackendData (..), BackendSource (..), Verification (..)) import Web.Sprinkles.Rule (expandReplacementBackend) import Data.AList (AList) import qualified Data.AList as AList-import Text.Ginger (GVal, ToGVal (..), Run, marshalGVal)+import Text.Ginger (GVal, ToGVal (..), Run, marshalGValEx, hoistRun, SourcePos)+import Text.Ginger.Run.VM (withEncoder) import Control.Monad.Writer (Writer)+import Web.Sprinkles.SessionHandle+import Web.Sprinkles.Exceptions+import qualified Data.Foldable as Foldable+import qualified Data.Aeson as JSON+import Data.Aeson (FromJSON (..))+import Text.Printf (printf) data NotFoundException = NotFoundException- deriving (Show)+ deriving (Show, Eq, Generic) instance Exception NotFoundException where data MethodNotAllowedException = MethodNotAllowedException- deriving (Show)+ deriving (Show, Eq, Generic) instance Exception MethodNotAllowedException where +data NotAllowedException = NotAllowedException+ deriving (Show, Eq, Generic)++instance Exception NotAllowedException where++data RequestValidationException = RequestValidationException+ deriving (Show, Eq, Generic)++instance Exception RequestValidationException+ type ContextualHandler =- HashMap Text (Items (BackendData IO Html)) ->+ HashMap Text (Items (BackendData SourcePos IO Html)) -> Project ->+ Maybe SessionHandle -> Wai.Application handleNotFound :: Project -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> NotFoundException -> IO Wai.ResponseReceived@@ -57,6 +79,20 @@ request respond +handleNotAllowed :: Project -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> NotAllowedException -> IO Wai.ResponseReceived+handleNotAllowed project request respond _ = do+ respond $ Wai.responseLBS+ status400+ [("Content-type", "text/plain")]+ "Not allowed"++handleRequestValidation :: Project -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> RequestValidationException -> IO Wai.ResponseReceived+handleRequestValidation project request respond _ = do+ respond $ Wai.responseLBS+ status400+ [("Content-type", "text/plain")]+ "Invalid Request"+ handleHttpError :: Status -> Text -> Text@@ -71,13 +107,14 @@ respondNormally = do backendData <- loadBackendDict (writeLog logger)- (pbsFromRequest request)+ (pbsFromRequest request Nothing) cache backendPaths (setFromList []) (mapFromList []) respondTemplateHtml project+ Nothing status templateName backendData@@ -106,37 +143,91 @@ message = "Something went pear-shaped. The problem seems to be on our side." loadBackendDict :: (LogLevel -> Text -> IO ())- -> PostBodySource+ -> RequestContext -> RawBackendCache -> AList Text BackendSpec -> Set Text- -> HashMap Text (GVal (Run (Writer Text) Text))- -> IO (HashMap Text (Items (BackendData IO Html)))-loadBackendDict writeLog postBodySrc cache backendPaths required globalContext = do+ -> HashMap Text (GVal (Run SourcePos IO Text))+ -> IO (HashMap Text (Items (BackendData SourcePos IO Html)))+loadBackendDict writeLog reqCtx cache backendPaths required globalContext = do mapFromList <$> go globalContext (AList.toList backendPaths) where- go :: HashMap Text (GVal (Run (Writer Text) Text))+ go :: HashMap Text (GVal (Run SourcePos IO Text)) -> [(Text, BackendSpec)]- -> IO [(Text, Items (BackendData IO Html))]- go _ [] = return []+ -> IO [(Text, Items (BackendData SourcePos IO Html))] go context ((key, backendSpec):specs) = do- let expBackendSpec = (expandReplacementBackend context backendSpec)- bd :: Items (BackendData IO Html)+ expBackendSpec <- expandReplacementBackend context backendSpec+ bd :: Items (BackendData SourcePos IO Html) <- loadBackendData writeLog- postBodySrc+ reqCtx cache expBackendSpec+ Foldable.traverse_ (verifyBD writeLog reqCtx) bd resultItem <- case bd of NotFound -> if key `elem` required then throwM NotFoundException else return (key, NotFound) _ -> return (key, bd)- let bdG :: GVal (Run IO Html)+ let bdG :: GVal (Run SourcePos IO Html) bdG = toGVal bd- bdGP :: GVal (Run (Writer Text) Text)- bdGP = marshalGVal bdG+ bdGP :: GVal (Run SourcePos IO Text)+ bdGP = marshalGValHtmlToText bdG context' = insertMap key bdGP context remainder <- go context' specs return $ resultItem:remainder+ go _ _ = return []++verifyBD :: (LogLevel -> Text -> IO ()) -> RequestContext -> BackendData SourcePos IO Html -> IO ()+verifyBD writeLog reqCtx bd =+ case bdVerification bd of+ Trusted -> do+ writeLog Debug "Trusted"+ return ()+ VerifyCSRF -> do+ writeLog Debug "CSRF"+ let csrfHeaderMay = decodeUtf8 <$> lookupHeader reqCtx "X-Form-Token"+ csrfFormFieldMay =+ (fromJSONMay (bdJSON bd) :: Maybe (HashMap Text JSON.Value))+ >>= lookup "__form_token"+ >>= fromJSONMay+ writeLog Debug $ "POST (JSON): " <> tshow (bdJSON bd)+ case sessionHandle reqCtx of+ Nothing -> do+ -- No session means there's no need to check the+ -- CSRF token, because without a session, the user+ -- cannot be holding an authenticated identity.+ writeLog Notice "No session, not performing CSRF validation"+ return ()+ Just session -> do+ writeLog Notice "Session found, checking CSRF token"+ csrfToken <- maybe+ (throwM RequestValidationException)+ return+ =<< sessionGet session "csrf"+ let candidates :: [Text]+ candidates = catMaybes [csrfHeaderMay, csrfFormFieldMay]+ writeLog Notice . pack $ printf "CSRF token: %s; candidates: %s"+ (show csrfToken) (show candidates)+ when (null candidates)+ (throwM RequestValidationException)+ when (any (/= csrfToken) candidates)+ (throwM RequestValidationException)++fromJSONMay :: FromJSON a => JSON.Value -> Maybe a+fromJSONMay x = case JSON.fromJSON x of+ JSON.Error _ -> Nothing+ JSON.Success a -> Just a++marshalGValHtmlToText :: GVal (Run SourcePos IO Html) -> GVal (Run SourcePos IO Text)+marshalGValHtmlToText = marshalGValEx hoistRunToText hoistRunFromText++hoistRunToText :: Run SourcePos IO Html a -> Run SourcePos IO Text a+hoistRunToText =+ hoistRun htmlSource unsafeRawHtml+ . withEncoder (unsafeRawHtml . Ginger.asText)+hoistRunFromText:: Run SourcePos IO Text a -> Run SourcePos IO Html a+hoistRunFromText =+ hoistRun unsafeRawHtml htmlSource+
@@ -11,7 +11,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends import qualified Network.Wai as Wai import Web.Sprinkles.Logger as Logger@@ -21,7 +21,7 @@ import Network.HTTP.Types (Status, status200, status302, status400, status404, status500) import Web.Sprinkles.Backends.Loader.Type- (PostBodySource (..), pbsFromRequest, pbsInvalid)+ (RequestContext (..), pbsFromRequest, pbsInvalid) import Data.Aeson as JSON import Data.Aeson.Encode.Pretty as JSON import Data.Aeson.TH as JSON@@ -31,6 +31,7 @@ handleJSONTarget :: ContextualHandler handleJSONTarget backendData project+ session request respond = do respond $ Wai.responseLBS
@@ -11,7 +11,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends import qualified Network.Wai as Wai import Web.Sprinkles.Logger as Logger@@ -21,7 +21,7 @@ import Network.HTTP.Types (Status, status200, status302, status400, status404, status500) import Web.Sprinkles.Backends.Loader.Type- (PostBodySource (..), pbsFromRequest, pbsInvalid)+ (RequestContext (..), pbsFromRequest, pbsInvalid) import qualified Data.ByteString.Lazy.UTF8 as LUTF8 import qualified Data.ByteString.UTF8 as UTF8 import Data.AList (AList)@@ -30,6 +30,7 @@ handleRedirectTarget redirectPath backendData project+ session request respond = respond $ Wai.responseLBS
@@ -7,19 +7,23 @@ {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE TypeApplications #-}+ module Web.Sprinkles.Handlers.Respond ( respondTemplateHtml , respondTemplateText ) where -import ClassyPrelude+import Web.Sprinkles.Prelude hiding (Builder) import Web.Sprinkles.Backends import qualified Network.Wai as Wai import Web.Sprinkles.Logger as Logger import Web.Sprinkles.Project import Web.Sprinkles.ProjectConfig import Web.Sprinkles.Exceptions+import Web.Sprinkles.TemplateContext+import Web.Sprinkles.SessionHandle import Text.Ginger (parseGinger, Template, runGingerT, GingerContext, GVal(..), ToGVal(..),@@ -29,7 +33,7 @@ import qualified Data.ByteString.UTF8 as UTF8 import qualified Data.ByteString.Lazy.UTF8 as LUTF8-import Data.ByteString.Builder (stringUtf8)+import Data.ByteString.Builder (stringUtf8, Builder) import qualified Data.Yaml as YAML import qualified Data.Aeson as JSON import qualified Data.Aeson.Encode.Pretty as JSON@@ -46,7 +50,7 @@ import Network.HTTP.Types.URI (queryToQueryText) import Web.Sprinkles.Backends.Loader.Type- (PostBodySource (..), pbsFromRequest, pbsInvalid)+ (RequestContext (..), pbsFromRequest, pbsInvalid) instance ToGVal m ByteString where toGVal = toGVal . UTF8.toString@@ -54,193 +58,87 @@ instance ToGVal m (CI.CI ByteString) where toGVal = toGVal . CI.original -instance ToGVal m Wai.Request where- toGVal rq =- Ginger.orderedDict- [ "httpVersion" ~> tshow (Wai.httpVersion rq)- , "method" ~> decodeUtf8 (Wai.requestMethod rq)- , "path" ~> decodeUtf8 (Wai.rawPathInfo rq)- , "query" ~> decodeUtf8 (Wai.rawQueryString rq)- , "pathInfo" ~> Wai.pathInfo rq- , ( "queryInfo"- , Ginger.orderedDict- [ (key, toGVal val)- | (key, val)- <- queryToQueryText (Wai.queryString rq)- ]- )- , ( "headers"- , Ginger.orderedDict- [ (decodeCI n, toGVal $ decodeUtf8 v)- | (n, v)- <- Wai.requestHeaders rq- ]- )- ]--decodeCI :: CI.CI ByteString -> Text-decodeCI = decodeUtf8 . CI.original--respondTemplateHtml :: ToGVal (Ginger.Run IO Html) a+respondTemplateHtml :: ToGVal (Ginger.Run Ginger.SourcePos IO Html) a => Project+ -> Maybe SessionHandle -> Status -> Text -> HashMap Text a -> Wai.Application-respondTemplateHtml project- status- templateName- contextMap- request- respond = do- let contentType = "text/html;charset=utf8"- contextLookup = mkContextLookup request project contextMap- headers = [("Content-type", contentType)]- template <- getTemplate project templateName- respond . Wai.responseStream status headers $ \write flush -> do- let writeHtml = write . stringUtf8 . unpack . htmlSource- context :: GingerContext IO Html- context = Ginger.makeContextHtmlM contextLookup writeHtml- runGingerT context template- flush+respondTemplateHtml =+ respondTemplate+ contentType+ writeText+ makeContext+ where+ contentType = "text/html;charset=utf8"+ writeText write = write . stringUtf8 . unpack . htmlSource+ makeContext = Ginger.makeContextHtmlM -respondTemplateText :: ToGVal (Ginger.Run IO Text) a+respondTemplateText :: ToGVal (Ginger.Run Ginger.SourcePos IO Text) a => Project+ -> Maybe SessionHandle -> Status -> Text -> HashMap Text a -> Wai.Application-respondTemplateText project- status- templateName- contextMap- request- respond = do- let contentType = "text/plain;charset=utf8"- contextLookup = mkContextLookup request project contextMap+respondTemplateText =+ respondTemplate+ contentType+ writeText+ makeContext+ where+ contentType = "text/plain;charset=utf8"+ writeText write = write . stringUtf8 . unpack @Text+ makeContext = Ginger.makeContextTextM++respondTemplate :: ToGVal (Ginger.Run Ginger.SourcePos IO h) a+ => ToGVal (Ginger.Run Ginger.SourcePos IO h) h+ => Monoid h+ => ByteString -- ^ content type+ -> ( (Builder -> IO ())+ -> h -> IO ()+ )+ -> ( (Text -> Ginger.Run Ginger.SourcePos IO h (GVal (Ginger.Run Ginger.SourcePos IO h)))+ -> (h -> IO ())+ -> GingerContext Ginger.SourcePos IO h+ )+ -> Project+ -> Maybe SessionHandle+ -> Status+ -> Text+ -> HashMap Text a+ -> Wai.Application+respondTemplate contentType+ writeText+ makeContext+ project+ session+ status+ templateName+ contextMap+ request+ respond = do+ let contextLookup = mkContextLookup request project session contextMap headers = [("Content-type", contentType)] template <- getTemplate project templateName respond . Wai.responseStream status headers $ \write flush -> do- let writeText = write . stringUtf8 . unpack- context :: GingerContext IO Text- context = Ginger.makeContextTextM contextLookup writeText+ let context = makeContext contextLookup (writeText write) runGingerT context template flush -mkContextLookup :: (ToGVal (Ginger.Run IO h) a)+mkContextLookup :: (ToGVal (Ginger.Run Ginger.SourcePos IO h) a) => Wai.Request -> Project+ -> Maybe SessionHandle -> HashMap Text a -> Text- -> Ginger.Run IO h (GVal (Ginger.Run IO h))-mkContextLookup request project contextMap key = do+ -> Ginger.Run Ginger.SourcePos IO h (GVal (Ginger.Run Ginger.SourcePos IO h))+mkContextLookup request project session contextMap key = do let cache = projectBackendCache project logger = projectLogger project- contextMap' =- fmap toGVal contextMap <>- mapFromList- [ "request" ~> request- , ("load", Ginger.fromFunction (gfnLoadBackendData (writeLog logger) cache))- , ("ellipse", Ginger.fromFunction gfnEllipse)- , ("json", Ginger.fromFunction gfnJSON)- , ("yaml", Ginger.fromFunction gfnYAML)- , ("getlocale", Ginger.fromFunction (gfnGetLocale (writeLog logger)))- , ("pandoc", Ginger.fromFunction (gfnPandoc (writeLog logger)))- , ("markdown", Ginger.fromFunction (gfnPandocAlias "markdown" (writeLog logger)))- , ("textile", Ginger.fromFunction (gfnPandocAlias "textile" (writeLog logger)))- , ("rst", Ginger.fromFunction (gfnPandocAlias "rst" (writeLog logger)))- , ("creole", Ginger.fromFunction (gfnPandocAlias "creole" (writeLog logger)))- ]+ sprinklesContext <- liftIO $+ sprinklesGingerContext cache request session logger+ let contextMap' =+ fmap toGVal contextMap <> sprinklesContext return . fromMaybe def $ lookup key contextMap'--gfnLoadBackendData :: forall h. (LogLevel -> Text -> IO ()) -> RawBackendCache -> Ginger.Function (Ginger.Run IO h)-gfnLoadBackendData writeLog cache args =- Ginger.dict <$> forM (zip [0..] args) loadPair- where- loadPair :: (Int, (Maybe Text, GVal (Ginger.Run IO h)))- -> Ginger.Run IO h (Text, GVal (Ginger.Run IO h))- loadPair (index, (keyMay, gBackendURL)) = do- let backendURL = Ginger.asText gBackendURL- backendData :: Items (BackendData IO h) <- liftIO $- loadBackendData writeLog pbsInvalid cache =<< parseBackendURI backendURL- return- ( fromMaybe (tshow index) keyMay- , toGVal backendData- )--catchToGinger :: forall h m. (LogLevel -> Text -> IO ())- -> IO (GVal m)- -> IO (GVal m)-catchToGinger writeLog action =- action- `catch` (\(e :: SomeException) -> do- writeLog Logger.Error . formatException $ e- return . toGVal $ False- )--gfnPandoc :: forall h. (LogLevel -> Text -> IO ()) -> Ginger.Function (Ginger.Run IO h)-gfnPandoc writeLog args = liftIO . catchToGinger writeLog $- case Ginger.extractArgsDefL [("src", ""), ("reader", "markdown")] args of- Right [src, readerName] -> toGVal <$> pandoc (Ginger.asText readerName) (Ginger.asText src)- _ -> throwM $ GingerInvalidFunctionArgs "pandoc" "string src, string reader"--gfnPandocAlias :: forall h. Text -> (LogLevel -> Text -> IO ()) -> Ginger.Function (Ginger.Run IO h)-gfnPandocAlias readerName writeLog args = liftIO . catchToGinger writeLog $- case Ginger.extractArgsDefL [("src", "")] args of- Right [src] -> toGVal <$> pandoc readerName (Ginger.asText src)- _ -> throwM $ GingerInvalidFunctionArgs "pandoc" "string src, string reader"--pandoc :: Text -> Text -> IO Pandoc.Pandoc-pandoc readerName src = do- reader <- either- (\err -> fail $ "Invalid reader: " ++ show err)- return- (getReader $ unpack readerName)- let read = case reader of- Pandoc.StringReader r -> r Pandoc.def . unpack- Pandoc.ByteStringReader r -> fmap (fmap fst) . r Pandoc.def . encodeUtf8- read (fromStrict src) >>= either- (\err -> fail $ "Reading " ++ show readerName ++ " failed: " ++ show err)- return- where- getReader "creole" = Right $ Pandoc.mkStringReader Pandoc.readCreole- getReader readerName = Pandoc.getReader readerName--gfnGetLocale :: forall h. (LogLevel -> Text -> IO ()) -> Ginger.Function (Ginger.Run IO h)-gfnGetLocale writeLog args = liftIO . catchToGinger writeLog $- case Ginger.extractArgsDefL [("category", "LC_TIME"), ("locale", "")] args of- Right [gCat, gName] ->- case (Ginger.asText gCat, Text.unpack . Ginger.asText $ gName) of- ("LC_TIME", "") -> toGVal <$> getLocale Nothing- ("LC_TIME", localeName) -> toGVal <$> getLocale (Just localeName)- (cat, localeName) -> return def -- valid call, but category not implemented- _ -> throwM $ GingerInvalidFunctionArgs "getlocale" "string category, string name"--gfnEllipse :: Ginger.Function (Ginger.Run IO h)-gfnEllipse [] = return def-gfnEllipse [(Nothing, str)] =- gfnEllipse [(Nothing, str), (Nothing, toGVal (100 :: Int))]-gfnEllipse [(Nothing, str), (Nothing, len)] = do- let txt = Ginger.asText str- actualLen = ClassyPrelude.length txt- targetLen = fromMaybe 100 $ ceiling <$> Ginger.asNumber len- txt' = if actualLen + 3 > targetLen- then take (targetLen - 3) txt <> "..."- else txt- return . toGVal $ txt'-gfnEllipse ((Nothing, str):xs) = do- let len = fromMaybe (toGVal (100 :: Int)) $ lookup (Just "len") xs- gfnEllipse [(Nothing, str), (Nothing, len)]-gfnEllipse xs = do- let str = fromMaybe def $ lookup (Just "str") xs- gfnEllipse $ (Nothing, str):xs--gfnJSON :: Ginger.Function (Ginger.Run IO h)-gfnJSON [] = return def-gfnJSON ((_, x):xs) =- return . toGVal . LUTF8.toString . JSON.encodePretty $ x--gfnYAML :: Ginger.Function (Ginger.Run IO h)-gfnYAML [] = return def-gfnYAML ((_, x):xs) =- return . toGVal . UTF8.toString . YAML.encode $ x
@@ -10,7 +10,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends import qualified Network.Wai as Wai import Web.Sprinkles.Logger as Logger@@ -20,7 +20,7 @@ import Network.HTTP.Types (Status, status200, status206, status302, status400, status404, status500) import Web.Sprinkles.Backends.Loader.Type- (PostBodySource (..), pbsFromRequest, pbsInvalid)+ (RequestContext (..), pbsFromRequest, pbsInvalid) import Web.Sprinkles.Backends.Data (RawBytes (..)) import Data.AList (AList)@@ -34,6 +34,7 @@ handleStaticTarget childPathMay backendData project+ session request respond = do backendItemBase <- case lookup "file" backendData of
@@ -11,7 +11,7 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Backends import qualified Network.Wai as Wai import Web.Sprinkles.Logger as Logger@@ -22,18 +22,21 @@ import Network.HTTP.Types (Status, status200, status302, status400, status404, status500) import Web.Sprinkles.Backends.Loader.Type- (PostBodySource (..), pbsFromRequest, pbsInvalid)+ (RequestContext (..), pbsFromRequest, pbsInvalid) import Data.AList (AList) import qualified Data.AList as AList+import Web.Sprinkles.SessionHandle handleTemplateTarget :: Text -> ContextualHandler handleTemplateTarget templateName backendData project+ session request respond = respondTemplateHtml project+ session status200 templateName backendData
@@ -14,11 +14,12 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Control.Concurrent (forkIO)-import Data.Aeson (FromJSON (..), Value (..), (.:))+import Data.Aeson (FromJSON (..), Value (..), (.:), withObject) import qualified System.Posix.Syslog as Syslog import Data.Default (Default (..))+import Foreign.C.String (withCStringLen) data LogLevel = Debug | Notice@@ -51,7 +52,7 @@ deriving (Show, Eq) instance FromJSON LogMessage where- parseJSON (Object o) =+ parseJSON = withObject "LogMessage" $ \o -> do LogMessage <$> o .: "timestamp" <*> o .: "level" <*> o .: "message"@@ -103,11 +104,13 @@ Syslog.withSyslog "sprinkles" []- Syslog.USER- (Syslog.logUpTo $ logLevelToSyslogPrio level) $+ Syslog.User $ do+ let minPrio = logLevelToSyslogPrio level+ Syslog.setlogmask [minPrio..maxBound]+ withCStringLen (unpack . lmMessage $ msg) $ Syslog.syslog- (logLevelToSyslogPrio . lmLevel $ msg)- (unpack . lmMessage $ msg)+ Nothing+ (logLevelToSyslogPrio . lmLevel $ msg) -- | A logger that wraps another logger and adds line buffering. newBufferedLogger :: Logger -> IO Logger
@@ -7,7 +7,7 @@ module Web.Sprinkles.MatchedText where -import ClassyPrelude+import Web.Sprinkles.Prelude import qualified Text.Ginger as Ginger import Text.Ginger
@@ -0,0 +1,15 @@+module Web.Sprinkles.Pandoc+where++import Text.Pandoc++pandocReaderOptions :: ReaderOptions+pandocReaderOptions = def+ { readerStandalone =+ True+ , readerExtensions =+ disableExtension Ext_raw_html+ . disableExtension Ext_raw_attribute+ . disableExtension Ext_raw_tex+ $ pandocExtensions+ }
@@ -7,10 +7,11 @@ module Web.Sprinkles.PandocGVal where -import ClassyPrelude hiding (asText, asList)+import Web.Sprinkles.Prelude hiding (asText, asList) import Text.Ginger as Ginger (GVal (..), ToGVal (..), dict, (~>)) import Text.Ginger.Html (unsafeRawHtml) import qualified Text.Ginger as Ginger+import qualified Text.Ginger.Html as Ginger import qualified Text.Ginger.Run.VM as Ginger import Text.Pandoc import Text.Pandoc.Walk (query, walk)@@ -19,12 +20,9 @@ import Data.Scientific (fromFloatDigits) writerOptions :: WriterOptions-writerOptions =- def { writerStandalone = False- , writerHtml5 = True- }+writerOptions = def -gfnWithMediaRoot :: Monad m => Pandoc -> Ginger.Function (Ginger.Run m h)+gfnWithMediaRoot :: Monad m => Pandoc -> Ginger.Function (Ginger.Run p m h) gfnWithMediaRoot pandoc args = do defMediaroot <- fromMaybe def . Ginger.lookupKey "path" <$> Ginger.getVar "request" let extracted =@@ -38,7 +36,7 @@ return $ toGVal pandoc' _ -> return def -gfnWithAppRoot :: Monad m => Pandoc -> Ginger.Function (Ginger.Run m h)+gfnWithAppRoot :: Monad m => Pandoc -> Ginger.Function (Ginger.Run p m h) gfnWithAppRoot pandoc args = do defApproot <- Ginger.getVar "approot" let extracted =@@ -84,7 +82,7 @@ relativeUrlPrefix :: String -> Pandoc -> Pandoc relativeUrlPrefix prefix = modifyUrls (prefixRelativeUrl prefix) -instance Monad m => ToGVal (Ginger.Run m h) Pandoc where+instance Monad m => ToGVal (Ginger.Run p m h) Pandoc where toGVal pandoc@(Pandoc meta blocks) = def { asList = Just $ map toGVal blocks , asDictItems =@@ -104,12 +102,12 @@ "withMediaRoot" -> Just . Ginger.fromFunction . gfnWithMediaRoot $ pandoc "withAppRoot" -> Just . Ginger.fromFunction . gfnWithAppRoot $ pandoc _ -> Nothing- , asHtml = unsafeRawHtml . pack . writeHtmlString writerOptions $ pandoc+ , asHtml = pandocToHtml pandoc , asText = unwords . fmap (asText . toGVal) $ blocks , asBoolean = True , asNumber = Nothing , asFunction = Nothing- , Ginger.length = Just (ClassyPrelude.length blocks)+ , Ginger.length = Just (Web.Sprinkles.Prelude.length blocks) , isNull = False } @@ -138,12 +136,12 @@ in def { asList = Just listItems , asDictItems = Just $ mapToList props , asLookup = Just $ \key -> lookup key props- , asHtml = unsafeRawHtml . pack . writeHtmlString writerOptions $ pandoc+ , asHtml = pandocToHtml pandoc , asText = unwords . fmap ((<> " ") . asText) $ listItems , asBoolean = True , asNumber = Nothing , asFunction = Nothing- , Ginger.length = Just (ClassyPrelude.length listItems)+ , Ginger.length = Just (Web.Sprinkles.Prelude.length listItems) , isNull = False } @@ -195,6 +193,13 @@ ] , fmap toGVal items )+blockChildren (LineBlock items) =+ ( mapFromList+ [ "type" ~> ("lines" :: Text)+ , "items" ~> items+ ]+ , fmap toGVal items+ ) blockChildren (DefinitionList pairs) = ( mapFromList [ "type" ~> ("dl" :: Text)@@ -252,6 +257,12 @@ toGVal AlignCenter = toGVal ("center" :: Text) toGVal AlignDefault = def +pandocToHtml :: Pandoc -> Ginger.Html+pandocToHtml pandoc =+ case runPure $ writeHtml5String writerOptions pandoc of+ Left err -> unsafeRawHtml ""+ Right html -> unsafeRawHtml html+ instance ToGVal m Inline where toGVal inline = let pandoc = Pandoc nullMeta [Plain [inline]]@@ -263,12 +274,12 @@ in def { asList = Just listItems , asDictItems = Just $ mapToList props , asLookup = Just $ \key -> lookup key props- , asHtml = unsafeRawHtml . pack . writeHtmlString writerOptions $ pandoc+ , asHtml = pandocToHtml pandoc , asText = unwords . fmap asText $ listItems , asBoolean = True , asNumber = Nothing , asFunction = Nothing- , Ginger.length = Just (ClassyPrelude.length listItems)+ , Ginger.length = Just (Web.Sprinkles.Prelude.length listItems) , isNull = False }
@@ -8,7 +8,7 @@ module Web.Sprinkles.Pattern where -import ClassyPrelude hiding ( (<|>) )+import Web.Sprinkles.Prelude hiding ( (<|>), Any, option ) import Text.Parsec as Parsec import qualified Data.Text as Text import Data.Aeson as JSON@@ -128,7 +128,7 @@ return $ Regex body options regexCharP :: Parsec Text () Char-regexCharP = (char '\\' >> char '/') <|> noneOf "/"+regexCharP = (Parsec.try $ char '\\' >> char '/') <|> noneOf "/" regexOptionP :: Parsec Text () RE.CompOption regexOptionP = (char 'm' >> return RE.compMultiline)
@@ -0,0 +1,486 @@+{-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE FlexibleInstances #-}+{-#LANGUAGE FlexibleContexts #-}+{-#LANGUAGE RankNTypes #-}+{-#LANGUAGE FunctionalDependencies #-}+{-#LANGUAGE OverloadedStrings #-}+module Web.Sprinkles.Prelude+( module P+, LText+, LByteString+, Packable (..)+, MapLike (..)+, SetLike (..)+, TextLike (..)+, ListLike (..)+, StrictnessConvert (..)+, Encode (..)+, splitElem+, readMay+, Cased (..)+, throwM+, FileIO (..)+, hPut+, (++)+, concat+, empty+, getArgs+, getEnv+, lookupEnv+, sha1, sha256, sha512+)+where++import Prelude as P hiding ( unwords+ , words+ , lookup+ , length+ , take+ , drop+ , takeWhile+ , dropWhile+ , null+ , tail+ , break+ , filter+ , unlines+ , putStr+ , putStrLn+ , hPutStr+ , hPutStrLn+ , readFile+ , writeFile+ , getContents+ , hGetContents+ , (++)+ , concat+ , empty+ , sha1+ )++import Data.Text as P (Text)+import Data.ByteString as P (ByteString)+import Data.Hashable (Hashable)+import Data.Map as P (Map)+import Data.HashMap.Strict as P (HashMap)+import Data.Set as P (Set)+import Data.HashSet as P (HashSet)+import Data.Hashable as P (Hashable (..))+import Data.Maybe as P (fromMaybe, catMaybes, isNothing, isJust)+import Data.String as P (IsString (..))+import Data.Vector as P (Vector)+import Data.List as P (sortOn)+import Data.Word as P (Word8, Word16, Word32, Word64, Word)+import Data.Int as P (Int8, Int16, Int32, Int64)+import Control.Monad as P+import Data.Semigroup as P hiding (getAll, All)+import Data.Monoid as P hiding (getFirst, getLast, First, Last, getAll, All)+import Control.Monad.IO.Class as P+import Control.Exception as P (bracket, bracket_, throw, catch)+import Control.Applicative as P hiding (empty)+import Data.IORef as P+import Control.Concurrent as P+import Control.Concurrent.STM as P+import Control.Concurrent.Chan as P+import Control.Concurrent.MVar as P+import Control.Exception as P hiding (throw)+import Data.Time as P (UTCTime (..), getCurrentTime)+import GHC.Generics as P (Generic)+import System.IO as P (stdin, stdout, stderr, Handle)+import System.IO.Error as P+import System.FilePath as P+import Text.Printf as P (printf)+import Control.Monad.Identity as P++import qualified Prelude+import qualified Data.List as List+import qualified Data.List.Split as List (splitWhen, splitOn)+import qualified Data.Map as Map+import qualified Data.Set as Set+import qualified Data.HashMap.Strict as HashMap+import qualified Data.HashSet as HashSet+import qualified Data.Text.Lazy as LText+import qualified Data.Text.Lazy.IO as LText+import qualified Data.Text.Lazy.Encoding as LText+import qualified Data.Text as Text+import qualified Data.Text.IO as Text+import qualified Data.Text.Encoding as Text+import qualified Data.Char as Char (toLower, toUpper)+import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString as BS+import qualified Data.Vector as Vector+import qualified System.IO+import qualified System.Environment as Env+import qualified Data.Digest.Pure.SHA as SHA+import Text.Read (readMaybe)+import Control.Exception (throw)++readMay :: (Read a, Packable t [Char]) => t -> Maybe a+readMay = readMaybe . unpack++throwM :: (Exception e, Monad m) => e -> m a+throwM = throw++type LText = LText.Text++type LByteString = LBS.ByteString++(++) :: Semigroup m => m -> m -> m+(++) = (<>)++concat :: Monoid m => [m] -> m+concat = mconcat++empty :: Monoid m => m+empty = mempty++class Packable t s | t -> s where+ pack :: s -> t+ unpack :: t -> s++instance Packable Text [Char] where+ pack = Text.pack+ unpack = Text.unpack++instance Packable LText [Char] where+ pack = LText.pack+ unpack = LText.unpack++instance Packable [a] [a] where+ pack = id+ unpack = id+++class MapLike m k v | m -> k, m -> v where+ mapFromList :: [(k,v)] -> m+ mapToList :: m -> [(k,v)]+ lookup :: k -> m -> Maybe v+ insertMap :: k -> v -> m -> m+ deleteMap :: k -> m -> m+ keys :: m -> [k]++instance (Eq k, Hashable k) => MapLike (HashMap k v) k v where+ mapFromList = HashMap.fromList+ mapToList = HashMap.toList+ lookup = HashMap.lookup+ insertMap = HashMap.insert+ deleteMap = HashMap.delete+ keys = HashMap.keys++instance (Ord k) => MapLike (Map k v) k v where+ mapFromList = Map.fromList+ mapToList = Map.toList+ lookup = Map.lookup+ insertMap = Map.insert+ deleteMap = Map.delete+ keys = Map.keys++instance (Ord k, Eq k) => MapLike [(k, v)] k v where+ mapFromList = id+ mapToList = id+ lookup = List.lookup+ insertMap = \k v -> ((k,v):)+ deleteMap = \k -> List.filter ((/= k) . fst)+ keys = map fst++class SetLike l c | l -> c where+ setFromList :: [c] -> l++instance Ord a => SetLike (Set a) a where+ setFromList = Set.fromList++class ListLike l c | l -> c where+ length :: l -> Int+ take :: Int -> l -> l+ drop :: Int -> l -> l+ takeWhile :: (c -> Bool) -> l -> l+ dropWhile :: (c -> Bool) -> l -> l+ break :: (c -> Bool) -> l -> (l, l)+ splitWhen :: (c -> Bool) -> l -> [l]+ splitSeq :: Eq c => l -> l -> [l]+ splitSeq = \sep lst ->+ map fromList $ List.splitOn (toList sep) (toList lst)+ toList :: l -> [c]+ fromList :: [c] -> l+ null :: l -> Bool+ headMay :: l -> Maybe c+ headMay = \xs -> case toList xs of+ (x:_) -> Just x+ _ -> Nothing+ tail :: l -> l+ tail = drop 1+ intercalate :: l -> [l] -> l+ filter :: (c -> Bool) -> l -> l+ filter = \p -> fromList . List.filter p . toList++splitElem :: (Eq c, ListLike l c) => c -> l -> [l]+splitElem c = splitWhen (== c)++instance ListLike [a] a where+ length = List.length+ take = List.take+ drop = List.drop+ takeWhile = List.takeWhile+ dropWhile = List.dropWhile+ break = List.break+ splitWhen = List.splitWhen+ toList = id+ fromList = id+ null = List.null+ intercalate = List.intercalate+ filter = List.filter++instance ListLike (Vector a) a where+ length = Vector.length+ take = Vector.take+ drop = Vector.drop+ takeWhile = Vector.takeWhile+ dropWhile = Vector.dropWhile+ break = Vector.break+ splitWhen = splitVectorWhen+ toList = Vector.toList+ fromList = Vector.fromList+ null = Vector.null+ intercalate = mintercalate+ filter = Vector.filter++mintercalate :: Monoid m => m -> [m] -> m+mintercalate _ [] = mempty+mintercalate _ [x] = x+mintercalate sep (x:xs) = x <> sep <> mintercalate sep xs++splitVectorWhen :: (a -> Bool) -> Vector a -> [Vector a]+splitVectorWhen p v = case Vector.findIndex p v of+ Nothing ->+ [v]+ Just index ->+ let (current, remainder) = Vector.splitAt index v+ in current : splitVectorWhen p remainder++instance ListLike Text Char where+ length = Text.length+ take = Text.take+ drop = Text.drop+ takeWhile = Text.takeWhile+ dropWhile = Text.dropWhile+ break = Text.break+ splitWhen = Text.split+ toList = Text.unpack+ fromList = Text.pack+ null = Text.null+ intercalate = Text.intercalate+ filter = Text.filter++instance ListLike LText Char where+ length = fromIntegral . LText.length+ take = LText.take . fromIntegral+ drop = LText.drop . fromIntegral+ takeWhile = LText.takeWhile+ dropWhile = LText.dropWhile+ break = LText.break+ splitWhen = LText.split+ toList = LText.unpack+ fromList = LText.pack+ null = LText.null+ intercalate = LText.intercalate+ filter = LText.filter++instance ListLike ByteString Word8 where+ length = BS.length+ take = BS.take+ drop = BS.drop+ takeWhile = BS.takeWhile+ dropWhile = BS.dropWhile+ break = BS.break+ splitWhen = BS.splitWith+ toList = BS.unpack+ fromList = BS.pack+ null = BS.null+ intercalate = BS.intercalate+ filter = BS.filter++instance ListLike LByteString Word8 where+ length = fromIntegral . LBS.length+ take = LBS.take . fromIntegral+ drop = LBS.drop . fromIntegral+ takeWhile = LBS.takeWhile+ dropWhile = LBS.dropWhile+ break = LBS.break+ splitWhen = LBS.splitWith+ toList = LBS.unpack+ fromList = LBS.pack+ null = LBS.null+ intercalate = LBS.intercalate+ filter = LBS.filter++class (Monoid t, Semigroup t, IsString t) => TextLike t where+ unwords :: [t] -> t+ words :: t -> [t]+ isPrefixOf :: t -> t -> Bool+ isSuffixOf :: t -> t -> Bool+ tshow :: forall a. Show a => a -> t+ unlines :: [t] -> t+ unlines = mintercalate "\n"++instance TextLike [Char] where+ unwords = Prelude.unwords+ words = Prelude.words+ isPrefixOf = List.isPrefixOf+ isSuffixOf = List.isSuffixOf+ tshow = show++instance TextLike Text where+ unwords = Text.unwords+ words = Text.words+ isPrefixOf = Text.isPrefixOf+ isSuffixOf = Text.isSuffixOf+ tshow = pack . show+ unlines = Text.unlines++instance TextLike LText where+ unwords = LText.unwords+ words = LText.words+ isPrefixOf = LText.isPrefixOf+ isSuffixOf = LText.isSuffixOf+ tshow = pack . show+ unlines = LText.unlines++class Encode decoded encoded | decoded -> encoded, encoded -> decoded where+ encodeUtf8 :: decoded -> encoded+ decodeUtf8 :: encoded -> decoded++instance Encode Text ByteString where+ encodeUtf8 = Text.encodeUtf8+ decodeUtf8 = Text.decodeUtf8++instance Encode LText LByteString where+ encodeUtf8 = LText.encodeUtf8+ decodeUtf8 = LText.decodeUtf8++class StrictnessConvert strict lazy | strict -> lazy, lazy -> strict where+ toStrict :: lazy -> strict+ fromStrict :: strict -> lazy++instance StrictnessConvert ByteString LByteString where+ toStrict = LBS.toStrict+ fromStrict = LBS.fromStrict++instance StrictnessConvert Text LText where+ toStrict = LText.toStrict+ fromStrict = LText.fromStrict++class Cased t where+ toUpper :: t -> t+ toLower :: t -> t++instance Cased Char where+ toUpper = Char.toUpper+ toLower = Char.toLower++instance (Functor f, Cased t) => Cased (f t) where+ toUpper = fmap toUpper+ toLower = fmap toLower++instance Cased Text where+ toUpper = Text.toUpper+ toLower = Text.toLower++class (Semigroup s, IsString s) => FileIO s where+ getContents :: IO s+ readFile :: FilePath -> IO s+ writeFile :: FilePath -> s -> IO ()+ hGetContents :: Handle -> IO s+ putStr :: s -> IO ()+ putStrLn :: s -> IO ()+ putStrLn = putStr . (<> "\n")+ hPutStr :: Handle -> s -> IO ()+ hPutStrLn :: Handle -> s -> IO ()+ hPutStrLn = \h -> hPutStr h . (<> "\n")++instance FileIO String where+ getContents = System.IO.getContents + readFile = System.IO.readFile + writeFile = System.IO.writeFile + hGetContents = System.IO.hGetContents + putStr = System.IO.putStr + putStrLn = System.IO.putStrLn + hPutStr = System.IO.hPutStr + hPutStrLn = System.IO.hPutStrLn ++instance FileIO Text where+ getContents = Text.getContents + readFile = Text.readFile + writeFile = Text.writeFile + hGetContents = Text.hGetContents + putStr = Text.putStr + putStrLn = Text.putStrLn + hPutStr = Text.hPutStr + hPutStrLn = Text.hPutStrLn ++instance FileIO LText where+ getContents = LText.getContents + readFile = LText.readFile + writeFile = LText.writeFile + hGetContents = LText.hGetContents + putStr = LText.putStr + putStrLn = LText.putStrLn + hPutStr = LText.hPutStr + hPutStrLn = LText.hPutStrLn ++instance FileIO ByteString where+ getContents = BS.getContents + readFile = BS.readFile + writeFile = BS.writeFile + hGetContents = BS.hGetContents + putStr = BS.putStr + hPutStr = BS.hPutStr ++instance FileIO LByteString where+ getContents = LBS.getContents + readFile = LBS.readFile + writeFile = LBS.writeFile + hGetContents = LBS.hGetContents + putStr = LBS.putStr + hPutStr = LBS.hPutStr ++hPut :: FileIO s => Handle -> s -> IO ()+hPut = hPutStr++getArgs :: (Packable t String) => IO [t]+getArgs = map pack <$> Env.getArgs++getEnv :: (Packable v String) => String -> IO v+getEnv = fmap pack . Env.getEnv++lookupEnv :: (Packable v String) => String -> IO (Maybe v)+lookupEnv = fmap (fmap pack) . Env.lookupEnv++class HashDigest a where+ digest :: forall t. SHA.Digest t -> a++instance HashDigest String where+ digest = SHA.showDigest++instance HashDigest Text where+ digest = pack . digest++instance HashDigest LText where+ digest = pack . digest++instance HashDigest LByteString where+ digest = SHA.bytestringDigest++instance HashDigest ByteString where+ digest = toStrict . digest++instance HashDigest Integer where+ digest = SHA.integerDigest++sha1 :: HashDigest a => LByteString -> a+sha1 = digest . SHA.sha1++sha256 :: HashDigest a => LByteString -> a+sha256 = digest . SHA.sha1++sha512 :: HashDigest a => LByteString -> a+sha512 = digest . SHA.sha1
@@ -6,7 +6,7 @@ module Web.Sprinkles.Project where -import ClassyPrelude+import Web.Sprinkles.Prelude hiding (readFile) import Data.Aeson as JSON import Text.Ginger ( parseGinger@@ -16,11 +16,13 @@ , GVal (..) , ToGVal (..) , (~>)+ , SourcePos ) import Text.Ginger.Html (Html, htmlSource) import qualified Text.Ginger as Ginger import System.Directory (makeAbsolute, doesDirectoryExist, doesFileExist, getDirectoryContents) import System.FilePath+import System.IO (readFile) import Data.Time.Clock.POSIX (POSIXTime) import Web.Sprinkles.Exceptions@@ -32,8 +34,11 @@ import Web.Sprinkles.Cache.Filesystem (filesystemCache) import Web.Sprinkles.Cache.Memory (memCache) import Web.Sprinkles.Cache.Memcached (memcachedCache)+import Web.Sprinkles.SessionStore+import Web.Sprinkles.SessionStore.Database (sqlSessionStore, DSN (..), SqlDriver (SqliteDriver))+import Web.Sprinkles.SessionStore.InProc (inProcSessionStore) -newtype TemplateCache = TemplateCache (HashMap Text Template)+newtype TemplateCache = TemplateCache (HashMap Text (Template SourcePos)) data Project = Project@@ -41,17 +46,30 @@ , projectTemplates :: TemplateCache , projectBackendCache :: Cache ByteString ByteString , projectLogger :: Logger+ , projectSessionStore :: SessionStore+ , projectSessionConfig :: SessionConfig } -loadProject :: ServerConfig -> FilePath -> IO Project-loadProject sconfig dir = do+loadProject :: ServerConfig -> IO Project+loadProject sconfig = do+ let dir = scRootDir sconfig pconfig <- loadProjectConfig dir logger <- createLogger $ fromMaybe (StdioLog Warning) (scLogger sconfig) templates <- preloadTemplates logger dir- caches <- sequence $ fmap (createCache dir) (scBackendCache sconfig)- let cache = mconcat caches- return $ Project pconfig templates cache logger+ cache <- fmap mconcat+ . sequence+ . fmap (createCache dir)+ $ scBackendCache sconfig+ let sessionConfig = scSessions sconfig+ sessionStore <- createSessionStore sessionConfig+ return $ Project pconfig templates cache logger sessionStore sessionConfig +createSessionStore :: SessionConfig -> IO SessionStore+createSessionStore config = do+ case sessDriver config of+ SqlSessionDriver dsn -> sqlSessionStore dsn+ InProcSessionDriver -> inProcSessionStore+ _ -> return nullSessionStore createLogger :: LoggerConfig -> IO Logger createLogger DiscardLog =@@ -64,8 +82,7 @@ createCache :: FilePath -> BackendCacheConfig -> IO (Cache ByteString ByteString) createCache cwd (FilesystemCache dir expiration) = return $ filesystemCache- (unpack . decodeUtf8) -- "serialize" key- (encodeUtf8 . pack) -- "unserialize" key+ (sha1 . fromStrict) -- "serialize" key hPut -- write value hGetContents -- read value (cwd </> dir)@@ -105,7 +122,7 @@ prefix <- makeAbsolute $ dir </> "templates" allFilenames <- findFilesR isTemplateFile prefix filenames <- findFiles isTemplateFile prefix- templateSources <- forM allFilenames readFile+ templateSources <- forM allFilenames (readFile :: String -> IO String) let templateSourceMap :: HashMap String String templateSourceMap = mapFromList $@@ -127,7 +144,7 @@ Right t -> return t return . TemplateCache . mapFromList $ zip (map pack relativeFilenames) templates -getTemplate :: Project -> Text -> IO Template+getTemplate :: Project -> Text -> IO (Template SourcePos) getTemplate project templateName = do let TemplateCache tm = projectTemplates project maybe
@@ -5,7 +5,7 @@ module Web.Sprinkles.ProjectConfig where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Rule import Data.Aeson as JSON import Data.Aeson.TH@@ -26,18 +26,28 @@ } deriving (Show) +makeProjectPathsAbsolute :: FilePath -> ProjectConfig -> ProjectConfig+makeProjectPathsAbsolute dir (ProjectConfig context rules) =+ ProjectConfig (fmap goBackendSpec context) (fmap goRule rules)+ where+ goBackendSpec = makeBackendSpecPathsAbsolute dir+ goRule = makeRulePathsAbsolute dir+ instance Default ProjectConfig where def = ProjectConfig { pcContextData = AList.empty , pcRules = [] } +instance Semigroup ProjectConfig where+ (<>) = pcAppend+ instance Monoid ProjectConfig where mempty = def mappend = pcAppend instance FromJSON ProjectConfig where- parseJSON (Object obj) = do+ parseJSON = withObject "ProjectConfig" $ \obj -> do contextData <- fromMaybe AList.empty <$> obj .:? "data" rulesValue <- fromMaybe (toJSON ([] :: [Value])) <$> (obj .:? "rules" <|> obj .:? "Rules") rules <- parseJSON rulesValue@@ -66,5 +76,4 @@ loadProjectConfig :: FilePath -> IO ProjectConfig loadProjectConfig dir =- loadProjectConfigFile $ dir </> "project.yml"-+ fmap (makeProjectPathsAbsolute dir) . loadProjectConfigFile $ dir </> "project.yml"
@@ -11,13 +11,13 @@ ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Data.Aeson as JSON import Data.Aeson.TH as JSON import Text.Ginger ( Template- , runGinger+ , runGingerT , parseGinger- , makeContextText+ , makeContextTextM , ToGVal , toGVal , GVal (..)@@ -27,23 +27,25 @@ import Data.Default import Control.Monad.Writer (Writer) import Web.Sprinkles.Exceptions (formatException)+import Data.Text.Lazy.Builder as TextBuilder data ReplacementItem = Literal Text | Variable Text deriving (Show, Eq) -newtype Replacement = Replacement Template+newtype Replacement =+ Replacement (Template Ginger.SourcePos) deriving (Show) -instance FromJSON Replacement where+instance FromJSON (Replacement) where parseJSON val = (either (fail . unpack . formatException) return . parseReplacement) =<< parseJSON val -expandReplacementText :: HashMap Text (GVal (Run (Writer Text) Text))+expandReplacementText :: HashMap Text (GVal (Run Ginger.SourcePos IO Text)) -> Text- -> Either SomeException Text+ -> IO Text expandReplacementText variables input =- expandReplacement variables <$> parseReplacement input+ expandReplacement variables =<< either throwM return (parseReplacement input) parseReplacement :: Text -> Either SomeException Replacement parseReplacement input =@@ -53,9 +55,12 @@ Nothing (unpack input) -expandReplacement :: HashMap Text (GVal (Run (Writer Text) Text)) -> Replacement -> Text-expandReplacement variables (Replacement template) =- runGinger context template- where- context = makeContextText lookupFn- lookupFn varName = fromMaybe def $ lookup varName variables+expandReplacement :: HashMap Text (GVal (Run Ginger.SourcePos IO Text)) -> Replacement -> IO Text+expandReplacement variables (Replacement template) = do+ output <- newIORef (TextBuilder.fromText "")+ let emit :: Text -> IO ()+ emit t = modifyIORef' output (<> TextBuilder.fromText t)+ context = makeContextTextM lookupFn emit+ lookupFn varName = return . fromMaybe def $ lookup varName variables+ runGingerT context template+ toStrict . TextBuilder.toLazyText <$> readIORef output
@@ -6,7 +6,7 @@ module Web.Sprinkles.Rule where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Pattern import Web.Sprinkles.Replacement import Web.Sprinkles.Backends@@ -18,9 +18,10 @@ import qualified Network.HTTP.Types as HTTP import qualified Data.AList as AList import Data.AList (AList)-import Text.Ginger (Run, ToGVal (..), GVal)+import Text.Ginger (Run, ToGVal (..), GVal, SourcePos) import Control.Monad.Writer (Writer) import qualified Data.Set as Set+import Data.Expandable data RuleTarget p = TemplateTarget p |@@ -43,7 +44,23 @@ String "forever" -> return CacheForever String x -> fail $ "Invalid cache expiry: " ++ show x Number n -> return $ MaxAge (floor n)+ _ -> fail "Invalid client cache setting" +-- | Describes if and how to initialize a session for a request.+data SessionDirective = AcceptSession -- ^ Accept if given, but do not require+ | IgnoreSession -- ^ Ignore all sessions+ | CreateNewSession -- ^ Always create a new session+ | RequireSession -- ^ Require a session, fail if none exists+ deriving (Eq, Show)++instance FromJSON SessionDirective where+ parseJSON = \case+ String "ignore" -> return IgnoreSession+ String "new" -> return CreateNewSession+ String "accept" -> return AcceptSession+ String "require" -> return RequireSession+ _ -> fail "Invalid session directive"+ data Rule = Rule { ruleRoutePattern :: Pattern@@ -53,9 +70,17 @@ , ruleAcceptedMethods :: Set HTTP.Method , ruleCaching :: ClientCacheSetting , ruleContentTypeOverride :: Maybe ByteString+ , ruleSessionDirective :: SessionDirective } deriving (Show) +makeRulePathsAbsolute :: FilePath -> Rule -> Rule+makeRulePathsAbsolute dir rule =+ rule+ { ruleContextData =+ fmap (makeBackendSpecPathsAbsolute dir) (ruleContextData rule)+ }+ orElse :: Monad m => m (Maybe a) -> m (Maybe a) -> m (Maybe a) orElse leftAction rightAction = do leftAction >>= maybe rightAction (return . Just)@@ -82,6 +107,7 @@ contentTypeOverride <- (fmap encodeUtf8) <$> (obj .:? "content-type")+ sessionDirective <- obj .:? "session" .!= AcceptSession return $ Rule pattern contextData@@ -90,18 +116,25 @@ methods caching contentTypeOverride+ sessionDirective parseJSON x = fail $ "Expected rule, but found " <> show x -expandRuleTarget :: HashMap Text (GVal (Run (Writer Text) Text)) -> RuleTarget Replacement -> RuleTarget Text-expandRuleTarget _ JSONTarget = JSONTarget-expandRuleTarget varMap (StaticTarget pMay) = StaticTarget $ fmap (expandReplacement varMap) pMay-expandRuleTarget varMap (TemplateTarget p) = TemplateTarget $ expandReplacement varMap p-expandRuleTarget varMap (RedirectTarget p) = RedirectTarget $ expandReplacement varMap p+expandRuleTarget :: HashMap Text (GVal (Run SourcePos IO Text)) -> RuleTarget Replacement -> IO (RuleTarget Text)+expandRuleTarget _ JSONTarget =+ return JSONTarget+expandRuleTarget varMap (StaticTarget pMay) =+ StaticTarget <$> mapM (expandReplacement varMap) pMay+expandRuleTarget varMap (TemplateTarget p) =+ TemplateTarget <$> expandReplacement varMap p+expandRuleTarget varMap (RedirectTarget p) =+ RedirectTarget <$> expandReplacement varMap p -expandReplacementBackend :: HashMap Text (GVal (Run (Writer Text) Text))- -> BackendSpec+expandReplacementBackend :: HashMap Text (GVal (Run SourcePos IO Text)) -> BackendSpec-expandReplacementBackend varMap = omap (eitherThrow . expandReplacementText varMap)+ -> IO BackendSpec+expandReplacementBackend varMap spec = do+ bsType' <- expandM (expandReplacementText varMap) (bsType spec)+ return $ spec { bsType = bsType' } data NonMatchReason = PathNotMatched | MethodNotMatched@@ -159,6 +192,6 @@ ( Rule , HashMap Text MatchedText )-applyRules [] _ _ _ = Left PathNotMatched applyRules (rule:rules) method path query = applyRule rule method path query <|+> applyRules rules method path query+applyRules _ _ _ _ = Left PathNotMatched
@@ -6,13 +6,15 @@ {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE TypeApplications #-}+ module Web.Sprinkles.Serve ( serveProject , appFromProject ) where -import ClassyPrelude+import Web.Sprinkles.Prelude import Control.Concurrent (forkIO) import Data.Aeson as JSON import Data.Aeson.Encode.Pretty as JSON@@ -25,7 +27,7 @@ import Data.Time.Clock.POSIX (getPOSIXTime) import Data.Yaml as YAML import Data.AList (AList)-import Data.Time (formatTime, addUTCTime, getCurrentTime)+import Data.Time (formatTime, addUTCTime, getCurrentTime, defaultTimeLocale) import Network.HTTP.Types (Status, status200, status302, status400, status404, status500)@@ -38,26 +40,26 @@ import qualified Network.Wai.Handler.Warp as Warp import Network.Wai.Middleware.Autohead (autohead) -import System.Environment (lookupEnv) import System.Locale.Read (getLocale) import qualified Text.Ginger as Ginger+import qualified Text.Ginger.Run.VM as Ginger import Text.Ginger (parseGinger, Template, runGingerT, GingerContext, GVal(..), ToGVal(..),- (~>))-import Text.Ginger.Html (Html, htmlSource)-import qualified Text.Pandoc as Pandoc-import qualified Text.Pandoc.Readers.Creole as Pandoc+ (~>), SourcePos)+import Text.Ginger.Html (Html, htmlSource, unsafeRawHtml) import Web.Sprinkles.Exceptions import Web.Sprinkles.Backends import Web.Sprinkles.Cache+import Web.Sprinkles.SessionStore import Web.Sprinkles.Logger as Logger import Web.Sprinkles.Project import Web.Sprinkles.ProjectConfig import Web.Sprinkles.Rule+import Web.Sprinkles.Sessions import Web.Sprinkles.ServerConfig import Web.Sprinkles.Backends.Loader.Type- (PostBodySource (..), pbsFromRequest, pbsInvalid)+ (RequestContext (..), pbsFromRequest, pbsInvalid) import Web.Sprinkles.Handlers ( handleStaticTarget , handleNotFound@@ -71,10 +73,15 @@ ( loadBackendDict , NotFoundException (..) , MethodNotAllowedException (..)+ , NotAllowedException (..) , handle500 , handle404+ , handleNotAllowed+ , handleRequestValidation+ , marshalGValHtmlToText ) import Web.Sprinkles.MatchedText (MatchedText (..))+import Web.Sprinkles.TemplateContext (sprinklesGingerContext) serveProject :: ServerConfig -> Project -> IO () serveProject config project = do@@ -87,6 +94,7 @@ CGIDriver -> serveCGI SCGIDriver -> serveSCGI FastCGIDriver -> serveFastCGI+ BakeDriver -> serveWarp Nothing vacuum = forever $ do itemsCleared <- cacheVacuum (projectBackendCache project) when (itemsCleared > 0) $@@ -101,8 +109,8 @@ port <- case portMay of Just p -> return p Nothing -> do- portEnvStr <- lookupEnv "PORT"- let portEnv = fromMaybe 5000 $ portEnvStr >>= readMay+ portEnvStr <- lookupEnv ("PORT" :: String)+ let portEnv = fromMaybe 5000 $ portEnvStr >>= readMay @Int @String return portEnv writeLog (projectLogger project) Notice $ "Running server on port " <> tshow port <> "..."@@ -140,6 +148,7 @@ handleRequest project request respond = go `catch` handleNotFound project request respond `catch` handleMethodNotAllowed project request respond+ `catch` handleRequestValidation project request respond `catch` \e -> handle500 e project request respond where go = do@@ -166,12 +175,24 @@ handleRule :: Rule -> HashMap Text MatchedText -> Project -> Wai.Application handleRule rule captures project request respond = do+ session <- case ruleSessionDirective rule of+ IgnoreSession -> return Nothing+ AcceptSession -> resumeSession project request+ RequireSession -> resumeSession project request >>= \case+ Nothing -> throwM NotAllowedException+ Just s -> return $ Just s+ CreateNewSession -> return Nothing++ newSession <- case ruleSessionDirective rule of+ CreateNewSession -> newSession project request+ _ -> return session+ let cache = projectBackendCache project capturesG = fmap toGVal captures globalBackendSpecs = pcContextData . projectConfig $ project backendSpecs = ruleContextData $ rule- target = expandRuleTarget capturesG . ruleTarget $ rule logger = projectLogger project+ context <- (capturesG <>) <$> sprinklesGingerContext cache request newSession logger now <- getCurrentTime let oneYear = 86400 * 365 -- good enough@@ -181,7 +202,7 @@ MaxAge seconds -> addUTCTime (fromInteger seconds) now let respond' :: Wai.Response -> IO Wai.ResponseReceived- respond' = respond . addExpiryHeader . overrideContentType+ respond' = respond . addCookie . addExpiryHeader . overrideContentType overrideContentType :: Wai.Response -> Wai.Response overrideContentType =@@ -194,6 +215,9 @@ x -> x ) + addCookie :: Wai.Response -> Wai.Response+ addCookie = maybe id (setSessionCookie project request) newSession+ addExpiryHeader :: Wai.Response -> Wai.Response addExpiryHeader = let expiryHeader =@@ -205,17 +229,24 @@ ) in mapResponseHeaders (expiryHeader:) - backendData :: HashMap Text (Items (BackendData IO Html))+ backendData :: HashMap Text (Items (BackendData SourcePos IO Html)) <- loadBackendDict (writeLog logger)- (pbsFromRequest request)+ (pbsFromRequest request session) cache (globalBackendSpecs <> backendSpecs) (ruleRequired rule)- capturesG+ context - let handle :: HashMap Text (Items (BackendData IO Html))+ let context' :: HashMap Text (GVal (Ginger.Run SourcePos IO Html))+ context' = fmap toGVal backendData+ context'' :: HashMap Text (GVal (Ginger.Run SourcePos IO Text))+ context'' = fmap marshalGValHtmlToText context'+ target <- expandRuleTarget (context'' <> context) . ruleTarget $ rule++ let handle :: HashMap Text (Items (BackendData SourcePos IO Html)) -> Project+ -> Maybe SessionHandle -> Wai.Application handle = case target of RedirectTarget redirectPath ->@@ -235,5 +266,6 @@ handle backendData project+ session request respond'
@@ -7,7 +7,7 @@ module Web.Sprinkles.ServerConfig where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Rule import Data.Aeson as JSON import Data.Aeson.TH@@ -15,13 +15,13 @@ import Web.Sprinkles.Backends import Data.Default import System.FilePath.Glob (glob)-import System.Environment (getEnv, lookupEnv) import Control.MaybeEitherMonad (maybeFail) import System.Directory (doesFileExist) import Data.Scientific (Scientific) import Data.Time.Clock.POSIX (POSIXTime) import Web.Sprinkles.Logger (LogLevel (..)) import Web.Sprinkles.Exceptions+import Web.Sprinkles.Databases (DSN (..), SqlDriver (..)) data BackendCacheConfig = FilesystemCache FilePath POSIXTime |@@ -54,6 +54,7 @@ | CGIDriver | SCGIDriver | FastCGIDriver+ | BakeDriver | DefaultDriver deriving (Show, Read, Eq) @@ -71,8 +72,12 @@ return FastCGIDriver parseJSON (String "scgi") = return SCGIDriver+ parseJSON (String "bake") =+ return BakeDriver parseJSON (String "default") = return def+ parseJSON (String x) =+ fail $ "Invalid server driver " ++ show x parseJSON (Object o) = do st :: Text <- o .: "type" case st of@@ -81,6 +86,10 @@ "fcgi" -> return FastCGIDriver "fastcgi" -> return FastCGIDriver "scgi" -> return SCGIDriver+ "bake" -> return BakeDriver+ x -> fail $ "Invalid server driver " ++ show x+ parseJSON x =+ fail $ "Invalid server driver " ++ show x data LoggerConfig = DiscardLog |@@ -99,14 +108,72 @@ StdioLog . fromMaybe Warning <$> obj .:? "level" Just "syslog" -> Syslog . fromMaybe Warning <$> obj .:? "level"-+ Just x -> fail $ "Invalid logger type " ++ show x parseJSON _ = fail "Invalid logger config" +data SessionExpiration = NeverExpire + | SlidingExpiration Integer+ deriving (Show)++instance FromJSON SessionExpiration where+ parseJSON = \case+ Number n -> return . SlidingExpiration . floor $ n+ Null -> return NeverExpire+ String "never" -> return NeverExpire+ _ -> fail "Invalid session expiration"++data SessionDriver = NoSessionDriver+ | InProcSessionDriver+ | SqlSessionDriver DSN+ deriving (Show)++instance FromJSON SessionDriver where+ parseJSON = \case+ Null -> return NoSessionDriver+ String "inproc" -> return InProcSessionDriver+ String "sql" -> return $ SqlSessionDriver (DSN SqliteDriver "sessions.sqlite")+ String x -> fail $ "Invalid session driver " ++ show x+ Object obj -> do+ ty <- obj .: "type"+ case (ty :: Text) of+ "inproc" ->+ return InProcSessionDriver+ "sql" -> do+ SqlSessionDriver <$>+ obj .:? "connection" .!= DSN SqliteDriver "sessions.sqlite"+ x -> fail $ "Invalid session driver " ++ show x+ _ -> fail "Invalid session driver"++data SessionConfig =+ SessionConfig+ { sessCookieName :: ByteString+ , sessCookieSecure :: Bool+ , sessExpiration :: SessionExpiration+ , sessDriver :: SessionDriver+ }+ deriving (Show)++instance FromJSON SessionConfig where+ parseJSON (Object obj) = do+ cookieName <- encodeUtf8 <$> obj .:? "cookie-name" .!= "ssid"+ expiration <- obj .:? "expiration" .!= NeverExpire+ secure <- obj .:? "secure" .!= True -- secure default+ driver <- obj .:? "driver" .!= InProcSessionDriver+ return $ SessionConfig cookieName secure expiration driver+ parseJSON x = do+ driver <- parseJSON x+ return $ SessionConfig "ssid" True NeverExpire driver++instance Default SessionConfig where+ def = SessionConfig "ssid" True NeverExpire NoSessionDriver+ data ServerConfig = ServerConfig { scBackendCache :: [BackendCacheConfig] , scDriver :: ServerDriver , scLogger :: Maybe LoggerConfig+ , scSessions :: SessionConfig+ , scRootDir :: FilePath } deriving (Show) @@ -115,8 +182,13 @@ { scBackendCache = def , scDriver = def , scLogger = Nothing+ , scSessions = def+ , scRootDir = "" } +instance Semigroup ServerConfig where+ (<>) = scAppend+ instance Monoid ServerConfig where mempty = def mappend = scAppend@@ -130,10 +202,14 @@ driver <- fromMaybe def <$> ( obj .:? "driver" ) logger <- obj .:? "log"+ sessions <- obj .:? "sessions" .!= def+ rootDir <- obj .:? "dir" .!= "" return ServerConfig { scBackendCache = caches , scDriver = driver , scLogger = logger+ , scSessions = sessions+ , scRootDir = rootDir } parseJSON _ = fail "Invalid server config" @@ -147,6 +223,12 @@ if scDriver b == DefaultDriver then scDriver a else scDriver b+ , scSessions =+ case sessDriver (scSessions b) of+ NoSessionDriver -> scSessions a+ _ -> scSessions b+ , scRootDir = + firstNonNull (scRootDir b) (scRootDir a) } firstNonNull :: [a] -> [a] -> [a]@@ -162,7 +244,7 @@ loadServerConfig :: FilePath -> IO ServerConfig loadServerConfig dir = do- homeDirMay <- lookupEnv "HOME"+ homeDirMay <- lookupEnv ("HOME" :: String) let systemGlobalFilename = "/etc/sprinkles/server.yml" globalFilename = "/usr/local/etc/sprinkles/server.yml" userFilenameMay = (</> ".config" </> "sprinkles" </> "server.yml") <$> homeDirMay
@@ -0,0 +1,66 @@+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-}+{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE ScopedTypeVariables #-}+{-#LANGUAGE FlexibleInstances #-}+{-#LANGUAGE FlexibleContexts #-}+{-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE TypeApplications #-}+module Web.Sprinkles.SessionHandle+( SessionHandle (..)+, makeSessionHandle+)+where++import Web.Sprinkles.Prelude+import Web.Sprinkles.SessionStore+import Text.Ginger+ (GVal(..), ToGVal(..),+ (~>))+import qualified Text.Ginger as Ginger+import Data.Default++data SessionHandle =+ SessionHandle+ { sessionID :: SessionID+ , sessionGet :: Text -> IO (Maybe Text)+ , sessionPut :: Text -> Text -> IO ()+ }++instance (Monad m, MonadIO m) => ToGVal (Ginger.Run p m h) SessionHandle where+ toGVal session =+ Ginger.dict+ [ "id" ~> decodeUtf8 @Text (sessionID session)+ , ("get", Ginger.fromFunction (gSessionGet $ session))+ , ("put", Ginger.fromFunction (gSessionPut $ session))+ ]++gSessionGet :: (Monad m, MonadIO m) => SessionHandle -> Ginger.Function (Ginger.Run p m h)+gSessionGet session args = do+ let (matched, position, named) = Ginger.matchFuncArgs ["key"] args+ case lookup "key" matched of+ Nothing ->+ return def+ Just key -> do+ toGVal <$> liftIO (sessionGet session $ Ginger.asText key)++gSessionPut :: (Monad m, MonadIO m) => SessionHandle -> Ginger.Function (Ginger.Run p m h)+gSessionPut session args = do+ let (matched, position, named) = Ginger.matchFuncArgs ["key", "value"] args+ case lookup "key" matched of+ Nothing ->+ return def+ Just key -> do+ liftIO $ sessionPut session+ (Ginger.asText key)+ (Ginger.asText . fromMaybe def $ lookup "value" matched)+ return def++makeSessionHandle :: SessionStore -> SessionID -> SessionHandle+makeSessionHandle ss ssid =+ SessionHandle+ { sessionID = ssid+ , sessionGet = ssGet ss ssid+ , sessionPut = ssPut ss ssid+ }
@@ -0,0 +1,60 @@+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-}+{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE TupleSections #-}+module Web.Sprinkles.SessionStore+where++import Web.Sprinkles.Prelude+import Data.Time.Clock.POSIX++type SessionID = ByteString++data SessionExpiry = NeverExpires | Expires POSIXTime+ deriving (Show, Eq)++data SessionNotFoundException = SessionNotFoundException+ deriving (Show)++instance Exception SessionNotFoundException where++data SessionSupportDisabled = SessionSupportDisabled+ deriving (Show)++instance Exception SessionSupportDisabled where++-- | Common interface for session store backends.+data SessionStore =+ SessionStore+ { ssGet :: SessionID -> Text -> IO (Maybe Text)+ -- ^ Attempt to retrieve a value from a session+ , ssList :: SessionID -> IO [Text]+ -- ^ List all the keys in the session+ , ssGetAll :: SessionID -> IO [(Text,Text)]+ -- ^ Retrieve the entire session contents (key, value)+ , ssPut :: SessionID -> Text -> Text -> IO ()+ -- ^ Store a value in a session. Fail if session does not exist.+ , ssCreateSession :: SessionID -> SessionExpiry -> IO ()+ -- ^ Create a new session+ , ssDropSession :: SessionID -> IO ()+ -- ^ Drop a session and delete its associated data+ , ssDoesSessionExist :: SessionID -> IO Bool+ -- ^ Checks if a session exists++ -- , ssTouchSession :: SessionID -> IO ()+ -- -- ^ Shift a session's expiry timestamp to current time + expiry+ -- , ssVacuum :: IO ()+ -- -- ^ Drop all expired sessions+ }++nullSessionStore =+ SessionStore+ { ssGet = const . const $ return Nothing+ , ssList = const $ return []+ , ssGetAll = const $ return []+ , ssPut = const . const . const $ return ()+ , ssCreateSession = const . const $ throwM SessionSupportDisabled+ , ssDropSession = const $ return ()+ , ssDoesSessionExist = const $ return False+ }
@@ -0,0 +1,153 @@+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-}+{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE TupleSections #-}+{-#LANGUAGE QuasiQuotes #-}+{-#LANGUAGE RankNTypes #-}+module Web.Sprinkles.SessionStore.Database+( sqlSessionStore+, DSN (..)+, SqlDriver (..)+)+where++import Web.Sprinkles.Prelude+import qualified Database.HDBC as HDBC+import qualified Web.Sprinkles.Databases as DB+import Web.Sprinkles.Databases (DSN (..), SqlDriver (..))+import Web.Sprinkles.SessionStore+import Text.Heredoc (here)+import Database.YeshQL.HDBC++sqlSessionStore :: DSN -> IO SessionStore+sqlSessionStore dsn = do+ conn <- sdbSetup dsn+ return SessionStore+ { ssGet = sdbGet conn+ , ssGetAll = sdbGetAll conn+ , ssList = sdbList conn+ , ssPut = sdbPut conn+ , ssCreateSession = sdbCreate conn+ , ssDropSession = sdbDrop conn+ , ssDoesSessionExist = sdbExists conn+ }++sdbSetup :: DSN -> IO HDBC.ConnWrapper+sdbSetup dsn = do+ conn <- DB.connect dsn+ HDBC.withTransaction conn $ \conn -> do+ HDBC.runRaw conn [here|+ CREATE TABLE IF NOT EXISTS sessions+ ( ssid VARCHAR NOT NULL+ ) |]+ HDBC.withTransaction conn $ \conn -> do+ HDBC.runRaw conn [here|+ CREATE TABLE IF NOT EXISTS session_data+ ( ssid VARCHAR NOT NULL+ , skey VARCHAR NOT NULL+ , sval VARCHAR NOT NULL+ , PRIMARY KEY (ssid, skey)+ , FOREIGN KEY (ssid)+ REFERENCES sessions (ssid)+ ON DELETE CASCADE+ ON UPDATE CASCADE+ );+ |]+ return conn++[yesh|+ -- name:get :: (Text)+ -- :ssid :: ByteString+ -- :skey :: Text+ SELECT sval+ FROM session_data+ WHERE ssid = :ssid+ AND skey = :skey LIMIT 1++ ;;;++ -- name:getAll :: [(Text, Text)]+ -- :ssid :: ByteString+ SELECT skey, sval+ FROM session_data+ WHERE ssid = :ssid++ ;;; ++ -- name:listKeys :: [(Text)]+ -- :ssid :: ByteString+ SELECT skey+ FROM session_data+ WHERE ssid = :ssid++ ;;; ++ -- name:put :: rowcount Int+ -- :ssid :: ByteString+ -- :skey :: Text+ -- :sval :: Text+ INSERT INTO session_data (ssid, skey, sval)+ SELECT ssid, :skey, :sval+ FROM sessions WHERE ssid = :ssid++ ;;; ++ -- name:dropSession :: rowcount Int+ -- :ssid :: ByteString+ DELETE FROM sessions WHERE ssid = :ssid++ ;;;++ -- name:createSession :: rowcount Int+ -- :ssid :: ByteString+ INSERT INTO sessions (ssid)+ VALUES (:ssid)++ ;;;++ -- name:sessionExists :: (Bool)+ -- :ssid :: ByteString+ SELECT COUNT(1) FROM sessions WHERE ssid = :ssid+|]++withDB :: HDBC.ConnWrapper+ -> (HDBC.ConnWrapper -> IO a)+ -> IO a+withDB conn inner = do+ conn' <- HDBC.clone conn+ HDBC.withTransaction conn' inner++sdbGet :: HDBC.ConnWrapper -> SessionID -> Text -> IO (Maybe Text)+sdbGet conn ssid skey =+ withDB conn $ get ssid skey++sdbList :: HDBC.ConnWrapper -> SessionID -> IO [Text]+sdbList conn ssid =+ withDB conn $ listKeys ssid++sdbGetAll :: HDBC.ConnWrapper -> SessionID -> IO [(Text, Text)]+sdbGetAll conn ssid =+ withDB conn $ getAll ssid++sdbPut :: HDBC.ConnWrapper -> SessionID -> Text -> Text -> IO ()+sdbPut conn ssid skey sval =+ (withDB conn $ put ssid skey sval) >>= \case+ 1 -> return ()+ 0 -> throwM SessionNotFoundException+ _ -> error "More than one session with the same key. This is strange."++sdbCreate :: HDBC.ConnWrapper -> SessionID -> SessionExpiry -> IO ()+sdbCreate conn ssid expiry =+ (withDB conn $ createSession ssid) >>= \case+ 1 -> return ()+ 0 -> error "Session creation failed"+ _ -> error "More than one session created. This is strange."++sdbDrop :: HDBC.ConnWrapper -> SessionID -> IO ()+sdbDrop conn ssid =+ (withDB conn $ dropSession ssid) >> return ()++sdbExists :: HDBC.ConnWrapper -> SessionID -> IO Bool+sdbExists conn ssid =+ fmap (fromMaybe False) . withDB conn $ sessionExists ssid
@@ -0,0 +1,81 @@+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-}+{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE TupleSections #-}+{-#LANGUAGE QuasiQuotes #-}+{-#LANGUAGE RankNTypes #-}+module Web.Sprinkles.SessionStore.InProc+( inProcSessionStore+)+where++import Web.Sprinkles.Prelude hiding (atomically)+import Web.Sprinkles.SessionStore+import Control.Concurrent.STM++inProcSessionStore :: IO SessionStore+inProcSessionStore = do+ db <- newDB+ return SessionStore+ { ssGet = dbGet db+ , ssGetAll = dbGetAll db+ , ssList = dbList db+ , ssPut = dbPut db+ , ssCreateSession = dbCreate db+ , ssDropSession = dbDrop db+ , ssDoesSessionExist = dbExists db+ }++type DB = TVar (HashMap SessionID (TVar Session))++type Session = HashMap Text Text++newDB :: IO DB+newDB = newTVarIO $ mapFromList []++dbCreate :: DB -> SessionID -> SessionExpiry -> IO ()+dbCreate db ssid expiry = atomically $ do+ session <- newTVar $ mapFromList []+ modifyTVar db $ insertMap ssid session++dbDrop :: DB -> SessionID -> IO ()+dbDrop db ssid = atomically $+ modifyTVar db (deleteMap ssid)++dbExists :: DB -> SessionID -> IO Bool+dbExists db ssid = atomically $+ isJust <$> dbGetSession db ssid++dbGetSessionVar :: DB -> SessionID -> STM (Maybe (TVar Session))+dbGetSessionVar db ssid = lookup ssid <$> readTVar db++dbGetSession :: DB -> SessionID -> STM (Maybe Session)+dbGetSession db ssid = do+ dbGetSessionVar db ssid >>= maybe+ (return Nothing)+ (fmap Just . readTVar)++dbWithSession :: DB -> SessionID -> (Session -> STM (Maybe a)) -> (STM (Maybe a))+dbWithSession db ssid inner =+ dbGetSession db ssid >>= maybe+ (return Nothing)+ inner++dbGet :: DB -> SessionID -> Text -> IO (Maybe Text)+dbGet db ssid k = atomically $ do+ dbWithSession db ssid $ return . lookup k++dbGetAll :: DB -> SessionID -> IO [(Text, Text)]+dbGetAll db ssid = atomically $ do+ fmap (fromMaybe []) . dbWithSession db ssid $ return . Just . mapToList++dbList :: DB -> SessionID -> IO [Text]+dbList db ssid = atomically $ do+ fmap (fromMaybe []) . dbWithSession db ssid $ return . Just . keys++dbPut :: DB -> SessionID -> Text -> Text -> IO ()+dbPut db ssid k v = atomically $ do+ dbGetSessionVar db ssid >>= maybe+ (throwM SessionNotFoundException)+ (flip modifyTVar $ insertMap k v)
@@ -0,0 +1,95 @@+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-}+{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE ScopedTypeVariables #-}+{-#LANGUAGE FlexibleInstances #-}+{-#LANGUAGE FlexibleContexts #-}+{-#LANGUAGE MultiParamTypeClasses #-}+module Web.Sprinkles.Sessions+( SessionHandle+, sessionID+, sessionGet+, sessionPut+, resumeSession+, newSession+, setSessionCookie+)+where++import Web.Sprinkles.Prelude+import Web.Sprinkles.SessionStore+import Web.Sprinkles.SessionHandle+import Web.Sprinkles.Logger as Logger+import Web.Sprinkles.Project+import Web.Sprinkles.ProjectConfig+import Web.Sprinkles.ServerConfig+import Network.Wai+import qualified Data.ByteString.Char8 as Char8+import Data.Char (isSpace)+import qualified Crypto.Nonce as Nonce+import Data.RandomString (randomStr)++setSessionCookie :: Project -> Request -> SessionHandle -> Response -> Response+setSessionCookie project request session =+ let sessionConfig = projectSessionConfig project+ cookieValue = mconcat [ (sessCookieName sessionConfig)+ , "="+ , (sessionID session)+ ]+ cookieHeader = ("Set-Cookie", cookieValue)+ in mapResponseHeaders (cookieHeader:)+++resumeSession :: Project -> Request -> IO (Maybe SessionHandle)+resumeSession project request = do+ let sessionConfig = projectSessionConfig project+ sessionStore = projectSessionStore project+ let cookieHeaderMay = lookup "Cookie" (requestHeaders request)+ maybe (return Nothing) (loadSession sessionStore) $+ cookieHeaderMay >>=+ parseCookieHeader >>=+ lookup (sessCookieName sessionConfig)++verifyCSRF :: Project -> Request -> IO Bool+verifyCSRF project request = do+ let sessionConfig = projectSessionConfig project+ sessionStore = projectSessionStore project+ resumeSession project request >>= \case+ Nothing ->+ return False+ Just handle -> do+ let csrfHeaderMay = decodeUtf8 <$> lookup "X-Form-Token" (requestHeaders request)+ csrfTokenMay <- sessionGet handle "csrf"+ return $+ (csrfHeaderMay == csrfTokenMay) &&+ isJust csrfHeaderMay++newSession :: Project -> Request -> IO (Maybe SessionHandle)+newSession project request = do+ ssid <- Nonce.new >>= Nonce.nonce128url+ csrfToken <- Nonce.new >>= Nonce.nonce128url+ let sessionConfig = projectSessionConfig project+ sessionStore = projectSessionStore project+ ssCreateSession sessionStore ssid NeverExpires+ let handle = makeSessionHandle sessionStore ssid+ sessionPut handle "csrf" . decodeUtf8 $ csrfToken+ return . Just $ handle++parseCookieHeader :: ByteString -> Maybe [(ByteString, ByteString)]+parseCookieHeader headerVal =+ let parts = map (Char8.dropWhile isSpace) $+ Char8.split ';' headerVal+ splitOnce sep str =+ let (a, b) = Char8.break (== sep) str+ in (a, Char8.drop 1 b)+ in Just (map (splitOnce '=') parts)++loadSession :: SessionStore -> ByteString -> IO (Maybe SessionHandle)+loadSession ss ssid = do+ exists <- ssDoesSessionExist ss ssid+ if exists+ then+ return . Just $ makeSessionHandle ss ssid+ else+ return Nothing
@@ -0,0 +1,289 @@+{-#LANGUAGE DeriveGeneric #-}+{-#LANGUAGE NoImplicitPrelude #-}+{-#LANGUAGE OverloadedStrings #-}+{-#LANGUAGE OverloadedLists #-}+{-#LANGUAGE LambdaCase #-}+{-#LANGUAGE ScopedTypeVariables #-}+{-#LANGUAGE FlexibleInstances #-}+{-#LANGUAGE FlexibleContexts #-}+{-#LANGUAGE MultiParamTypeClasses #-}+{-#LANGUAGE TypeApplications #-}++module Web.Sprinkles.TemplateContext+where++import Web.Sprinkles.Prelude+import Text.Ginger+ (parseGinger, Template, runGingerT, GingerContext, GVal(..), ToGVal(..),+ (~>))+import Text.Ginger.Html+ (unsafeRawHtml, html)+import qualified Text.Ginger as Ginger+import qualified Data.Yaml as YAML+import Data.Aeson (ToJSON (..), FromJSON (..))+import qualified Data.Aeson as JSON+import qualified Data.Aeson.Types as JSON+import qualified Data.Aeson.Encode.Pretty as JSON+import Data.Default (Default, def)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Locale.Read (getLocale)+import qualified Text.Pandoc as Pandoc+import qualified Text.Pandoc.Readers.CustomCreole as PandocCreole+import qualified Data.ByteString.UTF8 as UTF8+import qualified Data.ByteString.Lazy.UTF8 as LUTF8+import Data.ByteString.Builder (stringUtf8)+import qualified Network.Wai as Wai+import qualified Data.CaseInsensitive as CI+import Network.HTTP.Types.URI (queryToQueryText)+import qualified Crypto.BCrypt as BCrypt+import Control.Monad.Except (throwError)++import Web.Sprinkles.Pandoc (pandocReaderOptions)+import Web.Sprinkles.Backends+import Web.Sprinkles.Backends.Spec (backendSpecFromJSON)+import Web.Sprinkles.Exceptions+import Web.Sprinkles.Logger as Logger+import Web.Sprinkles.Backends.Loader.Type+ (RequestContext (..), pbsFromRequest, pbsInvalid)+import Web.Sprinkles.SessionHandle+import Data.RandomString (randomStr)++import Text.Printf (printf)++sprinklesGingerContext :: RawBackendCache+ -> Wai.Request+ -> Maybe SessionHandle+ -> Logger+ -> IO (HashMap Text (GVal (Ginger.Run p IO h)))+sprinklesGingerContext cache request session logger = do+ csrfTokenMay <- case session of+ Nothing -> return Nothing+ Just handle -> sessionGet handle "csrf"+ writeLog logger Debug . pack . printf "CSRF token: %s" . show $ csrfTokenMay+ let csrfTokenInput = case csrfTokenMay of+ Just token ->+ mconcat+ [ unsafeRawHtml "<input type=\"hidden\" name=\"__form_token\" value=\""+ , html token+ , unsafeRawHtml "\"/>"+ ]+ Nothing ->+ unsafeRawHtml "<!-- no form token defined -->"+ return $ mapFromList+ [ "request" ~> request+ , "session" ~> session+ , "formToken" ~> csrfTokenMay+ , "formTokenInput" ~> csrfTokenInput+ , ("load", Ginger.fromFunction (gfnLoadBackendData (writeLog logger) cache))+ ] <> baseGingerContext logger++baseGingerContext :: Logger+ -> HashMap Text (GVal (Ginger.Run p IO h))+baseGingerContext logger =+ mapFromList+ [ ("ellipse", Ginger.fromFunction gfnEllipse)+ , ("json", Ginger.fromFunction gfnJSON)+ , ("yaml", Ginger.fromFunction gfnYAML)+ , ("getlocale", Ginger.fromFunction (gfnGetLocale (writeLog logger)))+ , ("pandoc", Ginger.fromFunction (gfnPandoc (writeLog logger)))+ , ("markdown", Ginger.fromFunction (gfnPandocAlias "markdown" (writeLog logger)))+ , ("textile", Ginger.fromFunction (gfnPandocAlias "textile" (writeLog logger)))+ , ("rst", Ginger.fromFunction (gfnPandocAlias "rst" (writeLog logger)))+ , ("creole", Ginger.fromFunction (gfnPandocAlias "creole" (writeLog logger)))+ , ("bcrypt", gnsBCrypt)+ , ("randomStr", Ginger.fromFunction gfnRandomStr)+ ]++gnsBCrypt :: GVal (Ginger.Run p IO h)+gnsBCrypt =+ Ginger.dict+ [ ("hash", Ginger.fromFunction gfnBCryptHash)+ , ("validate", Ginger.fromFunction gfnBCryptValidate)+ ]++gfnBCryptHash :: Ginger.Function (Ginger.Run p IO h)+gfnBCryptHash args = do+ let argSpec :: [(Text, Ginger.GVal (Ginger.Run p IO h))]+ argSpec = [ ("password", def)+ , ("cost", toGVal (4 :: Int))+ , ("algorithm", toGVal ("$2y$" :: Text))+ ]+ case Ginger.extractArgsDefL argSpec args of+ Right [passwordG, costG, algorithmG] -> do+ let password = encodeUtf8 . Ginger.asText $ passwordG+ algorithm = encodeUtf8 . Ginger.asText $ algorithmG+ cost <- maybe+ (throwM $ GingerInvalidFunctionArgs "bcrypt.hash" "int cost")+ (return . ceiling)+ (asNumber costG)+ let policy = BCrypt.HashingPolicy cost algorithm+ hash <- liftIO $ BCrypt.hashPasswordUsingPolicy policy password+ return . toGVal . fmap decodeUtf8 $ hash+ _ -> throwM $ GingerInvalidFunctionArgs "bcrypt.hash" "string password, int cost, string algorithm"++gfnRandomStr :: Ginger.Function (Ginger.Run p IO h)+gfnRandomStr args = do+ let defaultAlphabet = ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] :: String+ argSpec :: [(Text, Ginger.GVal (Ginger.Run p IO h))]+ argSpec = [ ("length", toGVal (8 :: Int))+ , ("alphabet", toGVal defaultAlphabet)+ ]+ case Ginger.extractArgsDefL argSpec args of+ Right [lengthG, alphabetG] -> do+ desiredLength :: Int <- case fmap round . asNumber $ lengthG of+ Nothing -> throwM $ GingerInvalidFunctionArgs "randomStr" "int length"+ Just l -> return l+ let alphabet :: String+ alphabet = unpack . Ginger.asText $ alphabetG+ when (null alphabet)+ (throwM $ GingerInvalidFunctionArgs "randomStr" "alphabet too small")+ liftIO $ toGVal <$> randomStr alphabet desiredLength+ _ -> throwM $ GingerInvalidFunctionArgs "randomStr" "int length, string alphabet"++gfnBCryptValidate :: Ginger.Function (Ginger.Run p IO h)+gfnBCryptValidate args = do+ let argSpec :: [(Text, Ginger.GVal (Ginger.Run p IO h))]+ argSpec = [ ("hash", def)+ , ("password", def)+ ]+ case Ginger.extractArgsDefL argSpec args of+ Right [hashG, passwordG] -> do+ let hash = encodeUtf8 . Ginger.asText $ hashG+ password = encodeUtf8 . Ginger.asText $ passwordG+ return . toGVal $ BCrypt.validatePassword hash password+ _ -> throwM $ GingerInvalidFunctionArgs "bcrypt.validate" "string password, int cost, string algorithm"++gfnPandoc :: forall p h. (LogLevel -> Text -> IO ()) -> Ginger.Function (Ginger.Run p IO h)+gfnPandoc writeLog args = liftIO . catchToGinger writeLog $+ case Ginger.extractArgsDefL [("src", ""), ("reader", "markdown")] args of+ Right [src, readerName] -> toGVal <$> pandoc (Ginger.asText readerName) (Ginger.asText src)+ _ -> throwM $ GingerInvalidFunctionArgs "pandoc" "string src, string reader"++gfnPandocAlias :: forall p h. Text -> (LogLevel -> Text -> IO ()) -> Ginger.Function (Ginger.Run p IO h)+gfnPandocAlias readerName writeLog args = liftIO . catchToGinger writeLog $+ case Ginger.extractArgsDefL [("src", "")] args of+ Right [src] -> toGVal <$> pandoc readerName (Ginger.asText src)+ _ -> throwM $ GingerInvalidFunctionArgs "pandoc" "string src, string reader"++pandoc :: Text -> Text -> IO Pandoc.Pandoc+pandoc readerName src = do+ reader <- either+ (\err -> fail $ "Invalid reader: " ++ show err)+ return+ (getReader $ unpack readerName)+ let read = case reader of+ Pandoc.TextReader r ->+ r pandocReaderOptions+ Pandoc.ByteStringReader r ->+ r pandocReaderOptions . encodeUtf8 . fromStrict+ (pure . Pandoc.runPure . read $ src) >>= either+ (\err -> fail $ "Reading " ++ show readerName ++ " failed: " ++ show err)+ return+ where+ getReader :: String -> Either String (Pandoc.Reader Pandoc.PandocPure)+ getReader "creole-tdammers" = customCreoleReader+ getReader readerName = fst <$> Pandoc.getReader readerName++customCreoleReader :: Either String (Pandoc.Reader Pandoc.PandocPure)+customCreoleReader =+ Right . Pandoc.TextReader $ reader+ where+ reader :: Pandoc.ReaderOptions -> Text -> Pandoc.PandocPure Pandoc.Pandoc+ reader opts src =+ either throwError return $+ PandocCreole.readCustomCreole opts (unpack src)++gfnGetLocale :: forall p h. (LogLevel -> Text -> IO ()) -> Ginger.Function (Ginger.Run p IO h)+gfnGetLocale writeLog args = liftIO . catchToGinger writeLog $+ case Ginger.extractArgsDefL [("category", "LC_TIME"), ("locale", "")] args of+ Right [gCat, gName] ->+ case (Ginger.asText gCat, Text.unpack . Ginger.asText $ gName) of+ ("LC_TIME", "") -> toGVal <$> getLocale Nothing+ ("LC_TIME", localeName) -> toGVal <$> getLocale (Just localeName)+ (cat, localeName) -> return def -- valid call, but category not implemented+ _ -> throwM $ GingerInvalidFunctionArgs "getlocale" "string category, string name"++gfnEllipse :: Ginger.Function (Ginger.Run p IO h)+gfnEllipse [] = return def+gfnEllipse [(Nothing, str)] =+ gfnEllipse [(Nothing, str), (Nothing, toGVal (100 :: Int))]+gfnEllipse [(Nothing, str), (Nothing, len)] = do+ let txt = Ginger.asText str+ actualLen = Web.Sprinkles.Prelude.length txt+ targetLen = fromMaybe 100 $ ceiling <$> Ginger.asNumber len+ txt' = if actualLen + 3 > targetLen+ then take (targetLen - 3) txt <> "..."+ else txt+ return . toGVal $ txt'+gfnEllipse ((Nothing, str):xs) = do+ let len = fromMaybe (toGVal (100 :: Int)) $ lookup (Just "len") xs+ gfnEllipse [(Nothing, str), (Nothing, len)]+gfnEllipse xs = do+ let str = fromMaybe def $ lookup (Just "str") xs+ gfnEllipse $ (Nothing, str):xs++gfnJSON :: Ginger.Function (Ginger.Run p IO h)+gfnJSON ((_, x):_) =+ return . toGVal . LUTF8.toString . JSON.encodePretty $ x+gfnJSON _ =+ return def++gfnYAML :: Ginger.Function (Ginger.Run p IO h)+gfnYAML ((_, x):_) =+ return . toGVal . UTF8.toString . YAML.encode $ x+gfnYAML _ =+ return def++gfnLoadBackendData :: forall p h. (LogLevel -> Text -> IO ()) -> RawBackendCache -> Ginger.Function (Ginger.Run p IO h)+gfnLoadBackendData writeLog cache args =+ Ginger.dict <$> forM (zip [0..] args) loadPair+ where+ loadPair :: (Int, (Maybe Text, GVal (Ginger.Run p IO h)))+ -> Ginger.Run p IO h (Text, GVal (Ginger.Run p IO h))+ loadPair (index, (keyMay, gBackendURL)) = do+ backendSpec <- either fail pure . JSON.parseEither backendSpecFromJSON . toJSON $ gBackendURL+ backendData :: Items (BackendData p IO h) <- liftIO $+ loadBackendData writeLog pbsInvalid cache backendSpec+ return+ ( fromMaybe (tshow @Text index) keyMay+ , toGVal backendData+ )++catchToGinger :: forall h m. (LogLevel -> Text -> IO ())+ -> IO (GVal m)+ -> IO (GVal m)+catchToGinger writeLog action =+ action+ `catch` (\(e :: SomeException) -> do+ writeLog Logger.Error . formatException $ e+ return . toGVal $ False+ )++instance ToGVal m Wai.Request where+ toGVal rq =+ Ginger.orderedDict+ [ "httpVersion" ~> tshow @Text (Wai.httpVersion rq)+ , "method" ~> decodeUtf8 @Text (Wai.requestMethod rq)+ , "path" ~> decodeUtf8 @Text (Wai.rawPathInfo rq)+ , "query" ~> decodeUtf8 @Text (Wai.rawQueryString rq)+ , "pathInfo" ~> Wai.pathInfo rq+ , ( "queryInfo"+ , Ginger.orderedDict+ [ (key, toGVal val)+ | (key, val)+ <- queryToQueryText (Wai.queryString rq)+ ]+ )+ , ( "headers"+ , Ginger.orderedDict+ [ (decodeCI n, toGVal $ decodeUtf8 v)+ | (n, v)+ <- Wai.requestHeaders rq+ ]+ )+ ]++decodeCI :: CI.CI ByteString -> Text+decodeCI = decodeUtf8 . CI.original+
@@ -4,8 +4,9 @@ module Web.Sprinkles.ApplicationTest where -import ClassyPrelude+import Web.Sprinkles.Prelude import Web.Sprinkles.Project (Project (..), loadProject)+import Web.Sprinkles.ServerConfig (ServerConfig (..)) import Web.Sprinkles.Serve (appFromProject) import Web.Sprinkles.Logger (Logger (..), LogMessage (..), tChanLogger) import System.Directory@@ -62,9 +63,9 @@ go :: FilePath -> IO () go dir = do- let sconfig = def+ let sconfig = def { scRootDir = "." } logChan <- newTChanIO- project <- setFakeLogger logChan <$> loadProject sconfig "."+ project <- setFakeLogger logChan <$> loadProject sconfig inner project setFakeLogger :: TChan LogMessage -> Project -> Project
@@ -3,7 +3,7 @@ module Web.Sprinkles.PatternTest where -import ClassyPrelude+import Web.Sprinkles.Prelude hiding (Any) import Web.Sprinkles.Pattern import Test.Tasty import Test.Tasty.HUnit@@ -175,9 +175,9 @@ parseNamedRegexTest = testCase "parse named Regex" $ do- let src = "/{{who:/foo/}}"+ let src = "/{{who:/foo\\./}}" expected = Right $ Pattern- [ PatternPathItem (Just "who") (Regex "foo" RE.compBlank) MatchOne+ [ PatternPathItem (Just "who") (Regex "foo\\." RE.compBlank) MatchOne ] [] actual = parsePattern src