webmention-0.1.0.0: src/Network/HTTP/Webmention.hs
{-# LANGUAGE OverloadedStrings #-}
module Network.HTTP.Webmention
( webmention,
notify,
verify,
Webmention,
WebmentionNotification (..),
WebmentionVerification (..),
WebmentionError,
SourceURI,
TargetURI,
EndpointURI,
StatusURI,
)
where
import Control.Applicative
import Control.Exception
import Control.Monad
import Control.Monad.Catch
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as BL
import Data.CaseInsensitive
import Data.Char
import Data.Either.Combinators
import Data.List as L
import Data.Maybe
import Data.Text as T
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Network.HTTP.Client
import Network.HTTP.Client.TLS
import Network.HTTP.Types
import Network.HTTP.Types.Header
import Text.HTML.TagSoup
import Text.URI
type EndpointURI = URI
type StatusURI = URI
type SourceURI = URI
type TargetURI = URI
data Webmention = Webmention SourceURI TargetURI deriving (Eq, Show)
newtype WebmentionError = WebmentionError Text deriving (Eq, Show)
instance Exception WebmentionError where
displayException (WebmentionError msg) = T.unpack msg
data WebmentionNotification
= WebmentionAccepted
| WebmentionPending StatusURI
| WebmentionRejected EndpointURI (Response BL.ByteString)
| WebmentionServerError EndpointURI (Response BL.ByteString)
| WebmentionNotSupported
deriving (Eq, Show)
data WebmentionVerification
= SourceMentionsTarget
| SourceMissingTarget
| SourceServerError (Response BL.ByteString)
deriving (Eq, Show)
-- | Construct a valid 'Webmention' from source and target 'URI'.
--
-- Throws a 'WebmentionError' if source and target are the same, are
-- relative, or lack an HTTP(S) scheme.
webmention :: MonadThrow m => SourceURI -> TargetURI -> m Webmention
webmention source target = do
s <- isHttpUri source
t <- isHttpUri target
when (s == t) (throwM (WebmentionError "Source cannot match target."))
return (Webmention s t)
where
isHttpUri :: MonadThrow m => URI -> m URI
isHttpUri uri = do
let scheme = unRText <$> uriScheme uri
when (maybe False (\a -> "https" /= a && "http" /= a) scheme)
$ throwM
$ WebmentionError "Only HTTP and HTTPS URIs are allowed."
when (isLeft (uriAuthority uri) || isNothing (uriScheme uri))
$ throwM
$ WebmentionError "URI must be absolute, including HTTP or HTTPS scheme and authority."
return uri
-- | Notify target of 'Webmention'.
notify :: Webmention -> IO WebmentionNotification
notify wm@(Webmention source target) = do
endpoint <- discoverEndpoint wm
case endpoint of
Nothing -> return WebmentionNotSupported
Just uri -> do
let body = BL.fromStrict (encodeUtf8 ("source=" <> render source <> "&target=" <> render target))
length = encodeUtf8 (pack (show (BL.length body)))
headers = [(hContentType, "application/x-www-form-urlencoded"), (hContentLength, length)]
manager <- newTlsManagerWith $ tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 5000000}
req' <- parseRequest (unpack (render uri))
let req = req' {method = "POST", requestHeaders = headers, requestBody = RequestBodyLBS body}
res <- httpLbs req manager
return (wmNotification uri res)
-- | Verify 'Webmention' source mentions target.
verify :: Webmention -> IO WebmentionVerification
verify wm@(Webmention source target) = do
manager <- newTlsManagerWith $ tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 5000000}
request <- parseRequest (unpack (render source))
res <- httpLbs request manager
return (wmVerification wm res)
wmVerification :: Webmention -> Response BL.ByteString -> WebmentionVerification
wmVerification (Webmention _ target) res
| status >= status200 && status < status300 =
if encodeUtf8 (render target) `B.isInfixOf` (BL.toStrict (responseBody res))
then SourceMentionsTarget
else SourceMissingTarget
| status >= status400 && status < status500 = SourceServerError res
| otherwise = SourceServerError res
where
status = responseStatus res
wmNotification :: URI -> Response BL.ByteString -> WebmentionNotification
wmNotification uri res
| status == status201 = maybe WebmentionAccepted WebmentionPending (parseLocationUrl (responseHeaders res))
| status >= status200 && status < status300 = WebmentionAccepted
| status >= status400 && status < status500 = WebmentionRejected uri res
| otherwise = WebmentionServerError uri res
where
parseLocationUrl hs = mkURI . decodeUtf8 =<< (snd <$> L.find ((== hLocation) . fst) hs)
status = responseStatus res
discoverEndpoint :: Webmention -> IO (Maybe URI)
discoverEndpoint (Webmention source target) = do
manager <- newTlsManagerWith $ tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 5000000}
request <- parseRequest (unpack (render target))
(wm, res) <- withResponseHistory request manager $ \hr -> do
let req = hrFinalRequest hr
protocol = if secure req then "https://" else "http://"
finalRes = hrFinalResponse hr
finalUri = fromJust . mkURI . decodeUtf8 $ protocol <> host req <> path req <> queryString req
redirectedWm = Webmention source finalUri -- to resolve endpoint against redirected URI
body <- BL.fromChunks <$> brConsume (responseBody finalRes)
return (redirectedWm, finalRes {responseBody = body})
return (discoverEndpointFromHeader wm res <|> discoverEndpointFromHtml wm res)
discoverEndpointFromHeader :: Webmention -> Response BL.ByteString -> Maybe URI
discoverEndpointFromHeader (Webmention _ target) res = go (responseHeaders res)
where
go [] = Nothing
go (h : hs) = matchWmHeader h <|> go hs
headerVal txt = T.drop 1 (T.dropEnd 1 (L.head (strip <$> T.split (== ';') txt)))
headerParams txt = L.drop 1 (strip <$> T.split (== ';') txt)
headerVals (_, bs) = strip <$> split (== ',') (decodeUtf8 bs)
isWmRel txt = T.take 4 txt == "rel=" && "webmention" `L.elem` T.splitOn " " (unquote (T.drop 4 txt))
hasWmRel val = isJust (L.find isWmRel (headerParams val))
isWmLink (k, _) = k == mk "Link"
headerUri val = flip relativeTo target =<< mkURI val
matchWmHeader h =
if isWmLink h
then headerUri =<< (headerVal <$> L.find hasWmRel (headerVals h))
else Nothing
unquote txt = if T.take 1 txt == "\"" then T.drop 1 (T.dropEnd 1 txt) else txt
discoverEndpointFromHtml :: Webmention -> Response BL.ByteString -> Maybe URI
discoverEndpointFromHtml (Webmention _ target) res =
if isHtml (responseHeaders res) then go (parseTags (responseBody res)) else Nothing
where
go (t@(TagOpen "link" _) : ts) = if isRelWm t then hrefUri t <|> go ts else go ts
go (t@(TagOpen "a" _) : ts) = if isRelWm t then hrefUri t <|> go ts else go ts
go (t : ts) = go ts
go [] = Nothing
isHtml hs = maybe False (B.isInfixOf "html") (snd <$> L.find ((== hContentType) . fst) hs)
hrefUri t = resolve =<< href t
href (TagOpen _ attrs) = snd <$> L.find ((== "href") . fst) attrs
resolve "" = Just target -- empty href should resolve to same page
resolve bs = flip relativeTo target =<< mkURI (decodeUtf8 (BL.toStrict bs))
isRelWm t = "webmention" `L.elem` T.splitOn " " (decodeUtf8 (BL.toStrict (fromAttrib "rel" t)))