kopia lustrzana https://github.com/snarfed/bridgy-fed
move current user into Flask g request-global
rodzic
b2c60226b7
commit
fb5f7b3fb0
|
@ -6,7 +6,7 @@ import itertools
|
|||
import logging
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from flask import abort, request
|
||||
from flask import abort, g, request
|
||||
from granary import as1, as2
|
||||
from httpsig import HeaderVerifier
|
||||
from httpsig.requests_auth import HTTPSignatureAuth
|
||||
|
@ -52,13 +52,13 @@ class ActivityPub(Protocol):
|
|||
LABEL = 'activitypub'
|
||||
|
||||
@classmethod
|
||||
def send(cls, obj, url, *, user=None, log_data=True):
|
||||
def send(cls, obj, url, log_data=True):
|
||||
"""Delivers an AS2 activity to an inbox URL."""
|
||||
return signed_post(url, user=user, log_data=True, data=obj.as2)
|
||||
return signed_post(url, log_data=True, data=obj.as2)
|
||||
# TODO: return bool or otherwise unify return value with others
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, id, obj, *, user=None):
|
||||
def fetch(cls, id, obj):
|
||||
"""Tries to fetch an AS2 object and populate it into an :class:`Object`.
|
||||
|
||||
Uses HTTP content negotiation via the Content-Type header. If the url is
|
||||
|
@ -79,7 +79,6 @@ class ActivityPub(Protocol):
|
|||
Args:
|
||||
id: str, object's URL id
|
||||
obj: :class:`Object` to populate the fetched object into
|
||||
user: optional :class:`User` we're fetching on behalf of
|
||||
|
||||
Raises:
|
||||
:class:`requests.HTTPError`, :class:`werkzeug.exceptions.HTTPException`
|
||||
|
@ -98,7 +97,7 @@ class ActivityPub(Protocol):
|
|||
|
||||
def _get(url, headers):
|
||||
"""Returns None if we fetched and populated, resp otherwise."""
|
||||
resp = signed_get(url, user=user, headers=headers, gateway=True)
|
||||
resp = signed_get(url, headers=headers, gateway=True)
|
||||
if not resp.content:
|
||||
_error(resp, 'empty response')
|
||||
elif common.content_type(resp) == as2.CONTENT_TYPE:
|
||||
|
@ -128,12 +127,11 @@ class ActivityPub(Protocol):
|
|||
_error(resp)
|
||||
|
||||
@classmethod
|
||||
def verify_signature(cls, activity, *, user=None):
|
||||
def verify_signature(cls, activity):
|
||||
"""Verifies the current request's HTTP Signature.
|
||||
|
||||
Args:
|
||||
activity: dict, AS2 activity
|
||||
user: optional :class:`User`
|
||||
|
||||
Logs details of the result. Raises :class:`werkzeug.HTTPError` if the
|
||||
signature is missing or invalid, otherwise does nothing and returns None.
|
||||
|
@ -159,7 +157,7 @@ class ActivityPub(Protocol):
|
|||
error('Invalid Digest header, required for HTTP Signature', status=401)
|
||||
|
||||
try:
|
||||
key_actor = cls.get_object(keyId, user=user).as2
|
||||
key_actor = cls.get_object(keyId).as2
|
||||
except BadGateway:
|
||||
obj_id = as1.get_object(activity).get('id')
|
||||
if (activity.get('type') == 'Delete' and obj_id and
|
||||
|
@ -185,23 +183,25 @@ class ActivityPub(Protocol):
|
|||
error('HTTP Signature verification failed', status=401)
|
||||
|
||||
|
||||
def signed_get(url, *, user=None, **kwargs):
|
||||
return signed_request(util.requests_get, url, user=user, **kwargs)
|
||||
def signed_get(url, **kwargs):
|
||||
return signed_request(util.requests_get, url, **kwargs)
|
||||
|
||||
|
||||
def signed_post(url, *, user=None, **kwargs):
|
||||
assert user
|
||||
return signed_request(util.requests_post, url, user=user, **kwargs)
|
||||
def signed_post(url, **kwargs):
|
||||
assert g.user
|
||||
return signed_request(util.requests_post, url, **kwargs)
|
||||
|
||||
|
||||
def signed_request(fn, url, *, user=None, data=None, log_data=True,
|
||||
def signed_request(fn, url, data=None, log_data=True,
|
||||
headers=None, **kwargs):
|
||||
"""Wraps requests.* and adds HTTP Signature.
|
||||
|
||||
If the current session has a user (ie in g.user), signs with that user's
|
||||
key. Otherwise, uses the default user snarfed.org.
|
||||
|
||||
Args:
|
||||
fn: :func:`util.requests_get` or :func:`util.requests_get`
|
||||
url: str
|
||||
user: optional :class:`User` to sign request with
|
||||
data: optional AS2 object
|
||||
log_data: boolean, whether to log full data object
|
||||
kwargs: passed through to requests
|
||||
|
@ -212,8 +212,7 @@ def signed_request(fn, url, *, user=None, data=None, log_data=True,
|
|||
headers = {}
|
||||
|
||||
# prepare HTTP Signature and headers
|
||||
if not user:
|
||||
user = default_signature_user()
|
||||
user = g.user or default_signature_user()
|
||||
|
||||
if data:
|
||||
if log_data:
|
||||
|
@ -250,7 +249,7 @@ def signed_request(fn, url, *, user=None, data=None, log_data=True,
|
|||
|
||||
# handle GET redirects manually so that we generate a new HTTP signature
|
||||
if resp.is_redirect and fn == util.requests_get:
|
||||
return signed_request(fn, resp.headers['Location'], data=data, user=user,
|
||||
return signed_request(fn, resp.headers['Location'], data=data,
|
||||
headers=headers, log_data=log_data, **kwargs)
|
||||
|
||||
type = common.content_type(resp)
|
||||
|
@ -261,24 +260,24 @@ def signed_request(fn, url, *, user=None, data=None, log_data=True,
|
|||
return resp
|
||||
|
||||
|
||||
def postprocess_as2(activity, *, user=None, target=None, create=True):
|
||||
def postprocess_as2(activity, target=None, create=True):
|
||||
"""Prepare an AS2 object to be served or sent via ActivityPub.
|
||||
|
||||
g.user is required. Populates it into the actor.id and publicKey fields.
|
||||
|
||||
Args:
|
||||
activity: dict, AS2 object or activity
|
||||
user: :class:`User`, required. populated into actor.id and
|
||||
publicKey fields if needed.
|
||||
target: dict, AS2 object, optional. The target of activity's inReplyTo or
|
||||
Like/Announce/etc object, if any.
|
||||
create: boolean, whether to wrap `Note` and `Article` objects in a
|
||||
`Create` activity
|
||||
"""
|
||||
assert user
|
||||
assert g.user
|
||||
type = activity.get('type')
|
||||
|
||||
# actor objects
|
||||
if type == 'Person':
|
||||
postprocess_as2_actor(activity, user=user)
|
||||
postprocess_as2_actor(activity)
|
||||
if not activity.get('publicKey'):
|
||||
# underspecified, inferred from this issue and Mastodon's implementation:
|
||||
# https://github.com/w3c/activitypub/issues/203#issuecomment-297553229
|
||||
|
@ -288,7 +287,7 @@ def postprocess_as2(activity, *, user=None, target=None, create=True):
|
|||
'publicKey': {
|
||||
'id': actor_url,
|
||||
'owner': actor_url,
|
||||
'publicKeyPem': user.public_pem().decode(),
|
||||
'publicKeyPem': g.user.public_pem().decode(),
|
||||
},
|
||||
'@context': (util.get_list(activity, '@context') +
|
||||
['https://w3id.org/security/v1']),
|
||||
|
@ -296,7 +295,7 @@ def postprocess_as2(activity, *, user=None, target=None, create=True):
|
|||
return activity
|
||||
|
||||
for field in 'actor', 'attributedTo':
|
||||
activity[field] = [postprocess_as2_actor(actor, user=user)
|
||||
activity[field] = [postprocess_as2_actor(actor)
|
||||
for actor in util.get_list(activity, field)]
|
||||
if len(activity[field]) == 1:
|
||||
activity[field] = activity[field][0]
|
||||
|
@ -402,29 +401,28 @@ def postprocess_as2(activity, *, user=None, target=None, create=True):
|
|||
'@context': as2.CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': f'{activity["id"]}#bridgy-fed-create',
|
||||
'actor': postprocess_as2_actor({}, user=user),
|
||||
'actor': postprocess_as2_actor({}),
|
||||
'object': activity,
|
||||
}
|
||||
|
||||
return util.trim_nulls(activity)
|
||||
|
||||
|
||||
def postprocess_as2_actor(actor, *, user=None):
|
||||
def postprocess_as2_actor(actor):
|
||||
"""Prepare an AS2 actor object to be served or sent via ActivityPub.
|
||||
|
||||
Modifies actor in place.
|
||||
|
||||
Args:
|
||||
actor: dict, AS2 actor object
|
||||
user: :class:`User`
|
||||
|
||||
Returns:
|
||||
actor dict
|
||||
"""
|
||||
if not actor or isinstance(actor, str):
|
||||
return user.actor_id() if user.is_homepage(actor) else actor
|
||||
return g.user.actor_id() if g.user.is_homepage(actor) else actor
|
||||
|
||||
url = user.homepage if user else None
|
||||
url = g.user.homepage if g.user else None
|
||||
urls = util.get_list(actor, 'url')
|
||||
if not urls and url:
|
||||
urls = [url]
|
||||
|
@ -461,16 +459,16 @@ def actor(domain):
|
|||
if tld in TLD_BLOCKLIST:
|
||||
error('', status=404)
|
||||
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
return f'User {domain} not found', 404
|
||||
elif not user.actor_as2:
|
||||
elif not g.user.actor_as2:
|
||||
return f'User {domain} not fully set up', 404
|
||||
|
||||
# TODO: unify with common.actor()
|
||||
actor = postprocess_as2(user.actor_as2, user=user)
|
||||
actor = postprocess_as2(g.user.actor_as2)
|
||||
actor.update({
|
||||
'id': user.actor_id(),
|
||||
'id': g.user.actor_id(),
|
||||
# This has to be the 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. Background:
|
||||
|
@ -512,14 +510,12 @@ def inbox(domain=None):
|
|||
logger.info(f'Got {type} from {actor_id}: {json_dumps(activity, indent=2)}')
|
||||
|
||||
# load user
|
||||
# TODO: store in g instead of passing around
|
||||
user = None
|
||||
if domain:
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
error(f'User {domain} not found', status=404)
|
||||
|
||||
ActivityPub.verify_signature(activity, user=user)
|
||||
ActivityPub.verify_signature(activity)
|
||||
|
||||
# check that this activity is public. only do this for creates, not likes,
|
||||
# follows, or other activity types, since Mastodon doesn't currently mark
|
||||
|
@ -541,7 +537,7 @@ def inbox(domain=None):
|
|||
followee_url = redirect_unwrap(util.get_url(activity, 'object'))
|
||||
activity.setdefault('url', f'{follower_url}#followed-{followee_url}')
|
||||
|
||||
return ActivityPub.receive(activity.get('id'), user=user,
|
||||
return ActivityPub.receive(activity.get('id'),
|
||||
as2=redirect_unwrap(activity))
|
||||
|
||||
|
||||
|
|
6
app.py
6
app.py
|
@ -3,7 +3,7 @@ import json
|
|||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
from flask import Flask, g
|
||||
from flask_caching import Cache
|
||||
import flask_gae_static
|
||||
from lexrpc.server import Server
|
||||
|
@ -34,6 +34,10 @@ app.register_error_handler(Exception, flask_util.handle_exception)
|
|||
if appengine_info.LOCAL:
|
||||
flask_gae_static.init_app(app)
|
||||
|
||||
@app.before_request
|
||||
def init_globals():
|
||||
g.user = None
|
||||
|
||||
# don't redirect API requests with blank path elements
|
||||
app.url_map.redirect_defaults = True
|
||||
|
||||
|
|
11
common.py
11
common.py
|
@ -9,7 +9,7 @@ import threading
|
|||
import urllib.parse
|
||||
|
||||
import cachetools
|
||||
from flask import abort, make_response, request
|
||||
from flask import abort, g, make_response, request
|
||||
from granary import as1, as2, microformats2
|
||||
import mf2util
|
||||
from oauth_dropins.webutil import util, webmention
|
||||
|
@ -64,7 +64,7 @@ def error(msg, status=400):
|
|||
abort(status, response=make_response({'error': msg}, status))
|
||||
|
||||
|
||||
def pretty_link(url, text=None, user=None, **kwargs):
|
||||
def pretty_link(url, text=None, **kwargs):
|
||||
"""Wrapper around util.pretty_link() that converts Mastodon user URLs to @-@.
|
||||
|
||||
Eg for URLs like https://mastodon.social/@foo and
|
||||
|
@ -74,11 +74,10 @@ def pretty_link(url, text=None, user=None, **kwargs):
|
|||
Args:
|
||||
url: str
|
||||
text: str
|
||||
user: :class:`User`, optional, user for the current request
|
||||
kwargs: passed through to :func:`webutil.util.pretty_link`
|
||||
"""
|
||||
if user and user.is_homepage(url):
|
||||
return user.user_page_link()
|
||||
if g.user and g.user.is_homepage(url):
|
||||
return g.user.user_page_link()
|
||||
|
||||
if text is None:
|
||||
match = re.match(r'https?://([^/]+)/(@|users/)([^/]+)$', url)
|
||||
|
@ -218,7 +217,7 @@ def actor(user):
|
|||
actor_as1 = microformats2.json_to_object(hcard, rel_urls=mf2.get('rel-urls'))
|
||||
# TODO: fix circular dependency
|
||||
import activitypub
|
||||
actor_as2 = activitypub.postprocess_as2(as2.from_as1(actor_as1), user=user)
|
||||
actor_as2 = activitypub.postprocess_as2(as2.from_as1(actor_as1))
|
||||
# TODO: unify with activitypub.actor()
|
||||
actor_as2.update({
|
||||
'id': user.actor_id(),
|
||||
|
|
32
follow.py
32
follow.py
|
@ -7,7 +7,7 @@ https://www.rfc-editor.org/rfc/rfc7033
|
|||
import logging
|
||||
import urllib.parse
|
||||
|
||||
from flask import redirect, request, session
|
||||
from flask import g, redirect, request, session
|
||||
from granary import as2
|
||||
from oauth_dropins import indieauth
|
||||
from oauth_dropins.webutil import flask_util, util
|
||||
|
@ -81,8 +81,8 @@ def remote_follow():
|
|||
logger.info(f'Got: {request.values}')
|
||||
|
||||
domain = request.values['domain']
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
error(f'No Bridgy Fed user found for domain {domain}')
|
||||
|
||||
addr = request.values['address']
|
||||
|
@ -94,7 +94,7 @@ def remote_follow():
|
|||
if link.get('rel') == SUBSCRIBE_LINK_REL:
|
||||
template = link.get('template')
|
||||
if template and '{uri}' in template:
|
||||
return redirect(template.replace('{uri}', user.address()))
|
||||
return redirect(template.replace('{uri}', g.user.address()))
|
||||
|
||||
flash(f"Couldn't find remote follow link for {addr}")
|
||||
return redirect(f'/user/{domain}')
|
||||
|
@ -134,10 +134,10 @@ class FollowCallback(indieauth.Callback):
|
|||
session['indieauthed-me'] = me
|
||||
|
||||
domain = util.domain_from_link(me)
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
error(f'No user for domain {domain}')
|
||||
domain = user.key.id()
|
||||
domain = g.user.key.id()
|
||||
|
||||
addr = state
|
||||
if not state:
|
||||
|
@ -159,7 +159,7 @@ class FollowCallback(indieauth.Callback):
|
|||
return redirect(f'/user/{domain}/following')
|
||||
|
||||
# TODO: make this generic across protocols
|
||||
followee = activitypub.ActivityPub.get_object(as2_url, user=user).as2
|
||||
followee = activitypub.ActivityPub.get_object(as2_url).as2
|
||||
id = followee.get('id')
|
||||
inbox = followee.get('inbox')
|
||||
if not id or not inbox:
|
||||
|
@ -173,12 +173,12 @@ class FollowCallback(indieauth.Callback):
|
|||
'type': 'Follow',
|
||||
'id': follow_id,
|
||||
'object': followee,
|
||||
'actor': user.actor_id(),
|
||||
'actor': g.user.actor_id(),
|
||||
'to': [as2.PUBLIC_AUDIENCE],
|
||||
}
|
||||
obj = Object(id=follow_id, domains=[domain], labels=['user', 'activity'],
|
||||
source_protocol='ui', status='complete', as2=follow_as2)
|
||||
activitypub.ActivityPub.send(obj, inbox, user=user)
|
||||
activitypub.ActivityPub.send(obj, inbox)
|
||||
|
||||
Follower.get_or_create(dest=id, src=domain, status='active',
|
||||
last_follow=follow_as2)
|
||||
|
@ -221,10 +221,10 @@ class UnfollowCallback(indieauth.Callback):
|
|||
session['indieauthed-me'] = me
|
||||
|
||||
domain = util.domain_from_link(me)
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
error(f'No user for domain {domain}')
|
||||
domain = user.key.id()
|
||||
domain = g.user.key.id()
|
||||
|
||||
follower = Follower.get_by_id(state)
|
||||
if not follower:
|
||||
|
@ -237,7 +237,7 @@ class UnfollowCallback(indieauth.Callback):
|
|||
if isinstance(followee, str):
|
||||
# fetch as AS2 to get full followee with inbox
|
||||
followee_id = followee
|
||||
followee = activitypub.ActivityPub.get_object(followee_id, user=user).as2
|
||||
followee = activitypub.ActivityPub.get_object(followee_id).as2
|
||||
|
||||
inbox = followee.get('inbox')
|
||||
if not inbox:
|
||||
|
@ -250,13 +250,13 @@ class UnfollowCallback(indieauth.Callback):
|
|||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Undo',
|
||||
'id': unfollow_id,
|
||||
'actor': user.actor_id(),
|
||||
'actor': g.user.actor_id(),
|
||||
'object': follower.last_follow,
|
||||
}
|
||||
|
||||
obj = Object(id=unfollow_id, domains=[domain], labels=['user', 'activity'],
|
||||
source_protocol='ui', status='complete', as2=unfollow_as2)
|
||||
activitypub.ActivityPub.send(obj, inbox, user=user)
|
||||
activitypub.ActivityPub.send(obj, inbox)
|
||||
|
||||
follower.status = 'inactive'
|
||||
follower.put()
|
||||
|
|
20
models.py
20
models.py
|
@ -12,7 +12,7 @@ from werkzeug.exceptions import BadRequest, NotFound
|
|||
from Crypto import Random
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Util import number
|
||||
from flask import request
|
||||
from flask import g, request
|
||||
from google.cloud import ndb
|
||||
from granary import as1, as2, bluesky, microformats2
|
||||
from oauth_dropins.webutil.appengine_info import DEBUG
|
||||
|
@ -351,30 +351,26 @@ class Object(StringIdModel):
|
|||
return common.host_url('render?' +
|
||||
urllib.parse.urlencode({'id': self.key.id()}))
|
||||
|
||||
def actor_link(self, user=None):
|
||||
"""Returns a pretty actor link with their name and profile picture.
|
||||
|
||||
Args:
|
||||
cur_user: :class:`User`, optional, user for the current request
|
||||
"""
|
||||
def actor_link(self):
|
||||
"""Returns a pretty actor link with their name and profile picture."""
|
||||
attrs = {'class': 'h-card u-author'}
|
||||
|
||||
if (self.source_protocol in ('webmention', 'ui') and user and
|
||||
user.key.id() in self.domains):
|
||||
if (self.source_protocol in ('webmention', 'ui') and g.user and
|
||||
g.user.key.id() in self.domains):
|
||||
# outbound; show a nice link to the user
|
||||
return user.user_page_link()
|
||||
return g.user.user_page_link()
|
||||
|
||||
actor = (util.get_first(self.as1, 'actor')
|
||||
or util.get_first(self.as1, 'author')
|
||||
or {})
|
||||
if isinstance(actor, str):
|
||||
return common.pretty_link(actor, user=user, attrs=attrs)
|
||||
return common.pretty_link(actor, attrs=attrs)
|
||||
|
||||
url = util.get_first(actor, 'url') or ''
|
||||
name = actor.get('displayName') or actor.get('username') or ''
|
||||
image = util.get_url(actor, 'image')
|
||||
if not image:
|
||||
return common.pretty_link(url, text=name, user=user, attrs=attrs)
|
||||
return common.pretty_link(url, text=name, attrs=attrs)
|
||||
|
||||
return f"""\
|
||||
<a class="h-card u-author" href="{url}" title="{name}">
|
||||
|
|
43
pages.py
43
pages.py
|
@ -6,7 +6,7 @@ import os
|
|||
import re
|
||||
import urllib.parse
|
||||
|
||||
from flask import redirect, render_template, request
|
||||
from flask import g, redirect, render_template, request
|
||||
from google.cloud.ndb.stats import KindStat
|
||||
from granary import as1, as2, atom, microformats2, rss
|
||||
import humanize
|
||||
|
@ -58,9 +58,9 @@ def check_web_site():
|
|||
flash(f'No domain found in {url}')
|
||||
return render_template('enter_web_site.html')
|
||||
|
||||
user = User.get_or_create(domain)
|
||||
g.user = User.get_or_create(domain)
|
||||
try:
|
||||
user = user.verify()
|
||||
g.user = g.user.verify()
|
||||
except BaseException as e:
|
||||
code, body = util.interpret_http_exception(e)
|
||||
if code:
|
||||
|
@ -68,25 +68,25 @@ def check_web_site():
|
|||
return render_template('enter_web_site.html')
|
||||
raise
|
||||
|
||||
user.put()
|
||||
return redirect(f'/user/{user.key.id()}')
|
||||
g.user.put()
|
||||
return redirect(f'/user/{g.user.key.id()}')
|
||||
|
||||
|
||||
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>')
|
||||
def user(domain):
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
return USER_NOT_FOUND_HTML, 404
|
||||
elif user.key.id() != domain:
|
||||
return redirect(f'/user/{user.key.id()}', code=301)
|
||||
elif g.user.key.id() != domain:
|
||||
return redirect(f'/user/{g.user.key.id()}', code=301)
|
||||
|
||||
assert not user.use_instead
|
||||
assert not g.user.use_instead
|
||||
|
||||
query = Object.query(
|
||||
Object.domains == domain,
|
||||
Object.labels.IN(('notification', 'user')),
|
||||
)
|
||||
objects, before, after = fetch_objects(query, user)
|
||||
objects, before, after = fetch_objects(query)
|
||||
|
||||
followers = Follower.query(Follower.dest == domain, Follower.status == 'active')\
|
||||
.count(limit=FOLLOWERS_UI_LIMIT)
|
||||
|
@ -102,13 +102,15 @@ def user(domain):
|
|||
logs=logs,
|
||||
util=util,
|
||||
address=request.args.get('address'),
|
||||
g=g,
|
||||
**locals(),
|
||||
)
|
||||
|
||||
|
||||
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>')
|
||||
def followers_or_following(domain, collection):
|
||||
if not (user := User.get_by_id(domain)): # user var is used in template
|
||||
g.user = User.get_by_id(domain) # g.user is used in template
|
||||
if not g.user:
|
||||
return USER_NOT_FOUND_HTML, 404
|
||||
|
||||
followers, before, after = Follower.fetch_page(domain, collection)
|
||||
|
@ -125,6 +127,7 @@ def followers_or_following(domain, collection):
|
|||
f'{collection}.html',
|
||||
util=util,
|
||||
address=request.args.get('address'),
|
||||
g=g,
|
||||
**locals()
|
||||
)
|
||||
|
||||
|
@ -135,8 +138,9 @@ def feed(domain):
|
|||
if format not in ('html', 'atom', 'rss'):
|
||||
error(f'format {format} not supported; expected html, atom, or rss')
|
||||
|
||||
if not (user := User.get_by_id(domain)):
|
||||
return render_template('user_not_found.html', domain=domain), 404
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
return render_template('user_not_found.html', domain=domain), 404
|
||||
|
||||
objects, _, _ = Object.query(
|
||||
Object.domains == domain, Object.labels == 'feed') \
|
||||
|
@ -146,7 +150,7 @@ def feed(domain):
|
|||
|
||||
actor = {
|
||||
'displayName': domain,
|
||||
'url': user.homepage,
|
||||
'url': g.user.homepage,
|
||||
}
|
||||
title = f'Bridgy Fed feed for {domain}'
|
||||
|
||||
|
@ -155,7 +159,7 @@ def feed(domain):
|
|||
# syntax. maybe a fediverse kwarg down through the call chain?
|
||||
if format == 'html':
|
||||
entries = [microformats2.object_to_html(a) for a in activities]
|
||||
return render_template('feed.html', util=util, **locals())
|
||||
return render_template('feed.html', util=util, g=g, **locals())
|
||||
elif format == 'atom':
|
||||
body = atom.activities_to_atom(activities, actor=actor, title=title,
|
||||
request_url=request.url)
|
||||
|
@ -166,7 +170,7 @@ def feed(domain):
|
|||
return body, {'Content-Type': rss.CONTENT_TYPE}
|
||||
|
||||
|
||||
def fetch_objects(query, user):
|
||||
def fetch_objects(query):
|
||||
"""Fetches a page of Object entities from a datastore query.
|
||||
|
||||
Wraps :func:`common.fetch_page` and adds attributes to the returned Object
|
||||
|
@ -174,7 +178,6 @@ def fetch_objects(query, user):
|
|||
|
||||
Args:
|
||||
query: :class:`ndb.Query`
|
||||
user: :class:`User`
|
||||
|
||||
Returns:
|
||||
(results, new_before, new_after) tuple with:
|
||||
|
@ -228,7 +231,7 @@ def fetch_objects(query, user):
|
|||
'url': f'https://{obj.domains[0]}',
|
||||
})
|
||||
elif url:
|
||||
content = common.pretty_link(url, text=content, user=user)
|
||||
content = common.pretty_link(url, text=content)
|
||||
|
||||
obj.content = (obj.as1.get('content')
|
||||
or obj.as1.get('displayName')
|
||||
|
@ -238,7 +241,7 @@ def fetch_objects(query, user):
|
|||
if (type in ('like', 'follow', 'repost', 'share') or
|
||||
not obj.content):
|
||||
if obj.url:
|
||||
obj.phrase = common.pretty_link(obj.url, text=obj.phrase, user=user,
|
||||
obj.phrase = common.pretty_link(obj.url, text=obj.phrase,
|
||||
attrs={'class': 'u-url'})
|
||||
if content:
|
||||
obj.content = content
|
||||
|
|
41
protocol.py
41
protocol.py
|
@ -3,6 +3,7 @@ import logging
|
|||
import threading
|
||||
|
||||
from cachetools import cached, LRUCache
|
||||
from flask import g
|
||||
from google.cloud import ndb
|
||||
from google.cloud.ndb import OR
|
||||
from granary import as1, as2
|
||||
|
@ -53,7 +54,7 @@ class Protocol:
|
|||
assert False
|
||||
|
||||
@classmethod
|
||||
def send(cls, obj, url, *, user=None, log_data=True):
|
||||
def send(cls, obj, url, log_data=True):
|
||||
"""Sends an outgoing activity.
|
||||
|
||||
To be implemented by subclasses.
|
||||
|
@ -61,7 +62,6 @@ class Protocol:
|
|||
Args:
|
||||
obj: :class:`Object` with activity to send
|
||||
url: str, destination URL to send to
|
||||
user: :class:`User` this is on behalf of
|
||||
log_data: boolean, whether to log full data object
|
||||
|
||||
Returns:
|
||||
|
@ -74,7 +74,7 @@ class Protocol:
|
|||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, obj, id, *, user=None):
|
||||
def fetch(cls, obj, id):
|
||||
"""Fetches a protocol-specific object and populates it into an :class:`Object`.
|
||||
|
||||
To be implemented by subclasses.
|
||||
|
@ -82,7 +82,6 @@ class Protocol:
|
|||
Args:
|
||||
obj: :class:`Object` to populate the fetched object into
|
||||
id: str, object's URL id
|
||||
user: optional :class:`User` we're fetching on behalf of
|
||||
|
||||
Raises:
|
||||
:class:`werkzeug.HTTPException` if the fetch fails
|
||||
|
@ -90,12 +89,11 @@ class Protocol:
|
|||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def receive(cls, id, *, user=None, **props):
|
||||
def receive(cls, id, **props):
|
||||
"""Handles an incoming activity.
|
||||
|
||||
Args:
|
||||
id: str, activity id
|
||||
user: :class:`User`, optional receiving Bridgy Fed user
|
||||
props: property values to populate into the :class:`Object`
|
||||
|
||||
Returns:
|
||||
|
@ -195,18 +193,18 @@ class Protocol:
|
|||
|
||||
# fetch actor if necessary so we have name, profile photo, etc
|
||||
if actor and actor.keys() == set(['id']):
|
||||
actor = obj.as2['actor'] = cls.get_object(actor['id'], user=user).as2
|
||||
actor = obj.as2['actor'] = cls.get_object(actor['id']).as2
|
||||
|
||||
# fetch object if necessary so we can render it in feeds
|
||||
if obj.type == 'share' and inner_obj.keys() == set(['id']):
|
||||
inner_obj = obj.as2['object'] = as2.from_as1(
|
||||
cls.get_object(inner_obj_id, user=user).as1)
|
||||
cls.get_object(inner_obj_id).as1)
|
||||
|
||||
if obj.type == 'follow':
|
||||
cls.accept_follow(obj, user=user)
|
||||
cls.accept_follow(obj)
|
||||
|
||||
# send webmentions to each target
|
||||
send_webmentions(obj, user=user, proxy=True)
|
||||
send_webmentions(obj, proxy=True)
|
||||
|
||||
# deliver original posts and reposts to followers
|
||||
is_reply = (obj.type == 'comment' or
|
||||
|
@ -229,14 +227,13 @@ class Protocol:
|
|||
return 'OK'
|
||||
|
||||
@classmethod
|
||||
def accept_follow(cls, obj, *, user=None):
|
||||
def accept_follow(cls, obj):
|
||||
"""Replies to an AP Follow request with an Accept request.
|
||||
|
||||
TODO: move to Protocol
|
||||
|
||||
Args:
|
||||
obj: :class:`Object`
|
||||
user: :class:`User`
|
||||
"""
|
||||
logger.info('Replying to Follow with Accept')
|
||||
|
||||
|
@ -253,16 +250,16 @@ class Protocol:
|
|||
|
||||
# store Follower
|
||||
follower_obj = models.Follower.get_or_create(
|
||||
dest=user.key.id(), src=follower_id, last_follow=obj.as2)
|
||||
dest=g.user.key.id(), src=follower_id, last_follow=obj.as2)
|
||||
follower_obj.status = 'active'
|
||||
follower_obj.put()
|
||||
|
||||
# send AP Accept
|
||||
followee_actor_url = user.actor_id()
|
||||
followee_actor_url = g.user.actor_id()
|
||||
accept = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': util.tag_uri(common.PRIMARY_DOMAIN,
|
||||
f'accept/{user.key.id()}/{obj.key.id()}'),
|
||||
f'accept/{g.user.key.id()}/{obj.key.id()}'),
|
||||
'type': 'Accept',
|
||||
'actor': followee_actor_url,
|
||||
'object': {
|
||||
|
@ -272,12 +269,12 @@ class Protocol:
|
|||
}
|
||||
}
|
||||
|
||||
return cls.send(models.Object(as2=accept), inbox, user=user)
|
||||
return cls.send(models.Object(as2=accept), inbox)
|
||||
|
||||
@classmethod
|
||||
@cached(LRUCache(1000), key=lambda cls, id, user=None: util.fragmentless(id),
|
||||
@cached(LRUCache(1000), key=lambda cls, id: util.fragmentless(id),
|
||||
lock=threading.Lock())
|
||||
def get_object(cls, id, *, user=None):
|
||||
def get_object(cls, id):
|
||||
"""Loads and returns an Object from memory cache, datastore, or HTTP fetch.
|
||||
|
||||
Assumes id is a URL. Any fragment at the end is stripped before loading.
|
||||
|
@ -294,7 +291,6 @@ class Protocol:
|
|||
|
||||
Args:
|
||||
id: str
|
||||
user: optional, :class:`User` used to sign HTTP request, if necessary
|
||||
|
||||
Returns: Object
|
||||
|
||||
|
@ -313,18 +309,17 @@ class Protocol:
|
|||
|
||||
logger.info(f'Object not in datastore or has no data: {id}')
|
||||
obj.clear()
|
||||
cls.fetch(id, obj, user=user)
|
||||
cls.fetch(id, obj)
|
||||
obj.source_protocol = cls.LABEL
|
||||
obj.put()
|
||||
return obj
|
||||
|
||||
|
||||
def send_webmentions(obj, *, user=None, proxy=None):
|
||||
def send_webmentions(obj, proxy=None):
|
||||
"""Sends webmentions for an incoming ActivityPub inbox delivery.
|
||||
|
||||
Args:
|
||||
obj: :class:`Object`
|
||||
user: :class:`User`
|
||||
proxy: boolean, whether to use our proxy URL as the webmention source
|
||||
|
||||
Returns: boolean, True if any webmentions were sent, False otherwise
|
||||
|
@ -372,7 +367,7 @@ def send_webmentions(obj, *, user=None, proxy=None):
|
|||
while obj.undelivered:
|
||||
target = obj.undelivered.pop()
|
||||
domain = util.domain_from_link(target.uri, minimize=False)
|
||||
if user and domain == user.key.id():
|
||||
if g.user and domain == g.user.key.id():
|
||||
if 'notification' not in obj.labels:
|
||||
obj.labels.append('notification')
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ import logging
|
|||
import re
|
||||
import urllib.parse
|
||||
|
||||
from flask import redirect, request
|
||||
from flask import g, redirect, request
|
||||
from granary import as2
|
||||
from negotiator import ContentNegotiator, AcceptParameters, ContentType
|
||||
from oauth_dropins.webutil import flask_util, util
|
||||
|
@ -58,8 +58,8 @@ def redir(to):
|
|||
urllib.parse.urlparse(to).hostname))
|
||||
for domain in domains:
|
||||
if domain:
|
||||
user = User.get_by_id(domain)
|
||||
if user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if g.user:
|
||||
logger.info(f'Found User for domain {domain}')
|
||||
break
|
||||
else:
|
||||
|
@ -80,8 +80,7 @@ def redir(to):
|
|||
obj = Object.get_by_id(to)
|
||||
if not obj or obj.deleted:
|
||||
return f'Object not found: {to}', 404
|
||||
ret = activitypub.postprocess_as2(as2.from_as1(obj.as1),
|
||||
user=user, create=False)
|
||||
ret = activitypub.postprocess_as2(as2.from_as1(obj.as1), create=False)
|
||||
logger.info(f'Returning: {json_dumps(ret, indent=2)}')
|
||||
return ret, {
|
||||
'Content-Type': type,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{% for obj in objects %}
|
||||
<li class="row h-entry">
|
||||
<div class="e-content col-sm-{{ 5 if show_domains else 8 }}">
|
||||
{{ obj.actor_link(user=user)|safe }}
|
||||
{{ obj.actor_link()|safe }}
|
||||
{{ obj.phrase|safe }}
|
||||
<a target="_blank" href="{{ obj.url }}" class="u-url">
|
||||
{{ obj.content|default('--', true)|striptags|truncate(50) }}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
{% if user.has_redirects == False %}
|
||||
{% if g.user.has_redirects == False %}
|
||||
<div class="row promo warning">
|
||||
<form method="post" action="/web-site">
|
||||
Next step:
|
||||
|
@ -15,15 +15,15 @@
|
|||
<input type="submit" class="btn btn-default" value="Check now" />
|
||||
</form>
|
||||
|
||||
{% if user.redirects_error %}
|
||||
{% if g.user.redirects_error %}
|
||||
<details class="small">
|
||||
{{ user.redirects_error|safe }}
|
||||
{{ g.user.redirects_error|safe }}
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.has_hcard == False %}
|
||||
{% if g.user.has_hcard == False %}
|
||||
<div class="row promo warning">
|
||||
<form method="post" action="/web-site">
|
||||
Next step:
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<div class="row">
|
||||
<div class="big" style="display: inline">
|
||||
{{ user.user_page_link()|safe }}
|
||||
{{ g.user.user_page_link()|safe }}
|
||||
·
|
||||
<span title="Fediverse address">
|
||||
<nobr>
|
||||
<img class="logo" src="/static/fediverse_logo.svg">
|
||||
{{ user.address() }}
|
||||
{{ g.user.address() }}
|
||||
</nobr>
|
||||
</span>
|
||||
·
|
||||
|
|
|
@ -8,6 +8,7 @@ import logging
|
|||
from unittest.mock import ANY, call, patch
|
||||
import urllib.parse
|
||||
|
||||
from flask import g
|
||||
from google.cloud import ndb
|
||||
from granary import as2, microformats2
|
||||
from httpsig import HeaderSigner
|
||||
|
@ -1160,9 +1161,9 @@ class ActivityPubTest(testutil.TestCase):
|
|||
class ActivityPubUtilsTest(testutil.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR)
|
||||
self.app_context = app.test_request_context('/')
|
||||
self.app_context.__enter__()
|
||||
g.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR)
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.__exit__(None, None, None)
|
||||
|
@ -1176,7 +1177,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
}, activitypub.postprocess_as2({
|
||||
'id': 'xyz',
|
||||
'inReplyTo': ['foo', 'bar'],
|
||||
}, user=User(id='site')))
|
||||
}))
|
||||
|
||||
def test_postprocess_as2_multiple_url(self):
|
||||
self.assert_equals({
|
||||
|
@ -1186,7 +1187,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
}, activitypub.postprocess_as2({
|
||||
'id': 'xyz',
|
||||
'url': ['foo', 'bar'],
|
||||
}, user=User(id='site')))
|
||||
}))
|
||||
|
||||
def test_postprocess_as2_multiple_image(self):
|
||||
self.assert_equals({
|
||||
|
@ -1197,9 +1198,10 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
}, activitypub.postprocess_as2({
|
||||
'id': 'xyz',
|
||||
'image': [{'url': 'http://r/foo'}, {'url': 'http://r/bar'}],
|
||||
}, user=User(id='site')))
|
||||
}))
|
||||
|
||||
def test_postprocess_as2_actor_attributedTo(self):
|
||||
g.user = User(id='site')
|
||||
self.assert_equals({
|
||||
'actor': {
|
||||
'id': 'baj',
|
||||
|
@ -1219,7 +1221,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
}, activitypub.postprocess_as2({
|
||||
'attributedTo': [{'id': 'bar'}, {'id': 'baz'}],
|
||||
'actor': {'id': 'baj'},
|
||||
}, user=User(id='site')))
|
||||
}))
|
||||
|
||||
def test_postprocess_as2_note(self):
|
||||
self.assert_equals({
|
||||
|
@ -1235,7 +1237,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
}, activitypub.postprocess_as2({
|
||||
'id': 'xyz',
|
||||
'type': 'Note',
|
||||
}, user=User(id='site')))
|
||||
}))
|
||||
|
||||
def test_postprocess_as2_hashtag(self):
|
||||
"""https://github.com/snarfed/bridgy-fed/issues/45"""
|
||||
|
@ -1253,7 +1255,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
# should leave alone
|
||||
{'type': 'Mention', 'href': 'foo'},
|
||||
],
|
||||
}, user=User(id='site')))
|
||||
}))
|
||||
|
||||
# TODO: make these generic and use FakeProtocol
|
||||
@patch('requests.get')
|
||||
|
@ -1334,7 +1336,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
allow_redirects=False),
|
||||
requests_response(status=200, allow_redirects=False),
|
||||
]
|
||||
resp = activitypub.signed_get('https://first', user=self.user)
|
||||
resp = activitypub.signed_get('https://first')
|
||||
|
||||
first = mock_get.call_args_list[0][1]
|
||||
second = mock_get.call_args_list[1][1]
|
||||
|
@ -1350,7 +1352,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
allow_redirects=False),
|
||||
]
|
||||
|
||||
resp = activitypub.signed_post('https://first', user=self.user)
|
||||
resp = activitypub.signed_post('https://first')
|
||||
mock_post.assert_called_once()
|
||||
self.assertEqual(302, resp.status_code)
|
||||
|
||||
|
@ -1359,7 +1361,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
mock_get.return_value = AS2
|
||||
obj = Object()
|
||||
|
||||
ActivityPub.fetch('http://orig', obj, user=self.user)
|
||||
ActivityPub.fetch('http://orig', obj)
|
||||
self.assertEqual(AS2_OBJ, obj.as2)
|
||||
mock_get.assert_has_calls((
|
||||
self.as2_req('http://orig'),
|
||||
|
@ -1370,7 +1372,7 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
mock_get.side_effect = [HTML_WITH_AS2, AS2]
|
||||
obj = Object()
|
||||
|
||||
ActivityPub.fetch('http://orig', obj, user=self.user)
|
||||
ActivityPub.fetch('http://orig', obj)
|
||||
self.assertEqual(AS2_OBJ, obj.as2)
|
||||
mock_get.assert_has_calls((
|
||||
self.as2_req('http://orig'),
|
||||
|
@ -1381,19 +1383,19 @@ class ActivityPubUtilsTest(testutil.TestCase):
|
|||
def test_fetch_only_html(self, mock_get):
|
||||
mock_get.return_value = HTML
|
||||
with self.assertRaises(BadGateway):
|
||||
ActivityPub.fetch('http://orig', Object(), user=self.user)
|
||||
ActivityPub.fetch('http://orig', Object())
|
||||
|
||||
@patch('requests.get')
|
||||
def test_fetch_not_acceptable(self, mock_get):
|
||||
mock_get.return_value=NOT_ACCEPTABLE
|
||||
with self.assertRaises(BadGateway):
|
||||
ActivityPub.fetch('http://orig', Object(), user=self.user)
|
||||
ActivityPub.fetch('http://orig', Object())
|
||||
|
||||
@patch('requests.get')
|
||||
def test_fetch_ssl_error(self, mock_get):
|
||||
mock_get.side_effect = requests.exceptions.SSLError
|
||||
with self.assertRaises(BadGateway):
|
||||
ActivityPub.fetch('http://orig', Object(), user=self.user)
|
||||
ActivityPub.fetch('http://orig', Object())
|
||||
|
||||
@patch('requests.get')
|
||||
def test_fetch_datastore_no_content(self, mock_get):
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""Unit tests for common.py."""
|
||||
from unittest import mock
|
||||
|
||||
from flask import g
|
||||
from granary import as2
|
||||
from oauth_dropins.webutil import appengine_config, util
|
||||
from oauth_dropins.webutil.testutil import requests_response
|
||||
|
@ -24,6 +25,7 @@ class CommonTest(testutil.TestCase):
|
|||
super().setUp()
|
||||
self.app_context = app.test_request_context('/')
|
||||
self.app_context.push()
|
||||
g.user = User(id='site')
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
|
@ -46,7 +48,7 @@ class CommonTest(testutil.TestCase):
|
|||
|
||||
self.assertEqual(
|
||||
'<a class="h-card u-author" href="/user/site"><img src="" class="profile"> site</a>',
|
||||
common.pretty_link('https://site/', user=self.user))
|
||||
common.pretty_link('https://site/'))
|
||||
|
||||
def test_redirect_wrap_empty(self):
|
||||
self.assertIsNone(common.redirect_wrap(None))
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"""Unit tests for models.py."""
|
||||
from unittest import mock
|
||||
|
||||
from flask import get_flashed_messages
|
||||
from flask import g, get_flashed_messages
|
||||
from granary import as2
|
||||
from oauth_dropins.webutil.testutil import requests_response
|
||||
|
||||
|
@ -17,76 +17,80 @@ from .test_activitypub import ACTOR
|
|||
class UserTest(testutil.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(UserTest, self).setUp()
|
||||
self.user = self.make_user('y.z')
|
||||
super().setUp()
|
||||
self.app_context = app.test_request_context('/')
|
||||
self.app_context.push()
|
||||
g.user = self.make_user('y.z')
|
||||
|
||||
self.full_redir = requests_response(
|
||||
status=302,
|
||||
redirected_url='http://localhost/.well-known/webfinger?resource=acct:y.z@y.z')
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
super().tearDown()
|
||||
|
||||
def test_get_or_create(self):
|
||||
assert self.user.mod
|
||||
assert self.user.public_exponent
|
||||
assert self.user.private_exponent
|
||||
assert g.user.mod
|
||||
assert g.user.public_exponent
|
||||
assert g.user.private_exponent
|
||||
|
||||
same = User.get_or_create('y.z')
|
||||
self.assertEqual(same, self.user)
|
||||
self.assertEqual(same, g.user)
|
||||
|
||||
def test_get_or_create_use_instead(self):
|
||||
user = User.get_or_create('a.b')
|
||||
user.use_instead = self.user.key
|
||||
user.use_instead = g.user.key
|
||||
user.put()
|
||||
|
||||
self.assertEqual('y.z', User.get_or_create('a.b').key.id())
|
||||
|
||||
def test_href(self):
|
||||
href = self.user.href()
|
||||
href = g.user.href()
|
||||
self.assertTrue(href.startswith('data:application/magic-public-key,RSA.'), href)
|
||||
self.assertIn(self.user.mod, href)
|
||||
self.assertIn(self.user.public_exponent, href)
|
||||
self.assertIn(g.user.mod, href)
|
||||
self.assertIn(g.user.public_exponent, href)
|
||||
|
||||
def test_public_pem(self):
|
||||
pem = self.user.public_pem()
|
||||
pem = g.user.public_pem()
|
||||
self.assertTrue(pem.decode().startswith('-----BEGIN PUBLIC KEY-----\n'), pem)
|
||||
self.assertTrue(pem.decode().endswith('-----END PUBLIC KEY-----'), pem)
|
||||
|
||||
def test_private_pem(self):
|
||||
pem = self.user.private_pem()
|
||||
pem = g.user.private_pem()
|
||||
self.assertTrue(pem.decode().startswith('-----BEGIN RSA PRIVATE KEY-----\n'), pem)
|
||||
self.assertTrue(pem.decode().endswith('-----END RSA PRIVATE KEY-----'), pem)
|
||||
|
||||
def test_address(self):
|
||||
self.assertEqual('@y.z@y.z', self.user.address())
|
||||
self.assertEqual('@y.z@y.z', g.user.address())
|
||||
|
||||
self.user.actor_as2 = {'type': 'Person'}
|
||||
self.assertEqual('@y.z@y.z', self.user.address())
|
||||
g.user.actor_as2 = {'type': 'Person'}
|
||||
self.assertEqual('@y.z@y.z', g.user.address())
|
||||
|
||||
self.user.actor_as2 = {'url': 'http://foo'}
|
||||
self.assertEqual('@y.z@y.z', self.user.address())
|
||||
g.user.actor_as2 = {'url': 'http://foo'}
|
||||
self.assertEqual('@y.z@y.z', g.user.address())
|
||||
|
||||
self.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@y.z']}
|
||||
self.assertEqual('@baz@y.z', self.user.address())
|
||||
g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@y.z']}
|
||||
self.assertEqual('@baz@y.z', g.user.address())
|
||||
|
||||
def test_actor_id(self):
|
||||
with app.test_request_context('/'):
|
||||
self.assertEqual('http://localhost/y.z', self.user.actor_id())
|
||||
self.assertEqual('http://localhost/y.z', g.user.actor_id())
|
||||
|
||||
def _test_verify(self, redirects, hcard, actor, redirects_error=None):
|
||||
with app.test_request_context('/'):
|
||||
got = self.user.verify()
|
||||
self.assertEqual(self.user.key, got.key)
|
||||
got = g.user.verify()
|
||||
self.assertEqual(g.user.key, got.key)
|
||||
|
||||
with self.subTest(redirects=redirects, hcard=hcard, actor=actor,
|
||||
redirects_error=redirects_error):
|
||||
self.assert_equals(redirects, bool(self.user.has_redirects))
|
||||
self.assert_equals(hcard, bool(self.user.has_hcard))
|
||||
self.assert_equals(redirects, bool(g.user.has_redirects))
|
||||
self.assert_equals(hcard, bool(g.user.has_hcard))
|
||||
if actor is None:
|
||||
self.assertIsNone(self.user.actor_as2)
|
||||
self.assertIsNone(g.user.actor_as2)
|
||||
else:
|
||||
got = {k: v for k, v in self.user.actor_as2.items()
|
||||
got = {k: v for k, v in g.user.actor_as2.items()
|
||||
if k in actor}
|
||||
self.assert_equals(actor, got)
|
||||
self.assert_equals(redirects_error, self.user.redirects_error)
|
||||
self.assert_equals(redirects_error, g.user.redirects_error)
|
||||
|
||||
@mock.patch('requests.get')
|
||||
def test_verify_neither(self, mock_get):
|
||||
|
@ -176,9 +180,8 @@ http://this/404s
|
|||
empty, empty,
|
||||
]
|
||||
|
||||
with app.test_request_context('/'):
|
||||
got = www_user.verify()
|
||||
self.assertEqual('y.z', got.key.id())
|
||||
got = www_user.verify()
|
||||
self.assertEqual('y.z', got.key.id())
|
||||
|
||||
root_user = User.get_by_id('y.z')
|
||||
self.assertEqual(root_user.key, www_user.key.get().use_instead)
|
||||
|
@ -240,50 +243,57 @@ http://this/404s
|
|||
})
|
||||
|
||||
def test_homepage(self):
|
||||
self.assertEqual('https://y.z/', self.user.homepage)
|
||||
self.assertEqual('https://y.z/', g.user.homepage)
|
||||
|
||||
def test_is_homepage(self):
|
||||
for url in 'y.z', '//y.z', 'http://y.z', 'https://y.z':
|
||||
self.assertTrue(self.user.is_homepage(url), url)
|
||||
self.assertTrue(g.user.is_homepage(url), url)
|
||||
|
||||
for url in None, '', 'y', 'z', 'z.z', 'ftp://y.z', 'http://y', '://y.z':
|
||||
self.assertFalse(self.user.is_homepage(url), url)
|
||||
self.assertFalse(g.user.is_homepage(url), url)
|
||||
|
||||
|
||||
class ObjectTest(testutil.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app_context = app.test_request_context('/')
|
||||
self.app_context.push()
|
||||
g.user = None
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
super().tearDown()
|
||||
|
||||
def test_proxy_url(self):
|
||||
with app.test_request_context('/'):
|
||||
obj = Object(id='abc', as2={})
|
||||
self.assertEqual('http://localhost/render?id=abc', obj.proxy_url())
|
||||
obj = Object(id='abc', as2={})
|
||||
self.assertEqual('http://localhost/render?id=abc', obj.proxy_url())
|
||||
|
||||
def test_actor_link(self):
|
||||
with app.test_request_context('/'):
|
||||
for expected, as2 in (
|
||||
('href="">', {}),
|
||||
('href="http://foo">foo', {'actor': 'http://foo'}),
|
||||
('href="">Alice', {'actor': {'name': 'Alice'}}),
|
||||
('href="http://foo">Alice', {'actor': {
|
||||
'name': 'Alice',
|
||||
'url': 'http://foo',
|
||||
}}),
|
||||
("""\
|
||||
title="Alice">
|
||||
<img class="profile" src="http://pic" />
|
||||
Alice""", {'actor': {
|
||||
'name': 'Alice',
|
||||
'icon': {'type': 'Image', 'url': 'http://pic'},
|
||||
}}),
|
||||
):
|
||||
obj = Object(id='x', as2=as2)
|
||||
self.assert_multiline_in(expected, obj.actor_link())
|
||||
for expected, as2 in (
|
||||
('href="">', {}),
|
||||
('href="http://foo">foo', {'actor': 'http://foo'}),
|
||||
('href="">Alice', {'actor': {'name': 'Alice'}}),
|
||||
('href="http://foo">Alice', {'actor': {
|
||||
'name': 'Alice',
|
||||
'url': 'http://foo',
|
||||
}}),
|
||||
("""\
|
||||
title="Alice">
|
||||
<img class="profile" src="http://pic" />
|
||||
Alice""", {'actor': {
|
||||
'name': 'Alice',
|
||||
'icon': {'type': 'Image', 'url': 'http://pic'},
|
||||
}}),
|
||||
):
|
||||
obj = Object(id='x', as2=as2)
|
||||
self.assert_multiline_in(expected, obj.actor_link())
|
||||
|
||||
def test_actor_link_user(self):
|
||||
user = User(id='user.com', actor_as2={"name": "Alice"})
|
||||
g.user = User(id='user.com', actor_as2={"name": "Alice"})
|
||||
obj = Object(id='x', source_protocol='ui', domains=['user.com'])
|
||||
self.assertIn(
|
||||
'href="/user/user.com"><img src="" class="profile"> Alice</a>',
|
||||
obj.actor_link(user))
|
||||
obj.actor_link())
|
||||
|
||||
def test_put_updates_get_object_cache(self):
|
||||
obj = Object(id='x', as2={})
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""Unit tests for protocol.py."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import g
|
||||
from oauth_dropins.webutil.testutil import requests_response
|
||||
import requests
|
||||
|
||||
|
@ -29,6 +30,7 @@ class ProtocolTest(testutil.TestCase):
|
|||
self.user = self.make_user('foo.com', has_hcard=True)
|
||||
self.app_context = app.test_request_context('/')
|
||||
self.app_context.__enter__()
|
||||
g.user = None
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.__exit__(None, None, None)
|
||||
|
@ -41,7 +43,7 @@ class ProtocolTest(testutil.TestCase):
|
|||
# user.com webmention discovery
|
||||
mock_get.return_value = requests_response('<html></html>')
|
||||
|
||||
Protocol.receive(REPLY['id'], user=self.user, as2=REPLY)
|
||||
Protocol.receive(REPLY['id'], as2=REPLY)
|
||||
|
||||
self.assert_object(REPLY['id'],
|
||||
as2=REPLY,
|
||||
|
|
|
@ -4,6 +4,7 @@ import datetime
|
|||
import unittest
|
||||
from unittest.mock import ANY, call
|
||||
|
||||
from flask import g
|
||||
from granary import as2
|
||||
from granary.tests.test_as1 import (
|
||||
COMMENT,
|
||||
|
@ -29,11 +30,11 @@ class FakeProtocol(protocol.Protocol):
|
|||
LABEL = 'fake'
|
||||
|
||||
@classmethod
|
||||
def send(cls, url, activity, *, user=None, log_data=True):
|
||||
def send(cls, url, activity, log_data=True):
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, id, obj, *, user=None):
|
||||
def fetch(cls, id, obj):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
|
|
14
webfinger.py
14
webfinger.py
|
@ -8,7 +8,7 @@ import logging
|
|||
import re
|
||||
import urllib.parse
|
||||
|
||||
from flask import render_template, request
|
||||
from flask import g, render_template, request
|
||||
from granary import as2
|
||||
from oauth_dropins.webutil import flask_util, util
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
|
@ -38,22 +38,22 @@ class Actor(flask_util.XrdOrJrd):
|
|||
if domain.split('.')[-1] in NON_TLDS:
|
||||
error(f"{domain} doesn't look like a domain", status=404)
|
||||
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
error(f'No user for {domain}', status=404)
|
||||
|
||||
logger.info(f'Generating WebFinger data for {domain}')
|
||||
actor = user.to_as1() or {}
|
||||
actor = g.user.to_as1() or {}
|
||||
logger.info(f'AS1 actor: {actor}')
|
||||
urls = util.dedupe_urls(util.get_list(actor, 'urls') +
|
||||
util.get_list(actor, 'url') +
|
||||
[user.homepage])
|
||||
[g.user.homepage])
|
||||
logger.info(f'URLs: {urls}')
|
||||
canonical_url = urls[0]
|
||||
|
||||
# generate webfinger content
|
||||
data = util.trim_nulls({
|
||||
'subject': 'acct:' + user.address().lstrip('@'),
|
||||
'subject': 'acct:' + g.user.address().lstrip('@'),
|
||||
'aliases': urls,
|
||||
'links':
|
||||
[{
|
||||
|
@ -80,7 +80,7 @@ class Actor(flask_util.XrdOrJrd):
|
|||
# WARNING: in python 2 sometimes request.host_url lost port,
|
||||
# http://localhost:8080 would become just http://localhost. no
|
||||
# clue how or why. pay attention here if that happens again.
|
||||
'href': user.actor_id(),
|
||||
'href': g.user.actor_id(),
|
||||
}, {
|
||||
# AP reads this and sharedInbox from the AS2 actor, not
|
||||
# webfinger, so strictly speaking, it's probably not needed here.
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
"""Handles inbound webmentions.
|
||||
|
||||
TODO tests:
|
||||
* actor/attributedTo could be string URL
|
||||
"""
|
||||
"""Handles inbound webmentions."""
|
||||
import logging
|
||||
import urllib.parse
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import feedparser
|
||||
from flask import redirect, request
|
||||
from flask import g, redirect, request
|
||||
from flask.views import View
|
||||
from google.cloud.ndb import Key
|
||||
from granary import as1, as2, microformats2
|
||||
|
@ -39,7 +35,7 @@ class Webmention(View):
|
|||
LABEL = 'webmention'
|
||||
|
||||
@classmethod
|
||||
def send(cls, obj, url, *, user=None):
|
||||
def send(cls, obj, url):
|
||||
"""Sends a webmention to a given target URL."""
|
||||
source_url = obj.proxy_url()
|
||||
logger.info(f'Sending webmention from {source_url} to {url}')
|
||||
|
@ -54,7 +50,7 @@ class Webmention(View):
|
|||
return False
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, obj, id, *, user=None):
|
||||
def fetch(cls, obj, id):
|
||||
"""Fetches a URL over HTTP."""
|
||||
parsed = util.fetch_mf2(id, gateway=True)
|
||||
obj.mf2 = mf2util.find_first_entry(parsed, ['h-entry'])
|
||||
|
@ -71,7 +67,6 @@ class WebmentionView(View):
|
|||
source_mf2 = None # parsed mf2 dict
|
||||
source_as1 = None # AS1 dict
|
||||
source_as2 = None # AS2 dict
|
||||
user = None # User
|
||||
|
||||
def dispatch_request(self):
|
||||
logger.info(f'Params: {list(request.form.items())}')
|
||||
|
@ -80,14 +75,14 @@ class WebmentionView(View):
|
|||
domain = util.domain_from_link(source, minimize=False)
|
||||
logger.info(f'webmention from {domain}')
|
||||
|
||||
self.user = User.get_by_id(domain)
|
||||
if not self.user:
|
||||
g.user = User.get_by_id(domain)
|
||||
if not g.user:
|
||||
error(f'No user found for domain {domain}')
|
||||
|
||||
# if source is home page, send an actor Update to followers' instances
|
||||
if self.user.is_homepage(source):
|
||||
if g.user.is_homepage(source):
|
||||
self.source_url = source
|
||||
self.source_mf2, actor_as1, actor_as2 = common.actor(self.user)
|
||||
self.source_mf2, actor_as1, actor_as2 = common.actor(g.user)
|
||||
id = common.host_url(f'{source}#update-{util.now().isoformat()}')
|
||||
self.source_as1 = {
|
||||
'objectType': 'activity',
|
||||
|
@ -102,7 +97,7 @@ class WebmentionView(View):
|
|||
'id': id,
|
||||
'url': id,
|
||||
'object': actor_as2,
|
||||
}, user=self.user)
|
||||
})
|
||||
return self.try_activitypub() or 'No ActivityPub targets'
|
||||
|
||||
# fetch source page
|
||||
|
@ -194,7 +189,7 @@ class WebmentionView(View):
|
|||
status='in progress')
|
||||
|
||||
obj.populate(
|
||||
domains=[self.user.key.id()],
|
||||
domains=[g.user.key.id()],
|
||||
source_protocol='webmention',
|
||||
labels=['user'],
|
||||
)
|
||||
|
@ -221,12 +216,12 @@ class WebmentionView(View):
|
|||
|
||||
if not self.source_as2:
|
||||
self.source_as2 = activitypub.postprocess_as2(
|
||||
as2.from_as1(self.source_as1), target=target_as2, user=self.user)
|
||||
as2.from_as1(self.source_as1), target=target_as2)
|
||||
|
||||
orig_actor = self.source_as2.get('actor')
|
||||
if orig_actor:
|
||||
logging.info(f'Overriding actor with {self.user.actor_id()}; was {orig_actor}')
|
||||
self.source_as2['actor'] = self.user.actor_id()
|
||||
logging.info(f'Overriding actor with {g.user.actor_id()}; was {orig_actor}')
|
||||
self.source_as2['actor'] = g.user.actor_id()
|
||||
|
||||
if changed:
|
||||
self.source_as2['type'] = 'Update'
|
||||
|
@ -245,14 +240,14 @@ class WebmentionView(View):
|
|||
# https://github.com/snarfed/bridgy-fed/issues/307
|
||||
dest = ((target_as2.get('id') or util.get_first(target_as2, 'url'))
|
||||
if target_as2 else util.get_url(self.source_as1, 'object'))
|
||||
Follower.get_or_create(dest=dest, src=self.user.key.id(),
|
||||
Follower.get_or_create(dest=dest, src=g.user.key.id(),
|
||||
last_follow=self.source_as2)
|
||||
|
||||
try:
|
||||
last = activitypub.ActivityPub.send(
|
||||
# TODO: use obj
|
||||
Object(as2=self.source_as2),
|
||||
inbox, user=self.user, log_data=log_data)
|
||||
inbox, log_data=log_data)
|
||||
obj.delivered.append(target)
|
||||
last_success = last
|
||||
except BaseException as e:
|
||||
|
@ -315,13 +310,13 @@ class WebmentionView(View):
|
|||
)
|
||||
# not actually an error
|
||||
msg = ("Updating profile on followers' instances..."
|
||||
if self.user.is_homepage(self.source_url)
|
||||
if g.user.is_homepage(self.source_url)
|
||||
else 'Delivering to followers...')
|
||||
# TODO: switch this to return so that it doesn't log error
|
||||
error(msg, status=202)
|
||||
|
||||
inboxes = set()
|
||||
domain = self.user.key.id()
|
||||
domain = g.user.key.id()
|
||||
for follower in Follower.query().filter(
|
||||
Follower.key > Key('Follower', domain + ' '),
|
||||
Follower.key < Key('Follower', domain + chr(ord(' ') + 1))):
|
||||
|
@ -343,7 +338,7 @@ class WebmentionView(View):
|
|||
# fetch target page as AS2 object
|
||||
try:
|
||||
# TODO: make this generic across protocols
|
||||
target_obj = activitypub.ActivityPub.get_object(target, user=self.user).as2
|
||||
target_obj = activitypub.ActivityPub.get_object(target).as2
|
||||
except (requests.HTTPError, BadGateway) as e:
|
||||
resp = getattr(e, 'requests_response', None)
|
||||
if resp and resp.ok:
|
||||
|
@ -368,7 +363,7 @@ class WebmentionView(View):
|
|||
if not inbox_url:
|
||||
# fetch actor as AS object
|
||||
# TODO: make this generic across protocols
|
||||
actor = activitypub.ActivityPub.get_object(actor, user=self.user).as2
|
||||
actor = activitypub.ActivityPub.get_object(actor).as2
|
||||
inbox_url = actor.get('inbox')
|
||||
|
||||
if not inbox_url:
|
||||
|
@ -399,7 +394,7 @@ class WebmentionInteractive(WebmentionView):
|
|||
flash('OK')
|
||||
except HTTPException as e:
|
||||
flash(util.linkify(str(e.description), pretty=True))
|
||||
path = f'/user/{self.user.key.id()}' if self.user else '/'
|
||||
path = f'/user/{g.user.key.id()}' if g.user else '/'
|
||||
return redirect(path, code=302)
|
||||
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import logging
|
|||
import json
|
||||
import re
|
||||
|
||||
from flask import g
|
||||
from granary import microformats2, bluesky
|
||||
import mf2util
|
||||
from oauth_dropins.webutil import util
|
||||
|
@ -23,13 +24,13 @@ def getProfile(input, actor=None):
|
|||
if not actor or not re.match(util.DOMAIN_RE, actor):
|
||||
raise ValueError(f'{actor} is not a domain')
|
||||
|
||||
user = User.get_by_id(actor)
|
||||
if not user:
|
||||
g.user = User.get_by_id(actor)
|
||||
if not g.user:
|
||||
raise ValueError(f'User {actor} not found')
|
||||
elif not user.actor_as2:
|
||||
elif not g.user.actor_as2:
|
||||
return ValueError(f'User {actor} not fully set up')
|
||||
|
||||
actor_as1 = user.to_as1()
|
||||
actor_as1 = g.user.to_as1()
|
||||
logger.info(f'AS1 actor: {json.dumps(actor_as1, indent=2)}')
|
||||
|
||||
profile = {
|
||||
|
|
13
xrpc_feed.py
13
xrpc_feed.py
|
@ -3,6 +3,7 @@ import json
|
|||
import logging
|
||||
import re
|
||||
|
||||
from flask import g
|
||||
from granary import bluesky, microformats2
|
||||
import mf2util
|
||||
from oauth_dropins.webutil import util
|
||||
|
@ -21,10 +22,10 @@ def getAuthorFeed(input, author=None, limit=None, before=None):
|
|||
if not author or not re.match(util.DOMAIN_RE, author):
|
||||
raise ValueError(f'{author} is not a domain')
|
||||
|
||||
user = User.get_by_id(author)
|
||||
if not user:
|
||||
g.user = User.get_by_id(author)
|
||||
if not g.user:
|
||||
raise ValueError(f'User {author} not found')
|
||||
elif not user.actor_as2:
|
||||
elif not g.user.actor_as2:
|
||||
return ValueError(f'User {author} not fully set up')
|
||||
|
||||
# TODO: unify with pages.feed?
|
||||
|
@ -96,11 +97,11 @@ def getTimeline(input, algorithm=None, limit=50, before=None):
|
|||
lexicons/app/bsky/feed/getTimeline.json
|
||||
"""
|
||||
# TODO: how to get authed user?
|
||||
user = 'user.com'
|
||||
domain = 'user.com'
|
||||
|
||||
# TODO: de-dupe with pages.feed()
|
||||
logger.info(f'Fetching {limit} objects for {user}')
|
||||
objects, _, _ = Object.query(Object.domains == user, Object.labels == 'feed') \
|
||||
logger.info(f'Fetching {limit} objects for {domain}')
|
||||
objects, _, _ = Object.query(Object.domains == domain, Object.labels == 'feed') \
|
||||
.order(-Object.created) \
|
||||
.fetch_page(limit)
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue