diff options
Diffstat (limited to 'users/Profpatsch/cas-serve/CasServe.hs')
-rw-r--r-- | users/Profpatsch/cas-serve/CasServe.hs | 247 |
1 files changed, 247 insertions, 0 deletions
diff --git a/users/Profpatsch/cas-serve/CasServe.hs b/users/Profpatsch/cas-serve/CasServe.hs new file mode 100644 index 000000000000..3e658e58ccff --- /dev/null +++ b/users/Profpatsch/cas-serve/CasServe.hs @@ -0,0 +1,247 @@ +{-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE MultiWayIf #-} +{-# LANGUAGE OverloadedLabels #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE UndecidableInstances #-} +{-# OPTIONS_GHC -Wall #-} +{-# OPTIONS_GHC -Wno-orphans #-} + +module Main where + +import Control.Applicative +import qualified Crypto.Hash as Crypto +import qualified Data.ByteArray as ByteArray +import qualified Data.ByteString.Lazy as ByteString.Lazy +import qualified Data.ByteString.Lazy as Lazy +import Data.Functor.Compose +import Data.Int (Int64) +import qualified Data.List as List +import Data.Maybe (fromMaybe) +import qualified Data.Text as Text +import qualified Data.Text.IO as Text +import Database.SQLite.Simple (NamedParam ((:=))) +import qualified Database.SQLite.Simple as Sqlite +import qualified Database.SQLite.Simple.FromField as Sqlite +import qualified Database.SQLite.Simple.QQ as Sqlite +import GHC.TypeLits (Symbol) +import MyPrelude +import qualified Network.HTTP.Types as Http +import qualified Network.Wai as Wai +import qualified Network.Wai.Handler.Warp as Warp +import qualified SuperRecord as Rec +import System.IO (stderr) +import Control.Monad.Reader + +main :: IO () +main = do + withEnv $ \env -> + Warp.runSettings + (Warp.defaultSettings & Warp.setPort 7070) + (api env) + +withEnv :: (Env -> IO a) -> IO a +withEnv inner = do + withSqlite "./data.sqlite" $ \envData -> do + withSqlite "./wordlist.sqlite" $ \envWordlist -> inner Env {..} + +withSqlite :: String -> (Sqlite.Connection -> IO a) -> IO a +withSqlite fileName inner = Sqlite.withConnection fileName $ \conn -> do + Sqlite.setTrace conn (Just (\msg -> Text.hPutStrLn stderr [fmt|{fileName}: {msg}|])) + Sqlite.execute conn [Sqlite.sql|PRAGMA foreign_keys = ON|] () + inner conn + +api :: Env -> Wai.Application +api env req respond = do + case runHandler (getById <|> insertById) req env of + Nothing -> respond $ Wai.responseLBS Http.status404 [] "endpoint does not exist." + Just handler' -> do + handler' >>= \case + Left (status, err) -> respond $ Wai.responseLBS status [] (err & toLazyBytes) + Right (headers, body) -> + respond $ + Wai.responseLBS + Http.status200 + headers + ( body & toLazyBytes + ) + +data Env = Env + { envWordlist :: Sqlite.Connection, + envData :: Sqlite.Connection + } + +-- | I don’t need any fancy routing in this, so a handler is just something that returns a @Just (IO a)@ if it wants to handle the request. +newtype Handler a + = Handler ( ReaderT (Wai.Request, Env) (Compose Maybe IO) a ) + deriving newtype (Functor, Applicative, Alternative) + +handler :: ((Wai.Request, Env) -> Maybe (IO a)) -> Handler a +handler f = Handler (ReaderT (Compose . f)) + +runHandler :: Handler a -> Wai.Request -> Env -> Maybe (IO a) +runHandler (Handler handler') req env = getCompose $ handler' & (\readerT -> runReaderT readerT (req, env)) + +getById :: + Handler + ( Either + (Http.Status, ByteString) + ([(Http.HeaderName, ByteString)], ByteString) + ) +getById = handler $ \(req, env) -> do + guard ((req & Wai.requestMethod) == Http.methodGet) + case req & Wai.pathInfo of + ["v0", "by-id", filename] -> Just $ do + Sqlite.queryNamed + @( Rec.Rec + [ "mimetype" Rec.:= Text, + "content" Rec.:= ByteString, + "size" Rec.:= Int + ] + ) + (env & envData) + [Sqlite.sql| + SELECT + mimetype, + cast (content AS blob) as content, + size + FROM file_content + JOIN file_references + ON file_references.file_content = file_content.hash_sha256 + WHERE + file_references.reference_type = 'by-id' + AND (file_references.name || file_references.extension) = :filename + |] + [":filename" Sqlite.:= filename] + <&> \case + [] -> Left (Http.status404, "File not found.") + [res] -> + Right + ( [ ("Content-Type", res & Rec.get #mimetype & textToBytesUtf8), + ("Content-Length", res & Rec.get #size & showToText & textToBytesUtf8) + ], + -- TODO: should this be lazy/streamed? + res & Rec.get #content + ) + _more -> Left "file_references must be unique (in type and name)" & unwrapError + _ -> Nothing + +insertById :: Handler (Either a ([(Http.HeaderName, ByteString)], ByteString)) +insertById = handler $ \(req, env) -> do + guard ((req & Wai.requestMethod) == Http.methodPost) + case req & Wai.pathInfo of + ["v0", "by-id"] -> Just $ do + let maybeText bytes = case bytesToTextUtf8 bytes of + Left _err -> Nothing + Right t -> Just t + let mimeType = + ( (req & Wai.requestHeaders & List.lookup "X-Cas-Serve-Mimetype" >>= maybeText) + <|> (req & Wai.requestHeaders & List.lookup "Content-Type" >>= maybeText) + ) + & fromMaybe "application/octet-stream" + + let magicFileEnding mimeType' = case Text.split (== '/') mimeType' of + [_, ""] -> Nothing + ["", _] -> Nothing + [_, "any"] -> Nothing + ["image", ty] -> Just (Text.cons '.' ty) + ["video", ty] -> Just (Text.cons '.' ty) + ["text", "plain"] -> Just ".txt" + ["text", "html"] -> Just ".html" + ["application", "pdf"] -> Just ".pdf" + ["application", "json"] -> Just ".json" + _ -> Nothing + + let extension = + ( (req & Wai.requestHeaders & List.lookup "X-Cas-Serve-FileExtension" >>= maybeText) + <|> ( (req & Wai.requestHeaders & List.lookup "Content-Type") + >>= maybeText + >>= magicFileEnding + ) + ) + -- Just the empty extension if we can’t figure it out. + & fromMaybe "" + + body <- Wai.consumeRequestBodyStrict req + let hash :: Crypto.Digest Crypto.SHA256 = Crypto.hashlazy body + let hashBytes = hash & ByteArray.convert @(Crypto.Digest Crypto.SHA256) @ByteString + let len = ByteString.Lazy.length body + name <- getNameFromWordlist env + let fullname = name <> extension + + let conn = env & envData + Sqlite.withTransaction conn $ do + Sqlite.executeNamed + conn + [Sqlite.sql| + INSERT INTO file_content + (content, hash_sha256, size) + VALUES + (:content, :hash_sha256, :size) + ON CONFLICT (hash_sha256) DO NOTHING + |] + [ ":content" := (body :: Lazy.ByteString), + ":hash_sha256" := (hashBytes :: ByteString), + ":size" := (len :: Int64) + ] + + -- TODO: we are not checking if the name already exists, + -- we just assume that 1633^3 is enough to not get any collisions for now. + -- If the name exists, the user gets a 500. + Sqlite.executeNamed + conn + [Sqlite.sql| + INSERT INTO file_references + (file_content, reference_type, name, extension, mimetype) + VALUES + (:file_content, :reference_type, :name, :extension, :mimetype) + |] + [ ":file_content" := (hashBytes :: ByteString), + ":reference_type" := ("by-id" :: Text), + ":name" := name, + ":extension" := (extension :: Text), + ":mimetype" := (mimeType :: Text) + ] + pure $ + Right + ( [("Content-Type", "text/plain")], + [fmt|/v0/by-id/{fullname}|] + ) + _ -> Nothing + +-- Get a random name from a wordlist, that is three words connected by @-@. +getNameFromWordlist :: Env -> IO Text +getNameFromWordlist env = + do + let numberOfWords = 3 :: Int + Sqlite.queryNamed @(Sqlite.Only Text) + (env & envWordlist) + [Sqlite.sql|SELECT word FROM wordlist ORDER BY RANDOM() LIMIT :words|] + [":words" Sqlite.:= numberOfWords] + <&> map Sqlite.fromOnly + <&> Text.intercalate "-" + +-- | We can use a Rec with a named list of types to parse a returning row of sqlite!! +instance + ( Rec.UnsafeRecBuild rec rec FromFieldC + ) => + Sqlite.FromRow (Rec.Rec rec) + where + fromRow = do + Rec.unsafeRecBuild @rec @rec @FromFieldC (\_lbl _proxy -> Sqlite.field) + +class (Sqlite.FromField a) => FromFieldC (lbl :: Symbol) a + +instance (Sqlite.FromField a) => FromFieldC lbl a |