packages feed

espial-0.0.39: src/Archiver/ArchiveBox07.hs

module Archiver.ArchiveBox07
  ( archiveBox07Backend,
  )
where

import Archiver.Backend
import ClassyPrelude
import Control.Monad.Logger (LoggingT, logDebug, logWarn, runLoggingT)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT, throwE)
import Data.ByteString.Lazy qualified as BSL
import Data.Default (def)
import Data.Text qualified as T
import Database.Persist.Sql (ConnectionPool, Key, runSqlPool)
import Model (Bookmark, Url (..), User, updateBookmarkArchiveUrl)
import Network.Connection qualified as NC
import Network.HTTP.Client
  ( CookieJar,
    HttpException (..),
    HttpExceptionContent (ResponseTimeout),
    Manager,
    Request (..),
    RequestBody (..),
    createCookieJar,
    httpLbs,
    insertCookiesIntoRequest,
    parseRequest_,
    redirectCount,
    responseBody,
    responseHeaders,
    responseStatus,
    responseTimeout,
    responseTimeoutMicro,
    updateCookieJar,
  )
import Network.HTTP.Client.TLS (mkManagerSettings, newTlsManagerWith)
import Network.HTTP.Types qualified as HT
import Network.HTTP.Types.Header (hLocation, hReferer)
import Network.HTTP.Types.Status qualified as NStatus
import Network.HTTP.Types.URI qualified as NUri
import Settings (AppSettings (..))
import Web.FormUrlEncoded qualified as WH
import Yesod.Default.Main (LogFunc)

data ArchiveBoxContext = ArchiveBoxContext
  { archiveBoxCookieJarRef :: IORef CookieJar,
    archiveBoxBaseUrl :: Text,
    archiveBoxPublicUrl :: Text,
    archiveBoxUsername :: Text,
    archiveBoxPassword :: Text,
    archiveBoxTag :: Text,
    archiveBoxPlugins :: Maybe Text,
    archiveBoxConnPool :: ConnectionPool,
    archiveBoxManager :: Manager
  }

data ArchiveBoxSubmitResult
  = ArchiveBoxSubmitAccepted
  | ArchiveBoxSubmitTimedOutWaiting
  | ArchiveBoxSubmitFailed Text

archiveBox07Backend ::
  AppSettings ->
  ConnectionPool ->
  LogFunc ->
  IO (Maybe ArchiverBackend)
archiveBox07Backend AppSettings {..} connPool logFunc = flip runLoggingT logFunc $ do
  case (nonBlank appArchiveBoxUrl, nonBlank appArchiveBoxUsername, nonBlank appArchiveBoxPassword) of
    (Nothing, _, _) -> $(logWarn) "Archive backend `archivebox` selected but archivebox-url missing; archiving disabled" >> pure Nothing
    (_, Nothing, _) -> $(logWarn) "Archive backend `archivebox` selected but archivebox-username missing; archiving disabled" >> pure Nothing
    (_, _, Nothing) -> $(logWarn) "Archive backend `archivebox` selected but archivebox-password missing; archiving disabled" >> pure Nothing
    (Just baseUrl, Just username, Just password) -> do
      cookieJarRef <- newIORef (createCookieJar [])
      archiveManager <- newTlsManagerWith $ mkManagerSettings def (NC.SockSettingsSimple <$> fmap unpack (appArchiveSocksProxyHost) <*> fmap toEnum (appArchiveSocksProxyPort))
      let publicUrl = fromMaybe baseUrl (nonBlank appArchiveBoxPublicUrl)
          ctx =
            ArchiveBoxContext
              { archiveBoxCookieJarRef = cookieJarRef,
                archiveBoxBaseUrl = baseUrl,
                archiveBoxPublicUrl = publicUrl,
                archiveBoxUsername = username,
                archiveBoxPassword = password,
                archiveBoxTag = appArchiveBoxTag,
                archiveBoxPlugins = nonBlank appArchiveBoxPlugins,
                archiveBoxConnPool = connPool,
                archiveBoxManager = archiveManager
              }
          denyHosts = catMaybes [extractHost baseUrl, extractHost publicUrl]
      pure $
        Just
          ArchiverBackend
            { runArchiver = \uid bid _ua url ->
                flip runLoggingT logFunc $ _archiveBoxRun ctx uid bid url,
              isUrlDenylisted =
                \(Url inputUrl) ->
                  maybe False (\host -> any (hostMatches host) denyHosts) (extractHost inputUrl)
            }
  where
    hostMatches candidate denyHost = candidate == denyHost || ("." <> denyHost) `isSuffixOf` candidate
    extractHost rawUrl =
      let noScheme = fromMaybe rawUrl (stripPrefix "http://" rawUrl <|> stripPrefix "https://" rawUrl)
          authority = takeWhile (\c -> c /= '/' && c /= '?' && c /= '#') noScheme
          host = toLower (takeWhile (/= ':') authority)
       in if null host then Nothing else Just host

_archiveBoxRun ::
  ArchiveBoxContext ->
  Key User ->
  Key Bookmark ->
  Url ->
  LoggingT IO ()
_archiveBoxRun ctx@ArchiveBoxContext {..} userId bookmarkId url = do
  loginResult <- _ensureArchiveBoxSession ctx
  case loginResult of
    Left warning -> $(logWarn) $ "ArchiveBox archiving skipped: " <> warning
    Right () -> do
      addPageResult <- liftIO $ _httpGetNoRedirect ctx "/add/"
      case addPageResult of
        Left err -> $(logWarn) $ "ArchiveBox add page request failed: " <> err
        Right (status, body, _)
          | status /= NStatus.status200 -> $(logWarn) $ "ArchiveBox add page returned unexpected status: " <> tshow status
          | not (_isAddPage body) -> $(logWarn) "ArchiveBox add page unavailable; session may have expired"
          | otherwise ->
              case extractAddCsrfToken body of
                Nothing -> $(logWarn) "ArchiveBox add page did not contain a CSRF token"
                Just csrf -> do
                  let archiveUrl = buildArchiveBoxLookupUrl archiveBoxPublicUrl url
                      formFields =
                        [ ("csrfmiddlewaretoken", csrf),
                          ("url", unUrl url),
                          ("parser", "url_list"),
                          ("tag", archiveBoxTag),
                          ("depth", "0")
                        ]
                          <> pluginFormFields archiveBoxPlugins
                  $(logDebug) $ "Archiving URL with ArchiveBox: " <> unUrl url
                  submitResult <- liftIO $ _httpPostAdd ctx formFields
                  case submitResult of
                    ArchiveBoxSubmitAccepted ->
                      storeArchiveUrl archiveUrl
                    ArchiveBoxSubmitTimedOutWaiting -> do
                      $(logDebug) $ "ArchiveBox accepted URL (timed out): " <> archiveUrl
                      storeArchiveUrl archiveUrl
                    ArchiveBoxSubmitFailed err -> $(logWarn) $ "ArchiveBox add request failed: " <> err
  where
    storeArchiveUrl archiveUrl = do
      $(logDebug) $ "storing archive link: " <> archiveUrl
      liftIO $
        runSqlPool
          (updateBookmarkArchiveUrl userId bookmarkId (Just archiveUrl))
          archiveBoxConnPool
    pluginFormFields =
      maybe
        []
        ( map ("archive_methods",)
            . map T.strip
            . T.splitOn ","
            . T.filter (/= ' ')
        )
    extractAddCsrfToken body
      | _isAddPage body = _extractCsrfToken body
      | otherwise = Nothing
    buildArchiveBoxLookupUrl publicUrl (Url href) =
      _normalizeBaseUrl publicUrl <> "/archive/" <> encodeArchiveBoxPath href <> "/"
    encodeArchiveBoxPath href =
      let encoded = decodeUtf8 (NUri.urlEncode True (encodeUtf8 href))
       in T.replace "%2F" "/" $ T.replace "%3A" ":" encoded

