2023-05-26 23:07:36 +00:00
""" ActivityPub protocol implementation. """
2023-02-15 18:57:11 +00:00
from base64 import b64encode
2025-06-08 15:32:22 +00:00
import datetime
2023-02-15 18:57:11 +00:00
from hashlib import sha256
2023-03-08 21:10:41 +00:00
import itertools
2017-08-13 07:12:16 +00:00
import logging
2025-06-08 15:32:22 +00:00
import os
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
2024-05-23 22:02:49 +00:00
from unittest . mock import MagicMock
2017-08-13 07:12:16 +00:00
2024-11-01 01:09:30 +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
2025-02-21 05:21:46 +00:00
import oauth_dropins . mastodon
2025-02-22 23:10:02 +00:00
import oauth_dropins . pixelfed
2025-02-24 22:16:08 +00:00
import oauth_dropins . threads
2023-06-28 05:31:48 +00:00
from oauth_dropins . webutil import appengine_info , flask_util , util
2025-03-11 04:45:43 +00:00
from oauth_dropins . webutil . flask_util import FlashErrors , MovedPermanently
2024-10-31 00:09:05 +00:00
from oauth_dropins . webutil . util import add , fragmentless , json_dumps , json_loads
2023-03-08 21:10:41 +00:00
import requests
2024-05-28 04:02:59 +00:00
from requests import TooManyRedirects
from requests . models import DEFAULT_REDIRECT_LIMIT
2023-03-08 21:10:41 +00:00
from werkzeug . exceptions import BadGateway
2017-08-13 07:12:16 +00:00
2024-06-01 14:07:00 +00:00
from flask_app import app
2017-08-15 06:07:24 +00:00
import common
2023-03-08 21:10:41 +00:00
from common import (
2024-06-04 21:19:04 +00:00
CACHE_CONTROL ,
2025-01-29 05:13:35 +00:00
CACHE_CONTROL_VARY_ACCEPT ,
2023-03-08 21:10:41 +00:00
CONTENT_TYPE_HTML ,
2024-05-23 22:02:49 +00:00
create_task ,
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 ,
2025-02-23 16:37:43 +00:00
FlashErrors ,
2023-03-08 21:10:41 +00:00
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 ,
2024-12-06 22:51:40 +00:00
report_error ,
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
)
2025-06-22 04:34:25 +00:00
import ids
2025-01-10 00:57:01 +00:00
import memcache
2025-05-13 20:42:09 +00:00
from models import fetch_objects , Follower , Object , PROTOCOLS , User
2024-11-29 05:39:30 +00:00
from protocol import activity_id_memcache_key , DELETE_TASK_DELAY , 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-10-02 22:01:07 +00:00
# https://www.w3.org/ns/activitystreams#did-core
# https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
AKA_CONTEXT = { ' alsoKnownAs ' : { ' @id ' : ' as:alsoKnownAs ' , ' @type ' : ' @id ' } }
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
2025-01-15 19:48:04 +00:00
OLD_ACCOUNT_EXEMPT_DOMAINS = (
2025-01-31 16:31:20 +00:00
' channel.org ' ,
' newsmast.community ' ,
2025-01-15 19:48:04 +00:00
' pixelfed.social ' ,
2025-06-24 21:24:35 +00:00
)
2025-01-15 19:48:04 +00:00
2024-05-30 20:34:05 +00:00
# we can't yet authorize activities from these domains:
# * a.gup.pe groups sign with the group's actor but use the external author as
# actor and attributedTo, and don't include an LD Sig
# https://github.com/snarfed/bridgy-fed/issues/566#issuecomment-2130714037
NO_AUTH_DOMAINS = (
' a.gup.pe ' ,
)
2024-04-11 22:02:15 +00:00
FEDI_URL_RE = re . compile ( r ' https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)? ' )
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
2025-05-13 20:42:09 +00:00
_INSTANCE_ACTOR = web . Web . get_or_create ( PRIMARY_DOMAIN )
2024-01-06 21:59:31 +00:00
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 '
2025-01-31 17:31:00 +00:00
' '
2024-01-25 03:20:54 +00:00
PHRASE = ' the fediverse '
2025-01-31 17:31:00 +00:00
' '
2023-10-10 18:14:42 +00:00
LOGO_HTML = ' <img src= " /static/fediverse_logo.svg " > '
2025-01-31 17:31:00 +00:00
' '
2024-02-27 19:38:00 +00:00
CONTENT_TYPE = as2 . CONTENT_TYPE_LD_PROFILE
2025-01-31 17:31:00 +00:00
' '
2024-07-16 17:46:17 +00:00
REQUIRES_NAME = False
2025-01-31 17:31:00 +00:00
' '
2024-04-17 23:43:10 +00:00
DEFAULT_ENABLED_PROTOCOLS = ( ' web ' , )
2025-01-31 17:31:00 +00:00
' '
2024-07-04 23:58:06 +00:00
SUPPORTED_AS1_TYPES = (
tuple ( as1 . ACTOR_TYPES )
+ tuple ( as1 . POST_TYPES )
2024-07-05 04:11:38 +00:00
+ tuple ( as1 . CRUD_VERBS )
2024-07-04 23:58:06 +00:00
+ tuple ( as1 . VERBS_WITH_OBJECT )
2025-03-05 19:38:28 +00:00
+ ( ' audio ' , ' bookmark ' , ' image ' , ' move ' , ' video ' )
2024-07-04 23:58:06 +00:00
)
2025-01-31 17:31:00 +00:00
' '
2024-07-04 23:58:06 +00:00
SUPPORTED_AS2_TYPES = tuple (
as2 . OBJECT_TYPE_TO_TYPE . get ( t ) or as2 . VERB_TO_TYPE . get ( t )
for t in SUPPORTED_AS1_TYPES )
2025-01-31 17:31:00 +00:00
' '
2024-08-09 03:41:57 +00:00
SUPPORTS_DMS = True
2025-01-31 17:31:00 +00:00
' '
2023-03-08 21:10:41 +00:00
2025-05-16 17:52:06 +00:00
webfinger_addr = ndb . StringProperty ( )
""" Populated by :meth:`reload_profile`. """
2025-06-24 20:59:02 +00:00
@property
def REQUIRES_AVATAR ( self ) :
' '
2025-06-26 16:01:20 +00:00
return not util . domain_or_parent_in ( self . key . id ( ) , ids . ATPROTO_HANDLE_DOMAINS )
2025-06-24 20:59:02 +00:00
2025-01-15 19:48:04 +00:00
@property
def REQUIRES_OLD_ACCOUNT ( self ) :
2025-01-31 17:31:00 +00:00
' '
2025-06-24 21:24:35 +00:00
return not util . domain_or_parent_in (
2025-06-26 16:01:20 +00:00
self . key . id ( ) , OLD_ACCOUNT_EXEMPT_DOMAINS + ids . ATPROTO_HANDLE_DOMAINS )
2025-01-15 19:48:04 +00:00
2023-06-23 19:22:37 +00:00
def _pre_put_hook ( self ) :
2024-09-12 19:37:09 +00:00
r """ Validate id, require URL, don ' t allow Bridgy Fed domains.
2023-06-23 19:22:37 +00:00
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``. """
2025-05-16 17:52:06 +00:00
if self . webfinger_addr :
assert self . webfinger_addr . startswith ( ' @ ' )
return self . webfinger_addr
2023-06-16 04:22:20 +00:00
if self . obj and self . obj . as1 :
2024-06-03 21:11:23 +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 ) :
2024-08-05 20:20:51 +00:00
if self . obj and self . obj . as2 and as2 . is_server_actor ( self . obj . as2 ) :
return None
status = super ( ) . status
if status :
return status
2025-05-16 17:52:06 +00:00
def reload_profile ( self , * * kwargs ) :
""" Reloads this user ' s AP actor, then resolves their webfinger subject.
1. load AP actor
2. fetch Webfinger with preferredUsername
3. re - fetch Webfinger with subject from first Webfinger
https : / / www . w3 . org / community / reports / socialcg / CG - FINAL - apwf - 20240608 / #reverse-discovery
https : / / correct . webfinger - canary . fietkau . software / #developers
"""
super ( ) . reload_profile ( * * kwargs )
self . webfinger_addr = None
if self . handle :
if profile := webfinger . fetch ( self . handle ) :
if subject := profile . get ( ' subject ' ) :
addr = subject . removeprefix ( ' acct: ' )
if profile := webfinger . fetch ( addr ) :
if subject == profile . get ( ' subject ' ) :
logger . info ( f ' resolved webfinger subject to { subject } ' )
if not addr . startswith ( ' @ ' ) :
addr = ' @ ' + addr
self . webfinger_addr = addr
self . put ( )
2024-04-01 01:49:27 +00:00
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
2024-05-03 22:18:16 +00:00
def owns_handle ( cls , handle , allow_internal = False ) :
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
"""
2025-05-30 20:42:06 +00:00
if ( handle and handle [ 0 ] == ' @ '
and cls . is_user_at_domain ( handle [ 1 : ] , allow_internal = allow_internal ) ) :
return True
return False
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
2025-03-19 00:43:36 +00:00
user = ActivityPub . query ( ActivityPub . handle == handle ) . get ( )
2023-09-22 20:11:15 +00:00
if user :
return user . key . id ( )
return webfinger . fetch_actor_url ( handle )
2025-03-30 16:24:24 +00:00
def user_page_path ( self , rest = None , * * kwargs ) :
""" Always prefer handle, since id is a full URL. """
kwargs [ ' prefer_id ' ] = False
return super ( ) . user_page_path ( rest = rest , * * kwargs )
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
2024-10-04 19:50:24 +00:00
actor = cls . load ( inner_id , raise_ = False )
2023-06-21 00:06:32 +00:00
if actor and actor . as1 :
2024-06-14 03:11:37 +00:00
target = cls . target_for ( actor , shared = shared )
2023-06-21 00:06:32 +00:00
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
2025-06-06 19:56:59 +00:00
if not ( actor := cls . _convert ( obj ) ) :
return None
2023-06-16 20:16:17 +00:00
if shared :
2025-06-10 20:15:32 +00:00
shared_inbox = ( actor . get ( ' endpoints ' ) or { } ) . get ( ' sharedInbox ' )
2023-06-16 20:16:17 +00:00
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
2025-06-02 05:43:12 +00:00
def send ( to_cls , obj , inbox_url , from_user = None , orig_obj_id = 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
"""
2024-09-14 05:26:33 +00:00
if not from_user :
logger . info ( ' Skipping sending, no from_user! ' )
return False
2025-06-02 05:43:12 +00:00
elif to_cls . is_blocklisted ( inbox_url ) :
logger . info ( f ' Skipping sending to blocklisted { inbox_url } ' )
2023-07-23 06:02:12 +00:00
return False
2024-10-11 18:34:31 +00:00
orig_obj = None
if orig_obj_id :
orig_obj = to_cls . convert ( Object . get_by_id ( orig_obj_id ) ,
from_user = from_user )
2024-04-10 22:16:37 +00:00
activity = to_cls . convert ( obj , from_user = from_user , orig_obj = orig_obj )
2023-07-13 21:32:44 +00:00
2025-06-02 05:43:12 +00:00
return signed_post ( inbox_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
2025-05-18 17:50:59 +00:00
resp , obj . as2 = cls . _get ( url , headers = 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 ' :
2025-02-05 22:57:53 +00:00
logger . debug ( ' no AS2 available ' )
2023-07-14 19:45:47 +00:00
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 ' ] ) :
2025-02-05 22:57:53 +00:00
logger . debug ( ' no AS2 available ' )
2023-07-14 19:45:47 +00:00
return False
2023-03-23 03:49:28 +00:00
2025-05-18 17:50:59 +00:00
_ , obj . as2 = cls . _get ( link [ ' href ' ] )
if not obj . as2 :
return False
2023-03-08 21:10:41 +00:00
2025-05-18 17:50:59 +00:00
return True
@classmethod
def _get ( cls , url , headers = as2 . CONNEG_HEADERS ) :
""" Fetches a URL as AS2.
Args :
url ( str )
headers ( dict )
Returns :
( requests . Response , dict JSON response body or None ) tuple :
"""
def _error ( extra_msg = None ) :
msg = f " Couldn ' t fetch { url } as ActivityStreams 2 "
if extra_msg :
msg + = ' : ' + extra_msg
logger . warning ( msg )
# protocol.for_id depends on us raising this when an AP network
# fetch fails. if we change that, update for_id too!
err = BadGateway ( msg )
err . requests_response = resp
raise err
resp = None
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 resp , None
raise
if not resp . content :
_error ( ' empty response ' )
elif common . content_type ( resp ) in as2 . CONTENT_TYPES :
try :
obj = resp . json ( )
except requests . JSONDecodeError :
_error ( " Couldn ' t decode as JSON " )
cls . _hydrate ( obj )
return resp , obj
return resp , None
@classmethod
def _hydrate ( cls , obj ) :
""" Hydrates compacted values in ``obj``, in place.
Very minimal and incomplete ! Right now only handles the ` ` featured ` `
collection in actors .
Args :
obj ( dict )
"""
2025-06-02 21:37:47 +00:00
if util . get_first ( obj , ' type ' ) in as2 . ACTOR_TYPES :
2025-05-18 17:50:59 +00:00
if feat := as1 . get_object ( obj , ' featured ' ) :
if set ( feat . keys ( ) ) == { ' id ' } :
# fetch collection
_ , obj [ ' featured ' ] = cls . _get ( feat [ ' id ' ] )
2023-03-08 21:10:41 +00:00
2023-05-24 04:30:57 +00:00
@classmethod
2024-05-14 22:58:53 +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
2024-04-14 21:50:02 +00:00
# TODO: uncomment
2024-10-02 22:01:07 +00:00
# from_proto = PROTOCOLS.get(obj.source_protocol)
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
2025-03-05 19:53:00 +00:00
' @context ' : as2 . CONTEXT + [ SECURITY_CONTEXT ] ,
2024-01-29 02:15:37 +00:00
* * 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
2025-03-05 19:38:28 +00:00
@classmethod
def migrate_out ( cls , user , to_user_id ) :
""" Migrates a bridged account out to be a native account.
* https : / / www . manton . org / 2022 / 12 / 02 / moving - from - mastodon . html
* https : / / docs . joinmastodon . org / user / moving / #migration
* https : / / www . w3 . org / TR / activitystreams - vocabulary / #dfn-move
Args :
user ( models . User )
to_user_id ( str )
Raises :
ValueError : eg if ` ` ActivityPub ` ` doesn ' t own ``to_user_id``
"""
def _error ( msg ) :
logger . warning ( msg )
raise ValueError ( msg )
2025-03-05 21:08:04 +00:00
user_ap_id = user . id_as ( cls )
logger . info ( f " Migrating { user . key . id ( ) } ' s bridged AP actor { user_ap_id } to { to_user_id } " )
2025-03-05 19:38:28 +00:00
if cls . owns_id ( to_user_id ) is False :
_error ( f " { to_user_id } doesn ' t look like an { cls . LABEL } id " )
2025-03-07 23:17:06 +00:00
elif isinstance ( user , cls ) :
_error ( f " { user . handle_or_id ( ) } is on { cls . PHRASE } " )
2025-03-05 19:38:28 +00:00
elif not user . is_enabled ( cls ) :
_error ( f " { user . handle_or_id ( ) } isn ' t currently bridged to { cls . PHRASE } " )
# check that the destination actor has an alias to the bridged actor
to_actor = cls . load ( to_user_id , remote = True )
aka = util . get_list ( to_actor . as2 , ' alsoKnownAs ' )
if user_ap_id not in aka :
_error ( f " { to_user_id } ' s alsoKnownAs { aka } doesn ' t contain { user_ap_id } " )
# send a Move activity to all followers' inboxes
id = f ' { user_ap_id } #move- { to_user_id } '
move = Object ( id = id , as2 = {
' type ' : ' Move ' ,
' id ' : id ,
' actor ' : user_ap_id ,
' object ' : user_ap_id ,
' target ' : to_user_id ,
' to ' : [ as2 . PUBLIC_AUDIENCE ] ,
} )
move . put ( )
ret = user . deliver ( move , from_user = user , to_proto = cls )
2025-03-05 20:13:10 +00:00
# mark the bridged actor with movedTo
user . obj . our_as1 = {
* * user . obj . as1 ,
' movedTo ' : to_user_id ,
}
2025-03-05 19:38:28 +00:00
user . obj . put ( )
return ret
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 .
2024-05-25 17:40:13 +00:00
https : / / swicg . github . io / activitypub - http - signature /
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 )
2024-12-03 06:20:12 +00:00
logger . debug ( ' Verifying HTTP Signature ' )
2024-07-25 21:44:15 +00:00
logger . debug ( 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 )
2024-05-28 20:48:03 +00:00
key_id = fragmentless ( sig_fields . get ( ' keyid ' ) )
if not key_id :
2024-12-03 06:20:12 +00:00
error ( ' sig missing keyId ' , status = 401 )
2023-03-08 21:10:41 +00:00
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 :
2024-12-03 06:20:12 +00:00
error ( ' Missing Digest ' , status = 401 )
2023-03-08 21:10:41 +00:00
expected = b64encode ( sha256 ( request . data ) . digest ( ) ) . decode ( )
2023-11-12 15:48:48 +00:00
if digest . removeprefix ( ' SHA-256= ' ) . removeprefix ( ' sha-256= ' ) != expected :
2024-12-03 06:20:12 +00:00
error ( ' Invalid Digest ' , status = 401 )
2023-03-08 21:10:41 +00:00
2023-03-10 03:56:04 +00:00
try :
2024-05-28 20:48:03 +00:00
key_actor = cls . _load_key ( key_id )
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
2024-05-28 20:48:03 +00:00
and key_id == fragmentless ( obj_id ) ) :
2024-12-03 06:20:12 +00:00
logger . debug ( ' Object/actor being deleted is also keyId ' )
2023-10-19 22:01:19 +00:00
key_actor = Object . get_or_create (
2024-05-30 18:36:33 +00:00
id = key_id , authed_as = key_id , source_protocol = ' activitypub ' ,
deleted = True )
2023-03-29 19:48:50 +00:00
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 :
2024-05-28 20:48:03 +00:00
abort ( 202 , f ' Ignoring, signer { key_id } is already deleted ' )
2023-07-25 01:16:50 +00:00
elif not key_actor or not key_actor . as1 :
2024-05-28 20:48:03 +00:00
error ( f " Couldn ' t load { key_id } 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 :
2024-05-28 20:48:03 +00:00
error ( f ' No public key for { key_id } ' , status = 401 )
2023-07-25 01:16:50 +00:00
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 ( ' / ' ) )
2024-12-03 06:20:12 +00:00
logger . debug ( f ' Verifying signature for { path_query } with key { sig_fields [ " keyid " ] } ' )
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 :
2024-12-03 06:20:12 +00:00
error ( f ' sig verification failed: { e } ' , status = 401 )
2023-03-08 21:10:41 +00:00
if verified :
2024-12-03 06:20:12 +00:00
logger . debug ( ' sig ok ' )
2023-03-08 21:10:41 +00:00
else :
2024-12-03 06:20:12 +00:00
error ( ' sig failed ' , status = 401 )
2023-03-08 21:10:41 +00:00
2024-05-28 20:48:03 +00:00
return key_actor . key . id ( )
@classmethod
def _load_key ( cls , key_id , follow_owner = True ) :
""" Loads the ActivityPub actor for a given ``keyId``.
https : / / swicg . github . io / activitypub - http - signature / #how-to-obtain-a-signature-s-public-key
Args :
key_id ( str ) : ` ` keyId ` ` from an HTTP Signature
follow_owner ( bool ) : whether to follow ` ` owner ` ` / ` ` controller ` ` fields
Returns :
Object or None :
Raises :
requests . HTTPError :
"""
assert ' # ' not in key_id
2025-04-30 00:33:04 +00:00
# TODO: we don't currently handle when this raises InvalidURL, see error
# below, but I can't reproduce it, when I mock request.get to return
# InvalidURL in test_inbox_verify_sig_fetch_key_fails, we return 400
# instead of crashing :/
# https://console.cloud.google.com/errors/detail/COLzgISI47vpMg?project=bridgy-federated
2024-05-28 20:48:03 +00:00
actor = cls . load ( key_id )
if not actor :
return None
if follow_owner and actor . as1 :
actor_as2 = as2 . from_as1 ( actor . as1 )
key = actor_as2 . get ( ' publicKey ' , { } )
owner = key . get ( ' controller ' ) or key . get ( ' owner ' )
if not owner and actor . type not in as1 . ACTOR_TYPES :
owner = actor_as2 . get ( ' controller ' ) or actor_as2 . get ( ' owner ' )
if owner :
owner = fragmentless ( owner )
if owner != key_id :
2024-12-03 06:20:12 +00:00
logger . debug ( f ' keyId { key_id } has controller/owner { owner } , fetching that ' )
2024-05-28 20:48:03 +00:00
return cls . _load_key ( owner , follow_owner = False )
return actor
2023-10-16 18:13:38 +00:00
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
2024-05-28 04:02:59 +00:00
def signed_request ( fn , url , data = None , headers = None , from_user = None ,
_redirect_count = None , * * kwargs ) :
2023-10-06 06:32:31 +00:00
""" Wraps ``requests.*`` and adds HTTP Signature.
2023-03-08 21:10:41 +00:00
2024-05-25 17:40:13 +00:00
https : / / swicg . github . io / activitypub - 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
2024-12-05 22:09:15 +00:00
provided , uses the default user ` ` @fed.brid.gy @fed.brid.gy ` ` .
2024-05-28 04:02:59 +00:00
_redirect_count : internal , used to count redirects followed so far
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 :
2024-07-25 21:44:15 +00:00
logger . debug ( 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 ( ) } ' ,
}
2024-12-03 06:20:12 +00:00
logger . debug ( f " Signing with { from_user . key . id ( ) } ' 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 )
2017-08-15 06:07:24 +00:00
2024-05-23 22:02:49 +00:00
if fn == util . requests_get :
assert not isinstance ( resp , MagicMock ) , \
f ' unit test missing a mock HTTP response for { url } '
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 ' ] )
2024-05-28 04:02:59 +00:00
if _redirect_count is None :
_redirect_count = 0
elif _redirect_count > DEFAULT_REDIRECT_LIMIT :
raise TooManyRedirects ( response = resp )
2024-05-14 17:42:01 +00:00
return signed_request ( fn , new_url , data = data , from_user = from_user ,
2024-05-28 04:02:59 +00:00
headers = headers , _redirect_count = _redirect_count + 1 ,
* * 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 ' ) ) ) :
2024-07-25 21:44:15 +00:00
logger . debug ( 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.
2024-08-14 00:47:12 +00:00
TODO : get rid of orig_obj ! https : / / github . com / snarfed / bridgy - fed / issues / 1257
2023-03-08 21:10:41 +00:00
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
2024-08-14 00:47:12 +00:00
in_reply_to = util . get_list ( activity , ' inReplyTo ' )
2023-03-08 21:10:41 +00:00
if in_reply_to :
2024-08-14 00:47:12 +00:00
if orig_id : # TODO: and orig_id in in_reply_to ...or get rid of orig_obj
2023-06-21 03:59:32 +00:00
activity [ ' inReplyTo ' ] = orig_id
2024-08-14 00:47:12 +00:00
elif len ( in_reply_to ) > 1 :
# AS2 inReplyTo can be multiply valued, it's not marked Functional:
# https://www.w3.org/TR/activitystreaams-vocabulary/#dfn-inreplyto
# ...but most fediverse projects don't support that:
# https://funfedi.dev/support_tables/generated/in_reply_to/
logger . warning (
" AS2 doesn ' t support multiple inReplyTo URLs! "
f ' Only using the first: { in_reply_to [ 0 ] } ' )
2023-03-08 21:10:41 +00:00
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 ' )
2024-07-07 02:35:59 +00:00
if orig_id and type in as2 . TYPES_WITH_OBJECT and type != ' Undo ' :
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 :
2024-11-02 04:29:51 +00:00
# some fediverse servers (eg Misskey) require activity id and actor id
# to be on the same domain
2024-10-28 15:01:36 +00:00
# https://github.com/snarfed/bridgy-fed/issues/1093#issuecomment-2299247639
2024-11-02 04:29:51 +00:00
redirect_domain = util . domain_from_link ( as1 . get_id ( activity , ' actor ' ) )
if redirect_domain not in DOMAINS :
redirect_domain = None
activity [ ' id ' ] = redirect_wrap ( activity . get ( ' id ' ) , domain = redirect_domain )
2023-04-19 21:37:42 +00:00
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
obj_or_activity = obj if obj . keys ( ) > set ( [ ' id ' ] ) else activity
2024-06-19 20:29:20 +00:00
# move Link attachments to links in text since fediverse instances generate
# their own link previews.
# https://github.com/snarfed/bridgy-fed/issues/958
atts = util . pop_list ( obj_or_activity , ' attachment ' )
obj_or_activity [ ' attachment ' ] = [ a for a in atts if a . get ( ' type ' ) != ' Link ' ]
link_atts = [ a for a in atts if a . get ( ' type ' ) == ' Link ' ]
2025-02-07 20:30:01 +00:00
content = obj_or_activity . get ( ' content ' , ' ' )
2024-06-19 20:29:20 +00:00
for link in link_atts :
2024-12-19 17:46:19 +00:00
for url in util . get_list ( link , ' href ' ) :
2025-02-07 20:30:01 +00:00
if content :
content + = ' <br><br> '
content + = util . pretty_link ( url , text = link . get ( ' name ' ) )
if content :
as2 . set_content ( obj_or_activity , content )
2024-04-29 22:13:12 +00:00
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 ' ]
2024-05-19 22:38:13 +00:00
for img in imgs :
if isinstance ( img , str ) :
img = { ' url ' : img }
add ( atts , img )
2023-03-08 21:10:41 +00:00
2025-05-22 02:38:57 +00:00
# determine whether this is a DM *before* we modify the cc field, below
#
# WARNING: activity and obj here are AS2, but we're using as1.is_dm. right now
# the logic is effectively the same for our purposes, but watch out here if that
# ever changes.
if not as1 . is_dm ( activity ) :
# 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
add ( activity . setdefault ( ' to ' , [ ] ) , as2 . PUBLIC_AUDIENCE )
if obj and type in as2 . CRUD_VERBS :
add ( obj . setdefault ( ' to ' , [ ] ) , as2 . PUBLIC_AUDIENCE )
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
2024-08-09 03:35:56 +00:00
cc = obj_or_activity . setdefault ( ' cc ' , [ ] )
2023-10-12 20:55:17 +00:00
tags = util . get_list ( activity , ' tag ' ) + util . get_list ( obj , ' tag ' )
for tag in tags :
href = tag . get ( ' href ' )
2024-08-17 23:48:55 +00:00
if ( tag . get ( ' type ' ) == ' Mention '
and href
and href not in util . get_list ( obj_or_activity , ' to ' )
2023-10-12 20:55:17 +00:00
and not ActivityPub . is_blocklisted ( href ) ) :
2024-08-09 03:35:56 +00:00
add ( cc , href )
2023-10-12 20:55:17 +00:00
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 ) :
2024-08-09 03:35:56 +00:00
add ( cc , util . get_url ( recip ) or recip . get ( ' id ' ) )
2023-03-08 21:10:41 +00:00
2024-09-13 21:50:27 +00:00
# for some activities, Pleroma (and Akkoma?) seem to crash if the activity's
2024-08-21 18:39:06 +00:00
# to and cc aren't exactly the same as the object's. (I think?)
# https://indieweb.social/@diego@lounge.collabfc.com/112977955332152430
# https://git.pleroma.social/pleroma/pleroma/-/issues/3206#note_108296
2024-09-13 21:50:27 +00:00
# https://github.com/snarfed/bridgy-fed/issues/12#issuecomment-2302776658
2024-08-21 18:39:06 +00:00
if type in ( ' Create ' , ' Update ' ) :
activity [ ' to ' ] = util . get_list ( obj , ' to ' )
activity [ ' cc ' ] = util . get_list ( obj , ' cc ' )
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 '
2024-05-19 23:10:51 +00:00
url_path = f ' /hashtag/ { quote_plus ( name . removeprefix ( " # " ) ) } '
tag . setdefault ( ' href ' , urljoin ( activity [ ' id ' ] , url_path ) )
2023-03-14 21:59:28 +00:00
if not name . startswith ( ' # ' ) :
tag [ ' name ' ] = f ' # { name } '
2024-05-20 03:38:34 +00:00
as2 . link_tags ( obj_or_activity )
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
2024-05-20 19:18:41 +00:00
if content := obj_or_activity . get ( ' content ' ) :
2025-02-07 05:59:04 +00:00
# language, in contentMap
# https://github.com/snarfed/bridgy-fed/issues/681
2025-02-07 20:30:01 +00:00
obj_or_activity . setdefault ( ' contentMap ' , { ' en ' : content } )
2025-02-07 05:59:04 +00:00
2024-05-20 19:18:41 +00:00
# wrap in <p>. some fediverse servers (eg Mastodon) have a white-space:
# pre-wrap style that applies to p inside content. this preserves
# meaningful whitespace in plain text content.
# https://github.com/snarfed/bridgy-fed/issues/990
if not content . startswith ( ' <p> ' ) :
2025-02-07 20:30:01 +00:00
as2 . set_content ( obj_or_activity , f ' <p> { content } </p> ' )
2024-05-20 19:18:41 +00:00
2024-12-02 20:44:13 +00:00
activity . pop ( ' content_is_html ' , None )
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 ( )
2024-05-29 23:18:15 +00:00
if not id or user . is_web_url ( id ) or unwrap ( id ) in (
user_id , user . profile_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 ' )
2024-11-16 23:06:09 +00:00
if url and link 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
2025-04-21 23:33:46 +00:00
# required by pixelfed
#
# https://github.com/snarfed/bridgy-fed/issues/1893
actor . setdefault ( ' manuallyApprovesFollowers ' , False )
# https://github.com/snarfed/bridgy-fed/issues/39
2023-03-08 21:10:41 +00:00
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
2024-05-05 15:29:47 +00:00
actor [ ' publicKey ' ] = {
' id ' : f ' { id } #key ' ,
' owner ' : id ,
' publicKeyPem ' : user . public_pem ( ) . decode ( ) ,
}
actor [ ' @context ' ] = util . get_list ( actor , ' @context ' )
add ( actor [ ' @context ' ] , SECURITY_CONTEXT )
2023-11-24 17:20:52 +00:00
2025-05-22 20:00:29 +00:00
# TODO: bring back once we figure out how to get Mastodon to support this and
# Pleroma and Akkoma not to DDoS us
# https://github.com/snarfed/bridgy-fed/issues/1374#issuecomment-2891993190
#
# # featured collection, pinned posts
# if featured := actor.get('featured'):
# featured.setdefault('id', id + '/featured')
2025-05-19 03:33:54 +00:00
2023-03-08 21:10:41 +00:00
return actor
2023-02-26 13:34:15 +00:00
2024-08-17 19:01:58 +00:00
def _load_user ( handle_or_id , create = False ) :
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
2024-08-17 19:01:58 +00:00
proto = Web
2024-04-22 23:07:54 +00:00
else :
2024-08-17 19:01:58 +00:00
proto = Protocol . for_request ( fed = ' web ' )
2024-04-22 23:07:54 +00:00
2024-08-17 19:01:58 +00:00
if not proto :
2023-09-27 17:29:40 +00:00
error ( f " Couldn ' t determine protocol " , status = 404 )
2023-09-23 20:53:17 +00:00
2024-08-17 19:01:58 +00:00
if proto . owns_id ( handle_or_id ) is False :
if proto . owns_handle ( handle_or_id ) is False :
error ( f " { handle_or_id } doesn ' t look like a { proto . LABEL } id or handle " ,
2023-09-23 20:53:17 +00:00
status = 404 )
2024-08-17 19:01:58 +00:00
id = proto . handle_to_id ( handle_or_id )
2023-09-23 20:53:17 +00:00
if not id :
2024-08-17 19:01:58 +00:00
error ( f " Couldn ' t resolve { handle_or_id } as a { proto . LABEL } handle " ,
2023-09-23 20:53:17 +00:00
status = 404 )
else :
id = handle_or_id
assert id
2024-10-17 16:49:29 +00:00
try :
2025-05-13 20:42:09 +00:00
user = proto . get_or_create ( id ) if create else proto . get_by_id ( id )
2024-10-17 16:49:29 +00:00
except ValueError as e :
logging . warning ( e )
user = None
2024-04-28 03:27:33 +00:00
if not user or not user . is_enabled ( ActivityPub ) :
2024-08-17 19:01:58 +00:00
error ( f ' { proto . LABEL } user { id } not found ' , status = 404 )
return user
# 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!
@app.get ( f ' /ap/<handle_or_id> ' )
2025-01-27 03:40:29 +00:00
# special case Web users on fed.brid.gy subdomain without /ap/web/ prefix, for
# backward compatibility
2024-08-17 19:01:58 +00:00
@app.get ( f ' /<regex( " { DOMAIN_RE } " ):handle_or_id> ' )
2025-01-29 05:13:35 +00:00
@flask_util.headers ( CACHE_CONTROL_VARY_ACCEPT )
2024-08-17 19:01:58 +00:00
def actor ( handle_or_id ) :
""" Serves a user ' s AS2 actor from the datastore. """
user = _load_user ( handle_or_id , create = True )
proto = user
2024-11-01 01:09:30 +00:00
as2_type = common . as2_request_type ( )
if not as2_type :
return redirect ( user . web_url ( ) , code = 302 )
2024-08-17 19:01:58 +00:00
if proto . LABEL == ' web ' and request . path . startswith ( ' /ap/ ' ) :
# we started out with web users' AP ids as fed.brid.gy/[domain], so we
# need to preserve those for backward compatibility
raise MovedPermanently ( location = subdomain_wrap ( None , f ' / { handle_or_id } ' ) )
2023-11-26 23:44:43 +00:00
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 :
2024-08-17 19:01:58 +00:00
raise MovedPermanently ( location = id )
2023-12-01 00:31:41 +00:00
2023-11-26 04:07:14 +00:00
actor = ActivityPub . convert ( user . obj , from_user = user ) or {
2025-03-05 19:53:00 +00:00
' @context ' : as2 . CONTEXT ,
2023-06-04 14:33:30 +00:00
' type ' : ' Person ' ,
}
2023-11-26 04:07:14 +00:00
actor = postprocess_as2_actor ( actor , user = user )
2024-10-02 22:01:07 +00:00
actor [ ' @context ' ] = util . get_list ( actor , ' @context ' )
add ( actor [ ' @context ' ] , AKA_CONTEXT )
actor . setdefault ( ' alsoKnownAs ' , [ user . id_uri ( ) ] )
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 ' : {
2024-12-15 18:16:56 +00:00
' sharedInbox ' : urljoin ( id , ' /ap/sharedInbox ' ) ,
2023-02-14 16:25:41 +00:00
} ,
2023-11-21 23:18:21 +00:00
} )
2023-09-25 03:06:18 +00:00
2024-07-25 21:44:15 +00:00
logger . debug ( f ' Returning: { json_dumps ( actor , indent = 2 ) } ' )
2023-02-14 16:25:41 +00:00
return actor , {
2024-11-01 01:09:30 +00:00
' Content-Type ' : as2_type ,
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 ' )
2025-01-27 03:40:29 +00:00
# special case Web users on fed subdomain without /ap/web/ prefix
2023-11-15 21:19:37 +00:00
@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
2024-07-04 23:58:06 +00:00
# do we support this object type?
2024-07-05 04:11:38 +00:00
# (this logic is duplicated in Protocol.check_supported)
2024-12-06 22:51:40 +00:00
obj = as1 . get_object ( activity )
2024-07-05 04:11:38 +00:00
if type := activity . get ( ' type ' ) :
2024-12-06 22:51:40 +00:00
inner_type = as1 . object_type ( obj ) or ' '
2024-07-05 04:11:38 +00:00
if ( type not in ActivityPub . SUPPORTED_AS2_TYPES or
( type in as2 . CRUD_VERBS
and inner_type
and inner_type not in ActivityPub . SUPPORTED_AS2_TYPES ) ) :
2024-07-05 05:28:41 +00:00
error ( f " Bridgy Fed for ActivityPub doesn ' t support { type } { inner_type } yet: { json_dumps ( activity , indent = 2 ) } " , status = 204 )
2024-07-04 23:58:06 +00:00
2024-12-06 22:51:40 +00:00
# check actor, authz actor's domain against activity and object ids
# https://github.com/snarfed/bridgy-fed/security/advisories/GHSA-37r7-jqmr-3472
2024-12-09 22:15:37 +00:00
actor = ( as1 . get_object ( activity , ' actor ' )
or as1 . get_object ( activity , ' attributedTo ' ) )
2023-05-31 20:17:17 +00:00
actor_id = actor . get ( ' id ' )
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 ' )
2024-12-03 06:20:12 +00:00
actor_domain = util . domain_from_link ( actor_id )
2025-01-14 00:36:33 +00:00
# temporary, see emails w/Michael et al, and
# https://github.com/snarfed/bridgy-fed/issues/1686
if actor_domain == ' newsmast.community ' and type == ' Undo ' :
return ' :( ' , 204
2024-12-06 22:51:40 +00:00
id = activity . get ( ' id ' )
obj_id = obj . get ( ' id ' )
if id and actor_domain != util . domain_from_link ( id ) :
2024-12-09 22:15:37 +00:00
report_error ( f ' Auth: actor and activity on different domains: { json_dumps ( activity , indent = 2 ) } ' ,
2024-12-06 22:51:40 +00:00
user = f ' actor { actor_id } activity { id } ' )
2024-12-09 22:15:37 +00:00
return f ' actor { actor_id } and activity { id } on different domains ' , 403
2024-12-06 22:51:40 +00:00
elif ( type in as2 . CRUD_VERBS and obj_id
and actor_domain != util . domain_from_link ( obj_id ) ) :
2024-12-09 22:15:37 +00:00
report_error ( f ' Auth: actor and object on different domains { json_dumps ( activity , indent = 2 ) } ' ,
2024-12-06 23:31:21 +00:00
user = f ' actor { actor_id } object { obj_id } ' )
2024-12-09 22:15:37 +00:00
return f ' actor { actor_id } and object { obj_id } on different domains ' , 403
2024-12-06 22:51:40 +00:00
# are we already processing or done with this activity?
if id :
domain = util . domain_from_link ( id )
2025-01-10 00:57:01 +00:00
if memcache . memcache . get ( activity_id_memcache_key ( id ) ) :
2024-12-06 22:51:40 +00:00
logger . info ( f ' Already seen { id } ' )
return ' ' , 204
# check signature, auth
2023-10-16 18:13:38 +00:00
authed_as = ActivityPub . verify_signature ( activity )
2023-02-15 18:57:11 +00:00
2025-06-26 16:01:20 +00:00
if util . domain_or_parent_in ( authed_as , NO_AUTH_DOMAINS ) :
error ( f " Ignoring, sorry, we don ' t know how to authorize { util . domain_from_link ( authed_as ) } activities yet. https://github.com/snarfed/bridgy-fed/issues/566 " , status = 204 )
2024-05-30 20:34:05 +00:00
2024-05-30 04:17:38 +00:00
# if we need the LD Sig to authorize this activity, bail out, we don't do
# those yet
if authed_as != actor_id and activity . get ( ' signature ' ) :
error ( f " Ignoring LD Signature, sorry, we can ' t verify those yet. https://github.com/snarfed/bridgy-fed/issues/566 " , status = 202 )
2024-12-06 22:51:40 +00:00
logger . info ( f ' Got { type } { id } from { actor_id } ' )
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 } ' )
2024-06-13 20:54:37 +00:00
if not id :
2024-12-06 22:51:40 +00:00
id = f ' { actor_id } # { type } - { obj_id or " " } - { util . now ( ) . isoformat ( ) } '
2024-06-27 18:26:44 +00:00
2024-08-05 20:20:51 +00:00
# automatically bridge server aka instance actors
# https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md
if as2 . is_server_actor ( actor ) :
2024-08-06 02:20:57 +00:00
all_protocols = [
label for label , proto in PROTOCOLS . items ( )
2025-06-04 21:50:53 +00:00
if label and proto and label not in ( ' ui ' , ' activitypub ' , ' ap ' ) ]
2024-08-05 20:20:51 +00:00
user = ActivityPub . get_or_create ( actor_id , propagate = True ,
enabled_protocols = all_protocols )
if user and not user . existing :
2025-05-22 21:11:09 +00:00
logger . info ( f ' Automatically enabled AP server actor { actor_id } ' )
2024-08-05 20:20:51 +00:00
2024-11-29 05:39:30 +00:00
delay = DELETE_TASK_DELAY if type in ( ' Delete ' , ' Undo ' ) else None
2024-10-02 04:44:12 +00:00
return create_task ( queue = ' receive ' , id = id , as2 = activity ,
2024-10-24 04:11:21 +00:00
source_protocol = ActivityPub . LABEL , authed_as = authed_as ,
2024-11-29 05:39:30 +00:00
received_at = util . now ( ) . isoformat ( ) , delay = delay )
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> ' )
2025-01-27 03:40:29 +00:00
# special case Web users on fed.brid.gy subdomain 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 ' ] )
2024-06-04 21:19:04 +00:00
@flask_util.headers ( CACHE_CONTROL )
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 )
2024-08-17 19:01:58 +00:00
user = _load_user ( id )
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 ,
} )
2024-07-25 21:44:15 +00:00
logger . debug ( 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
2024-06-26 17:56:18 +00:00
ret = {
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-01-20 15:02:55 +00:00
' first ' : page ,
2022-11-22 02:46:10 +00:00
}
2024-06-26 17:56:18 +00:00
# count total if it's small, <= 1k. we should eventually precompute this
# so that we can always return it cheaply.
prop = Follower . to if collection == ' followers ' else Follower . from_
count = Follower . query ( prop == user . key , Follower . status == ' active ' ) \
. count ( limit = 1001 )
if count != 1001 :
ret [ ' totalItems ' ] = count
2024-07-25 21:44:15 +00:00
logger . debug ( f ' Returning { json_dumps ( collection , indent = 2 ) } ' )
2024-06-26 17:56:18 +00:00
return ret , {
2024-05-30 21:55:35 +00:00
' 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 ' )
2025-01-27 03:40:29 +00:00
# special case Web users on fed.brid.gy subdomain 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 ' ] )
2024-06-04 21:19:04 +00:00
@flask_util.headers ( CACHE_CONTROL )
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 ( )
"""
2024-08-17 19:01:58 +00:00
user = _load_user ( id )
2023-11-23 05:40:36 +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
2024-08-14 04:22:00 +00:00
# TODO: bring this back once we filter it by author status, etc
2024-08-14 15:30:39 +00:00
# query = Object.query(Object.users == user.key)
# objects, new_before, new_after = fetch_objects(query, by=Object.updated,
# user=user)
2024-08-14 04:22:00 +00:00
# page = {
# 'type': 'CollectionPage',
# 'partOf': request.base_url,
# 'items': util.trim_nulls([ActivityPub.convert(obj, from_user=user)
# for obj in objects]),
# }
# 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.debug(f'Returning {json_dumps(page, indent=2)}')
# return page, {'Content-Type': as2.CONTENT_TYPE_LD_PROFILE}
2023-11-24 06:35:38 +00:00
2024-06-26 17:56:18 +00:00
ret = {
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 " ,
2024-08-14 15:30:39 +00:00
' totalItems ' : 0 ,
2024-08-14 04:22:00 +00:00
# 'first': page,
' first ' : {
' type ' : ' CollectionPage ' ,
' partOf ' : request . base_url ,
' items ' : [ ] ,
} ,
2024-05-30 21:55:35 +00:00
}
2024-06-26 17:56:18 +00:00
2024-08-14 15:30:39 +00:00
# # count total if it's small, <= 1k. we should eventually precompute this
# # so that we can always return it cheaply.
# count = query.count(limit=1001)
# if count != 1001:
# ret['totalItems'] = count
2024-06-26 17:56:18 +00:00
return ret , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }
2025-05-19 03:33:54 +00:00
# protocol in subdomain
2025-05-22 20:00:29 +00:00
@app.get ( ' /ap/<id>/featured ' )
2025-05-22 03:23:48 +00:00
def featured ( id ) :
""" Serves a user ' s AP featured collection for pinned posts.
https : / / docs . joinmastodon . org / spec / activitypub / #featured
We inline the featured collection in users ' actors, but Mastodon (and
Pleroma / Akkoma ? ) require it to be fetchable separately too . : (
Also , it ' s critical that the collection items here are expanded objects!
Originally they were compacted string ids , but that triggered a massive flood of
requests from Pleroma and Akkoma :
https : / / github . com / snarfed / bridgy - fed / issues / 1374 #issuecomment-2891993190
"""
2025-05-22 20:00:29 +00:00
# TODO: bring back once we figure out how to get Mastodon to support this and
# Pleroma and Akkoma not to DDoS us
# https://github.com/snarfed/bridgy-fed/issues/1374#issuecomment-2891993190
return ' ' , 404
2025-05-22 03:23:48 +00:00
user = _load_user ( id )
items = [ ]
if user . obj and user . obj . as1 :
for obj in as1 . get_objects ( user . obj . as1 . get ( ' featured ' , { } ) , ' items ' ) :
if set ( obj . keys ( ) ) == { ' id ' } :
if obj := user . load ( obj [ ' id ' ] ) :
if obj . as1 :
2025-05-22 19:07:06 +00:00
items . append ( ActivityPub . convert ( obj ) )
2025-05-22 03:23:48 +00:00
elif obj :
2025-05-22 19:07:06 +00:00
items . append ( ActivityPub . convert ( Object ( our_as1 = obj ) ) )
2025-05-22 03:23:48 +00:00
return {
' @context ' : as2 . CONTEXT ,
' type ' : ' OrderedCollection ' ,
' id ' : request . base_url ,
' totalItems ' : len ( items ) ,
' orderedItems ' : items ,
} , { ' Content-Type ' : as2 . CONTENT_TYPE_LD_PROFILE }
2025-02-21 05:21:46 +00:00
2025-06-08 15:32:22 +00:00
@app.get ( ' /.well-known/nodeinfo ' )
@flask_util.canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
@flask_util.headers ( CACHE_CONTROL )
def nodeinfo_jrd ( ) :
"""
https : / / nodeinfo . diaspora . software / protocol . html
"""
return {
' links ' : [ {
' rel ' : ' http://nodeinfo.diaspora.software/ns/schema/2.1 ' ,
' href ' : common . host_url ( ' nodeinfo.json ' ) ,
} , {
" rel " : " https://www.w3.org/ns/activitystreams#Application " ,
" href " : instance_actor ( ) . id_as ( ActivityPub ) ,
} ] ,
} , {
' Content-Type ' : ' application/jrd+json ' ,
}
@app.get ( ' /nodeinfo.json ' )
@flask_util.canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
@memcache.memoize ( expire = datetime . timedelta ( hours = 1 ) )
@flask_util.headers ( CACHE_CONTROL )
def nodeinfo ( ) :
"""
https : / / nodeinfo . diaspora . software / schema . html
"""
from atproto import ATProto
from nostr import Nostr
from web import Web
atp = ATProto . query ( ATProto . enabled_protocols != None ) . count ( )
ap = ActivityPub . query ( ActivityPub . enabled_protocols != None ) . count ( )
nostr = Nostr . query ( Nostr . enabled_protocols != None ) . count ( )
web = Web . query ( Web . status == None ) . count ( )
total = atp + ap + nostr + web
logger . info ( f ' Users: ap: { ap } ' )
logger . info ( f ' Users: atproto: { atp } ' )
logger . info ( f ' Users: web: { web } ' )
logger . info ( f ' Users: total: { total } ' )
return {
' version ' : ' 2.1 ' ,
' software ' : {
' name ' : ' bridgy-fed ' ,
' version ' : os . getenv ( ' GAE_VERSION ' ) ,
' repository ' : ' https://github.com/snarfed/bridgy-fed ' ,
' homepage ' : ' https://fed.brid.gy/ ' ,
} ,
' protocols ' : [
' activitypub ' ,
' atprotocol ' ,
' webmention ' ,
] ,
' services ' : {
' outbound ' : [ ] ,
' inbound ' : [ ] ,
} ,
' usage ' : {
' users ' : {
' total ' : total ,
# 'activeMonth':
# 'activeHalfyear':
} ,
# these are too heavy
# 'localPosts': Object.query(Object.source_protocol.IN(('web', 'webmention')),
# Object.type.IN(['note', 'article']),
# ).count(),
# 'localComments': Object.query(Object.source_protocol.IN(('web', 'webmention')),
# Object.type == 'comment',
# ).count(),
} ,
' openRegistrations ' : True ,
' metadata ' : {
' users ' : {
' activitypub ' : ap ,
' atprotocol ' : atp ,
' webmention ' : web ,
} ,
} ,
} , {
# https://nodeinfo.diaspora.software/protocol.html
' Content-Type ' : ' application/json; profile= " http://nodeinfo.diaspora.software/ns/schema/2.1# " ' ,
}
@app.get ( ' /api/v1/instance ' )
@flask_util.canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
@flask_util.headers ( CACHE_CONTROL )
def instance_info ( ) :
"""
https : / / docs . joinmastodon . org / methods / instance / #v1
"""
return {
' uri ' : ' fed.brid.gy ' ,
' title ' : ' Bridgy Fed ' ,
' version ' : os . getenv ( ' GAE_VERSION ' ) ,
' short_description ' : ' Bridging the new social internet ' ,
' description ' : ' Bridging the new social internet ' ,
' email ' : ' feedback@brid.gy ' ,
' thumbnail ' : ' https://fed.brid.gy/static/bridgy_logo_with_alpha.png ' ,
' registrations ' : True ,
' approval_required ' : False ,
' invites_enabled ' : False ,
' contact_account ' : {
' username ' : ' snarfed.org ' ,
' acct ' : ' snarfed.org ' ,
' display_name ' : ' Ryan ' ,
' url ' : ' https://snarfed.org/ ' ,
} ,
}
2025-02-23 16:37:43 +00:00
#
# OAuth
#
class MastodonStart ( FlashErrors , oauth_dropins . mastodon . Start ) :
2025-05-21 18:09:30 +00:00
def app_name ( self ) :
return ' Bridgy Fed '
def app_url ( self ) :
return ' https://fed.brid.gy/ '
2025-02-23 16:37:43 +00:00
class MastodonCallback ( FlashErrors , oauth_dropins . mastodon . Callback ) :
pass
class PixelfedStart ( FlashErrors , oauth_dropins . pixelfed . Start ) :
2025-05-21 18:09:30 +00:00
def app_name ( self ) :
return ' Bridgy Fed '
def app_url ( self ) :
return ' https://fed.brid.gy/ '
2025-02-23 16:37:43 +00:00
class PixelfedCallback ( FlashErrors , oauth_dropins . pixelfed . Callback ) :
2025-02-21 05:21:46 +00:00
pass
2025-02-22 05:41:23 +00:00
2025-02-24 22:16:08 +00:00
class ThreadsStart ( FlashErrors , oauth_dropins . threads . Start ) :
pass
class ThreadsCallback ( FlashErrors , oauth_dropins . threads . Callback ) :
pass
2025-02-22 05:41:23 +00:00
2025-02-23 16:37:43 +00:00
app . add_url_rule ( ' /oauth/mastodon/start ' , view_func = MastodonStart . as_view (
2025-02-22 05:41:23 +00:00
' /oauth/mastodon/start ' , ' /oauth/mastodon/finish ' ) ,
methods = [ ' POST ' ] )
2025-02-23 16:37:43 +00:00
app . add_url_rule ( ' /oauth/mastodon/finish ' , view_func = MastodonCallback . as_view (
2025-02-22 05:41:23 +00:00
' /oauth/mastodon/finish ' , ' /settings ' ) )
2025-02-24 22:16:08 +00:00
2025-02-23 16:37:43 +00:00
app . add_url_rule ( ' /oauth/pixelfed/start ' , view_func = PixelfedStart . as_view (
2025-02-22 23:10:02 +00:00
' /oauth/pixelfed/start ' , ' /oauth/pixelfed/finish ' ) ,
methods = [ ' POST ' ] )
2025-02-23 16:37:43 +00:00
app . add_url_rule ( ' /oauth/pixelfed/finish ' , view_func = PixelfedCallback . as_view (
2025-02-22 23:10:02 +00:00
' /oauth/pixelfed/finish ' , ' /settings ' ) )
2025-02-24 22:16:08 +00:00
app . add_url_rule ( ' /oauth/threads/start ' , view_func = ThreadsStart . as_view (
' /oauth/threads/start ' , ' /oauth/threads/finish ' ) ,
methods = [ ' POST ' ] )
app . add_url_rule ( ' /oauth/threads/finish ' , view_func = ThreadsCallback . as_view (
' /oauth/threads/finish ' , ' /settings ' ) )