commit b71fabfa4b5b5cf1a5b0d804c1555f5b5af6a4ff
Author: krasjet
Date: 2020-03-17 03:49Z

init, take 2

Diffstat:
A.gitignore | 8++++++++
A.stylish-haskell.yaml | 24++++++++++++++++++++++++
AMakefile | 29+++++++++++++++++++++++++++++
AREADME.md | 3+++
ASetup.hs | 2++
Aapp/Main.hs | 6++++++
Akamome | 1+
Akarasu.cabal | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackage.yaml | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Karasu.hs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Karasu/Api.hs | 24++++++++++++++++++++++++
Asrc/Karasu/Database.hs | 34++++++++++++++++++++++++++++++++++
Asrc/Karasu/Environment.hs | 41+++++++++++++++++++++++++++++++++++++++++
Asrc/Karasu/Handler.hs | 15+++++++++++++++
Asrc/Karasu/Handlers/ApiCreateDoc.hs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Karasu/Handlers/ApiEditDoc.hs | 35+++++++++++++++++++++++++++++++++++
Asrc/Karasu/Handlers/ApiGetDoc.hs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Karasu/Handlers/ApiPreviewDoc.hs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Karasu/Handlers/Static.hs | 6++++++
Asrc/Karasu/Models.hs | 30++++++++++++++++++++++++++++++
Asrc/Karasu/Server.hs | 33+++++++++++++++++++++++++++++++++
Astack.yaml | 7+++++++
22 files changed, 684 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,8 @@ +.stack-work/ +db/ +docs/ +.env +static +design +stack.yaml.lock + diff --git a/.stylish-haskell.yaml b/.stylish-haskell.yaml @@ -0,0 +1,24 @@ +steps: + - simple_align: + cases: true + top_level_patterns: true + records: true + - imports: + align: group + list_align: after_alias + pad_module_names: true + long_list_align: inline + empty_list_align: right_after + list_padding: module_name + separate_lists: true + space_surround: false + - language_pragmas: + style: vertical + align: true + remove_redundant: true + language_prefix: LANGUAGE + - trailing_whitespace: {} + +columns: 80 +newline: lf +cabal: true diff --git a/Makefile b/Makefile @@ -0,0 +1,29 @@ +.PHONY: run run-kamome run-karasu build-karasu build-kamome debug ghci clean + +run: build-kamome run-karasu + +run-kamome: + @ln -snf kamome/dist static + @cd kamome && yarn start + +run-karasu: + @stack run + +build-karasu: + @stack build + +build-kamome: + @cd kamome && yarn build + @ln -snf kamome/dist static + +debug: + @stack run karasu-debug + +ghci: + @stack ghci + +clean: + rm -rf ./db + rm -rf ./docs + rm -rf ./kamome/dist/* + diff --git a/README.md b/README.md @@ -0,0 +1,3 @@ +# karasu + +A work in progress. diff --git a/Setup.hs b/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/app/Main.hs b/app/Main.hs @@ -0,0 +1,6 @@ +module Main where + +import Karasu + +main :: IO () +main = runKarasu diff --git a/kamome b/kamome @@ -0,0 +1 @@ +Subproject commit 238d3ec812b60c000c7f4b7d00e0e928c5be5264 diff --git a/karasu.cabal b/karasu.cabal @@ -0,0 +1,92 @@ +cabal-version: >=2.0 + +-- This file has been generated from package.yaml by hpack version 0.31.2. +-- +-- see: https://github.com/sol/hpack +-- +-- hash: 1c6146379423834387b558bd03ebccfc375815488f25cfa367445b5a4c94e74c + +name: karasu +version: 0.1.0.0 +description: A work in progress. +category: Web +author: Krasjet +maintainer: Krasjet +copyright: Copyright (c) 2020 Krasjet +license: MIT +build-type: Simple +extra-source-files: + README.md + +library + exposed-modules: + Karasu + Karasu.Api + Karasu.Database + Karasu.Environment + Karasu.Handler + Karasu.Handlers.ApiCreateDoc + Karasu.Handlers.ApiEditDoc + Karasu.Handlers.ApiGetDoc + Karasu.Handlers.ApiPreviewDoc + Karasu.Handlers.Static + Karasu.Models + Karasu.Server + other-modules: + Paths_karasu + hs-source-dirs: + src + ghc-options: -Wall -Werror + build-depends: + aeson + , base >=4.7 && <5 + , blaze-html + , bytestring + , directory + , dotenv + , filepath + , monad-logger + , mtl + , pandoc >=2.7 && <2.10 + , persistent-sqlite + , persistent-template + , servant + , servant-blaze + , servant-server + , text + , transformers + , wai + , wai-extra + , warp + default-language: Haskell2010 + +executable karasu + main-is: Main.hs + other-modules: + Paths_karasu + hs-source-dirs: + app + ghc-options: -Wall -Werror -threaded -rtsopts -with-rtsopts=-N + build-depends: + aeson + , base >=4.7 && <5 + , blaze-html + , bytestring + , directory + , dotenv + , filepath + , karasu + , monad-logger + , mtl + , pandoc >=2.7 && <2.10 + , persistent-sqlite + , persistent-template + , servant + , servant-blaze + , servant-server + , text + , transformers + , wai + , wai-extra + , warp + default-language: Haskell2010 diff --git a/package.yaml b/package.yaml @@ -0,0 +1,56 @@ +name: karasu +version: 0.1.0.0 +license: MIT +author: "Krasjet" +maintainer: "Krasjet" +copyright: "Copyright (c) 2020 Krasjet" + +extra-source-files: +- README.md + +category: Web + +description: "A work in progress." + +dependencies: +- base >= 4.7 && < 5 +- aeson +- blaze-html +- bytestring +- directory +- dotenv +- filepath +- monad-logger +- mtl +- pandoc >= 2.7 && < 2.10 +- persistent-template +- persistent-sqlite +- servant +- servant-server +- servant-blaze +- text +- transformers +- wai +- wai-extra +- warp + +ghc-options: +- -Wall +- -Werror + +library: + source-dirs: src + +executables: + karasu: + main: Main.hs + source-dirs: app + ghc-options: + - -threaded + - -rtsopts + - -with-rtsopts=-N + dependencies: + - karasu + +verbatim: + cabal-version: ">=2.0" diff --git a/src/Karasu.hs b/src/Karasu.hs @@ -0,0 +1,54 @@ +module Karasu (runKarasu) where + +import Karasu.Api +import Karasu.Database +import Karasu.Environment +import Karasu.Server + +import qualified Data.Text as T + +import Configuration.Dotenv (defaultConfig, loadFile) +import Control.Monad (void) +import Network.Wai.Handler.Warp +import Network.Wai.Middleware.RequestLogger (logStdout) +import Servant +import System.FilePath.Posix ((</>)) + +-- * application +app :: KarasuEnv -> Application +app env = serve karasuApi $ karasuServer env + +-- * server +karasuSettings :: KarasuEnv -> Settings +karasuSettings env = + let port = envPort env in + setPort port $ + setBeforeMainLoop (putStrLn $ "Karasu listening on port " ++ show port) + defaultSettings + +-- | The actual main function +runKarasu :: IO () +runKarasu = do + -- load environments + env <- loadEnv + let settings = karasuSettings env + runSettings settings $ logStdout $ app env + -- ^ middleware for logging + +-- | Load runtime environments +loadEnv :: IO KarasuEnv +loadEnv = do + void $ loadFile defaultConfig + debug <- lookupEnvVarParse "KARASU_DEBUG" False + port <- lookupEnvVarParse "KARASU_PORT" 8080 + dbFile <- lookupEnvVar "KARASU_DB" $ "db" </> "karasu.db" + masterPass <- lookupEnvVar "KARASU_MASTERPASS" "karasu" + docDir <- lookupEnvVar "KARASU_DOCDIR" "docs" + pool <- mkPool dbFile debug + return KarasuEnv + { envDebug = debug + , envPort = port + , envPool = pool + , envMaster = T.pack masterPass + , envDocDir = docDir + } diff --git a/src/Karasu/Api.hs b/src/Karasu/Api.hs @@ -0,0 +1,24 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeOperators #-} + +-- | A summary of all the API definitions +module Karasu.Api (KarasuApi, ReqApi, karasuApi, reqApi) where + +import Karasu.Handlers.ApiCreateDoc +import Karasu.Handlers.ApiEditDoc +import Karasu.Handlers.ApiGetDoc +import Karasu.Handlers.ApiPreviewDoc +import Karasu.Handlers.Static + +import Servant + +-- API for requests +type ReqApi = CreateDocApi :<|> EditDocApi :<|> GetDocApi :<|> PreviewDocApi +-- combined APIs for the app +type KarasuApi = ReqApi :<|> StaticFiles + +reqApi :: Proxy ReqApi +reqApi = Proxy + +karasuApi :: Proxy KarasuApi +karasuApi = Proxy diff --git a/src/Karasu/Database.hs b/src/Karasu/Database.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +-- | Functions for querying database +module Karasu.Database (doMigrations, runDb, mkPool) where + +import Karasu.Environment +import Karasu.Models + +import qualified Data.Text as T + +import Control.Monad.IO.Class (MonadIO, liftIO) +import Control.Monad.Logger (runNoLoggingT, runStdoutLoggingT) +import Control.Monad.Reader (MonadReader, asks) +import Database.Persist.Sqlite +import System.Directory (createDirectoryIfMissing) +import System.FilePath (takeDirectory) + +doMigrations :: SqlPersistT IO () +doMigrations = runMigration migrateAll + +runDb :: (MonadReader KarasuEnv m, MonadIO m) => SqlPersistT IO b -> m b +runDb query = do + pool <- asks envPool + liftIO $ runSqlPool query pool + +-- | Create databse pool +mkPool :: FilePath -> Bool -> IO ConnectionPool +mkPool dbFile debug = do + createDirectoryIfMissing True $ takeDirectory dbFile + pool <- if debug + then runStdoutLoggingT $ createSqlitePool (T.pack dbFile) 5 + else runNoLoggingT $ createSqlitePool (T.pack dbFile) 5 + runSqlPool doMigrations pool + return pool diff --git a/src/Karasu/Environment.hs b/src/Karasu/Environment.hs @@ -0,0 +1,41 @@ +-- | +-- A few ultility functions to deal with runtime environments, i.e. a global +-- state shared by all handlers +module Karasu.Environment (KarasuEnv(..), lookupEnvVar, lookupEnvVarParse) where + +import Karasu.Models (MasterPassword) + +import Configuration.Dotenv.Environment (lookupEnv) +import Data.Maybe (fromMaybe) +import Database.Persist.Sqlite (ConnectionPool) +import Network.Wai.Handler.Warp (Port) +import Text.Read (readMaybe) + +-- * Environment variables +data KarasuEnv + = KarasuEnv + { envDebug :: Bool -- in debug mode or not + , envPool :: ConnectionPool -- connection pool for database + , envPort :: Port -- port number of the server + , envMaster :: MasterPassword -- master password for creating documents (TODO encryption) + , envDocDir :: FilePath -- directory storing markdown files + } + +-- | Obtain an environment variable and parse with default value +lookupEnvVarParse + :: Read a + => String -- ^ the environemnt variable + -> a -- ^ the default value + -> IO a +lookupEnvVarParse envVar defVal = do + var <- lookupEnv envVar + return $ fromMaybe defVal $ var >>= readMaybe + +-- | Obtain an environment variable with default value +lookupEnvVar + :: String -- ^ the environemnt variable + -> String -- ^ the default value + -> IO String +lookupEnvVar envVar defVal = do + var <- lookupEnv envVar + return $ fromMaybe defVal var diff --git a/src/Karasu/Handler.hs b/src/Karasu/Handler.hs @@ -0,0 +1,15 @@ +-- | Our own custom handler +module Karasu.Handler (KHandler, nt) where + +import Karasu.Environment (KarasuEnv) + +import Control.Monad.Trans.Reader (ReaderT, runReaderT) +import Servant (Handler) + +-- | A wrapper for servant Handler +-- This allows us to compose monads on top of Handler (might expand later) +type KHandler = ReaderT KarasuEnv Handler + +-- the natural transformation KHandler ~> Handler +nt :: KarasuEnv -> KHandler a -> Handler a +nt env kHld = runReaderT kHld env diff --git a/src/Karasu/Handlers/ApiCreateDoc.hs b/src/Karasu/Handlers/ApiCreateDoc.hs @@ -0,0 +1,76 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeOperators #-} + +-- | API for creating a document, for private use only +module Karasu.Handlers.ApiCreateDoc (CreateDocApi, createDoc) where + +import Karasu.Database +import Karasu.Environment +import Karasu.Handler +import Karasu.Models + +import qualified Data.Text as T +import qualified Data.Text.IO as TIO + +import Control.Monad (unless, when) +import Control.Monad.IO.Class (liftIO) +import Control.Monad.Reader (asks) +import Data.Aeson +import Data.Char (isAlphaNum) +import Data.Maybe (isNothing) +import Data.Text (Text) +import Database.Persist.Sqlite +import GHC.Generics +import Servant +import System.Directory (createDirectoryIfMissing) +import System.FilePath (isPathSeparator, isValid, takeDirectory, (<.>), + (</>)) + +data CreateDocBody = CreateDocBody { + docId :: DocId, + accessCode :: Maybe AccessCode, + masterPass :: MasterPassword +} deriving (Generic, Show) + +instance ToJSON CreateDocBody +instance FromJSON CreateDocBody + +type CreateDocApi = "api" + :> "create" + :> ReqBody '[JSON] CreateDocBody + :> PostCreated '[PlainText] Text + +-- | Validate the docId +-- 1. valid path (empty string should fail here) +-- 2. not containing any path separator +-- 3. start with alphanumeric +validId :: FilePath -> Bool +validId path = isValid path && not (any isPathSeparator path) && isAlphaNum (head path) + +-- | Create new document +createDoc :: CreateDocBody -> KHandler Text +createDoc docBody = do + correctPass <- asks envMaster + -- master password incorrect + unless (correctPass == masterPass docBody) $ + throwError err403 { errBody = "Nope, try again." } + + let dId = docId docBody + -- docId not valid filename + unless (validId dId) $ + throwError err400 { errBody = "Can you read the document id?" } + + -- finally insert the document + res <- runDb $ insertUnique $ DocInfo (docId docBody) (accessCode docBody) 1 + -- the docId already exists + when (isNothing res) $ + throwError err409 { errBody = "Something is already there." } + + -- write default markdown file + docDir <- asks envDocDir + let mdFile = docDir </> dId <.> ".md" + liftIO $ createDirectoryIfMissing True $ takeDirectory mdFile + liftIO $ TIO.writeFile mdFile $ T.pack $ "# " <> dId <> "\n" + return "The doc is up. Hooray!" diff --git a/src/Karasu/Handlers/ApiEditDoc.hs b/src/Karasu/Handlers/ApiEditDoc.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeOperators #-} + +-- | API for creating a document, for private use only +module Karasu.Handlers.ApiEditDoc (EditDocApi, editDoc) where + +import Karasu.Database +import Karasu.Handler +import Karasu.Models + +import qualified Data.Text.IO as TIO + +import Control.Monad.IO.Class (liftIO) +import Database.Persist.Sqlite +import Servant +import Servant.HTML.Blaze +import System.FilePath ((<.>), (</>)) +import Text.Blaze.Html + +type EditDocApi = "edit" + :> Capture "docId" DocId + :> Get '[HTML] Html + +-- | Send the editor page +editDoc :: DocId -> KHandler Html +editDoc docId = do + res <- runDb $ getBy $ UniqueDocId docId + case res of + Nothing -> throwError err404 { errBody = "Nothing here." } + Just _ -> do + -- TODO might want to use responseFile to avoid extra buffering + -- https://github.com/haskell-servant/servant/issues/1281 + html <- liftIO $ TIO.readFile $ "static" </> "editor" <.> "html" + return $ preEscapedToMarkup html diff --git a/src/Karasu/Handlers/ApiGetDoc.hs b/src/Karasu/Handlers/ApiGetDoc.hs @@ -0,0 +1,48 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeOperators #-} + +-- | API for retrieving the markdown file +module Karasu.Handlers.ApiGetDoc (GetDocApi, getDoc) where + +import Karasu.Database +import Karasu.Environment +import Karasu.Handler +import Karasu.Models + +import qualified Data.Text.IO as TIO + +import Control.Monad.IO.Class (liftIO) +import Control.Monad.Reader (asks) +import Data.Aeson +import Database.Persist.Sqlite +import GHC.Generics +import Servant +import System.FilePath ((<.>), (</>)) + +data GetDocRes = GetDocRes { + markdown :: Markdown, + version :: Version +} deriving (Generic, Show) + +instance ToJSON GetDocRes +instance FromJSON GetDocRes + +type GetDocApi = "api" + :> "get" + :> Capture "docId" DocId + :> Get '[JSON] GetDocRes + +-- | Return the markdown of the document +getDoc :: DocId -> KHandler GetDocRes +getDoc docId = do + res <- runDb $ getBy $ UniqueDocId docId + case res of + Nothing -> throwError err404 { errBody = "Nothing here." } + Just (Entity _ doc) -> do + let docVer = docInfoVersion doc + docDir <- asks envDocDir + let mdFile = docDir </> docId <.> ".md" + md <- liftIO $ TIO.readFile mdFile + return $ GetDocRes md docVer diff --git a/src/Karasu/Handlers/ApiPreviewDoc.hs b/src/Karasu/Handlers/ApiPreviewDoc.hs @@ -0,0 +1,60 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeOperators #-} + +-- | API for markdown -> HTML preview +module Karasu.Handlers.ApiPreviewDoc (PreviewDocApi, previewDoc) where + +import Karasu.Database +import Karasu.Handler +import Karasu.Models + +import qualified Data.ByteString.Lazy.Char8 as LB8 + +import Control.Monad (when) +import Control.Monad.IO.Class (liftIO) +import Data.Aeson +import Database.Persist.Sqlite +import GHC.Generics +import Servant +import Servant.HTML.Blaze +import Text.Blaze.Html +import Text.Pandoc + +data PreviewDocBody = PreviewDocBody { + docId :: DocId, + markdown :: Markdown, + accessCode :: Maybe AccessCode +} deriving (Generic, Show) + +instance ToJSON PreviewDocBody +instance FromJSON PreviewDocBody + +type PreviewDocApi = "api" + :> "preview" + :> ReqBody '[JSON] PreviewDocBody + :> Post '[HTML] Html -- apparently GET request cannot have a body + +-- | Process markdown and send preview +previewDoc :: PreviewDocBody -> KHandler Html +previewDoc prevBody = do + liftIO $ print prevBody + let dId = docId prevBody + -- check access code + res <- runDb $ getBy $ UniqueDocId dId + case res of + Nothing -> throwError err403 { errBody = "Something wrong with the docId." } + Just (Entity _ doc) -> + -- yes, no accessCode doesn't mean no protection + when (docInfoAccCode doc /= accessCode prevBody) $ + throwError err403 { errBody = "Nope, try again." } + -- now, start rendering the markdown file (TODO lift to a Pandoc package) + let md = markdown prevBody + + let out = runPure $ do + pandoc <- readMarkdown def md + writeHtml5 def pandoc + case out of + Left err -> throwError err400 { errBody = LB8.pack $ show err } + Right html -> return $ preEscapedToMarkup html diff --git a/src/Karasu/Handlers/Static.hs b/src/Karasu/Handlers/Static.hs @@ -0,0 +1,6 @@ +-- | The route for serving static files +module Karasu.Handlers.Static (StaticFiles) where + +import Servant + +type StaticFiles = Raw diff --git a/src/Karasu/Models.hs b/src/Karasu/Models.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE EmptyDataDecls #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeFamilies #-} + +-- | Models for database and requests, anything in this module will be exported +module Karasu.Models where + +import Data.Text (Text) +import Database.Persist.TH + +type DocId = FilePath +type Version = Int +type MasterPassword = Text +type AccessCode = Text +type Markdown = Text + +share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase| +DocInfo + docId DocId + accCode AccessCode Maybe + version Version + UniqueDocId docId + deriving Show Eq +|] diff --git a/src/Karasu/Server.hs b/src/Karasu/Server.hs @@ -0,0 +1,33 @@ +-- | All the server related stuff +module Karasu.Server (karasuServer, staticServer, apiServer) where + +import Karasu.Api +import Karasu.Environment +import Karasu.Handler +import Karasu.Handlers.ApiCreateDoc +import Karasu.Handlers.ApiEditDoc +import Karasu.Handlers.ApiGetDoc +import Karasu.Handlers.ApiPreviewDoc +import Karasu.Handlers.Static + +import Servant + +-- * Static server, which serves all the static files +staticServer :: Server StaticFiles +staticServer = serveDirectoryWebApp "static" + +-- * API server, which serves all APIs, of course + +-- | API server with KHandler wrapper +-- We will need to use hoistServer to apply the +-- natural transformation KHandler ~> Handler +apiServerK :: ServerT ReqApi KHandler +apiServerK = createDoc :<|> editDoc :<|> getDoc :<|> previewDoc + +-- | Transformed server +apiServer :: KarasuEnv -> Server ReqApi +apiServer env = hoistServer reqApi (nt env) apiServerK + +-- * Combined server +karasuServer :: KarasuEnv -> Server KarasuApi +karasuServer env = apiServer env :<|> staticServer diff --git a/stack.yaml b/stack.yaml @@ -0,0 +1,7 @@ +resolver: lts-14.27 + +packages: +- . + +extra-deps: [] +