_ensureArchiveBoxSession :: ArchiveBoxContext -> LoggingT IO (Either Text ())
_ensureArchiveBoxSession ctx = runExceptT $ do
  (status, body, _) <- ExceptT $ liftIO $ _httpGetNoRedirect ctx "/add/"
  if status == NStatus.status200 && _isAddPage body
    then pure ()
    else do
      ExceptT $ liftIO $ _archiveBoxLogin ctx
      (verifyStatus, verifyBody, _) <- ExceptT $ liftIO $ _httpGetNoRedirect ctx "/add/"
      if verifyStatus == NStatus.status200 && _isAddPage verifyBody
        then pure ()
        else throwE "ArchiveBox login failed or add page still unavailable"

_httpPostAdd :: ArchiveBoxContext -> [(Text, Text)] -> IO ArchiveBoxSubmitResult
_httpPostAdd ctx@ArchiveBoxContext {..} fields = do
  let body = _buildFormBody fields
      referer = _normalizeBaseUrl archiveBoxBaseUrl <> "/add/"
      request =
        (_buildArchiveBoxRequest ctx "/add/")
          { method = "POST",
            requestHeaders =
              [ (hReferer, encodeUtf8 referer),
                ("Accept", "text/html,application/xhtml+xml"),
                ("Content-Type", "application/x-www-form-urlencoded")
              ],
            requestBody = RequestBodyLBS body,
            redirectCount = 0,
            responseTimeout = responseTimeoutMicro archiveBoxAddResponseTimeoutMicro
          }
  _httpRequestRaw ctx "/add/" (const request) >>= \case
    Left ex
      | Just (HttpExceptionRequest _ ResponseTimeout) <- fromException ex ->
          pure ArchiveBoxSubmitTimedOutWaiting
      | otherwise ->
          pure $ ArchiveBoxSubmitFailed (tshow ex)
    Right (submitStatus, _, headers)
      | isArchiveBoxAddAccepted submitStatus headers ->
          pure ArchiveBoxSubmitAccepted
      | redirectsToLogin headers ->
          pure $ ArchiveBoxSubmitFailed "ArchiveBox add request rejected; not logged in"
      | otherwise ->
          pure $
            ArchiveBoxSubmitFailed $
              "ArchiveBox add request returned unexpected status: " <> tshow submitStatus
  where
    -- \| ArchiveBox runs extractors synchronously before responding to POST /add/, so
    -- we use a short timeout and treat ResponseTimeout as a successful enqueue.
    archiveBoxAddResponseTimeoutMicro = 10 * 1000000
    isArchiveBoxAddAccepted status headers =
      status == NStatus.status200
        || (status == NStatus.status302 && not (redirectsToLogin headers))
    redirectsToLogin =
      any
        ( \(headerName, headerValue) ->
            headerName == hLocation && "login" `T.isInfixOf` decodeUtf8 headerValue
        )

_archiveBoxLogin :: ArchiveBoxContext -> IO (Either Text ())
_archiveBoxLogin ctx@ArchiveBoxContext {..} = runExceptT $ do
  (status, body) <- ExceptT $ _httpGet ctx "/admin/login/"
  if status /= NStatus.status200
    then throwE $ "ArchiveBox login page returned unexpected status: " <> tshow status
    else case _extractCsrfToken body of
      Nothing -> throwE "ArchiveBox login page did not contain a CSRF token"
      Just csrf -> do
        (submitStatus, _) <-
          ExceptT $
            httpPostFormNoRedirect
              "/admin/login/"
              [ ("csrfmiddlewaretoken", csrf),
                ("username", archiveBoxUsername),
                ("password", archiveBoxPassword),
                ("next", "/add/")
              ]
        if submitStatus == NStatus.status200 || submitStatus == NStatus.status302
          then pure ()
          else throwE $ "ArchiveBox login returned unexpected status: " <> tshow submitStatus
  where
    httpPostFormNoRedirect ::
      Text -> [(Text, Text)] -> IO (Either Text (NStatus.Status, [HT.Header]))
    httpPostFormNoRedirect path fields = do
      let body = _buildFormBody fields
          referer = _normalizeBaseUrl archiveBoxBaseUrl <> path
          request =
            (_buildArchiveBoxRequest ctx path)
              { method = "POST",
                requestHeaders =
                  [ (hReferer, encodeUtf8 referer),
                    ("Accept", "text/html,application/xhtml+xml"),
                    ("Content-Type", "application/x-www-form-urlencoded")
                  ],
                requestBody = RequestBodyLBS body,
                redirectCount = 0
              }
      _httpRequest ctx path (const request) <&> \case
        Left err -> Left err
        Right (status, _, headers) -> Right (status, headers)

