packages feed

espial-0.0.42: src/Handler/Add.hs

module Handler.Add where

import Data.Aeson qualified as A
import Data.Attoparsec.ByteString qualified as AP
import Data.ByteString qualified as BS
import Data.Char (ord)
import Data.Either (isRight)
import Data.Text.Lazy.Builder (toLazyText)
import HTMLEntities.Decoder (htmlEncodedText)
import Handler.Archive
import Handler.Common (browserUserAgent)
import Import
import Network.HTTP.Client qualified as NH
import Network.HTTP.Client.TLS qualified as NHT
import Network.HTTP.Types.URI qualified as NUri
import Network.PrivateAddress (isDisallowedFetchHost)
import Util

-- * View

getAddViewR :: Handler Html
getAddViewR = do
  Entity userId user <- requireAuth
  yesod <- getYesod
  let frontendBundleName = appFrontendBundleName yesod
      archiveBackendEnabled = isJust (appArchiver yesod)
  murl <- lookupGetParam "url"
  mBookmarkDb <- runDB (fetchBookmarkByUrl userId murl)
  newBookmarkForm <- mkNewBookmarkFormForUrl archiveBackendEnabled
  let mExistingBookmarkForm = toBookmarkForm <$> mBookmarkDb
      bookmarkForm = mExistingBookmarkForm <|> Just newBookmarkForm
      renderEl = "addForm" :: Text

  popupLayout do
    toWidget
      [whamlet|
      <div id="#{ renderEl }">
    |]
    toWidgetBody
      [julius|
      app.dat.bmark = #{ toJSON bookmarkForm };
      app.dat.archiveBackendEnabled = #{ archiveBackendEnabled };
      app.dat.suggestTags = #{ userSuggestTags user };
      app.dat.suggestTagsUseReturnKey = #{ userSuggestTagsUseReturnKey user };
    |]
    toWidget
      [hamlet|
      <script type="module">
        import { renderAddForm } from '@{StaticR (StaticRoute ["js", frontendBundleName] [])}'
        renderAddForm('##{renderEl}')(app.dat.bmark)();
    |]
  where
    mkNewBookmarkFormForUrl :: Bool -> Handler BookmarkForm
    mkNewBookmarkFormForUrl archiveBackendEnabled = do
      Entity _ user <- requireAuth
      url <- lookupGetParam "url" <&> fromMaybe ""
      title <- lookupGetParam "title"
      description <- lookupGetParam "description" <&> fmap Textarea
      tags <- lookupGetParam "tags"
      private <- lookupGetParam "private" <&> fmap parseChk <&> (<|> Just (userPrivateDefault user))
      toread <- lookupGetParam "toread" <&> fmap parseChk
      pure (mkNewBookmarkForm archiveBackendEnabled user url title description tags private toread)
      where
        parseChk s = s == "yes" || s == "on" || s == "true" || s == "1"

-- * API

postAddR :: Handler Text
postAddR = do
  app <- getYesod
  (_, authUser) <- requireAuthPair
  lang <- getCurrentLang (LangSourceUser (Just authUser))
  let t key = appTranslate app lang (I18nKey key)
  bookmarkForm <- requireCheckJsonBody
  handleFormSuccess bookmarkForm >>= \case
    Created bid -> sendStatusJSON created201 bid
    Updated _ -> sendResponseStatus noContent204 ()
    Failed reason -> sendResponseStatus status400 (translateFailedReason t reason)
  where
    handleFormSuccess :: BookmarkForm -> Handler (UpsertResult (Key Bookmark))
    handleFormSuccess bookmarkForm = do
      (userId, user) <- requireAuthPair
      appSettings <- appSettings <$> getYesod
      case (appAllowNonHttpUrlSchemes appSettings, (parseRequest . unpack . _url) bookmarkForm) of
        (False, Nothing) -> pure $ Failed (ReasonInvalidInput "")
        _ -> do
          let mkbid = toSqlKey <$> _bid bookmarkForm
              tags = maybe [] normalizeTags (_tags bookmarkForm)
          bm <- liftIO $ bookmarkFormToBookmark userId bookmarkForm
          res <- runDBWrite (upsertBookmark userId mkbid bm tags)
          case res of
            Created kbid | fromMaybe (userArchiveDefault user) (_archiveRequested bookmarkForm) -> _archive kbid bm
            Updated kbid | isJust (_archiveRequested bookmarkForm) -> _archive kbid bm
            _ -> pure ()
          pure res
    _archive :: Key Bookmark -> Bookmark -> Handler ()
    _archive = archiveBookmarkUrl
    translateFailedReason :: (Text -> Text) -> FailedReason -> Text
    translateFailedReason t reason = case reason of
      ReasonUnauthorized -> t "error.upsertUnauthorized"
      ReasonNotFound -> t "error.upsertNotFound"
      ReasonHrefUsedByOther -> t "error.upsertHrefUsedByOther"
      ReasonConflictWithNewer -> t "error.upsertConflictWithNewer"
      ReasonInvalidInput detail -> if null detail then t "error.upsertInvalidInput" else detail

postAddBulkR :: Handler Text
postAddBulkR = do
  bookmarkForms <- requireCheckJsonBody
  maxItems <- appAddBulkMaxItems . appSettings <$> getYesod
  when (length bookmarkForms > maxItems) do
    sendResponseStatus status413 ("Too many items: max " <> tshow maxItems :: Text)
  ress <- handleFormSuccess bookmarkForms
  sendStatusJSON ok200 (fmap _toJson ress)
  where
    handleFormSuccess :: [BookmarkForm] -> Handler [UpsertResult (Key Bookmark)]
    handleFormSuccess bookmarkForms = do
      (userId, user) <- requireAuthPair
      appSettings <- appSettings <$> getYesod
      let allowNonHttpUrlSchemes = appAllowNonHttpUrlSchemes appSettings
          urlResults = parseRequest . unpack . _url <$> bookmarkForms
          marked =
            [ if allowNonHttpUrlSchemes || isRight urlResult
                then Right bookmarkForm
                else Left $ ReasonInvalidInput $ either (("Invalid URL: " <>) . pack . show) (const "Invalid URL") urlResult
            | (bookmarkForm, urlResult) <- zip bookmarkForms urlResults
            ]
          validForms = [bookmarkForm | Right bookmarkForm <- marked]
          mkbids :: [Maybe (Key Bookmark)] = (fmap toSqlKey . _bid) <$> validForms
          tagss = maybe [] normalizeTags <$> (fmap _tags validForms)
      bms <- liftIO $ traverse (bookmarkFormToBookmark userId) validForms
      validRess <- runDBWrite (upsertBookmarks userId mkbids bms tagss)
      let toArchive =
            catMaybes
              [ case res of
                  Created kbid | fromMaybe (userArchiveDefault user) (_archiveRequested bookmarkForm) -> Just (kbid, bm)
                  Updated kbid | isJust (_archiveRequested bookmarkForm) -> Just (kbid, bm)
                  _ -> Nothing
              | (res, bm, bookmarkForm) <- zip3 validRess bms validForms
              ]
      unless (null toArchive) (archiveBookmarkUrls toArchive)
      pure (mergeBulkAddResults marked validRess)

    -- \| Re-interleaves per-item validation failures with the upsert results of the valid subset, restoring the original request order.
    mergeBulkAddResults :: [Either FailedReason BookmarkForm] -> [UpsertResult (Key Bookmark)] -> [UpsertResult (Key Bookmark)]
    mergeBulkAddResults (Left err : rest) valids = Failed (err) : mergeBulkAddResults rest valids
    mergeBulkAddResults (Right _ : rest) (v : valids) = v : mergeBulkAddResults rest valids
    mergeBulkAddResults _ _ = []

    _toJson (Created a) = object ["status" .= ("created" :: Text), "id" .= a]
    _toJson (Updated a) = object ["status" .= ("updated" :: Text), "id" .= a]
    _toJson (Failed reason) = object ["status" .= ("failed" :: Text), "reason" .= failedReasonTag reason, "detail" .= failedReasonDetail reason]
      where
        failedReasonTag :: FailedReason -> Text
        failedReasonTag = \case
          ReasonUnauthorized -> "unauthorized"
          ReasonNotFound -> "not_found"
          ReasonConflictWithNewer -> "conflict_with_newer"
          ReasonHrefUsedByOther -> "href_used_by_other"
          ReasonInvalidInput _ -> "invalid_input"
        failedReasonDetail (ReasonInvalidInput detail) | not (null detail) = Just detail
        failedReasonDetail _ = Nothing

postTagSuggestionsR :: Handler Text
postTagSuggestionsR = do
  userId <- requireAuthId
  tagSuggestionRequest <- requireCheckJsonBody
  let suggestion_limit = 10
  tagSuggestions <- runDB $ getTagSuggestions userId tagSuggestionRequest suggestion_limit
  sendStatusJSON ok200 tagSuggestions

postLookupTitleR :: Handler ()
postLookupTitleR = do
  void requireAuthId
  bookmarkForm <- (requireCheckJsonBody :: Handler BookmarkForm)
  fetchPageTitle (Url (_url bookmarkForm)) >>= \case
    Left _ -> sendResponseStatus noContent204 ()
    Right title -> sendResponseStatus ok200 title

data RequestOverride = RequestOverride
  { requestOverrideMatch :: NH.Request -> Bool,
    requestOverrideTransform :: Url -> Url,
    requestOverrideParseTitle :: ByteString -> Either String Text
  }

newtype OEmbedResponse = OEmbedResponse Text

instance A.FromJSON OEmbedResponse where
  parseJSON = A.withObject "OEmbedResponse" \o -> OEmbedResponse <$> o A..: "title"

fetchPageTitle :: Url -> Handler (Either String Text)
fetchPageTitle url = do
  $(logDebug) $ "fetchPageTitle: looking up title for " <> unUrl url
  result <-
    (buildRequest url >>= \(req, parseBody) -> fetchFollowingRedirects maxRedirects req parseBody)
      `catch` ( \(e :: SomeException) -> do
                  $(logError) $ "fetchPageTitle: " <> unUrl url <> " raised " <> (pack . show) e
                  pure (Left (show e))
              )
  $(logDebug) $ "fetchPageTitle: result for " <> unUrl url <> " = " <> tshow result
  pure result
  where
    maxTitleFetchBytes :: Int
    maxTitleFetchBytes = 1024 * 1024

    maxRedirects :: Int
    maxRedirects = 5
    -- redirectCount is 0 on every request built here so each hop can be
    -- re-matched against requestOverrides before it's sent. parseBody is
    -- carried alongside the request since an override may swap in a
    -- differently-shaped response (e.g. reddit's JSON oEmbed API).
    fetchFollowingRedirects :: Int -> NH.Request -> (ByteString -> Either String Text) -> Handler (Either String Text)
    fetchFollowingRedirects hopsLeft req parseBody = do
      let reqHost = decodeUtf8 (NH.host req)
      $(logDebug) $ "fetchPageTitle: requesting " <> reqHost <> decodeUtf8 (NH.path req) <> " (hopsLeft=" <> tshow hopsLeft <> ")"
      disallowed <- liftIO $ isDisallowedFetchHost (unpack reqHost)
      if disallowed
        then do
          $(logDebug) $ "fetchPageTitle: host disallowed: " <> reqHost
          pure (Left "URL host is not allowed")
        else do
          manager <- liftIO NHT.getGlobalManager
          (status, step) <- liftIO $ NH.withResponse req manager \resp -> do
            let status = NH.responseStatus resp
            (,) status
              <$> case redirectLocation resp of
                Just location | hopsLeft > 0 -> pure (Left location)
                _ -> Right <$> readBoundedBody (NH.responseBody resp)
          $(logDebug) $ "fetchPageTitle: " <> reqHost <> " responded " <> tshow status
          case step of
            Left location -> do
              $(logDebug) $ "fetchPageTitle: received redirect from " <> reqHost <> " to " <> decodeUtf8 location
              (nextReq, nextParseBody) <- buildRequest (Url (decodeUtf8 location))
              fetchFollowingRedirects (hopsLeft - 1) nextReq nextParseBody
            Right body
              | statusIsRedirection status -> do
                  $(logWarn) $ "fetchPageTitle: max redirects reached at " <> reqHost
                  pure (parseBody body)
              | otherwise -> pure (parseBody body)
    redirectLocation resp
      | statusCode (NH.responseStatus resp) `elem` [301, 302, 303, 307, 308] =
          lookup "Location" (NH.responseHeaders resp)
      | otherwise = Nothing
    buildRequest :: Url -> Handler (NH.Request, ByteString -> Either String Text)
    buildRequest u = do
      let UserAgent ua = browserUserAgent
          mkReq u' =
            (NH.parseRequest_ (unpack (unUrl u')))
              { NH.redirectCount = 0,
                NH.requestHeaders =
                  [ ("Cache-Control", "max-age=0"),
                    ("User-Agent", encodeUtf8 ua)
                  ]
              }
          req = mkReq u
      case find (\o -> requestOverrideMatch o req) requestOverrides of
        Just o -> do
          let u' = requestOverrideTransform o u
          $(logDebug) $ "fetchPageTitle: applying request override, " <> unUrl u <> " -> " <> unUrl u'
          pure (mkReq u', requestOverrideParseTitle o)
        Nothing -> pure (req, parseTitle)
    readBoundedBody br = BS.concat <$> go 0
      where
        go total
          | total >= maxTitleFetchBytes = pure []
          | otherwise = do
              chunk <- NH.brRead br
              if null chunk then pure [] else (chunk :) <$> go (total + BS.length chunk)
    parseTitle bs =
      flip AP.parseOnly bs do
        _ <- skipAnyTill (AP.string "<title")
        _ <- skipAnyTill (AP.string ">")
        let lt = toEnum (ord '<')
        decodeHtmlBs <$> AP.takeTill (== lt)
      where
        skipAnyTill end = go where go = end $> () <|> AP.anyWord8 *> go
        decodeHtmlBs = toStrict . toLazyText . htmlEncodedText . decodeUtf8
    requestOverrides =
      [ RequestOverride
          { requestOverrideMatch = \r ->
              NH.host r
                `elem` (["reddit.com", "www.reddit.com", "np.reddit.com", "old.reddit.com"] :: [ByteString])
                && not ("/s/" `BS.isInfixOf` NH.path r),
            requestOverrideTransform = oEmbedTransform "https://www.reddit.com/oembed",
            requestOverrideParseTitle = parseOEmbedTitle
          },
        -- youtu.be share links are accepted by the oEmbed endpoint directly, no redirect to resolve first.
        RequestOverride
          { requestOverrideMatch = \r ->
              NH.host r `elem` (["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"] :: [ByteString]),
            requestOverrideTransform = oEmbedTransform "https://www.youtube.com/oembed",
            requestOverrideParseTitle = parseOEmbedTitle
          },
        -- vm.tiktok.com/vt.tiktok.com share links 400 against the oEmbed endpoint, so leave those
        -- unmatched until they redirect to a canonical www.tiktok.com/@user/video/<id> URL.
        RequestOverride
          { requestOverrideMatch = \r ->
              NH.host r `elem` (["tiktok.com", "www.tiktok.com"] :: [ByteString]),
            requestOverrideTransform = oEmbedTransform "https://www.tiktok.com/oembed",
            requestOverrideParseTitle = parseOEmbedTitle
          },
        -- spotify.link share links aren't matched here either, same reasoning as the reddit/tiktok short domains.
        RequestOverride
          { requestOverrideMatch = \r -> NH.host r == "open.spotify.com",
            requestOverrideTransform = oEmbedTransform "https://open.spotify.com/oembed",
            requestOverrideParseTitle = parseOEmbedTitle
          }
      ]
      where
        oEmbedTransform oEmbedEndpoint u =
          Url (oEmbedEndpoint <> "?format=json&url=" <> decodeUtf8 (NUri.urlEncode True (encodeUtf8 (unUrl u))))
        parseOEmbedTitle bs = case A.eitherDecodeStrict bs of
          Right (OEmbedResponse t) -> Right t
          Left err -> Left err