never executed always true always false
    1 module PureClaw.Handles.Network
    2   ( -- * URL authorization (constructor intentionally NOT exported)
    3     AllowedUrl
    4   , UrlError (..)
    5   , mkAllowedUrl
    6   , getAllowedUrl
    7     -- * Response type
    8   , HttpResponse (..)
    9     -- * Handle type
   10   , NetworkHandle (..)
   11     -- * Implementations
   12   , mkNetworkHandle
   13   , mkNoOpNetworkHandle
   14   ) where
   15 
   16 import Data.ByteString (ByteString)
   17 import Data.ByteString.Lazy qualified as BL
   18 import Data.Text (Text)
   19 import Data.Text qualified as T
   20 import Network.HTTP.Client qualified as HTTP
   21 import Network.HTTP.Types.Status qualified as Status
   22 
   23 import PureClaw.Core.Types
   24 
   25 -- | A URL that has been validated against a domain allow-list.
   26 -- Constructor is intentionally NOT exported — the only way to obtain an
   27 -- 'AllowedUrl' is through 'mkAllowedUrl'.
   28 --
   29 -- Follows the same proof-carrying pattern as 'SafePath' and
   30 -- 'AuthorizedCommand': the type is evidence that validation occurred.
   31 newtype AllowedUrl = AllowedUrl { getAllowedUrl :: Text }
   32   deriving stock (Eq, Ord)
   33 
   34 instance Show AllowedUrl where
   35   show u = "AllowedUrl " ++ show (getAllowedUrl u)
   36 
   37 -- | Errors from URL validation.
   38 data UrlError
   39   = UrlNotAllowed Text   -- ^ URL's domain is not in the allow-list
   40   | UrlMalformed Text    -- ^ URL could not be parsed
   41   deriving stock (Show, Eq)
   42 
   43 -- | Validate a URL against an allow-list of permitted domains.
   44 -- The URL must start with @https://@ and its domain must be in the list.
   45 mkAllowedUrl :: AllowList Text -> Text -> Either UrlError AllowedUrl
   46 mkAllowedUrl allowList url
   47   | not (T.isPrefixOf "https://" url) && not (T.isPrefixOf "http://" url) =
   48       Left (UrlMalformed url)
   49   | isAllowed allowList domain = Right (AllowedUrl url)
   50   | otherwise = Left (UrlNotAllowed url)
   51   where
   52     domain = extractDomain url
   53 
   54 -- | Extract the domain from a URL. Simple extraction — takes the text
   55 -- between @://@ and the next @/@ (or end of string).
   56 extractDomain :: Text -> Text
   57 extractDomain url =
   58   let afterScheme = T.drop 1 $ T.dropWhile (/= '/') $ T.drop 1 $ T.dropWhile (/= '/') url
   59       -- afterScheme is everything after "://"
   60       -- For "https://example.com/path", we want "example.com"
   61   in T.takeWhile (\c -> c /= '/' && c /= ':' && c /= '?') afterScheme
   62 
   63 -- | HTTP response from a network request.
   64 data HttpResponse = HttpResponse
   65   { _hr_statusCode :: Int
   66   , _hr_body       :: ByteString
   67   }
   68   deriving stock (Show, Eq)
   69 
   70 -- | HTTP network capability. Only accepts 'AllowedUrl', which is proof
   71 -- that the URL passed domain validation.
   72 data NetworkHandle = NetworkHandle
   73   { _nh_httpGet  :: AllowedUrl -> IO HttpResponse
   74   , _nh_httpPost :: AllowedUrl -> ByteString -> IO HttpResponse
   75   }
   76 
   77 -- | Real network handle using @http-client@.
   78 mkNetworkHandle :: HTTP.Manager -> NetworkHandle
   79 mkNetworkHandle manager = NetworkHandle
   80   { _nh_httpGet = \url -> do
   81       req <- HTTP.parseRequest (T.unpack (getAllowedUrl url))
   82       resp <- HTTP.httpLbs req manager
   83       pure HttpResponse
   84         { _hr_statusCode = Status.statusCode (HTTP.responseStatus resp)
   85         , _hr_body       = BL.toStrict (HTTP.responseBody resp)
   86         }
   87   , _nh_httpPost = \url body -> do
   88       initReq <- HTTP.parseRequest (T.unpack (getAllowedUrl url))
   89       let req = initReq
   90             { HTTP.method = "POST"
   91             , HTTP.requestBody = HTTP.RequestBodyBS body
   92             }
   93       resp <- HTTP.httpLbs req manager
   94       pure HttpResponse
   95         { _hr_statusCode = Status.statusCode (HTTP.responseStatus resp)
   96         , _hr_body       = BL.toStrict (HTTP.responseBody resp)
   97         }
   98   }
   99 
  100 -- | No-op network handle. Returns 200 with empty body.
  101 mkNoOpNetworkHandle :: NetworkHandle
  102 mkNoOpNetworkHandle = NetworkHandle
  103   { _nh_httpGet  = \_ -> pure HttpResponse { _hr_statusCode = 200, _hr_body = "" }
  104   , _nh_httpPost = \_ _ -> pure HttpResponse { _hr_statusCode = 200, _hr_body = "" }
  105   }