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