mirror of
https://code.sup39.dev/repos/Wqawg
synced 2024-12-28 12:24:51 +09:00
When a client posts to their outbox, allow only Create Note, not near-any JSON
This commit is contained in:
parent
0731597e1b
commit
754709833a
3 changed files with 136 additions and 143 deletions
|
@ -34,7 +34,6 @@ import Control.Monad.Trans.Except (ExceptT (ExceptT), runExceptT)
|
||||||
import Crypto.Error (CryptoFailable (..))
|
import Crypto.Error (CryptoFailable (..))
|
||||||
import Crypto.PubKey.Ed25519 (publicKey, signature, verify)
|
import Crypto.PubKey.Ed25519 (publicKey, signature, verify)
|
||||||
import Data.Aeson
|
import Data.Aeson
|
||||||
import Data.Aeson.Encode.Pretty.ToEncoding
|
|
||||||
import Data.Bifunctor (first, second)
|
import Data.Bifunctor (first, second)
|
||||||
import Data.HashMap.Strict (HashMap)
|
import Data.HashMap.Strict (HashMap)
|
||||||
import Data.List.NonEmpty (NonEmpty (..))
|
import Data.List.NonEmpty (NonEmpty (..))
|
||||||
|
@ -50,15 +49,16 @@ import Network.HTTP.Client (Manager, HttpException, requestFromURI)
|
||||||
import Network.HTTP.Simple (httpJSONEither, getResponseBody, setRequestManager, addRequestHeader)
|
import Network.HTTP.Simple (httpJSONEither, getResponseBody, setRequestManager, addRequestHeader)
|
||||||
import Network.HTTP.Types.Header (hDate, hHost)
|
import Network.HTTP.Types.Header (hDate, hHost)
|
||||||
import Text.Blaze.Html (Html)
|
import Text.Blaze.Html (Html)
|
||||||
|
import Text.Shakespeare.I18N (RenderMessage)
|
||||||
import UnliftIO.Exception (try)
|
import UnliftIO.Exception (try)
|
||||||
import Yesod.Auth (requireAuth)
|
import Yesod.Auth (requireAuth)
|
||||||
import Yesod.Core (ContentType, defaultLayout, whamlet, toHtml)
|
import Yesod.Core (ContentType, defaultLayout, whamlet, toHtml, HandlerSite)
|
||||||
import Yesod.Core.Content (TypedContent)
|
import Yesod.Core.Content (TypedContent)
|
||||||
import Yesod.Core.Json (requireJsonBody)
|
import Yesod.Core.Json (requireJsonBody)
|
||||||
import Yesod.Core.Handler
|
import Yesod.Core.Handler
|
||||||
import Yesod.Form.Fields (Textarea (..), textareaField)
|
import Yesod.Form.Fields (Textarea (..), textField, textareaField)
|
||||||
import Yesod.Form.Functions (areq, checkMMap, runFormPost, renderDivs)
|
import Yesod.Form.Functions
|
||||||
import Yesod.Form.Types (Field, Enctype, FormResult (..))
|
import Yesod.Form.Types
|
||||||
import Yesod.Persist.Core (runDB, get404)
|
import Yesod.Persist.Core (runDB, get404)
|
||||||
|
|
||||||
import qualified Data.ByteString.Char8 as BC (unpack)
|
import qualified Data.ByteString.Char8 as BC (unpack)
|
||||||
|
@ -74,8 +74,10 @@ import Yesod.HttpSignature (verifyRequestSignature)
|
||||||
|
|
||||||
import qualified Network.HTTP.Signature as S (Algorithm (..))
|
import qualified Network.HTTP.Signature as S (Algorithm (..))
|
||||||
|
|
||||||
|
import Data.Aeson.Encode.Pretty.ToEncoding
|
||||||
import Network.FedURI
|
import Network.FedURI
|
||||||
import Web.ActivityPub
|
import Web.ActivityPub
|
||||||
|
import Yesod.Auth.Unverified
|
||||||
|
|
||||||
import Vervis.ActorKey
|
import Vervis.ActorKey
|
||||||
import Vervis.Foundation
|
import Vervis.Foundation
|
||||||
|
@ -91,32 +93,9 @@ getInboxR = do
|
||||||
Welcome to the ActivityPub inbox test page! It's the beginning of
|
Welcome to the ActivityPub inbox test page! It's the beginning of
|
||||||
federation support in Vervis. Currently POSTing activities
|
federation support in Vervis. Currently POSTing activities
|
||||||
doesn't do anything, they're just verified and the results are
|
doesn't do anything, they're just verified and the results are
|
||||||
displayed on this page. Here's how to POST an activity
|
displayed on this page. To test, go to another Vervis instance's
|
||||||
successfully:
|
outbox page, submit an activity, and come back here to see
|
||||||
<p>
|
results.
|
||||||
(NOTE: Currently only Ed25519 signatures are supported, which is
|
|
||||||
incompatible with the default RSA-SHA256 used on the Fediverse)
|
|
||||||
<ol>
|
|
||||||
<li>
|
|
||||||
Publish an actor JSON document. That's like a regular
|
|
||||||
ActivityPub actor, except its <var>publicKey</var> object
|
|
||||||
should have one extra field named
|
|
||||||
<code>https://forgefed.angeley.es/ns#algorithm</code> and its
|
|
||||||
value should be
|
|
||||||
<code>https://forgefed.angeley.es/ns#ed25519</code>. The actual
|
|
||||||
key PEM should indeed be an Ed25519 public key, rather than
|
|
||||||
RSA.
|
|
||||||
<li>
|
|
||||||
Prepare an activity JSON document.
|
|
||||||
<li>
|
|
||||||
POST it to this page's URL, with an HTTP signature in a
|
|
||||||
Signature header, and use at least the headers Host, Date and
|
|
||||||
(request-target).
|
|
||||||
<p>
|
|
||||||
I'm aware these instructions aren't exactly clear and
|
|
||||||
self-contained. Soon I'll either clarify them or further
|
|
||||||
development will make things easier. In particular, by using one
|
|
||||||
Vervis instance to POST an activity to another Vervis instance.
|
|
||||||
<p>Last 10 activities posted:
|
<p>Last 10 activities posted:
|
||||||
<ul>
|
<ul>
|
||||||
$forall (time, result) <- acts
|
$forall (time, result) <- acts
|
||||||
|
@ -188,54 +167,41 @@ postInboxR = do
|
||||||
_ -> Left "Activity's object isn't a JSON object"
|
_ -> Left "Activity's object isn't a JSON object"
|
||||||
return (contentType, o)
|
return (contentType, o)
|
||||||
|
|
||||||
|
{-
|
||||||
jsonField :: (FromJSON a, ToJSON a) => Field Handler a
|
jsonField :: (FromJSON a, ToJSON a) => Field Handler a
|
||||||
jsonField = checkMMap fromTextarea toTextarea textareaField
|
jsonField = checkMMap fromTextarea toTextarea textareaField
|
||||||
where
|
where
|
||||||
toTextarea = Textarea . TL.toStrict . encodePrettyToLazyText
|
toTextarea = Textarea . TL.toStrict . encodePrettyToLazyText
|
||||||
fromTextarea = return . first T.pack . eitherDecodeStrict' . encodeUtf8 . unTextarea
|
fromTextarea = return . first T.pack . eitherDecodeStrict' . encodeUtf8 . unTextarea
|
||||||
|
-}
|
||||||
|
|
||||||
activityForm :: Form Activity
|
fedUriField
|
||||||
activityForm = renderDivs $ areq jsonField "" $ Just defval
|
:: (Monad m, RenderMessage (HandlerSite m) FormMessage) => Field m FedURI
|
||||||
where
|
fedUriField = Field
|
||||||
defval = Activity
|
{ fieldParse = parseHelper $ \ t ->
|
||||||
{ activityTo = FedURI "forge.angeley.es" "/p/aviva" ""
|
case parseFedURI t of
|
||||||
, activityJSON = M.fromList
|
Left e -> Left $ MsgInvalidUrl $ T.pack e <> ": " <> t
|
||||||
[ "@context" .= ("https://www.w3.org/ns/activitystreams" :: Text)
|
Right u -> Right u
|
||||||
, "type" .= ("Create" :: Text)
|
, fieldView = \theId name attrs val isReq ->
|
||||||
, "object" .= object
|
[whamlet|<input ##{theId} name=#{name} *{attrs} type=url :isReq:required value=#{either id renderFedURI val}>|]
|
||||||
[ "type" .= ("Note" :: Text)
|
, fieldEnctype = UrlEncoded
|
||||||
, "content" .= ("Hi! Nice to meet you :)" :: Text)
|
|
||||||
, "to" .= ("https://forge.angeley.es/p/luke" :: Text)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activityForm :: Form (FedURI, Text)
|
||||||
|
activityForm = renderDivs $ (,)
|
||||||
|
<$> areq fedUriField "To" (Just defto)
|
||||||
|
<*> areq textField "Message" (Just defmsg)
|
||||||
|
where
|
||||||
|
defto = FedURI "forge.angeley.es" "/p/fr33" ""
|
||||||
|
defmsg = "Hi! Nice to meet you :)"
|
||||||
|
|
||||||
activityWidget :: Widget -> Enctype -> Widget
|
activityWidget :: Widget -> Enctype -> Widget
|
||||||
activityWidget widget enctype =
|
activityWidget widget enctype =
|
||||||
[whamlet|
|
[whamlet|
|
||||||
<p>Enter an activity JSON document and click "Submit" to send it.
|
<p>
|
||||||
<p>NOTES:
|
This is a federation test page. Provide a recepient actor URI and
|
||||||
<ul>
|
message text, and a Create activity creating a new Note will be sent
|
||||||
<li>
|
to the destination server.
|
||||||
This is a test page for implementing federation in Vervis. The
|
|
||||||
activities just reach a test page, nothing really gets published or
|
|
||||||
changed otherwise.
|
|
||||||
<li>
|
|
||||||
The activity itself just needs to be valid JSON and pass some sanity
|
|
||||||
checks. It isn't verified to look like an ActivityPub activity with
|
|
||||||
ActivityStreams2 properties. So, you can probably post weird things
|
|
||||||
and they will pass.
|
|
||||||
<li>
|
|
||||||
The generated HTTP Signature uses Ed25519, while AFAIK the
|
|
||||||
Fediverse generally uses RSA, specifically RSA-PKCS1.5 (i.e. not
|
|
||||||
PSS) with SHA-256. In other words, send the activities to another
|
|
||||||
Vervis instance, not to Mastodon etc., because the latter won't
|
|
||||||
accept them.
|
|
||||||
<li>
|
|
||||||
Addressing is determined by the "to" field, which has to be a
|
|
||||||
single actor URL. The fields "cc" and "bcc" are ignored at the
|
|
||||||
moment.
|
|
||||||
|
|
||||||
<form method=POST action=@{OutboxR} enctype=#{enctype}>
|
<form method=POST action=@{OutboxR} enctype=#{enctype}>
|
||||||
^{widget}
|
^{widget}
|
||||||
<input type=submit>
|
<input type=submit>
|
||||||
|
@ -246,6 +212,12 @@ getOutboxR = do
|
||||||
((_result, widget), enctype) <- runFormPost activityForm
|
((_result, widget), enctype) <- runFormPost activityForm
|
||||||
defaultLayout $ activityWidget widget enctype
|
defaultLayout $ activityWidget widget enctype
|
||||||
|
|
||||||
|
route2uri' :: (Route App -> Text) -> Route App -> FedURI
|
||||||
|
route2uri' renderUrl r =
|
||||||
|
case parseFedURI $ renderUrl r of
|
||||||
|
Left e -> error e
|
||||||
|
Right u -> u
|
||||||
|
|
||||||
postOutboxR :: Handler Html
|
postOutboxR :: Handler Html
|
||||||
postOutboxR = do
|
postOutboxR = do
|
||||||
((result, widget), enctype) <- runFormPost activityForm
|
((result, widget), enctype) <- runFormPost activityForm
|
||||||
|
@ -253,20 +225,28 @@ postOutboxR = do
|
||||||
case result of
|
case result of
|
||||||
FormMissing -> setMessage "Field(s) missing"
|
FormMissing -> setMessage "Field(s) missing"
|
||||||
FormFailure _l -> setMessage "Invalid input, see below"
|
FormFailure _l -> setMessage "Invalid input, see below"
|
||||||
FormSuccess (Activity to act) -> do
|
FormSuccess (to, msg) -> do
|
||||||
Entity _pid person <- requireAuth
|
shr <- do
|
||||||
let sid = personIdent person
|
Entity _pid person <- requireVerifiedAuth
|
||||||
sharer <- runDB $ get404 sid
|
sharer <- runDB $ get404 $ personIdent person
|
||||||
let shr = sharerIdent sharer
|
return $ sharerIdent sharer
|
||||||
renderUrl <- getUrlRender
|
renderUrl <- getUrlRender
|
||||||
let actorID = renderUrl $ PersonR shr
|
let route2uri = route2uri' renderUrl
|
||||||
actID = actorID <> "/fake/1"
|
actor = route2uri $ PersonR shr
|
||||||
objID = actorID <> "/fake/2"
|
actorID = renderUrl $ PersonR shr
|
||||||
keyID1 = renderUrl ActorKey1R
|
appendPath u t = u { furiPath = furiPath u <> t }
|
||||||
keyID2 = renderUrl ActorKey2R
|
activity = CreateActivity Create
|
||||||
updateObj (Object obj) = Object $ M.insert "attributedTo" (String actorID) $ M.insert "id" (String objID) obj
|
{ createId = appendPath actor "/fake-activity"
|
||||||
updateObj v = v
|
, createTo = to
|
||||||
updateAct = M.adjust updateObj "object" . M.insert "actor" (String actorID) . M.insert "id" (String actID)
|
, createActor = actor
|
||||||
|
, createObject = Note
|
||||||
|
{ noteId = appendPath actor "/fake-note"
|
||||||
|
, noteAttrib = actor
|
||||||
|
, noteTo = to
|
||||||
|
, noteReplyTo = Nothing
|
||||||
|
, noteContent = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
manager <- getsYesod appHttpManager
|
manager <- getsYesod appHttpManager
|
||||||
eres <- httpGetAP manager to
|
eres <- httpGetAP manager to
|
||||||
case eres of
|
case eres of
|
||||||
|
@ -281,10 +261,10 @@ postOutboxR = do
|
||||||
(akey1, akey2, new1) <- liftIO . readTVarIO =<< getsYesod appActorKeys
|
(akey1, akey2, new1) <- liftIO . readTVarIO =<< getsYesod appActorKeys
|
||||||
let (keyID, akey) =
|
let (keyID, akey) =
|
||||||
if new1
|
if new1
|
||||||
then (keyID1, akey1)
|
then (renderUrl ActorKey1R, akey1)
|
||||||
else (keyID2, akey2)
|
else (renderUrl ActorKey2R, akey2)
|
||||||
sign b = (KeyId $ encodeUtf8 keyID, actorKeySign akey b)
|
sign b = (KeyId $ encodeUtf8 keyID, actorKeySign akey b)
|
||||||
eres' <- httpPostAP manager (actorInbox actor) (hRequestTarget :| [hHost, hDate, hActivityPubActor]) sign actorID (updateAct act)
|
eres' <- httpPostAP manager (actorInbox actor) (hRequestTarget :| [hHost, hDate, hActivityPubActor]) sign actorID activity
|
||||||
case eres' of
|
case eres' of
|
||||||
Left e -> setMessage $ toHtml $ "Failed to POST to recipient's inbox: " <> T.pack (displayException e)
|
Left e -> setMessage $ toHtml $ "Failed to POST to recipient's inbox: " <> T.pack (displayException e)
|
||||||
Right _ -> setMessage "Activity posted! You can go to the target server's /inbox to see the result."
|
Right _ -> setMessage "Activity posted! You can go to the target server's /inbox to see the result."
|
||||||
|
@ -295,11 +275,7 @@ getActorKey choose route = do
|
||||||
actorKey <-
|
actorKey <-
|
||||||
liftIO . fmap (actorKeyPublicBin . choose) . readTVarIO =<<
|
liftIO . fmap (actorKeyPublicBin . choose) . readTVarIO =<<
|
||||||
getsYesod appActorKeys
|
getsYesod appActorKeys
|
||||||
renderUrl <- getUrlRender
|
route2uri <- route2uri' <$> getUrlRender
|
||||||
let route2uri r =
|
|
||||||
case parseFedURI $ renderUrl r of
|
|
||||||
Left e -> error e
|
|
||||||
Right u -> u
|
|
||||||
selectRep $
|
selectRep $
|
||||||
provideAP PublicKey
|
provideAP PublicKey
|
||||||
{ publicKeyId = route2uri route
|
{ publicKeyId = route2uri route
|
||||||
|
|
|
@ -25,10 +25,8 @@ module Web.ActivityPub
|
||||||
, Actor (..)
|
, Actor (..)
|
||||||
|
|
||||||
-- * Activity
|
-- * Activity
|
||||||
--
|
, Note (..)
|
||||||
-- Very basic activity document which is just general JSON with some
|
, Create (..)
|
||||||
-- basic checks. 'FromJSON' instance for receiving POSTs, and 'ToJSON'
|
|
||||||
-- instance for delivering to other servers.
|
|
||||||
, Activity (..)
|
, Activity (..)
|
||||||
|
|
||||||
-- * Utilities
|
-- * Utilities
|
||||||
|
@ -46,7 +44,7 @@ import Prelude
|
||||||
|
|
||||||
import Control.Applicative ((<|>), optional)
|
import Control.Applicative ((<|>), optional)
|
||||||
import Control.Exception (Exception, displayException, try)
|
import Control.Exception (Exception, displayException, try)
|
||||||
import Control.Monad ((<=<))
|
import Control.Monad (unless, (<=<))
|
||||||
import Control.Monad.IO.Class
|
import Control.Monad.IO.Class
|
||||||
import Control.Monad.Trans.Except
|
import Control.Monad.Trans.Except
|
||||||
import Control.Monad.Trans.Writer (Writer)
|
import Control.Monad.Trans.Writer (Writer)
|
||||||
|
@ -221,62 +219,81 @@ instance ToJSON Actor where
|
||||||
<> "inbox" .= inbox
|
<> "inbox" .= inbox
|
||||||
<> "publicKey" .= pkeys
|
<> "publicKey" .= pkeys
|
||||||
|
|
||||||
-- | This may seem trivial, but it exists for a good reason: In the 'FromJSON'
|
data Note = Note
|
||||||
-- instance we perform sanity checks. We just don't need to remember the fields
|
{ noteId :: FedURI
|
||||||
-- after checking, so we don't unnecessarily add them as fields. We just keep
|
, noteAttrib :: FedURI
|
||||||
-- the _to_ field, which tells us who the target actor is (we currently support
|
, noteTo :: FedURI
|
||||||
-- only the _to_ field, and it has to be a single URI, and that URI has to be
|
, noteReplyTo :: Maybe FedURI
|
||||||
-- an actor, not a collection). The 'Object' we keep is simply for encoding
|
, noteContent :: Text
|
||||||
-- 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 :: FedURI
|
|
||||||
, activityJSON :: Object
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
instance FromJSON Note where
|
||||||
|
parseJSON = withObject "Note" $ \ o -> do
|
||||||
|
typ <- o .: "type"
|
||||||
|
unless (typ == ("Note" :: Text)) $ fail "type isn't Note"
|
||||||
|
Note
|
||||||
|
<$> o .: "id"
|
||||||
|
<*> o .: "attributedTo"
|
||||||
|
<*> o .: "to"
|
||||||
|
<*> o .:? "inReplyTo"
|
||||||
|
<*> o .: "content"
|
||||||
|
|
||||||
|
instance ToJSON Note where
|
||||||
|
toJSON = error "toJSON Note"
|
||||||
|
toEncoding (Note id_ attrib to mreply content) =
|
||||||
|
pairs
|
||||||
|
$ "type" .= ("Note" :: Text)
|
||||||
|
<> "id" .= id_
|
||||||
|
<> "attributedTo" .= attrib
|
||||||
|
<> "to" .= to
|
||||||
|
<> "inReplyTo" .=? mreply
|
||||||
|
<> "content" .= content
|
||||||
|
|
||||||
|
data Create = Create
|
||||||
|
{ createId :: FedURI
|
||||||
|
, createTo :: FedURI
|
||||||
|
, createActor :: FedURI
|
||||||
|
, createObject :: Note
|
||||||
|
}
|
||||||
|
|
||||||
|
instance FromJSON Create where
|
||||||
|
parseJSON = withObject "Create" $ \ o -> do
|
||||||
|
typ <- o .: "type"
|
||||||
|
unless (typ == ("Create" :: Text)) $ fail "type isn't Create"
|
||||||
|
Create
|
||||||
|
<$> o .: "id"
|
||||||
|
<*> o .: "to"
|
||||||
|
<*> o .: "actor"
|
||||||
|
<*> o .: "object"
|
||||||
|
|
||||||
|
instance ToJSON Create where
|
||||||
|
toJSON = error "toJSON Create"
|
||||||
|
toEncoding (Create id_ to actor obj) =
|
||||||
|
pairs
|
||||||
|
$ "@context" .= as2context
|
||||||
|
<> "type" .= ("Create" :: Text)
|
||||||
|
<> "id" .= id_
|
||||||
|
<> "to" .= to
|
||||||
|
<> "actor" .= actor
|
||||||
|
<> "object" .= obj
|
||||||
|
|
||||||
|
data Activity = CreateActivity Create
|
||||||
|
|
||||||
instance FromJSON Activity where
|
instance FromJSON Activity where
|
||||||
parseJSON = withObject "Activity" $ \ o -> do
|
parseJSON = withObject "Activity" $ \ o -> do
|
||||||
c <- o .: "@context"
|
ctx <- o .: "@context"
|
||||||
if c == as2context
|
if ctx == as2context
|
||||||
then return ()
|
then return ()
|
||||||
else fail "@context isn't the AS2 context URI"
|
else fail "@context isn't the AS2 context URI"
|
||||||
case M.lookup "id" o of
|
typ <- o .: "type"
|
||||||
Nothing -> return ()
|
let v = Object o
|
||||||
Just _ -> fail "id is provided; let the server set it"
|
case typ of
|
||||||
case M.lookup "type" o of
|
"Create" -> CreateActivity <$> parseJSON v
|
||||||
Nothing -> fail "Activity type missing"
|
_ -> fail $ "Unrecognized activity type: " ++ T.unpack typ
|
||||||
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 u -> return u
|
|
||||||
return $ Activity to o
|
|
||||||
|
|
||||||
instance ToJSON Activity where
|
instance ToJSON Activity where
|
||||||
toJSON = error "toJSON Activity"
|
toJSON = error "toJSON Activity"
|
||||||
toEncoding = toEncoding . activityJSON
|
toEncoding (CreateActivity c) = toEncoding c
|
||||||
|
|
||||||
typeActivityStreams2 :: ContentType
|
typeActivityStreams2 :: ContentType
|
||||||
typeActivityStreams2 = "application/activity+json"
|
typeActivityStreams2 = "application/activity+json"
|
||||||
|
|
Loading…
Reference in a new issue