We don't need SHA verification or Merkle dags in our transport bundle. Content stores can handle both bundle and term verification and hashing.
211 lines
7.2 KiB
Haskell
211 lines
7.2 KiB
Haskell
module Server
|
|
( runServer
|
|
, runServerWithPath
|
|
) where
|
|
|
|
import ContentStore (initContentStore, initContentStoreWithPath, nameToTerm, hashToTerm, listStoredTerms,
|
|
parseNameList, StoredTerm(..), termHash, loadTree)
|
|
import Database.SQLite.Simple (Connection, close)
|
|
import Wire (buildBundle, encodeBundle)
|
|
|
|
import Control.Monad (when, void)
|
|
import Data.Maybe (catMaybes)
|
|
|
|
import Network.HTTP.Types (Header, Status, status200, status400, status404, status405, hContentType)
|
|
import Network.Wai
|
|
import Network.Wai.Handler.Warp (defaultSettings, runSettings, setHost, setPort)
|
|
|
|
import Data.String (fromString)
|
|
import Data.Text (Text)
|
|
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
|
import Data.Char (isHexDigit, toLower)
|
|
import Data.ByteString (ByteString)
|
|
import Data.ByteString.Char8 (unpack)
|
|
import Data.ByteString.Lazy (fromStrict)
|
|
import qualified Data.Text as T
|
|
|
|
-- | Start an HTTP server that serves Arboricx bundles from the
|
|
-- local content store.
|
|
runServer :: String -> Int -> IO ()
|
|
runServer = runServerWithPath Nothing
|
|
|
|
-- | Start an HTTP server with an explicit database path.
|
|
runServerWithPath :: Maybe FilePath -> String -> Int -> IO ()
|
|
runServerWithPath mDbPath hostStr port =
|
|
runSettings settings (app mkConn)
|
|
where
|
|
mkConn = initContentStoreWithPath mDbPath
|
|
settings = setPort port $ setHost (fromString hostStr) defaultSettings
|
|
|
|
-- | WAI application backed by the content store.
|
|
app :: IO Connection -> Application
|
|
app mkConn request respond = case (requestMethod request, pathInfo request) of
|
|
("GET", ["health"]) ->
|
|
respond $ healthResponse
|
|
|
|
("GET", ["bundle", "roots"]) ->
|
|
rootsHandler mkConn request respond
|
|
|
|
("GET", ["bundle", "name", nameText]) -> do
|
|
body <- nameHandler mkConn nameText
|
|
respond body
|
|
|
|
("GET", ["bundle", "hash", hashText]) -> do
|
|
body <- hashHandler mkConn hashText
|
|
respond body
|
|
|
|
("GET", ["terms"]) -> do
|
|
body <- termsResponse mkConn
|
|
respond body
|
|
|
|
("POST", _) ->
|
|
respond $ responseLBS status405 [] "Method not allowed"
|
|
|
|
("PUT", _) ->
|
|
respond $ responseLBS status405 [] "Method not allowed"
|
|
|
|
("DELETE", _) ->
|
|
respond $ responseLBS status405 [] "Method not allowed"
|
|
|
|
_ ->
|
|
respond $ responseLBS status404 [] "not found"
|
|
|
|
healthResponse :: Response
|
|
healthResponse = responseLBS status200 [] "ok"
|
|
|
|
-- | GET /bundle/roots?n=root&n=helper&h=abc123...
|
|
rootsHandler :: IO Connection -> Request -> (Response -> IO a) -> IO a
|
|
rootsHandler mkConn request respond = do
|
|
conn <- mkConn
|
|
let qs = queryString request
|
|
nParams = catMaybes [v | (k, v) <- qs, map toLower (unpack k) == "n"]
|
|
hParams = catMaybes [v | (k, v) <- qs, map toLower (unpack k) == "h"]
|
|
-- Resolve 'n' params to (name, hash) pairs
|
|
nResults <- mapM (\nVal -> do
|
|
stored <- nameToTerm conn (decodeUtf8 nVal)
|
|
case stored of
|
|
Nothing -> return Nothing
|
|
Just t -> return $ Just (decodeUtf8 nVal, termHash t)) nParams
|
|
let namedHashesFromN = catMaybes nResults
|
|
-- Validate 'h' params and build (name, hash) pairs
|
|
namedHashesFromH <- mapM (\hVal -> do
|
|
let raw = T.pack (dropWhile (=='#') (T.unpack (decodeUtf8 hVal)))
|
|
if T.all isHexDigit raw && T.length raw >= 16
|
|
then do
|
|
stored <- hashToTerm conn raw
|
|
let names = maybe "root" firstOrRoot (termNames <$> stored)
|
|
return $ Just (names, raw)
|
|
else return Nothing)
|
|
hParams
|
|
let allNamedHashes = namedHashesFromN ++ catMaybes namedHashesFromH
|
|
-- Require at least one root
|
|
when (null allNamedHashes) $ do
|
|
let resp = responseLBS status400 [] "400 Bad Request: at least one n= or h= parameter required"
|
|
close conn
|
|
void $ respond resp
|
|
-- Build and return the bundle
|
|
bundleData <- buildAndEncodeBundle conn allNamedHashes
|
|
let firstHash = snd (head allNamedHashes)
|
|
cd = T.pack "attachment; filename=roots.bundle"
|
|
close conn
|
|
respond $ responseLBS status200
|
|
(bundleHeaders firstHash cd)
|
|
(fromStrict bundleData)
|
|
|
|
-- | GET /bundle/name/:name
|
|
nameHandler :: IO Connection -> Text -> IO Response
|
|
nameHandler mkConn nameText = do
|
|
conn <- mkConn
|
|
stored <- nameToTerm conn nameText
|
|
case stored of
|
|
Nothing -> do
|
|
close conn
|
|
return $ textResponse status404 ("not found: " <> nameText)
|
|
Just term' -> do
|
|
let th = termHash term'
|
|
namedHashes = [(firstOrRoot (termNames term'), th)]
|
|
bundleData <- buildAndEncodeBundle conn namedHashes
|
|
let cd = T.pack $ "attachment; filename=" ++ safeFileName (T.unpack nameText) ++ ".bundle"
|
|
close conn
|
|
return $ responseLBS status200 (bundleHeaders th cd) (fromStrict bundleData)
|
|
|
|
-- | GET /bundle/hash/:hash
|
|
hashHandler :: IO Connection -> Text -> IO Response
|
|
hashHandler mkConn hashText =
|
|
let raw = T.pack (dropWhile (== '#') (T.unpack hashText))
|
|
in if not (T.all isHexDigit raw) || T.length raw < 16
|
|
then return $ responseLBS status400 [] "400 Bad Request: invalid hash"
|
|
else do
|
|
conn <- mkConn
|
|
stored <- hashToTerm conn raw
|
|
case stored of
|
|
Nothing -> do
|
|
close conn
|
|
return $ textResponse status404 ("not found: " <> hashText)
|
|
Just term' -> do
|
|
let th = termHash term'
|
|
namedHashes' = [(firstOrRoot (termNames term'), th)]
|
|
bundleData <- buildAndEncodeBundle conn namedHashes'
|
|
close conn
|
|
return $ responseLBS status200
|
|
(bundleHeaders th "attachment; filename=hash.bundle")
|
|
(fromStrict bundleData)
|
|
|
|
-- | Helper: load terms by hash and build an indexed bundle.
|
|
buildAndEncodeBundle :: Connection -> [(Text, Text)] -> IO ByteString
|
|
buildAndEncodeBundle conn namedHashes = do
|
|
terms <- mapM (\(_, h) -> do
|
|
maybeTree <- loadTree conn h
|
|
case maybeTree of
|
|
Nothing -> error $ "Server: hash not found in store: " ++ T.unpack h
|
|
Just tree -> return tree) namedHashes
|
|
let namedTerms = zip (map fst namedHashes) terms
|
|
bundle = buildBundle namedTerms
|
|
return $ encodeBundle bundle
|
|
|
|
-- | GET /terms
|
|
termsResponse :: IO Connection -> IO Response
|
|
termsResponse mkConn = do
|
|
conn <- mkConn
|
|
terms <- listStoredTerms conn
|
|
close conn
|
|
let lines' = [ names <> " " <> hash <> " " <> T.pack (show created)
|
|
| term <- terms
|
|
, let names = termNames term
|
|
, let hash = termHash term
|
|
, let created = termCreatedAt term ]
|
|
return $ responseLBS status200
|
|
[ (hContentType, encodeUtf8 "text/plain; charset=utf-8")
|
|
]
|
|
(fromStrict $ encodeUtf8 $ T.unlines lines')
|
|
|
|
textResponse :: Status -> Text -> Response
|
|
textResponse status body =
|
|
responseLBS status
|
|
[ (hContentType, encodeUtf8 "text/plain; charset=utf-8") ]
|
|
(fromStrict $ encodeUtf8 body)
|
|
|
|
bundleHeaders :: Text -> Text -> [Header]
|
|
bundleHeaders root cd =
|
|
[ (hContentType, encodeUtf8 "application/vnd.arboricx.bundle")
|
|
, ("X-Arboricx-Root-Hash", encodeUtf8 root)
|
|
, ("Content-Disposition", encodeUtf8 cd)
|
|
]
|
|
|
|
firstOrRoot :: Text -> Text
|
|
firstOrRoot names =
|
|
case parseNameList names of
|
|
[] -> "root"
|
|
(x:_) -> x
|
|
|
|
safeFileName :: String -> String
|
|
safeFileName = map go
|
|
where
|
|
go c
|
|
| c >= 'a' && c <= 'z' = c
|
|
| c >= 'A' && c <= 'Z' = c
|
|
| c >= '0' && c <= '9' = c
|
|
| c == '-' = c
|
|
| c == '_' = c
|
|
| otherwise = '_'
|