2019-01-21 16:54:57 +01:00
|
|
|
{- This file is part of Vervis.
|
|
|
|
-
|
|
|
|
- Written in 2019 by fr33domlover <fr33domlover@riseup.net>.
|
|
|
|
-
|
|
|
|
- ♡ Copying is an act of love. Please copy, reuse and share.
|
|
|
|
-
|
|
|
|
- The author(s) have dedicated all copyright and related and neighboring
|
|
|
|
- rights to this software to the public domain worldwide. This software is
|
|
|
|
- distributed without any warranty.
|
|
|
|
-
|
|
|
|
- You should have received a copy of the CC0 Public Domain Dedication along
|
|
|
|
- with this software. If not, see
|
|
|
|
- <http://creativecommons.org/publicdomain/zero/1.0/>.
|
|
|
|
-}
|
|
|
|
|
|
|
|
module Web.ActivityPub
|
|
|
|
( -- * Actor
|
|
|
|
--
|
|
|
|
-- ActivityPub actor document including a public key, with a 'FromJSON'
|
|
|
|
-- instance for fetching and a 'ToJSON' instance for publishing.
|
|
|
|
ActorType (..)
|
|
|
|
, Algorithm (..)
|
|
|
|
, PublicKey (..)
|
|
|
|
, Actor (..)
|
|
|
|
|
|
|
|
-- * Activity
|
|
|
|
--
|
|
|
|
-- Very basic activity document which is just general JSON with some
|
|
|
|
-- basic checks. 'FromJSON' instance for receiving POSTs, and 'ToJSON'
|
|
|
|
-- instance for delivering to other servers.
|
|
|
|
, Activity (..)
|
|
|
|
|
|
|
|
-- * Utilities
|
|
|
|
, provideAP
|
|
|
|
, APGetError (..)
|
|
|
|
, httpGetAP
|
|
|
|
, httpPostAP
|
|
|
|
)
|
|
|
|
where
|
|
|
|
|
|
|
|
import Prelude
|
|
|
|
|
|
|
|
import Control.Applicative ((<|>))
|
|
|
|
import Control.Exception (Exception, try)
|
|
|
|
import Control.Monad.IO.Class
|
|
|
|
import Control.Monad.Trans.Writer (Writer)
|
|
|
|
import Data.Aeson
|
|
|
|
import Data.Aeson.Types (Parser)
|
|
|
|
import Data.ByteString (ByteString)
|
|
|
|
import Data.List.NonEmpty (NonEmpty)
|
|
|
|
import Data.PEM
|
|
|
|
import Data.Semigroup (Endo)
|
|
|
|
import Data.Text (Text)
|
|
|
|
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
|
|
|
import Network.HTTP.Client
|
|
|
|
import Network.HTTP.Client.Conduit.ActivityPub (httpAPEither)
|
|
|
|
import Network.HTTP.Client.Signature (signRequest)
|
|
|
|
import Network.HTTP.Signature (KeyId, Signature)
|
|
|
|
import Network.HTTP.Simple (JSONException)
|
|
|
|
import Network.HTTP.Types.Header (HeaderName, hContentType)
|
|
|
|
import Network.URI
|
|
|
|
import Yesod.Core.Content (ContentType)
|
|
|
|
import Yesod.Core.Handler (ProvidedRep, provideRepType)
|
|
|
|
|
|
|
|
import qualified Data.HashMap.Strict as M (lookup)
|
|
|
|
import qualified Data.Text as T (unpack)
|
|
|
|
import qualified Data.Vector as V (fromList)
|
|
|
|
|
|
|
|
frg :: Text
|
|
|
|
frg = "https://forgefed.angeley.es/ns#"
|
|
|
|
|
|
|
|
as2context :: Text
|
|
|
|
as2context = "https://www.w3.org/ns/activitystreams"
|
|
|
|
|
|
|
|
actorContext :: Value
|
|
|
|
actorContext = Array $ V.fromList
|
|
|
|
[ String as2context
|
|
|
|
, String "https://w3id.org/security/v1"
|
|
|
|
]
|
|
|
|
|
|
|
|
parseURI' :: Text -> Parser URI
|
|
|
|
parseURI' t =
|
|
|
|
case parseURI $ T.unpack t of
|
|
|
|
Nothing -> fail "Invalid absolute URI"
|
|
|
|
Just u ->
|
|
|
|
if uriScheme u == "https:"
|
|
|
|
then return u
|
|
|
|
else fail "URI scheme isn't https"
|
|
|
|
|
|
|
|
renderURI :: URI -> String
|
|
|
|
renderURI u = uriToString id u ""
|
|
|
|
|
|
|
|
data ActorType = ActorTypePerson | ActorTypeOther Text
|
|
|
|
|
|
|
|
instance FromJSON ActorType where
|
|
|
|
parseJSON = withText "ActorType" $ \ t ->
|
|
|
|
pure $ case t of
|
|
|
|
"Person" -> ActorTypePerson
|
|
|
|
_ -> ActorTypeOther t
|
|
|
|
|
|
|
|
instance ToJSON ActorType where
|
|
|
|
toJSON = error "toJSON ActorType"
|
|
|
|
toEncoding at =
|
|
|
|
toEncoding $ case at of
|
|
|
|
ActorTypePerson -> "Person"
|
|
|
|
ActorTypeOther t -> t
|
|
|
|
|
|
|
|
data Algorithm = AlgorithmEd25519 | AlgorithmOther Text
|
|
|
|
|
|
|
|
instance FromJSON Algorithm where
|
|
|
|
parseJSON = withText "Algorithm" $ \ t ->
|
|
|
|
pure $ if t == frg <> "ed25519"
|
|
|
|
then AlgorithmEd25519
|
|
|
|
else AlgorithmOther t
|
|
|
|
|
|
|
|
instance ToJSON Algorithm where
|
|
|
|
toJSON = error "toJSON Algorithm"
|
|
|
|
toEncoding algo =
|
|
|
|
toEncoding $ case algo of
|
|
|
|
AlgorithmEd25519 -> frg <> "ed25519"
|
|
|
|
AlgorithmOther t -> t
|
|
|
|
|
|
|
|
data PublicKey = PublicKey
|
|
|
|
{ publicKeyId :: URI
|
|
|
|
, publicKeyOwner :: URI
|
|
|
|
, publicKeyPem :: PEM
|
|
|
|
, publicKeyAlgo :: Maybe Algorithm
|
|
|
|
}
|
|
|
|
|
|
|
|
instance FromJSON PublicKey where
|
|
|
|
parseJSON = withObject "PublicKey" $ \ o ->
|
|
|
|
PublicKey
|
|
|
|
<$> (parseURI' =<< o .: "id")
|
|
|
|
<*> (parseURI' =<< o .: "owner")
|
|
|
|
<*> (parsePEM =<< o .: "publicKeyPem")
|
|
|
|
<*> o .:? (frg <> "algorithm")
|
|
|
|
where
|
|
|
|
parsePEM t =
|
|
|
|
case pemParseBS $ encodeUtf8 t of
|
|
|
|
Left e -> fail $ "PEM parsing failed: " ++ e
|
|
|
|
Right xs ->
|
|
|
|
case xs of
|
|
|
|
[] -> fail "Empty PEM"
|
|
|
|
[x] -> pure x
|
|
|
|
_ -> fail "Multiple PEM sections"
|
|
|
|
|
|
|
|
instance ToJSON PublicKey where
|
|
|
|
toJSON = error "toJSON PublicKey"
|
|
|
|
toEncoding (PublicKey id_ owner pem malgo) =
|
|
|
|
pairs
|
|
|
|
$ "id" .= renderURI id_
|
|
|
|
<> "owner" .= renderURI owner
|
|
|
|
<> "publicKeyPem" .= decodeUtf8 (pemWriteBS pem)
|
|
|
|
<> maybe mempty ((frg <> "algorithm") .=) malgo
|
|
|
|
|
|
|
|
data Actor = Actor
|
|
|
|
{ actorId :: URI
|
|
|
|
, actorType :: ActorType
|
|
|
|
, actorUsername :: Text
|
|
|
|
, actorInbox :: URI
|
|
|
|
, actorPublicKey :: PublicKey
|
|
|
|
}
|
|
|
|
|
|
|
|
instance FromJSON Actor where
|
|
|
|
parseJSON = withObject "Actor" $ \ o ->
|
|
|
|
Actor
|
|
|
|
<$> (parseURI' =<< o .: "id")
|
|
|
|
<*> o .: "type"
|
|
|
|
<*> o .: "preferredUsername"
|
|
|
|
<*> (parseURI' =<< o .: "inbox")
|
|
|
|
<*> o .: "publicKey"
|
|
|
|
|
|
|
|
instance ToJSON Actor where
|
|
|
|
toJSON = error "toJSON Actor"
|
|
|
|
toEncoding (Actor id_ typ username inbox pkey) =
|
|
|
|
pairs
|
|
|
|
$ "@context" .= actorContext
|
|
|
|
<> "id" .= renderURI id_
|
|
|
|
<> "type" .= typ
|
|
|
|
<> "preferredUsername" .= username
|
|
|
|
<> "inbox" .= renderURI inbox
|
|
|
|
<> "publicKey" .= pkey
|
|
|
|
|
|
|
|
-- | This may seem trivial, but it exists for a good reason: In the 'FromJSON'
|
|
|
|
-- instance we perform sanity checks. We just don't need to remember the fields
|
|
|
|
-- after checking, so we don't unnecessarily add them as fields. We just keep
|
|
|
|
-- the _to_ field, which tells us who the target actor is (we currently support
|
|
|
|
-- only the _to_ field, and it has to be a single URI, and that URI has to be
|
|
|
|
-- an actor, not a collection). The 'Object' we keep is simply for encoding
|
|
|
|
-- back to JSON. I suppose that's actually silly, we could just keep the actual
|
|
|
|
-- ByteString, but I guess it's okay for now, and it happens to guarantee the
|
|
|
|
-- JSON we POST has no extra whitespace.
|
|
|
|
data Activity = Activity
|
|
|
|
{ activityTo :: URI
|
|
|
|
, activityJSON :: Object
|
|
|
|
}
|
|
|
|
|
|
|
|
instance FromJSON Activity where
|
|
|
|
parseJSON = withObject "Activity" $ \ o -> do
|
|
|
|
c <- o .: "@context"
|
|
|
|
if c == as2context
|
|
|
|
then return ()
|
|
|
|
else fail "@context isn't the AS2 context URI"
|
|
|
|
case M.lookup "id" o of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "id is provided; let the server set it"
|
|
|
|
case M.lookup "type" o of
|
|
|
|
Nothing -> fail "Activity type missing"
|
|
|
|
Just (String _) -> return ()
|
|
|
|
Just _ -> fail "Activity type isn't a string"
|
|
|
|
case M.lookup "actor" o of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "actor is provided; let the server set it"
|
|
|
|
mto <- case M.lookup "object" o of
|
|
|
|
Nothing -> return Nothing
|
|
|
|
Just v -> case v of
|
|
|
|
String _ -> return Nothing
|
|
|
|
Object obj -> do
|
|
|
|
case M.lookup "id" obj of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "object's id is provided; let the server set it"
|
|
|
|
case M.lookup "type" obj of
|
|
|
|
Nothing -> fail "Activity object type missing"
|
|
|
|
Just (String _) -> return ()
|
|
|
|
Just _ -> fail "Activity object type isn't a string"
|
|
|
|
case M.lookup "actor" o <|> M.lookup "attributedTo" o of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "attribution is provided; let the server set it"
|
|
|
|
obj .:? "to"
|
|
|
|
_ -> fail "Activity object isn't JSON string or object"
|
|
|
|
mto2 <- o .:? "to"
|
|
|
|
to <- case mto <|> mto2 of
|
|
|
|
Nothing -> fail "to not provided"
|
|
|
|
Just t -> parseURI' t
|
|
|
|
return $ Activity to o
|
|
|
|
|
|
|
|
instance ToJSON Activity where
|
|
|
|
toJSON = error "toJSON Activity"
|
|
|
|
toEncoding = toEncoding . activityJSON
|
|
|
|
|
|
|
|
typeActivityStreams2 :: ContentType
|
|
|
|
typeActivityStreams2 = "application/activity+json"
|
|
|
|
|
|
|
|
typeActivityStreams2LD :: ContentType
|
|
|
|
typeActivityStreams2LD =
|
|
|
|
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
|
|
|
|
|
|
|
|
provideAP :: (Monad m, ToJSON a) => a -> Writer (Endo [ProvidedRep m]) ()
|
|
|
|
provideAP v = do
|
|
|
|
let enc = toEncoding v
|
|
|
|
-- provideRepType typeActivityStreams2 $ return enc
|
|
|
|
provideRepType typeActivityStreams2LD $ return enc
|
|
|
|
|
|
|
|
data APGetError
|
|
|
|
= APGetErrorHTTP HttpException
|
|
|
|
| APGetErrorJSON JSONException
|
2019-01-21 23:24:09 +01:00
|
|
|
| APGetErrorContentType Text
|
2019-01-21 16:54:57 +01:00
|
|
|
deriving Show
|
|
|
|
|
|
|
|
instance Exception APGetError
|
|
|
|
|
|
|
|
-- | Perform an HTTP GET request to fetch an ActivityPub object.
|
|
|
|
--
|
|
|
|
-- * Verify the URI scheme is _https:_ and authority part is present
|
|
|
|
-- * Set _Accept_ request header
|
|
|
|
-- * Perform the GET request
|
|
|
|
-- * Verify the _Content-Type_ response header
|
|
|
|
-- * Parse the JSON response body
|
|
|
|
httpGetAP
|
|
|
|
:: (MonadIO m, FromJSON a)
|
|
|
|
=> Manager
|
|
|
|
-> URI
|
|
|
|
-> m (Either APGetError (Response a))
|
|
|
|
httpGetAP manager uri =
|
|
|
|
if uriScheme uri /= "https:"
|
|
|
|
then return $ Left $ APGetErrorHTTP $ InvalidUrlException (show uri) "Scheme isn't https"
|
|
|
|
else liftIO $ mkResult <$> try (httpAPEither manager =<< requestFromURI uri)
|
|
|
|
where
|
|
|
|
lookup' x = map snd . filter ((== x) . fst)
|
|
|
|
mkResult (Left e) = Left $ APGetErrorHTTP e
|
|
|
|
mkResult (Right r) =
|
|
|
|
case lookup' hContentType $ responseHeaders r of
|
|
|
|
[] -> Left $ APGetErrorContentType "No Content-Type"
|
|
|
|
[b] -> if b == typeActivityStreams2LD || b == typeActivityStreams2
|
|
|
|
then case responseBody r of
|
|
|
|
Left e -> Left $ APGetErrorJSON e
|
|
|
|
Right v -> Right $ v <$ r
|
2019-01-21 23:24:09 +01:00
|
|
|
else Left $ APGetErrorContentType $ "Non-AP Content-Type: " <> decodeUtf8 b
|
2019-01-21 16:54:57 +01:00
|
|
|
_ -> Left $ APGetErrorContentType "Multiple Content-Type"
|
|
|
|
|
|
|
|
-- Set method to POST, Set Content-Type, make HTTP signature, set response to throw on non-2xx
|
|
|
|
-- status
|
|
|
|
|
|
|
|
-- | Perform an HTTP POST request to submit an ActivityPub object.
|
|
|
|
--
|
|
|
|
-- * Verify the URI scheme is _https:_ and authority part is present
|
|
|
|
-- * Set _Content-Type_ request header
|
|
|
|
-- * Compute HTTP signature and add _Signature_ request header
|
|
|
|
-- * Perform the POST request
|
|
|
|
-- * Verify the response status is 2xx
|
|
|
|
httpPostAP
|
|
|
|
:: (MonadIO m, ToJSON a)
|
|
|
|
=> Manager
|
|
|
|
-> URI
|
|
|
|
-> NonEmpty HeaderName
|
|
|
|
-> (ByteString -> (KeyId, Signature))
|
|
|
|
-> a
|
|
|
|
-> m (Either HttpException (Response ()))
|
|
|
|
httpPostAP manager uri headers sign value =
|
|
|
|
if uriScheme uri /= "https:"
|
|
|
|
then return $ Left $ InvalidUrlException (show uri) "Scheme isn't https"
|
|
|
|
else liftIO $ try $ do
|
|
|
|
req <- requestFromURI uri
|
|
|
|
let req' =
|
|
|
|
setRequestCheckStatus $
|
|
|
|
consHeader hContentType typeActivityStreams2LD $
|
|
|
|
req { method = "POST"
|
|
|
|
, requestBody = RequestBodyLBS $ encode value
|
|
|
|
}
|
|
|
|
sign' b =
|
|
|
|
let (k, s) = sign b
|
|
|
|
in (Nothing, k, s)
|
|
|
|
req'' <- signRequest headers sign' Nothing req'
|
2019-02-03 11:59:35 +01:00
|
|
|
httpNoBody req'' manager
|
2019-01-21 16:54:57 +01:00
|
|
|
where
|
|
|
|
consHeader n b r = r { requestHeaders = (n, b) : requestHeaders r }
|