bluesky-tools-0.6.0.4: src/Bluesky/Handle.hs
module Bluesky.Handle
( Handle, rawHandle, makeHandle, HandleError(..), validTld
, resolveViaDns, resolveViaHttp, resolveViaBoth, BothFailed(..)
, verifyHandle, resolveVerify
) where
import qualified Control.Concurrent.Async as Async
import qualified Control.Exception as Except
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe
import qualified Data.Aeson as Aeson
import qualified Data.Bifunctor as Bifunctor
import qualified Data.ByteString.Lazy as BSL
import Data.Char
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Text (Text)
import GHC.Generics
import GHC.Stack (HasCallStack)
import qualified Network.DNS as DNS
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types.Status as HTTP
import Web.HttpApiData (FromHttpApiData (parseUrlPiece))
import Bluesky.Did
-- | https://atproto.com/specs/handle
newtype Handle = Handle { rawHandle :: Text }
deriving stock (Eq, Ord, Show, Generic)
deriving newtype (Aeson.ToJSON)
data HandleError
= TooLong
| BadCharacters
| EmptySegment
| SegmentTooLong
| SegmentStartsWithHyphen
| SegmentEndsWithHyphen
| OnlyOneSegment
| LastSegmentStartsWithNumber
deriving stock (Eq, Ord, Show)
makeHandle :: Text -> Either HandleError Handle
makeHandle t
| Text.length t > 253 = Left TooLong
| otherwise = checkParts True t
where
checkParts firstCheck remaining = case Text.breakOn "." remaining of
(before, after)
| Text.null before -> Left EmptySegment
| Text.length before > 63 -> Left SegmentTooLong
| Text.any (not . segmentChar) before -> Left BadCharacters
| Text.take 1 before == "-" -> Left SegmentStartsWithHyphen
| Text.takeEnd 1 before == "-" -> Left SegmentEndsWithHyphen
| Text.null after ->
if firstCheck
then Left OnlyOneSegment
else if Text.all isNumber (Text.take 1 before)
then Left LastSegmentStartsWithNumber
else Right (Handle (Text.toLower t))
| otherwise -> checkParts False (Text.drop 1 after)
segmentChar c = isAscii c && isAlphaNum c || c == '-'
-- | See "Additonal Non-Syntax Restrictions" in the spec
validTld :: Handle -> Bool
validTld (Handle h) =
all (\tld -> not (tld `Text.isSuffixOf` h))
[ ".alt"
, ".arpa"
, ".example"
, ".internal"
, ".invalid"
, ".local"
, ".localhost"
, ".onion"
]
instance FromHttpApiData Handle where
parseUrlPiece = Bifunctor.first (Text.pack . show) . makeHandle
instance Aeson.FromJSON Handle where
parseJSON =
Aeson.withText "Bluesky.Handle.Handle" $ either (fail . show) pure . makeHandle
-- | Returns 'Nothing' in ordinary cases where this handle can't be resolved by
-- DNS. May raise an exception if:
--
-- * the handle has an invalid TLD,
-- * something goes wrong with DNS resolution,
-- * the DID returned is syntactically invalid.
--
-- Note that this handle shouldn't be considered valid for this DID until you've
-- looked up the associated DID document and checked it appears there.
resolveViaDns :: HasCallStack => Handle -> IO (Maybe Did)
resolveViaDns handle@(Handle rawHandle)
| not (validTld handle) = error "handle has invalid TLD"
| otherwise = do
-- should we share rs / the resolver between calls?
rs <- DNS.makeResolvSeed DNS.defaultResolvConf
results <- DNS.withResolver rs $ \resolver ->
DNS.lookupTXT resolver ("_atproto." <> Text.encodeUtf8 rawHandle)
case results of
Right [Text.decodeASCII -> Text.stripPrefix "did=" -> Just rawDid] ->
either (error . show) (pure . Just) $ makeDid rawDid
Right [] -> pure Nothing
Left DNS.NameError -> pure Nothing
other -> error (show other)
-- | Returns 'Nothing' when the expected hostname reports 404 for the HTTP
-- resolution endpoint. May raise an exception if either the handle has an
-- invalid TLD, the HTTP server doesn't return 200 or 404, or there's no HTTP
-- server at the expected domain at all. (This is probably a bit too strict, and
-- should ignore more HTTP errors, but I'll see based on my real-world
-- experience.)
--
-- Note that this handle shouldn't be considered valid for this DID until you've
-- looked up the associated DID document and checked it appears there.
resolveViaHttp :: HasCallStack => HTTP.Manager -> Handle -> IO (Maybe Did)
resolveViaHttp httpManager handle@(Handle rawHandle)
| not (validTld handle) = error "handle has invalid TLD"
| otherwise = do
let rawHandleString = Text.unpack rawHandle
req <-
HTTP.parseRequest
-- This could be a bad thing to do if the handle was arbitrary user
-- data. But we validated it on the way in.
-- (It still might be better to construct the URL in some structured way
-- that insists the handle can only go in the hostname portion. But this
-- will do.)
("https://" <> rawHandleString <> "/.well-known/atproto-did")
resp <- HTTP.httpLbs req httpManager
case HTTP.statusCode (HTTP.responseStatus resp) of
404 -> pure Nothing
200 ->
either (fail . show) (pure . Just) . makeDid
. Text.decodeASCII . BSL.toStrict
$ HTTP.responseBody resp
other -> fail $ "Unexpected HTTP status " <> show other
-- | Raised by 'resolveViaBoth' when both methods raise exceptions.
data BothFailed = BothFailed
{ dnsException :: Except.SomeException
, httpException :: Except.SomeException
} deriving stock (Show)
deriving anyclass (Except.Exception)
-- | If either 'resolveViaDns' or 'resolveViaHttp' return a 'Did', return that
-- 'Did'. Otherwise, if one or both of them raised an exception, reraise it (or
-- them, via 'BothFailed'). (Otherwise, return 'Nothing').
resolveViaBoth :: HasCallStack => HTTP.Manager -> Handle -> IO (Maybe Did)
resolveViaBoth httpManager handle =
fromE =<< Async.concurrentlyE
(toE $ resolveViaDns handle)
(toE $ resolveViaHttp httpManager handle)
where
toE act = do
r <- Except.try act
case r of
Right (Just did) -> pure (Left did)
Right Nothing -> pure (Right Nothing)
Left err -> pure (Right (Just err))
fromE (Left r) = pure (Just r)
fromE (Right (Nothing, Nothing)) = pure Nothing
fromE (Right (Just e, Nothing)) = Except.throwIO e
fromE (Right (Nothing, Just e)) = Except.throwIO e
fromE (Right (Just dnsException, Just httpException)) =
Except.throwIO BothFailed{ dnsException, httpException }
-- | @Just True@ if this 'Handle' appears in the DID 'Document' for the 'Did'.
-- @Just False@ if the document is available and doesn't affirm the handle.
-- 'Nothing' if the document can't be fetched.
verifyHandle :: HTTP.Manager -> Handle -> Did -> IO (Maybe Bool)
verifyHandle httpManager (Handle rawHandle) did = runMaybeT $ do
doc <- MaybeT $ getDocument httpManager did
pure $ ("at://" <> rawHandle) `elem` alsoKnownAs doc
-- | Combines 'resolveViaBoth' and 'verifyHandle'. Raises an error if
-- verification fails.
resolveVerify :: HasCallStack => HTTP.Manager -> Handle -> IO (Maybe Did)
resolveVerify httpManager handle = runMaybeT $ do
did <- MaybeT $ resolveViaBoth httpManager handle
verified <- lift $ verifyHandle httpManager handle did
case verified of
Nothing -> error "Can't get DID document to verify handle"
Just False -> error "Handle failed verification: not in DID document"
Just True -> pure did