2023-05-26 23:07:36 +00:00
""" ActivityPub protocol implementation. """
2023-02-15 18:57:11 +00:00
from base64 import b64encode
from hashlib import sha256
2023-03-08 21:10:41 +00:00
import itertools
2017-08-13 07:12:16 +00:00
import logging
2023-09-23 20:53:17 +00:00
import re
2023-10-27 00:58:10 +00:00
from urllib . parse import quote_plus , urljoin , urlparse
2017-08-13 07:12:16 +00:00
2023-11-03 01:03:20 +00:00
from flask import abort , g , redirect , request
2023-06-02 04:37:58 +00:00
from google . cloud import ndb
2024-04-01 01:49:27 +00:00
from google . cloud . ndb . query import FilterNode , OR , Query
2023-01-29 22:13:58 +00:00
from granary import as1 , as2
2023-02-15 18:57:11 +00:00
from httpsig import HeaderVerifier
2023-03-08 21:10:41 +00:00
from httpsig . requests_auth import HTTPSignatureAuth
2023-02-16 04:10:17 +00:00
from httpsig . utils import parse_signature_header
2023-06-28 05:31:48 +00:00
from oauth_dropins . webutil import appengine_info , flask_util , util
2023-03-10 03:56:04 +00:00
from oauth_dropins . webutil . util import fragmentless , json_dumps , json_loads
2023-03-08 21:10:41 +00:00
import requests
from werkzeug . exceptions import BadGateway
2017-08-13 07:12:16 +00:00
2023-04-19 00:17:48 +00:00
from flask_app import app , cache
2017-08-15 06:07:24 +00:00
import common
2023-03-08 21:10:41 +00:00
from common import (
2023-06-30 19:36:20 +00:00
add ,
2023-03-08 21:10:41 +00:00
CACHE_TIME ,
CONTENT_TYPE_HTML ,
2024-01-11 23:16:33 +00:00
DOMAINS ,
2023-09-23 20:53:17 +00:00
DOMAIN_RE ,
2023-03-08 21:10:41 +00:00
error ,
host_url ,
2023-11-03 01:03:20 +00:00
LOCAL_DOMAINS ,
PRIMARY_DOMAIN ,
2024-04-21 15:08:05 +00:00
PROTOCOL_DOMAINS ,
2023-03-08 21:10:41 +00:00
redirect_wrap ,
2023-10-23 22:44:32 +00:00
subdomain_wrap ,
2023-10-24 17:46:57 +00:00
unwrap ,
2023-03-08 21:10:41 +00:00
)
2024-02-28 18:57:30 +00:00
from models import fetch_objects , Follower , Object , PROTOCOLS , User
2023-05-31 17:10:14 +00:00
from protocol import Protocol
2023-09-22 20:11:15 +00:00
import webfinger
2023-06-10 14:53:07 +00:00
2022-02-12 06:38:56 +00:00
logger = logging . getLogger ( __name__ )
2023-03-08 21:10:41 +00:00
CONNEG_HEADERS_AS2_HTML = {
2024-02-27 19:38:00 +00:00
' Accept ' : f ' { as2 . CONNEG_HEADERS [ " Accept " ] } , { CONTENT_TYPE_HTML } ; q=0.5 '
2023-03-08 21:10:41 +00:00
}
HTTP_SIG_HEADERS = ( ' Date ' , ' Host ' , ' Digest ' , ' (request-target) ' )
2024-01-29 02:15:37 +00:00
SECURITY_CONTEXT = ' https://w3id.org/security/v1 '
2024-01-06 21:59:31 +00:00
# https://seb.jambor.dev/posts/understanding-activitypub-part-4-threads/#the-instance-actor
_INSTANCE_ACTOR = None
2023-03-08 21:10:41 +00:00
2024-04-01 01:49:27 +00:00
# populated in User.status
WEB_OPT_OUT_DOMAINS = None
2024-04-11 22:02:15 +00:00
FEDI_URL_RE = re . compile ( r ' https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)? ' )
2024-04-21 15:36:03 +00:00
# can't use translate_user_id because Web.owns_id checks valid_domain, which
# doesn't allow our protocol subdomains
2024-04-21 19:18:12 +00:00
BOT_ACTOR_IDS = tuple ( f ' https:// { domain } / { domain } ' for domain in PROTOCOL_DOMAINS )
2024-04-21 15:08:05 +00:00
2024-04-01 01:49:27 +00:00
2024-01-06 21:59:31 +00:00
def instance_actor ( ) :
global _INSTANCE_ACTOR
if _INSTANCE_ACTOR is None :
2023-06-10 14:53:07 +00:00
import web
2024-01-06 21:59:31 +00:00
_INSTANCE_ACTOR = web . Web . get_or_create ( PRIMARY_DOMAIN )
return _INSTANCE_ACTOR
2023-03-08 21:10:41 +00:00
2023-05-31 17:10:14 +00:00
class ActivityPub ( User , Protocol ) :
2023-05-31 20:17:17 +00:00
""" ActivityPub protocol class.
Key id is AP / AS2 actor id URL . ( * Not * fediverse / WebFinger @ - @ handle ! )
"""
2023-06-11 15:14:17 +00:00
ABBREV = ' ap '
2024-01-25 03:20:54 +00:00
PHRASE = ' the fediverse '
2023-10-10 18:14:42 +00:00
LOGO_HTML = ' <img src= " /static/fediverse_logo.svg " > '
2024-02-27 19:38:00 +00:00
CONTENT_TYPE = as2 . CONTENT_TYPE_LD_PROFILE
2023-11-27 22:44:05 +00:00
HAS_FOLLOW_ACCEPTS = True
2024-04-17 23:43:10 +00:00
DEFAULT_ENABLED_PROTOCOLS = ( ' web ' , )
2023-03-08 21:10:41 +00:00
2023-06-23 19:22:37 +00:00
def _pre_put_hook ( self ) :
""" Validate id, require URL, don ' t allow Bridgy Fed domains.
TODO : normalize scheme and domain to lower case . Add that to
2023-10-06 06:32:31 +00:00
: class : ` oauth_dropins . webutil . util . UrlCanonicalizer ` \?
2023-06-23 19:22:37 +00:00
"""
super ( ) . _pre_put_hook ( )
id = self . key . id ( )
assert id
assert util . is_web ( id ) , f ' { id } is not a URL '
domain = util . domain_from_link ( id )
assert domain , ' missing domain '
2023-09-07 00:32:35 +00:00
assert not self . is_blocklisted ( domain ) , f ' { id } is a blocked domain '
2023-06-23 19:22:37 +00:00
2023-06-01 01:34:33 +00:00
def web_url ( self ) :
2023-09-27 21:58:33 +00:00
""" Returns this user ' s web URL aka web_url, eg ``https://foo.com/``. """
2023-06-16 04:22:20 +00:00
if self . obj and self . obj . as1 :
2024-04-02 06:06:25 +00:00
url = as1 . get_url ( self . obj . as1 )
2023-06-16 04:22:20 +00:00
if url :
return url
2023-12-01 00:31:41 +00:00
return self . key . id ( )
2023-06-01 01:34:33 +00:00
2023-09-25 22:08:14 +00:00
@ndb.ComputedProperty
2023-09-25 21:15:24 +00:00
def handle ( self ) :
2023-09-25 17:27:08 +00:00
""" Returns this user ' s ActivityPub address, eg ``@user@foo.com``. """
2023-06-16 04:22:20 +00:00
if self . obj and self . obj . as1 :
2023-11-26 23:59:20 +00:00
addr = as2 . address ( self . convert ( self . obj , from_user = self ) )
2023-06-16 04:22:20 +00:00
if addr :
return addr
return as2 . address ( self . key . id ( ) )
2023-06-01 01:34:33 +00:00
2024-04-01 01:49:27 +00:00
@ndb.ComputedProperty
def status ( self ) :
""" Override :meth:`Model.status` and include Web opted out domains. """
global WEB_OPT_OUT_DOMAINS
if WEB_OPT_OUT_DOMAINS is None :
WEB_OPT_OUT_DOMAINS = {
key . id ( ) for key in Query (
' MagicKey ' ,
filters = FilterNode ( ' manual_opt_out ' , ' = ' , True )
) . fetch ( keys_only = True )
}
logger . info ( f ' Loaded { len ( WEB_OPT_OUT_DOMAINS ) } manually opted out Web users ' )
status = super ( ) . status
if status :
return status
return util . domain_or_parent_in ( util . domain_from_link ( self . key . id ( ) ) ,
WEB_OPT_OUT_DOMAINS )
2023-06-13 20:17:11 +00:00
@classmethod
def owns_id ( cls , id ) :
2023-10-06 15:22:50 +00:00
""" Returns None if ``id`` is an http(s) URL, False otherwise.
2023-06-13 20:17:11 +00:00
All AP ids are http ( s ) URLs , but not all http ( s ) URLs are AP ids .
https : / / www . w3 . org / TR / activitypub / #obj-id
2024-01-26 19:37:34 +00:00
I used to include a heuristic here that no actor is the root path on its
host , which was nice because it let us assume that home pages are Web
users without making any network requests . . . but then I inevitably ran
into AP actors that _are_ the root path , eg microblog . pub sites like
https : / / bw3 . dev / .
https : / / docs . microblog . pub / user_guide . html #activitypub
2023-06-13 20:17:11 +00:00
"""
2024-01-26 19:37:34 +00:00
if util . is_web ( id ) and not cls . is_blocklisted ( id ) :
2023-07-02 21:55:05 +00:00
return None
return False
2023-06-13 20:17:11 +00:00
2023-09-22 19:14:50 +00:00
@classmethod
def owns_handle ( cls , handle ) :
2023-10-06 15:22:50 +00:00
""" Returns True if handle is a WebFinger ``@-@`` handle, False otherwise.
2023-09-22 19:14:50 +00:00
Example : ` ` @user @instance.com ` ` . The leading ` ` @ ` ` is optional .
https : / / datatracker . ietf . org / doc / html / rfc7033 #section-3.1
https : / / datatracker . ietf . org / doc / html / rfc7033 #section-4.5
"""
parts = handle . lstrip ( ' @ ' ) . split ( ' @ ' )
2024-01-15 15:56:59 +00:00
if len ( parts ) != 2 :
return False
user , domain = parts
return user and domain and not cls . is_blocklisted ( domain )
2023-09-22 19:14:50 +00:00
2023-09-22 20:11:15 +00:00
@classmethod
def handle_to_id ( cls , handle ) :
""" Looks in the datastore first, then queries WebFinger. """
assert cls . owns_handle ( handle )
if not handle . startswith ( ' @ ' ) :
handle = ' @ ' + handle
2023-09-25 22:08:14 +00:00
user = ActivityPub . query ( OR ( ActivityPub . handle == handle ,
ActivityPub . readable_id == handle ) ,
) . get ( )
2023-09-22 20:11:15 +00:00
if user :
return user . key . id ( )
return webfinger . fetch_actor_url ( handle )
2023-06-16 20:16:17 +00:00
@classmethod
def target_for ( cls , obj , shared = False ) :
2023-10-06 06:32:31 +00:00
""" Returns ``obj`` ' s or its author ' s/actor ' s inbox, if available. """
2023-06-21 00:06:32 +00:00
if not obj . as1 :
return None
2023-06-16 20:16:17 +00:00
if obj . type not in as1 . ACTOR_TYPES :
2023-06-21 03:59:32 +00:00
for field in ' actor ' , ' author ' , ' attributedTo ' :
2023-06-21 00:06:32 +00:00
inner_obj = as1 . get_object ( obj . as1 , field )
inner_id = inner_obj . get ( ' id ' ) or as1 . get_url ( inner_obj )
2023-07-27 22:04:15 +00:00
if ( not inner_id
or inner_id == obj . as1 . get ( ' id ' )
or ( obj . key and inner_id == obj . key . id ( ) ) ) :
2023-06-21 00:06:32 +00:00
continue
# TODO: need a "soft" kwarg for load to suppress errors?
actor = cls . load ( inner_id )
if actor and actor . as1 :
target = cls . target_for ( actor )
if target :
logger . info ( f ' Target for { obj . key } via { inner_id } is { target } ' )
return target
logger . info ( f ' { obj . key } type { obj . type } is not an actor and has no author or actor with inbox ' )
2023-06-16 20:16:17 +00:00
2023-10-25 20:23:11 +00:00
actor = ActivityPub . convert ( obj )
2023-06-16 20:16:17 +00:00
if shared :
shared_inbox = actor . get ( ' endpoints ' , { } ) . get ( ' sharedInbox ' )
if shared_inbox :
return shared_inbox
2023-06-21 03:59:32 +00:00
return actor . get ( ' publicInbox ' ) or actor . get ( ' inbox ' )
2023-06-16 20:16:17 +00:00
2023-03-08 21:10:41 +00:00
@classmethod
2023-11-26 04:07:14 +00:00
def send ( to_cls , obj , url , from_user = None , orig_obj = None ) :
2023-06-21 03:59:32 +00:00
""" Delivers an activity to an inbox URL.
2023-04-02 02:13:51 +00:00
2023-10-06 06:32:31 +00:00
If ` ` obj . recipient_obj ` ` is set , it ' s interpreted as the receiving actor
who we ' re delivering to and its id is populated into ``cc``.
2023-06-21 03:59:32 +00:00
"""
2023-09-09 21:17:47 +00:00
if to_cls . is_blocklisted ( url ) :
2023-10-18 19:14:18 +00:00
logger . info ( f ' Skipping sending to blocklisted { url } ' )
2023-07-23 06:02:12 +00:00
return False
2024-04-10 22:16:37 +00:00
orig_obj = to_cls . convert ( orig_obj , from_user = from_user )
activity = to_cls . convert ( obj , from_user = from_user , orig_obj = orig_obj )
2023-07-13 21:32:44 +00:00
2023-11-26 04:07:14 +00:00
return signed_post ( url , data = activity , from_user = from_user ) . ok
2023-03-08 21:10:41 +00:00
@classmethod
2023-06-14 03:24:09 +00:00
def fetch ( cls , obj , * * kwargs ) :
2023-03-23 03:49:28 +00:00
""" Tries to fetch an AS2 object.
2023-03-08 21:10:41 +00:00
2023-10-06 06:32:31 +00:00
Assumes ` ` obj . id ` ` is a URL . Any fragment at the end is stripped before
2023-06-13 02:01:50 +00:00
loading . This is currently underspecified and somewhat inconsistent
across AP implementations :
2023-10-06 06:32:31 +00:00
* https : / / socialhub . activitypub . rocks / t / problems - posting - to - mastodon - inbox / 801 / 11
* https : / / socialhub . activitypub . rocks / t / problems - posting - to - mastodon - inbox / 801 / 23
* https : / / socialhub . activitypub . rocks / t / s2s - create - activity / 1647 / 5
* https : / / github . com / mastodon / mastodon / issues / 13879 ( open ! )
* https : / / github . com / w3c / activitypub / issues / 224
2023-06-13 02:01:50 +00:00
2023-10-06 06:32:31 +00:00
Uses HTTP content negotiation via the ` ` Content - Type ` ` header . If the
url is HTML and it has a ` ` rel - alternate ` ` link with an AS2 content
type , fetches and returns that URL .
2023-03-08 21:10:41 +00:00
Includes an HTTP Signature with the request .
2023-10-06 06:32:31 +00:00
* https : / / w3c . github . io / activitypub / #authorization
* https : / / tools . ietf . org / html / draft - cavage - http - signatures - 07
* https : / / github . com / mastodon / mastodon / pull / 11269
Mastodon requires this signature if ` ` AUTHORIZED_FETCH ` ` aka secure mode
is on : https : / / docs . joinmastodon . org / admin / config / #authorized_fetch
2023-03-08 21:10:41 +00:00
2023-03-27 21:18:24 +00:00
Signs the request with the current user ' s key. If not provided, defaults to
2023-03-08 21:10:41 +00:00
using @snarfed.org @snarfed.org ' s key.
2023-10-06 06:32:31 +00:00
See : meth : ` protocol . Protocol . fetch ` for more details .
2023-07-14 19:45:47 +00:00
2023-03-08 21:10:41 +00:00
Args :
2023-10-06 06:32:31 +00:00
obj ( models . Object ) : with the id to fetch . Fills data into the as2
2023-04-03 14:53:15 +00:00
property .
2023-06-14 03:24:09 +00:00
kwargs : ignored
2023-03-08 21:10:41 +00:00
2023-07-14 19:45:47 +00:00
Returns :
2023-10-06 06:32:31 +00:00
bool : True if the object was fetched and populated successfully ,
2023-07-14 19:45:47 +00:00
False otherwise
2023-03-08 21:10:41 +00:00
Raises :
2023-10-06 06:32:31 +00:00
requests . HTTPError :
werkzeug . exceptions . HTTPException : will have an additional
` ` requests_response ` ` attribute with the last
: class : ` requests . Response ` we received .
2023-03-08 21:10:41 +00:00
"""
2023-07-14 19:45:47 +00:00
url = obj . key . id ( )
if not util . is_web ( url ) :
logger . info ( f ' { url } is not a URL ' )
return False
2023-03-23 03:49:28 +00:00
resp = None
def _error ( extra_msg = None ) :
2023-07-14 19:45:47 +00:00
msg = f " Couldn ' t fetch { url } as ActivityStreams 2 "
2023-03-08 21:10:41 +00:00
if extra_msg :
msg + = ' : ' + extra_msg
logger . warning ( msg )
2023-07-14 19:45:47 +00:00
# protocol.for_id depends on us raising this when an AP network
# fetch fails. if we change that, update for_id too!
2023-03-08 21:10:41 +00:00
err = BadGateway ( msg )
err . requests_response = resp
raise err
def _get ( url , headers ) :
""" Returns None if we fetched and populated, resp otherwise. """
2023-03-23 03:49:28 +00:00
nonlocal resp
2023-07-14 19:45:47 +00:00
try :
resp = signed_get ( url , headers = headers , gateway = True )
except BadGateway as e :
# ugh, this is ugly, should be something structured
if ' 406 Client Error ' in str ( e ) :
return
raise
2023-03-08 21:10:41 +00:00
if not resp . content :
2023-03-23 03:49:28 +00:00
_error ( ' empty response ' )
2023-05-28 15:06:36 +00:00
elif common . content_type ( resp ) in as2 . CONTENT_TYPES :
2023-03-08 21:10:41 +00:00
try :
2023-04-03 14:53:15 +00:00
return resp . json ( )
2023-03-08 21:10:41 +00:00
except requests . JSONDecodeError :
2023-03-23 03:49:28 +00:00
_error ( " Couldn ' t decode as JSON " )
2023-04-03 14:53:15 +00:00
2023-07-14 19:45:47 +00:00
obj . as2 = _get ( url , CONNEG_HEADERS_AS2_HTML )
2023-04-03 14:53:15 +00:00
if obj . as2 :
2023-07-14 19:45:47 +00:00
return True
elif not resp :
return False
2023-03-08 21:10:41 +00:00
# look in HTML to find AS2 link
if common . content_type ( resp ) != ' text/html ' :
2023-07-14 19:45:47 +00:00
logger . info ( ' no AS2 available ' )
return False
2023-03-08 21:10:41 +00:00
parsed = util . parse_html ( resp )
link = parsed . find ( ' link ' , rel = ( ' alternate ' , ' self ' ) , type = (
as2 . CONTENT_TYPE , as2 . CONTENT_TYPE_LD ) )
if not ( link and link [ ' href ' ] ) :
2023-07-14 19:45:47 +00:00
logger . info ( ' no AS2 available ' )
return False
2023-03-23 03:49:28 +00:00
2023-04-03 14:53:15 +00:00
obj . as2 = _get ( link [ ' href ' ] , as2 . CONNEG_HEADERS )
if obj . as2 :
2023-07-14 19:45:47 +00:00
return True
2023-03-08 21:10:41 +00:00
2023-07-14 19:45:47 +00:00
return False
2023-03-08 21:10:41 +00:00
2023-05-24 04:30:57 +00:00
@classmethod
2023-11-26 04:07:14 +00:00
def convert ( cls , obj , orig_obj = None , from_user = None ) :
2023-10-24 23:09:28 +00:00
""" Convert a :class:`models.Object` to AS2.
Args :
obj ( models . Object )
2023-11-26 00:01:34 +00:00
orig_obj ( dict ) : AS2 object , optional . The target of activity ' s
` ` inReplyTo ` ` or ` ` Like ` ` / ` ` Announce ` ` / etc object , if any . Passed
through to : func : ` postprocess_as2 ` .
2023-11-26 04:07:14 +00:00
from_user ( models . User ) : user ( actor ) this activity / object is from
2023-10-24 23:09:28 +00:00
Returns :
dict : AS2 JSON
"""
2023-11-24 16:34:39 +00:00
if not obj or not obj . as1 :
2023-10-25 20:23:11 +00:00
return { }
2024-02-28 18:57:30 +00:00
from_proto = PROTOCOLS . get ( obj . source_protocol )
2024-04-10 18:49:53 +00:00
user_id = from_user . key . id ( ) if from_user and from_user . key else None
2024-04-14 21:50:02 +00:00
# TODO: uncomment
2024-04-28 03:27:33 +00:00
# if from_proto and not from_user.is_enabled(cls):
2024-04-14 21:50:02 +00:00
# error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
2024-02-28 18:57:30 +00:00
2023-10-25 20:23:11 +00:00
if obj . as2 :
2024-01-29 02:15:37 +00:00
return {
# add back @context since we strip it when we store Objects
' @context ' : [ as2 . CONTEXT , SECURITY_CONTEXT ] ,
* * obj . as2 ,
}
2023-10-25 20:23:11 +00:00
2023-11-24 16:01:06 +00:00
translated = cls . translate_ids ( obj . as1 )
2023-11-24 16:34:39 +00:00
# compact actors to just string id for compatibility, since many other
# AP implementations choke on objects.
# https://github.com/snarfed/bridgy-fed/issues/658
#
2023-11-24 17:20:52 +00:00
# TODO: expand this to general purpose compact() function and use
# elsewhere, eg in models.resolve_id
2023-11-24 16:34:39 +00:00
for o in translated , as1 . get_object ( translated ) :
for field in ' actor ' , ' attributedTo ' , ' author ' :
actors = as1 . get_objects ( o , field )
ids = [ a [ ' id ' ] for a in actors if a . get ( ' id ' ) ]
o [ field ] = ids [ 0 ] if len ( ids ) == 1 else ids
2023-11-24 16:01:06 +00:00
converted = as2 . from_as1 ( translated )
2023-11-24 17:20:52 +00:00
2023-11-03 22:11:21 +00:00
if obj . source_protocol in ( ' ap ' , ' activitypub ' ) :
2023-11-24 16:01:06 +00:00
return converted
2023-11-03 22:11:21 +00:00
2023-11-27 19:49:14 +00:00
# special cases where obj or obj['object'] or obj['object']['object']
# are an actor
2023-12-14 23:48:02 +00:00
if from_user :
if as1 . object_type ( obj . as1 ) in as1 . ACTOR_TYPES :
return postprocess_as2_actor ( converted , user = from_user )
2023-11-24 17:20:52 +00:00
2023-12-14 23:48:02 +00:00
inner_obj = as1 . get_object ( obj . as1 )
if as1 . object_type ( inner_obj ) in as1 . ACTOR_TYPES :
converted [ ' object ' ] = postprocess_as2_actor ( converted [ ' object ' ] ,
user = from_user )
2023-11-24 17:20:52 +00:00
2023-12-14 23:48:02 +00:00
# eg Accept of a Follow
if from_user . is_web_url ( as1 . get_object ( inner_obj ) . get ( ' id ' ) ) :
converted [ ' object ' ] [ ' object ' ] = from_user . id_as ( ActivityPub )
2023-11-27 19:49:14 +00:00
# convert!
return postprocess_as2 ( converted , orig_obj = orig_obj )
2023-05-24 04:30:57 +00:00
2023-03-08 21:10:41 +00:00
@classmethod
2023-03-20 21:28:14 +00:00
def verify_signature ( cls , activity ) :
2023-03-08 21:10:41 +00:00
""" Verifies the current request ' s HTTP Signature.
2023-10-16 18:13:38 +00:00
Raises : class : ` werkzeug . exceptions . HTTPError ` if the signature is
missing or invalid , otherwise does nothing and returns the id of the
actor whose key signed the request .
2023-10-06 15:22:50 +00:00
Logs details of the result .
2023-03-08 21:10:41 +00:00
Args :
2023-10-06 06:32:31 +00:00
activity ( dict ) : AS2 activity
2023-10-16 18:13:38 +00:00
Returns :
str : signing AP actor id
2023-03-08 21:10:41 +00:00
"""
2023-04-17 00:38:03 +00:00
headers = dict ( request . headers ) # copy so we can modify below
sig = headers . get ( ' Signature ' )
2023-03-08 21:10:41 +00:00
if not sig :
2023-06-28 05:31:48 +00:00
if appengine_info . DEBUG :
2024-03-13 20:07:30 +00:00
logger . info ( ' No HTTP Signature, allowing due to DEBUG=true ' )
2023-06-28 05:31:48 +00:00
return
2023-03-08 21:10:41 +00:00
error ( ' No HTTP Signature ' , status = 401 )
2023-04-03 14:53:15 +00:00
logger . info ( ' Verifying HTTP Signature ' )
2023-11-14 05:01:38 +00:00
# logger.info(f'Headers: {json_dumps(headers, indent=2)}')
2023-03-08 21:10:41 +00:00
# parse_signature_header lower-cases all keys
2023-04-16 23:35:31 +00:00
sig_fields = parse_signature_header ( sig )
keyId = fragmentless ( sig_fields . get ( ' keyid ' ) )
2023-03-08 21:10:41 +00:00
if not keyId :
error ( ' HTTP Signature missing keyId ' , status = 401 )
2023-04-17 00:38:03 +00:00
# TODO: right now, assume hs2019 is rsa-sha256. the real answer is...
# ...complicated and unclear. 🤷
2023-04-16 23:35:31 +00:00
# https://github.com/snarfed/bridgy-fed/issues/430#issuecomment-1510462267
# https://arewehs2019yet.vpzom.click/
2023-04-17 00:38:03 +00:00
# https://socialhub.activitypub.rocks/t/state-of-http-signatures/754/23
# https://socialhub.activitypub.rocks/t/http-signatures-libraray/2087/2
# https://github.com/mastodon/mastodon/pull/14556
2023-04-16 23:35:31 +00:00
if sig_fields . get ( ' algorithm ' ) == ' hs2019 ' :
2023-04-17 00:38:03 +00:00
headers [ ' Signature ' ] = headers [ ' Signature ' ] . replace (
' algorithm= " hs2019 " ' , ' algorithm=rsa-sha256 ' )
2023-04-16 23:35:31 +00:00
2023-04-17 00:38:03 +00:00
digest = headers . get ( ' Digest ' ) or ' '
2023-03-08 21:10:41 +00:00
if not digest :
error ( ' Missing Digest header, required for HTTP Signature ' , status = 401 )
expected = b64encode ( sha256 ( request . data ) . digest ( ) ) . decode ( )
2023-11-12 15:48:48 +00:00
if digest . removeprefix ( ' SHA-256= ' ) . removeprefix ( ' sha-256= ' ) != expected :
2023-03-08 21:10:41 +00:00
error ( ' Invalid Digest header, required for HTTP Signature ' , status = 401 )
2023-03-10 03:56:04 +00:00
try :
2023-03-29 20:13:32 +00:00
key_actor = cls . load ( keyId )
2023-03-10 03:56:04 +00:00
except BadGateway :
2023-03-10 15:49:41 +00:00
obj_id = as1 . get_object ( activity ) . get ( ' id ' )
2023-06-20 18:22:54 +00:00
if ( activity . get ( ' type ' ) == ' Delete ' and obj_id
and keyId == fragmentless ( obj_id ) ) :
2023-04-03 14:53:15 +00:00
logger . info ( ' Object/actor being deleted is also keyId ' )
2023-10-19 22:01:19 +00:00
key_actor = Object . get_or_create (
id = keyId , source_protocol = ' activitypub ' , deleted = True )
2023-03-29 19:48:50 +00:00
key_actor . put ( )
else :
raise
2023-03-10 03:56:04 +00:00
2023-07-14 19:45:47 +00:00
if key_actor and key_actor . deleted :
2023-03-29 19:48:50 +00:00
abort ( 202 , f ' Ignoring, signer { keyId } is already deleted ' )
2023-07-25 01:16:50 +00:00
elif not key_actor or not key_actor . as1 :
2023-07-14 19:45:47 +00:00
error ( f " Couldn ' t load { keyId } to verify signature " , status = 401 )
2023-03-29 19:48:50 +00:00
2023-10-25 20:23:11 +00:00
# don't ActivityPub.convert since we don't want to postprocess_as2
key = as2 . from_as1 ( key_actor . as1 ) . get ( ' publicKey ' , { } ) . get ( ' publicKeyPem ' )
2023-07-25 01:16:50 +00:00
if not key :
error ( f ' No public key for { keyId } ' , status = 401 )
2023-10-20 05:15:39 +00:00
# can't use request.full_path because it includes a trailing ? even if
# it wasn't in the request. https://github.com/pallets/flask/issues/2867
path_query = request . url . removeprefix ( request . host_url . rstrip ( ' / ' ) )
logger . info ( f ' Verifying signature for { path_query } with key { key } ' )
2023-03-08 21:10:41 +00:00
try :
2023-04-17 00:38:03 +00:00
verified = HeaderVerifier ( headers , key ,
2023-03-08 21:10:41 +00:00
required_headers = [ ' Digest ' ] ,
method = request . method ,
2023-10-20 05:15:39 +00:00
path = path_query ,
sign_header = ' signature ' ,
) . verify ( )
2023-03-08 21:10:41 +00:00
except BaseException as e :
error ( f ' HTTP Signature verification failed: { e } ' , status = 401 )
if verified :
logger . info ( ' HTTP Signature verified! ' )
else :
error ( ' HTTP Signature verification failed ' , status = 401 )
2023-10-16 18:13:38 +00:00
return keyId
2023-03-08 21:10:41 +00:00
2023-11-26 04:07:14 +00:00
def signed_get ( url , from_user = None , * * kwargs ) :
Revert "cache outbound HTTP request responses, locally to each inbound request"
This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a.
seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now.
```
('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected))
...
File "oauth_dropins/webutil/util.py", line 1673, in call
resp = getattr((session or requests), fn)(url, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 102, in get
return self.request('GET', url, params=params, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 158, in request
return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/sessions.py", line 589, in request
resp = self.send(prep, **send_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 205, in send
response = self._send_and_cache(request, actions, cached_response, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 233, in _send_and_cache
self.cache.save_response(response, actions.cache_key, actions.expires)
File "requests_cache/backends/base.py", line 89, in save_response
cached_response = CachedResponse.from_response(response, expires=expires)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/response.py", line 102, in from_response
obj.raw = CachedHTTPResponse.from_response(response)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/raw_response.py", line 69, in from_response
_ = response.content # This property reads, decodes, and stores response content
^^^^^^^^^^^^^^^^
File "requests/models.py", line 899, in content
self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/models.py", line 818, in generate
raise ChunkedEncodingError(e)
```
2024-03-08 21:24:28 +00:00
return signed_request ( util . requests_get , url , from_user = from_user , * * kwargs )
2023-03-08 21:10:41 +00:00
2023-03-09 04:50:55 +00:00
2023-11-26 04:07:14 +00:00
def signed_post ( url , from_user , * * kwargs ) :
assert from_user
Revert "cache outbound HTTP request responses, locally to each inbound request"
This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a.
seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now.
```
('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected))
...
File "oauth_dropins/webutil/util.py", line 1673, in call
resp = getattr((session or requests), fn)(url, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 102, in get
return self.request('GET', url, params=params, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 158, in request
return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/sessions.py", line 589, in request
resp = self.send(prep, **send_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 205, in send
response = self._send_and_cache(request, actions, cached_response, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 233, in _send_and_cache
self.cache.save_response(response, actions.cache_key, actions.expires)
File "requests_cache/backends/base.py", line 89, in save_response
cached_response = CachedResponse.from_response(response, expires=expires)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/response.py", line 102, in from_response
obj.raw = CachedHTTPResponse.from_response(response)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/raw_response.py", line 69, in from_response
_ = response.content # This property reads, decodes, and stores response content
^^^^^^^^^^^^^^^^
File "requests/models.py", line 899, in content
self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/models.py", line 818, in generate
raise ChunkedEncodingError(e)
```
2024-03-08 21:24:28 +00:00
return signed_request ( util . requests_post , url , from_user = from_user , * * kwargs )
2023-03-08 21:10:41 +00:00
2023-11-26 04:07:14 +00:00
def signed_request ( fn , url , data = None , headers = None , from_user = None , * * kwargs ) :
2023-10-06 06:32:31 +00:00
""" Wraps ``requests.*`` and adds HTTP Signature.
2023-03-08 21:10:41 +00:00
Args :
Revert "cache outbound HTTP request responses, locally to each inbound request"
This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a.
seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now.
```
('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected))
...
File "oauth_dropins/webutil/util.py", line 1673, in call
resp = getattr((session or requests), fn)(url, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 102, in get
return self.request('GET', url, params=params, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 158, in request
return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/sessions.py", line 589, in request
resp = self.send(prep, **send_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 205, in send
response = self._send_and_cache(request, actions, cached_response, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 233, in _send_and_cache
self.cache.save_response(response, actions.cache_key, actions.expires)
File "requests_cache/backends/base.py", line 89, in save_response
cached_response = CachedResponse.from_response(response, expires=expires)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/response.py", line 102, in from_response
obj.raw = CachedHTTPResponse.from_response(response)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/raw_response.py", line 69, in from_response
_ = response.content # This property reads, decodes, and stores response content
^^^^^^^^^^^^^^^^
File "requests/models.py", line 899, in content
self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/models.py", line 818, in generate
raise ChunkedEncodingError(e)
```
2024-03-08 21:24:28 +00:00
fn ( callable ) : : func : ` util . requests_get ` or : func : ` util . requests_post `
2023-10-06 06:32:31 +00:00
url ( str ) :
data ( dict ) : optional AS2 object
2023-11-26 04:23:19 +00:00
from_user ( models . User ) : user to sign request as ; optional . If not
provided , uses the default user ` ` @snarfed.org @snarfed.org ` ` .
2023-03-08 21:10:41 +00:00
kwargs : passed through to requests
2023-10-06 06:32:31 +00:00
Returns :
requests . Response :
2023-03-08 21:10:41 +00:00
"""
if headers is None :
headers = { }
# prepare HTTP Signature and headers
2023-11-26 04:07:14 +00:00
if not from_user or isinstance ( from_user , ActivityPub ) :
2023-10-18 20:51:34 +00:00
# ActivityPub users are remote, so we don't have their keys
2024-01-06 21:59:31 +00:00
from_user = instance_actor ( )
2023-03-08 21:10:41 +00:00
if data :
2023-10-31 19:49:15 +00:00
logger . info ( f ' Sending AS2 object: { json_dumps ( data , indent = 2 ) } ' )
2023-03-08 21:10:41 +00:00
data = json_dumps ( data ) . encode ( )
headers = {
* * headers ,
# required for HTTP Signature
# https://tools.ietf.org/html/draft-cavage-http-signatures-07#section-2.1.3
' Date ' : util . now ( ) . strftime ( ' %a , %d % b % Y % H: % M: % S GMT ' ) ,
# required by Mastodon
# https://github.com/tootsuite/mastodon/pull/14556#issuecomment-674077648
' Host ' : util . domain_from_link ( url , minimize = False ) ,
2024-02-27 19:38:00 +00:00
' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE ,
2023-03-08 21:10:41 +00:00
# required for HTTP Signature and Mastodon
' Digest ' : f ' SHA-256= { b64encode ( sha256 ( data or b " " ) . digest ( ) ) . decode ( ) } ' ,
}
2023-11-26 04:07:14 +00:00
logger . info ( f " Signing with { from_user . key } ' s key " )
2023-03-08 21:10:41 +00:00
# (request-target) is a special HTTP Signatures header that some fediverse
# implementations require, eg Peertube.
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
2023-08-26 16:21:53 +00:00
# https://www.w3.org/wiki/SocialCG/ActivityPub/Authentication_Authorization#Signing_requests_using_HTTP_Signatures
# https://docs.joinmastodon.org/spec/security/#http
2023-12-01 00:31:41 +00:00
key_id = f ' { from_user . id_as ( ActivityPub ) } #key '
2023-11-26 04:07:14 +00:00
auth = HTTPSignatureAuth ( secret = from_user . private_pem ( ) , key_id = key_id ,
2023-03-08 21:10:41 +00:00
algorithm = ' rsa-sha256 ' , sign_header = ' signature ' ,
headers = HTTP_SIG_HEADERS )
# make HTTP request
kwargs . setdefault ( ' gateway ' , True )
resp = fn ( url , data = data , auth = auth , headers = headers , allow_redirects = False ,
* * kwargs )
logger . info ( f ' Got { resp . status_code } headers: { resp . headers } ' )
2017-08-15 06:07:24 +00:00
2023-03-08 21:10:41 +00:00
# handle GET redirects manually so that we generate a new HTTP signature
Revert "cache outbound HTTP request responses, locally to each inbound request"
This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a.
seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now.
```
('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected))
...
File "oauth_dropins/webutil/util.py", line 1673, in call
resp = getattr((session or requests), fn)(url, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 102, in get
return self.request('GET', url, params=params, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 158, in request
return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/sessions.py", line 589, in request
resp = self.send(prep, **send_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 205, in send
response = self._send_and_cache(request, actions, cached_response, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 233, in _send_and_cache
self.cache.save_response(response, actions.cache_key, actions.expires)
File "requests_cache/backends/base.py", line 89, in save_response
cached_response = CachedResponse.from_response(response, expires=expires)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/response.py", line 102, in from_response
obj.raw = CachedHTTPResponse.from_response(response)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/raw_response.py", line 69, in from_response
_ = response.content # This property reads, decodes, and stores response content
^^^^^^^^^^^^^^^^
File "requests/models.py", line 899, in content
self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/models.py", line 818, in generate
raise ChunkedEncodingError(e)
```
2024-03-08 21:24:28 +00:00
if resp . is_redirect and fn == util . requests_get :
2023-07-15 01:16:10 +00:00
new_url = urljoin ( url , resp . headers [ ' Location ' ] )
2023-10-14 01:28:04 +00:00
return signed_request ( fn , new_url , data = data , headers = headers ,
2023-10-31 19:49:15 +00:00
* * kwargs )
2023-02-13 06:17:04 +00:00
2023-03-08 21:10:41 +00:00
type = common . content_type ( resp )
if ( type and type != ' text/html ' and
2023-06-20 18:22:54 +00:00
( type . startswith ( ' text/ ' ) or type . endswith ( ' +json ' )
or type . endswith ( ' /json ' ) ) ) :
2023-03-08 21:10:41 +00:00
logger . info ( resp . text )
2017-10-20 14:13:04 +00:00
2023-03-08 21:10:41 +00:00
return resp
2023-11-27 19:49:14 +00:00
def postprocess_as2 ( activity , orig_obj = None , wrap = True ) :
2023-03-08 21:10:41 +00:00
""" Prepare an AS2 object to be served or sent via ActivityPub.
Args :
2023-10-06 06:32:31 +00:00
activity ( dict ) : AS2 object or activity
2023-10-11 23:17:43 +00:00
orig_obj ( dict ) : AS2 object , optional . The target of activity ' s
` ` inReplyTo ` ` or ` ` Like ` ` / ` ` Announce ` ` / etc object , if any .
2023-10-10 16:57:10 +00:00
wrap ( bool ) : whether to wrap ` ` id ` ` , ` ` url ` ` , ` ` object ` ` , ` ` actor ` ` , and
` ` attributedTo ` `
2023-03-08 21:10:41 +00:00
"""
2023-04-02 02:13:51 +00:00
if not activity or isinstance ( activity , str ) :
2023-10-25 23:39:59 +00:00
return redirect_wrap ( activity ) if wrap else activity
2023-10-25 20:23:11 +00:00
elif activity . keys ( ) == { ' id ' } :
2023-10-25 23:39:59 +00:00
return redirect_wrap ( activity [ ' id ' ] ) if wrap else activity [ ' id ' ]
2023-04-02 02:13:51 +00:00
2023-03-08 21:10:41 +00:00
type = activity . get ( ' type ' )
# inReplyTo: singly valued, prefer id over url
2023-06-21 03:59:32 +00:00
# TODO: ignore orig_obj, do for all inReplyTo
orig_id = orig_obj . get ( ' id ' ) if orig_obj else None
2023-03-08 21:10:41 +00:00
in_reply_to = activity . get ( ' inReplyTo ' )
if in_reply_to :
2023-06-21 03:59:32 +00:00
if orig_id :
activity [ ' inReplyTo ' ] = orig_id
2023-03-08 21:10:41 +00:00
elif isinstance ( in_reply_to , list ) :
if len ( in_reply_to ) > 1 :
2024-05-02 00:45:24 +00:00
# this isn't actually true, AS2 inReplyTo can be multiply
# valued. Why do we truncate it to one value? interop somewhere?
# it's not marked Functional:
# https://www.w3.org/TR/activitystreams-vocabulary/#dfn-inreplyto
2023-03-08 21:10:41 +00:00
logger . warning (
" AS2 doesn ' t support multiple inReplyTo URLs! "
f ' Only using the first: { in_reply_to [ 0 ] } ' )
activity [ ' inReplyTo ' ] = in_reply_to [ 0 ]
# Mastodon evidently requires a Mention tag for replies to generate a
# notification to the original post's author. not required for likes,
# reposts, etc. details:
# https://github.com/snarfed/bridgy-fed/issues/34
2023-06-21 03:59:32 +00:00
if orig_obj :
for to in ( util . get_list ( orig_obj , ' attributedTo ' ) +
util . get_list ( orig_obj , ' author ' ) +
util . get_list ( orig_obj , ' actor ' ) ) :
2023-03-08 21:10:41 +00:00
if isinstance ( to , dict ) :
to = util . get_first ( to , ' url ' ) or to . get ( ' id ' )
if to :
2023-10-12 20:55:17 +00:00
add ( activity . setdefault ( ' tag ' , [ ] ) , {
2023-03-08 21:10:41 +00:00
' type ' : ' Mention ' ,
' href ' : to ,
} )
# activity objects (for Like, Announce, etc): prefer id over url
2023-03-14 21:59:28 +00:00
obj = as1 . get_object ( activity )
id = obj . get ( ' id ' )
2023-06-21 03:59:32 +00:00
if orig_id and type in as2 . TYPES_WITH_OBJECT :
2023-04-19 23:21:21 +00:00
# inline most objects as bare string ids, not composite objects, for interop
2023-06-21 03:59:32 +00:00
activity [ ' object ' ] = orig_id
2023-04-19 23:21:21 +00:00
elif not id :
2023-12-06 18:03:48 +00:00
obj [ ' id ' ] = util . get_first ( obj , ' url ' )
2023-03-08 21:10:41 +00:00
# id is required for most things. default to url if it's not set.
if not activity . get ( ' id ' ) :
activity [ ' id ' ] = util . get_first ( activity , ' url ' )
2023-04-19 21:37:42 +00:00
if wrap :
activity [ ' id ' ] = redirect_wrap ( activity . get ( ' id ' ) )
activity [ ' url ' ] = [ redirect_wrap ( u ) for u in util . get_list ( activity , ' url ' ) ]
if len ( activity [ ' url ' ] ) == 1 :
activity [ ' url ' ] = activity [ ' url ' ] [ 0 ]
2023-04-17 22:36:29 +00:00
2023-03-08 21:10:41 +00:00
# TODO: find a better way to check this, sometimes or always?
# removed for now since it fires on posts without u-id or u-url, eg
# https://chrisbeckstrom.com/2018/12/27/32551/
# assert activity.get('id') or (isinstance(obj, dict) and obj.get('id'))
2024-04-29 22:13:12 +00:00
# drop Link attachments since fediverse instances generate their own link previews
# https://github.com/snarfed/bridgy-fed/issues/958
obj_or_activity = obj if obj . keys ( ) > set ( [ ' id ' ] ) else activity
obj_or_activity [ ' attachment ' ] = [
a for a in as1 . get_objects ( obj_or_activity , ' attachment ' )
if a . get ( ' type ' ) != ' Link ' ]
2023-03-08 21:10:41 +00:00
# copy image(s) into attachment(s). may be Mastodon-specific.
# https://github.com/snarfed/bridgy-fed/issues/33#issuecomment-440965618
2023-04-02 02:13:51 +00:00
imgs = util . get_list ( obj_or_activity , ' image ' )
if imgs :
2024-04-29 22:13:12 +00:00
atts = obj_or_activity [ ' attachment ' ]
2023-04-02 02:13:51 +00:00
atts . extend ( img for img in imgs if img not in atts )
2023-03-08 21:10:41 +00:00
2023-10-12 20:55:17 +00:00
# cc target's author(s), recipients, mentions
2023-03-08 21:10:41 +00:00
# https://www.w3.org/TR/activitystreams-vocabulary/#audienceTargeting
# https://w3c.github.io/activitypub/#delivery
2023-10-12 20:55:17 +00:00
# https://docs.joinmastodon.org/spec/activitypub/#Mention
obj_or_activity . setdefault ( ' cc ' , [ ] )
tags = util . get_list ( activity , ' tag ' ) + util . get_list ( obj , ' tag ' )
for tag in tags :
href = tag . get ( ' href ' )
if ( href and tag . get ( ' type ' ) == ' Mention '
and not ActivityPub . is_blocklisted ( href ) ) :
add ( obj_or_activity [ ' cc ' ] , href )
2023-06-21 03:59:32 +00:00
if orig_obj and type in as2 . TYPE_TO_VERB :
2023-10-12 20:55:17 +00:00
for field in ' actor ' , ' attributedTo ' , ' to ' , ' cc ' :
for recip in as1 . get_objects ( orig_obj , field ) :
add ( obj_or_activity [ ' cc ' ] , util . get_url ( recip ) or recip . get ( ' id ' ) )
2023-03-08 21:10:41 +00:00
# to public, since Mastodon interprets to public as public, cc public as unlisted:
# https://socialhub.activitypub.rocks/t/visibility-to-cc-mapping/284
# https://wordsmith.social/falkreon/securing-activitypub
to = activity . setdefault ( ' to ' , [ ] )
2023-06-30 19:36:20 +00:00
add ( to , as2 . PUBLIC_AUDIENCE )
2023-03-08 21:10:41 +00:00
2023-03-14 21:59:28 +00:00
# hashtags. Mastodon requires:
# * type: Hashtag
# * name starts with #
2023-03-14 22:36:18 +00:00
# * href is set to a valid, fully qualified URL
2023-03-14 21:59:28 +00:00
#
2023-03-14 22:36:18 +00:00
# If content has an <a> tag with a fully qualified URL and the hashtag name
# (with leading #) as its text, Mastodon will rewrite its href to the local
# instance's search for that hashtag. If content doesn't have a link for a
# given hashtag, Mastodon won't add one, but that hashtag will still be
# indexed in search.
2023-03-14 21:59:28 +00:00
#
# https://docs.joinmastodon.org/spec/activitypub/#properties-used
# https://github.com/snarfed/bridgy-fed/issues/45
2023-10-12 20:55:17 +00:00
for tag in tags :
2023-03-14 21:59:28 +00:00
name = tag . get ( ' name ' )
if name and tag . get ( ' type ' , ' Tag ' ) == ' Tag ' :
tag [ ' type ' ] = ' Hashtag '
tag . setdefault ( ' href ' , common . host_url (
f ' hashtag/ { quote_plus ( name . removeprefix ( " # " ) ) } ' ) )
if not name . startswith ( ' # ' ) :
tag [ ' name ' ] = f ' # { name } '
2023-10-20 20:37:38 +00:00
# language, in contentMap
# https://github.com/snarfed/bridgy-fed/issues/681
if content := obj_or_activity . get ( ' content ' ) :
obj_or_activity . setdefault ( ' contentMap ' , { ' en ' : content } )
2023-10-25 20:23:11 +00:00
activity [ ' object ' ] = [
postprocess_as2 ( o , orig_obj = orig_obj ,
2023-11-27 19:49:14 +00:00
wrap = wrap and type in ( ' Create ' , ' Update ' , ' Delete ' ) )
2023-10-25 20:23:11 +00:00
for o in as1 . get_objects ( activity ) ]
if len ( activity [ ' object ' ] ) == 1 :
activity [ ' object ' ] = activity [ ' object ' ] [ 0 ]
2023-03-08 21:10:41 +00:00
return util . trim_nulls ( activity )
2023-12-14 23:48:02 +00:00
def postprocess_as2_actor ( actor , user ) :
2023-03-08 21:10:41 +00:00
""" Prepare an AS2 actor object to be served or sent via ActivityPub.
Modifies actor in place .
Args :
2023-10-06 06:32:31 +00:00
actor ( dict ) : AS2 actor object
2023-11-27 19:51:52 +00:00
user ( models . User ) : current user
2023-03-08 21:10:41 +00:00
Returns :
actor dict
"""
2023-05-23 06:09:36 +00:00
if not actor :
return actor
2023-03-19 16:32:12 +00:00
2023-11-26 04:07:14 +00:00
assert isinstance ( actor , dict )
2023-12-14 23:48:02 +00:00
assert user
2023-11-26 04:07:14 +00:00
url = user . web_url ( )
2023-11-29 22:51:56 +00:00
urls = [ u for u in util . get_list ( actor , ' url ' ) if u and not u . startswith ( ' acct: ' ) ]
2023-03-08 21:10:41 +00:00
if not urls and url :
2023-06-20 18:22:54 +00:00
urls = [ url ]
2023-11-27 19:51:52 +00:00
if urls :
2023-04-19 21:37:42 +00:00
urls [ 0 ] = redirect_wrap ( urls [ 0 ] )
2023-03-08 21:10:41 +00:00
2023-05-23 06:09:36 +00:00
id = actor . get ( ' id ' )
2023-12-01 21:06:59 +00:00
user_id = user . key . id ( )
if not id or user . is_web_url ( id ) or unwrap ( id ) in ( user_id , f ' www. { user_id } ' ) :
2023-12-01 00:31:41 +00:00
id = actor [ ' id ' ] = user . id_as ( ActivityPub )
2023-05-23 06:09:36 +00:00
2023-10-25 20:23:11 +00:00
actor [ ' url ' ] = urls [ 0 ] if len ( urls ) == 1 else urls
# required by ActivityPub
# https://www.w3.org/TR/activitypub/#actor-objects
2023-12-01 00:31:41 +00:00
actor . setdefault ( ' inbox ' , id + ' /inbox ' )
actor . setdefault ( ' outbox ' , id + ' /outbox ' )
2023-09-25 03:06:18 +00:00
2024-01-26 14:52:03 +00:00
# For web, this has to be domain for Mastodon etc interop! It seems like it
# should be the custom username from the acct: u-url in their h-card, but
# that breaks Mastodon's Webfinger discovery.
2023-11-24 06:06:08 +00:00
# Background:
# https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
# https://docs.joinmastodon.org/spec/webfinger/#mastodons-requirements-for-webfinger
# https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
# https://github.com/snarfed/bridgy-fed/issues/77
2024-01-26 14:52:03 +00:00
if user . LABEL == ' web ' :
actor [ ' preferredUsername ' ] = user . key . id ( )
else :
handle = user . handle_as ( ActivityPub )
if handle :
actor [ ' preferredUsername ' ] = handle . strip ( ' @ ' ) . split ( ' @ ' ) [ 0 ]
2023-03-08 21:10:41 +00:00
# Override the label for their home page to be "Web site"
for att in util . get_list ( actor , ' attachment ' ) :
2023-06-20 18:22:54 +00:00
if att . get ( ' type ' ) == ' PropertyValue ' :
val = att . get ( ' value ' , ' ' )
link = util . parse_html ( val ) . find ( ' a ' )
2023-10-24 23:02:16 +00:00
if url and url . rstrip ( ' / ' ) in [ val . rstrip ( ' / ' ) ,
link . get ( ' href ' ) . rstrip ( ' / ' ) ] :
2023-06-20 18:22:54 +00:00
att [ ' name ' ] = ' Web site '
2023-03-08 21:10:41 +00:00
# required by pixelfed. https://github.com/snarfed/bridgy-fed/issues/39
actor . setdefault ( ' summary ' , ' ' )
2023-11-24 17:20:52 +00:00
2023-11-28 05:01:02 +00:00
if not actor . get ( ' publicKey ' ) and not isinstance ( user , ActivityPub ) :
2023-11-24 17:20:52 +00:00
# underspecified, inferred from this issue and Mastodon's implementation:
# https://github.com/w3c/activitypub/issues/203#issuecomment-297553229
# https://github.com/tootsuite/mastodon/blob/bc2c263504e584e154384ecc2d804aeb1afb1ba3/app/services/activitypub/process_account_service.rb#L77
actor . update ( {
' publicKey ' : {
2023-12-01 00:31:41 +00:00
' id ' : f ' { id } #key ' ,
' owner ' : id ,
2023-11-26 04:07:14 +00:00
' publicKeyPem ' : user . public_pem ( ) . decode ( ) ,
2023-11-24 17:20:52 +00:00
} ,
2024-01-29 02:15:37 +00:00
' @context ' : ( util . get_list ( actor , ' @context ' ) + [ SECURITY_CONTEXT ] ) ,
2023-11-24 17:20:52 +00:00
} )
2024-01-11 23:16:33 +00:00
if ( user . key . id ( ) not in DOMAINS
and ( not user . direct
# Web users only
2024-01-14 21:53:28 +00:00
or ( user . LABEL == ' web '
and not getattr ( user , ' last_webmention_in ' , ' unset ' )
and not getattr ( user , ' has_redirects ' , None ) ) ) ) :
2024-01-11 23:16:33 +00:00
actor [ ' type ' ] = ' Application '
disclaimer = f ' [<a href= " https:// { PRIMARY_DOMAIN } { user . user_page_path ( ) } " >bridged</a> from <a href= " { user . web_url ( ) } " > { user . handle_or_id ( ) } </a> by <a href= " https:// { PRIMARY_DOMAIN } / " >Bridgy Fed</a>] '
if not actor [ ' summary ' ] . endswith ( disclaimer ) :
if actor [ ' summary ' ] :
2024-01-12 01:43:56 +00:00
actor [ ' summary ' ] + = ' <br><br> '
2024-01-11 23:16:33 +00:00
actor [ ' summary ' ] + = disclaimer
2023-03-08 21:10:41 +00:00
return actor
2023-02-26 13:34:15 +00:00
2023-09-26 23:43:48 +00:00
# source protocol in subdomain.
# WARNING: the user page handler in pages.py overrides this for fediverse
# addresses with leading @ character. be careful when changing this route!
2023-09-27 03:04:49 +00:00
@app.get ( f ' /ap/<handle_or_id> ' )
# source protocol in path; primarily for backcompat
@app.get ( f ' /ap/web/<handle_or_id> ' )
2023-05-30 23:36:18 +00:00
# special case Web users without /ap/web/ prefix, for backward compatibility
2023-09-27 03:04:49 +00:00
@app.get ( f ' /<regex( " { DOMAIN_RE } " ):handle_or_id> ' )
2023-02-14 16:25:41 +00:00
@flask_util.cached ( cache , CACHE_TIME )
2023-09-27 03:04:49 +00:00
def actor ( handle_or_id ) :
2023-02-14 16:25:41 +00:00
""" Serves a user ' s AS2 actor from the datastore. """
2024-04-22 23:07:54 +00:00
if handle_or_id == PRIMARY_DOMAIN or handle_or_id in PROTOCOL_DOMAINS :
from web import Web
cls = Web
else :
cls = Protocol . for_request ( fed = ' web ' )
2023-09-27 17:29:40 +00:00
if not cls :
error ( f " Couldn ' t determine protocol " , status = 404 )
2023-12-01 00:31:41 +00:00
elif cls . LABEL == ' web ' and request . path . startswith ( ' /ap/ ' ) :
2023-11-03 01:03:20 +00:00
# we started out with web users' AP ids as fed.brid.gy/[domain], so we
# need to preserve those for backward compatibility
return redirect ( subdomain_wrap ( None , f ' / { handle_or_id } ' ) , code = 301 )
2023-09-23 20:53:17 +00:00
if cls . owns_id ( handle_or_id ) is False :
if cls . owns_handle ( handle_or_id ) is False :
error ( f " { handle_or_id } doesn ' t look like a { cls . LABEL } id or handle " ,
status = 404 )
id = cls . handle_to_id ( handle_or_id )
if not id :
error ( f " Couldn ' t resolve { handle_or_id } as a { cls . LABEL } handle " ,
status = 404 )
else :
id = handle_or_id
assert id
2023-11-20 05:52:05 +00:00
user = cls . get_or_create ( id )
2024-04-28 03:27:33 +00:00
if not user or not user . is_enabled ( ActivityPub ) :
2023-11-26 23:44:43 +00:00
error ( f ' { cls . LABEL } user { id } not found ' , status = 404 )
2023-12-01 00:31:41 +00:00
id = user . id_as ( ActivityPub )
2023-12-01 20:57:21 +00:00
# check that we're serving from the right subdomain
if request . host != urlparse ( id ) . netloc :
2023-12-01 00:31:41 +00:00
return redirect ( id )
2023-11-20 05:52:05 +00:00
if not user . obj or not user . obj . as1 :
user . obj = cls . load ( user . profile_id ( ) , gateway = True )
2023-11-26 04:07:14 +00:00
if user . obj :
user . obj . put ( )
2023-02-14 16:25:41 +00:00
2023-11-26 04:07:14 +00:00
actor = ActivityPub . convert ( user . obj , from_user = user ) or {
2023-06-04 14:33:30 +00:00
' @context ' : [ as2 . CONTEXT ] ,
' type ' : ' Person ' ,
}
2023-11-26 04:07:14 +00:00
actor = postprocess_as2_actor ( actor , user = user )
2023-03-19 04:45:27 +00:00
actor . update ( {
2023-12-01 00:31:41 +00:00
' id ' : id ,
' inbox ' : id + ' /inbox ' ,
' outbox ' : id + ' /outbox ' ,
' following ' : id + ' /following ' ,
' followers ' : id + ' /followers ' ,
2023-02-14 16:25:41 +00:00
' endpoints ' : {
2023-10-23 22:44:32 +00:00
' sharedInbox ' : subdomain_wrap ( cls , ' /ap/sharedInbox ' ) ,
2023-02-14 16:25:41 +00:00
} ,
2023-09-23 20:53:17 +00:00
# add this if we ever change the Web actor ids to be /web/[id]
# 'alsoKnownAs': [host_url(id)],
2023-11-21 23:18:21 +00:00
} )
2023-09-25 03:06:18 +00:00
2023-02-14 16:25:41 +00:00
logger . info ( f ' Returning: { json_dumps ( actor , indent = 2 ) } ' )
return actor , {
2024-02-27 19:38:00 +00:00
' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE ,
2021-07-10 15:07:40 +00:00
' Access-Control-Allow-Origin ' : ' * ' ,
2023-02-14 16:25:41 +00:00
}
2021-07-10 15:07:40 +00:00
2017-08-13 07:12:16 +00:00
2023-09-27 20:55:16 +00:00
# note that this shared inbox path overlaps with the /ap/<handle_or_id> actor
# route above, but doesn't collide because this is POST and that one is GET.
2023-05-30 23:36:18 +00:00
@app.post ( ' /ap/sharedInbox ' )
2023-09-27 20:55:16 +00:00
# source protocol in subdomain
@app.post ( f ' /ap/<id>/inbox ' )
# source protocol in path; primarily for backcompat
2023-11-15 21:19:37 +00:00
@app.post ( f ' /ap/<protocol>/<id>/inbox ' )
2023-05-30 23:36:18 +00:00
# special case Web users without /ap/web/ prefix, for backward compatibility
2023-11-15 21:19:37 +00:00
@app.post ( ' /inbox ' )
@app.post ( f ' /<regex( " { DOMAIN_RE } " ):id>/inbox ' )
2023-09-27 20:55:16 +00:00
def inbox ( protocol = None , id = None ) :
2022-11-16 18:09:24 +00:00
""" Handles ActivityPub inbox delivery. """
2021-07-10 15:07:40 +00:00
# parse and validate AS2 activity
try :
activity = request . json
2023-03-03 17:24:59 +00:00
assert activity and isinstance ( activity , dict )
2021-07-10 15:07:40 +00:00
except ( TypeError , ValueError , AssertionError ) :
2023-03-10 04:20:41 +00:00
body = request . get_data ( as_text = True )
2023-03-03 17:24:59 +00:00
error ( f " Couldn ' t parse body as non-empty JSON mapping: { body } " , exc_info = True )
2021-07-10 15:07:40 +00:00
2023-03-11 20:58:36 +00:00
type = activity . get ( ' type ' )
2023-05-31 20:17:17 +00:00
actor = as1 . get_object ( activity , ' actor ' )
actor_id = actor . get ( ' id ' )
2023-03-11 20:58:36 +00:00
logger . info ( f ' Got { type } from { actor_id } : { json_dumps ( activity , indent = 2 ) } ' )
2021-07-10 15:07:40 +00:00
2024-02-27 06:52:52 +00:00
if ActivityPub . is_blocklisted ( actor_id ) :
error ( f ' Actor { actor_id } is blocklisted ' )
2023-10-16 18:13:38 +00:00
authed_as = ActivityPub . verify_signature ( activity )
2023-02-15 18:57:11 +00:00
2023-03-08 21:10:41 +00:00
# check that this activity is public. only do this for creates, not likes,
# follows, or other activity types, since Mastodon doesn't currently mark
# those as explicitly public. Use as2's is_public instead of as1's because
# as1's interprets unlisted as true.
2024-04-21 15:08:05 +00:00
# TODO: move this to Protocol
2024-04-21 15:36:03 +00:00
object = as1 . get_object ( activity )
to_cc = set ( as1 . get_ids ( object , ' to ' ) + as1 . get_ids ( activity , ' cc ' ) +
as1 . get_ids ( object , ' to ' ) + as1 . get_ids ( object , ' cc ' ) )
if ( type == ' Create ' and not as2 . is_public ( activity , unlisted = False )
# DM to one of our protocol bot users
and not ( len ( to_cc ) == 1 and to_cc . pop ( ) in BOT_ACTOR_IDS ) ) :
2023-03-03 23:12:51 +00:00
logger . info ( ' Dropping non-public activity ' )
return ' OK '
2023-03-11 20:58:36 +00:00
if type == ' Follow ' :
# rendered mf2 HTML proxy pages (in render.py) fall back to redirecting
# to the follow's AS2 id field, but Mastodon's Accept ids are URLs that
# don't load in browsers, eg:
# https://jawns.club/ac33c547-ca6b-4351-80d5-d11a6879a7b0
#
# so, set a synthetic URL based on the follower's profile.
# https://github.com/snarfed/bridgy-fed/issues/336
2023-10-24 17:46:57 +00:00
follower_url = unwrap ( util . get_url ( activity , ' actor ' ) )
followee_url = unwrap ( util . get_url ( activity , ' object ' ) )
2023-03-11 20:58:36 +00:00
activity . setdefault ( ' url ' , f ' { follower_url } #followed- { followee_url } ' )
2023-10-24 17:46:57 +00:00
obj = Object ( id = activity . get ( ' id ' ) , as2 = unwrap ( activity ) )
2023-10-16 18:13:38 +00:00
return ActivityPub . receive ( obj , authed_as = authed_as )
2021-07-10 15:07:40 +00:00
2023-09-27 03:04:49 +00:00
# protocol in subdomain
@app.get ( f ' /ap/<id>/<any(followers,following):collection> ' )
# source protocol in path; primarily for backcompat
@app.get ( f ' /ap/web/<regex( " { DOMAIN_RE } " ):id>/<any(followers,following):collection> ' )
2023-05-30 23:36:18 +00:00
# special case Web users without /ap/web/ prefix, for backward compatibility
2023-11-24 06:41:52 +00:00
@app.route ( f ' /<regex( " { DOMAIN_RE } " ):id>/<any(followers,following):collection> ' ,
methods = [ ' GET ' , ' HEAD ' ] )
2022-11-22 02:46:10 +00:00
@flask_util.cached ( cache , CACHE_TIME )
2023-09-27 03:04:49 +00:00
def follower_collection ( id , collection ) :
2023-01-19 04:32:23 +00:00
""" ActivityPub Followers and Following collections.
2022-11-22 02:46:10 +00:00
2023-09-27 03:04:49 +00:00
* https : / / www . w3 . org / TR / activitypub / #followers
* https : / / www . w3 . org / TR / activitypub / #collections
* https : / / www . w3 . org / TR / activitystreams - core / #paging
2023-11-24 06:35:38 +00:00
TODO : unify page generation with outbox ( )
2022-11-22 02:46:10 +00:00
"""
2023-11-28 05:01:02 +00:00
if ( request . path . startswith ( ' /ap/ ' )
and request . host in ( PRIMARY_DOMAIN , ) + LOCAL_DOMAINS ) :
# UI request. unfortunate that the URL paths overlap like this!
import pages
return pages . followers_or_following ( ' ap ' , id , collection )
2023-09-27 20:55:16 +00:00
protocol = Protocol . for_request ( fed = ' web ' )
2023-09-27 03:04:49 +00:00
assert protocol
2023-11-20 04:48:31 +00:00
user = protocol . get_by_id ( id )
if not user :
2023-09-27 03:04:49 +00:00
return f ' { protocol } user { id } not found ' , 404
2022-11-22 02:46:10 +00:00
2023-11-24 06:41:52 +00:00
if request . method == ' HEAD ' :
2024-02-27 19:38:00 +00:00
return ' ' , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }
2023-11-24 06:41:52 +00:00
2023-01-20 15:02:55 +00:00
# page
2023-11-20 04:48:31 +00:00
followers , new_before , new_after = Follower . fetch_page ( collection , user = user )
2023-01-20 15:02:55 +00:00
page = {
' type ' : ' CollectionPage ' ,
' partOf ' : request . base_url ,
2023-11-26 23:59:20 +00:00
' items ' : util . trim_nulls ( [ ActivityPub . convert ( f . user . obj , from_user = f . user )
for f in followers ] ) ,
2023-01-20 15:02:55 +00:00
}
if new_before :
page [ ' next ' ] = f ' { request . base_url } ?before= { new_before } '
if new_after :
page [ ' prev ' ] = f ' { request . base_url } ?after= { new_after } '
if ' before ' in request . args or ' after ' in request . args :
page . update ( {
' @context ' : ' https://www.w3.org/ns/activitystreams ' ,
' id ' : request . url ,
} )
logger . info ( f ' Returning { json_dumps ( page , indent = 2 ) } ' )
2024-02-27 19:38:00 +00:00
return page , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }
2023-01-20 15:02:55 +00:00
# collection
2023-11-20 04:48:31 +00:00
num_followers , num_following = user . count_followers ( )
2023-01-20 15:02:55 +00:00
collection = {
2022-11-22 02:46:10 +00:00
' @context ' : ' https://www.w3.org/ns/activitystreams ' ,
2023-01-20 15:02:55 +00:00
' id ' : request . base_url ,
2022-11-22 02:46:10 +00:00
' type ' : ' Collection ' ,
2023-09-27 03:04:49 +00:00
' summary ' : f " { id } ' s { collection } " ,
2023-11-08 19:56:01 +00:00
' totalItems ' : num_followers if collection == ' followers ' else num_following ,
2023-01-20 15:02:55 +00:00
' first ' : page ,
2022-11-22 02:46:10 +00:00
}
2023-01-20 15:02:55 +00:00
logger . info ( f ' Returning { json_dumps ( collection , indent = 2 ) } ' )
2024-02-27 19:38:00 +00:00
return collection , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }
2023-01-25 21:12:24 +00:00
2023-09-27 03:04:49 +00:00
# protocol in subdomain
@app.get ( f ' /ap/<id>/outbox ' )
# source protocol in path; primarily for backcompat
@app.get ( f ' /ap/web/<regex( " { DOMAIN_RE } " ):id>/outbox ' )
2023-05-30 23:36:18 +00:00
# special case Web users without /ap/web/ prefix, for backward compatibility
2023-11-24 06:41:52 +00:00
@app.route ( f ' /<regex( " { DOMAIN_RE } " ):id>/outbox ' , methods = [ ' GET ' , ' HEAD ' ] )
2023-11-23 05:40:36 +00:00
@flask_util.cached ( cache , CACHE_TIME )
2023-09-27 03:04:49 +00:00
def outbox ( id ) :
2023-11-24 06:35:38 +00:00
""" Serves a user ' s AP outbox.
TODO : unify page generation with follower_collection ( )
"""
2023-09-27 20:55:16 +00:00
protocol = Protocol . for_request ( fed = ' web ' )
2023-11-23 05:40:36 +00:00
if not protocol :
error ( f " Couldn ' t determine protocol " , status = 404 )
2023-11-26 04:23:19 +00:00
user = protocol . get_by_id ( id )
if not user :
2023-11-23 05:40:36 +00:00
error ( f ' User { id } not found ' , status = 404 )
2023-11-24 06:41:52 +00:00
if request . method == ' HEAD ' :
2024-02-27 19:38:00 +00:00
return ' ' , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }
2023-11-24 06:41:52 +00:00
2023-11-26 04:23:19 +00:00
query = Object . query ( Object . users == user . key )
2023-11-24 06:35:38 +00:00
objects , new_before , new_after = fetch_objects ( query , by = Object . updated ,
2023-11-26 04:23:19 +00:00
user = user )
2023-11-24 06:35:38 +00:00
# page
page = {
' type ' : ' CollectionPage ' ,
' partOf ' : request . base_url ,
2023-11-26 23:59:20 +00:00
' items ' : util . trim_nulls ( [ ActivityPub . convert ( obj , from_user = user )
for obj in objects ] ) ,
2023-11-24 06:35:38 +00:00
}
if new_before :
page [ ' next ' ] = f ' { request . base_url } ?before= { new_before } '
if new_after :
page [ ' prev ' ] = f ' { request . base_url } ?after= { new_after } '
2023-11-23 05:40:36 +00:00
2023-11-24 06:35:38 +00:00
if ' before ' in request . args or ' after ' in request . args :
page . update ( {
' @context ' : ' https://www.w3.org/ns/activitystreams ' ,
' id ' : request . url ,
} )
logger . info ( f ' Returning { json_dumps ( page , indent = 2 ) } ' )
2024-02-27 19:38:00 +00:00
return page , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }
2023-11-24 06:35:38 +00:00
# collection
2023-01-25 21:12:24 +00:00
return {
2023-11-24 06:08:19 +00:00
' @context ' : ' https://www.w3.org/ns/activitystreams ' ,
' id ' : request . url ,
' type ' : ' OrderedCollection ' ,
2023-11-24 06:35:38 +00:00
' summary ' : f " { id } ' s outbox " ,
' totalItems ' : query . count ( ) ,
' first ' : page ,
2024-02-27 19:38:00 +00:00
} , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }