packages feed

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)))