_httpGet :: ArchiveBoxContext -> Text -> IO (Either Text (NStatus.Status, Text))
_httpGet ctx path =
  _httpRequest ctx path id <&> \case
    Left err -> Left err
    Right (status, body, _) -> Right (status, body)

_httpGetNoRedirect :: ArchiveBoxContext -> Text -> IO (Either Text (NStatus.Status, Text, [HT.Header]))
_httpGetNoRedirect ctx path =
  _httpRequest ctx path (\req -> req {redirectCount = 0})

_httpRequest ::
  ArchiveBoxContext ->
  Text ->
  (Request -> Request) ->
  IO (Either Text (NStatus.Status, Text, [HT.Header]))
_httpRequest ctx path adjustRequest =
  _httpRequestRaw ctx path adjustRequest <&> \case
    Left ex -> Left (tshow ex)
    Right response -> Right response

_httpRequestRaw ::
  ArchiveBoxContext ->
  Text ->
  (Request -> Request) ->
  IO (Either SomeException (NStatus.Status, Text, [HT.Header]))
_httpRequestRaw ctx@ArchiveBoxContext {..} path adjustRequest = do
  now <- getCurrentTime
  cookieJar <- readIORef archiveBoxCookieJarRef
  let baseRequest = adjustRequest (_buildArchiveBoxRequest ctx path)
      (requestWithCookies, jarForSend) =
        insertCookiesIntoRequest baseRequest cookieJar now
  responseResult <- tryAny (httpLbs requestWithCookies archiveBoxManager)
  case responseResult of
    Left ex -> pure (Left ex)
    Right response -> do
      let (updatedJar, _) =
            updateCookieJar response requestWithCookies now jarForSend
      writeIORef archiveBoxCookieJarRef updatedJar
      pure $
        Right
          ( responseStatus response,
            decodeUtf8 (BSL.toStrict (responseBody response)),
            responseHeaders response
          )

_buildArchiveBoxRequest :: ArchiveBoxContext -> Text -> Request
_buildArchiveBoxRequest ArchiveBoxContext {..} path =
  let requestUrl = _normalizeBaseUrl archiveBoxBaseUrl <> path
   in parseRequest_ (unpack requestUrl)

_buildFormBody :: [(Text, Text)] -> BSL.ByteString
_buildFormBody fields =
  WH.urlEncodeAsForm
    ( map
        (\(key, value) -> (unpack key, unpack value))
        fields
    )

_extractCsrfToken :: Text -> Maybe Text
_extractCsrfToken html =
  let needle = "csrfmiddlewaretoken\" value=\""
   in case T.breakOn needle html of
        (_, rest)
          | not (T.null rest) ->
              Just $ T.takeWhile (/= '"') $ T.drop (T.length needle) rest
        _ -> Nothing

_isAddPage :: Text -> Bool
_isAddPage body = "id=\"add-form\"" `T.isInfixOf` body

_normalizeBaseUrl :: Text -> Text
_normalizeBaseUrl = T.dropWhileEnd (== '/')

nonBlank :: Maybe Text -> Maybe Text
nonBlank = (>>= nonEmptyText)
  where
    nonEmptyText value =
      let trimmed = T.strip value
       in if T.null trimmed then Nothing else Just trimmed