kopia lustrzana https://github.com/snarfed/bridgy-fed
				
				
				
			
		
			
				
	
	
		
			464 wiersze
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			464 wiersze
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
| """UI pages."""
 | |
| import datetime
 | |
| import itertools
 | |
| import logging
 | |
| import os
 | |
| import re
 | |
| import time
 | |
| 
 | |
| from flask import render_template, request
 | |
| from google.cloud.ndb import tasklets
 | |
| from google.cloud.ndb.query import AND, OR
 | |
| from granary import as1, as2, atom, microformats2, rss
 | |
| from oauth_dropins.webutil import flask_util, logs, util
 | |
| from oauth_dropins.webutil.flask_util import (
 | |
|     canonicalize_request_domain,
 | |
|     error,
 | |
|     flash,
 | |
| )
 | |
| import requests
 | |
| import werkzeug.exceptions
 | |
| from werkzeug.exceptions import NotFound
 | |
| 
 | |
| from activitypub import ActivityPub, instance_actor
 | |
| from atproto import ATProto
 | |
| import common
 | |
| from common import CACHE_CONTROL, DOMAIN_RE, PROTOCOL_DOMAINS
 | |
| from flask_app import app
 | |
| from flask import redirect
 | |
| import ids
 | |
| import memcache
 | |
| from models import fetch_objects, fetch_page, Follower, Object, PAGE_SIZE, PROTOCOLS
 | |
| from protocol import Protocol
 | |
| from web import Web
 | |
| 
 | |
| # precompute this because we get a ton of requests for non-existing users
 | |
| # from weird open redirect referrers:
 | |
| # https://github.com/snarfed/bridgy-fed/issues/422
 | |
| with app.test_request_context('/'):
 | |
|     USER_NOT_FOUND_HTML = render_template('user_not_found.html')
 | |
| 
 | |
| logger = logging.getLogger(__name__)
 | |
| 
 | |
| TEMPLATE_VARS = {
 | |
|     'as1': as1,
 | |
|     'as2': as2,
 | |
|     'ids': ids,
 | |
|     'isinstance': isinstance,
 | |
|     'logs': logs,
 | |
|     'PROTOCOLS': PROTOCOLS,
 | |
|     'set': set,
 | |
|     'util': util,
 | |
| }
 | |
| 
 | |
| 
 | |
| def load_user(protocol, id):
 | |
|     """Loads and returns the current request's user.
 | |
| 
 | |
|     Args:
 | |
|       protocol (str):
 | |
|       id (str):
 | |
| 
 | |
|     Returns:
 | |
|       models.User:
 | |
| 
 | |
|     Raises:
 | |
|       :class:`werkzeug.exceptions.HTTPException` on error or redirect
 | |
|     """
 | |
|     assert id
 | |
| 
 | |
|     if id in PROTOCOL_DOMAINS:
 | |
|         error(f'{protocol} user {id} not found', status=404)
 | |
| 
 | |
|     cls = PROTOCOLS[protocol]
 | |
| 
 | |
|     if cls.ABBREV == 'ap' and not id.startswith('@'):
 | |
|         id = '@' + id
 | |
|     elif cls.ABBREV == 'bsky':
 | |
|         id = id.removeprefix('@')
 | |
| 
 | |
|     user = cls.get_by_id(id)
 | |
| 
 | |
|     if not user and cls.ABBREV != 'web':
 | |
|         # query by handle, except for web. Web.handle is custom username, which
 | |
|         # isn't unique
 | |
|         user = cls.query(cls.handle == id, cls.status == None).get()
 | |
|         if user and user.use_instead:
 | |
|             user = user.use_instead.get()
 | |
| 
 | |
|     if user and id not in (user.key.id(), user.handle):
 | |
|         error('', status=302, location=user.user_page_path())
 | |
| 
 | |
|     if user and not user.status and (user.enabled_protocols
 | |
|                                      or user.DEFAULT_SERVE_USER_PAGES):
 | |
|         assert not user.use_instead
 | |
|         return user
 | |
| 
 | |
|     # TODO: switch back to USER_NOT_FOUND_HTML
 | |
|     # not easy via exception/abort because this uses Werkzeug's built in
 | |
|     # NotFound exception subclass, and we'd need to make it implement
 | |
|     # get_body to return arbitrary HTML.
 | |
|     error(f'{protocol} user {id} not found', status=404)
 | |
| 
 | |
| 
 | |
| @app.route('/')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| @flask_util.headers(CACHE_CONTROL)
 | |
| def front_page():
 | |
|     """View for the front page."""
 | |
|     return render_template('index.html')
 | |
| 
 | |
| 
 | |
| @app.route('/docs')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| @flask_util.headers(CACHE_CONTROL)
 | |
| def docs():
 | |
|     """View for the docs page."""
 | |
|     return render_template('docs.html')
 | |
| 
 | |
| 
 | |
| @app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>')
 | |
| # WARNING: this overrides the /ap/... actor URL route in activitypub.py, *only*
 | |
| # for handles with leading @ character. be careful when changing this route!
 | |
| @app.get(f'/ap/@<id>', defaults={'protocol': 'ap'})
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| def profile(protocol, id):
 | |
|     user = load_user(protocol, id)
 | |
|     query = Object.query(Object.users == user.key)
 | |
|     objects, before, after = fetch_objects(query, by=Object.updated, user=user)
 | |
|     num_followers, num_following = user.count_followers()
 | |
|     return render_template('profile.html', **TEMPLATE_VARS, **locals())
 | |
| 
 | |
| 
 | |
| @app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/home')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| def home(protocol, id):
 | |
|     user = load_user(protocol, id)
 | |
|     query = Object.query(Object.feed == user.key)
 | |
|     objects, before, after = fetch_objects(query, by=Object.created, user=user)
 | |
| 
 | |
|     # this calls Object.actor_link serially for each object, which loads the
 | |
|     # actor from the datastore if necessary. TODO: parallelize those fetches
 | |
|     return render_template('home.html', **TEMPLATE_VARS, **locals())
 | |
| 
 | |
| 
 | |
| @app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/notifications')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| def notifications(protocol, id):
 | |
|     user = load_user(protocol, id)
 | |
| 
 | |
|     query = Object.query(Object.notify == user.key)
 | |
|     objects, before, after = fetch_objects(query, by=Object.updated, user=user)
 | |
| 
 | |
|     format = request.args.get('format')
 | |
|     if format:
 | |
|         return serve_feed(objects=objects, format=format, as_snippets=True,
 | |
|                           user=user, title=f'Bridgy Fed notifications for {id}',
 | |
|                           quiet=request.args.get('quiet'))
 | |
| 
 | |
|     # notifications tab UI page
 | |
|     return render_template('notifications.html', **TEMPLATE_VARS, **locals())
 | |
| 
 | |
| 
 | |
| @app.get(f'/user-page')
 | |
| @flask_util.headers(CACHE_CONTROL)
 | |
| def find_user_page_form():
 | |
|     return render_template('find_user_page.html')
 | |
| 
 | |
| 
 | |
| @app.post(f'/user-page')
 | |
| def find_user_page():
 | |
|     id = request.form['id']
 | |
| 
 | |
|     proto = Protocol.for_id(id)
 | |
| 
 | |
|     resolved_id = None
 | |
|     if not proto:
 | |
|         proto, resolved_id = Protocol.for_handle(id)
 | |
|         if not proto:
 | |
|             flash(f"Couldn't determine network for {id}.")
 | |
|             return render_template('find_user_page.html'), 404
 | |
| 
 | |
|     try:
 | |
|         user = load_user(proto.LABEL, resolved_id or id)
 | |
|     except NotFound:
 | |
|         flash(f"User {id} on {proto.PHRASE} isn't signed up.")
 | |
|         return render_template('find_user_page.html'), 404
 | |
| 
 | |
|     return redirect(user.user_page_path(), code=302)
 | |
| 
 | |
| 
 | |
| @app.post(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/update-profile')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| def update_profile(protocol, id):
 | |
|     user = load_user(protocol, id)
 | |
|     link = f'<a href="{user.web_url()}">{user.handle_or_id()}</a>'
 | |
|     redir = redirect(user.user_page_path(), code=302)
 | |
| 
 | |
|     try:
 | |
|         user.reload_profile()
 | |
|     except (requests.RequestException, werkzeug.exceptions.HTTPException) as e:
 | |
|         _, msg = util.interpret_http_exception(e)
 | |
|         flash(f"Couldn't update profile for {link}: {msg}")
 | |
|         return redir
 | |
| 
 | |
|     if not user.obj:
 | |
|         flash(f"Couldn't update profile for {link}")
 | |
|         return redir
 | |
| 
 | |
|     common.create_task(queue='receive', obj_id=user.obj_key.id(),
 | |
|                        authed_as=user.key.id())
 | |
|     flash(f'Updating profile from {link}...')
 | |
| 
 | |
|     if user.LABEL == 'web':
 | |
|         if user.status:
 | |
|             logger.info(f'Disabling web user {user.key.id()}')
 | |
|             user.delete()
 | |
|         else:
 | |
|             for label in list(user.DEFAULT_ENABLED_PROTOCOLS) + user.enabled_protocols:
 | |
|                 try:
 | |
|                     PROTOCOLS[label].set_username(user, id)
 | |
|                 except (AssertionError, ValueError, RuntimeError, NotImplementedError):
 | |
|                     pass
 | |
| 
 | |
|     return redir
 | |
| 
 | |
| 
 | |
| @app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/<any(followers,following):collection>')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| def followers_or_following(protocol, id, collection):
 | |
|     user = load_user(protocol, id)
 | |
|     id = user.key.id()
 | |
|     handle = user.handle
 | |
| 
 | |
|     followers, before, after = Follower.fetch_page(collection, user)
 | |
|     num_followers, num_following = user.count_followers()
 | |
|     return render_template(
 | |
|         f'{collection}.html',
 | |
|         address=request.args.get('address'),
 | |
|         follow_url=request.values.get('url'),
 | |
|         **TEMPLATE_VARS,
 | |
|         **locals(),
 | |
|     )
 | |
| 
 | |
| 
 | |
| @app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/feed')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| @flask_util.headers(CACHE_CONTROL)
 | |
| def feed(protocol, id):
 | |
|     user = load_user(protocol, id)
 | |
|     query = Object.query(Object.feed == user.key)
 | |
|     objects, _, _ = fetch_objects(query, by=Object.created, user=user)
 | |
|     return serve_feed(objects=objects, format=request.args.get('format', 'html'),
 | |
|                       user=user, title=f'Bridgy Fed feed for {id}')
 | |
| 
 | |
| 
 | |
| def serve_feed(*, objects, format, user, title, as_snippets=False, quiet=False):
 | |
|     """Generates a feed based on :class:`Object` s.
 | |
| 
 | |
|     Args:
 | |
|       objects (sequence of models.Object)
 | |
|       format (str): ``html``, ``atom``, or ``rss``
 | |
|       user (models.User)
 | |
|       title (str)
 | |
|       as_snippets (bool): if True, render short snippets for objects instead of
 | |
|         full contents
 | |
|       quiet (bool): if True, exclude follows, unfollows, likes, and reposts
 | |
| 
 | |
|     Returns:
 | |
|       str or (str, dict) tuple: Flask response
 | |
|     """
 | |
|     if format not in ('html', 'atom', 'rss'):
 | |
|         error(f'format {format} not supported; expected html, atom, or rss')
 | |
| 
 | |
|     objects = [obj for obj in objects if not obj.deleted]
 | |
|     if quiet:
 | |
|         objects = [obj for obj in objects if obj.type not in
 | |
|                    ('delete', 'follow', 'stop-following', 'like', 'share',
 | |
|                     'undo', 'update')]
 | |
| 
 | |
|     if as_snippets:
 | |
|         activities = [{
 | |
|             'objectType': 'note',
 | |
|             'id': obj.key.id(),
 | |
|             'content': f'{obj.actor_link(image=False, user=user)} {obj.phrase} {obj.content}',
 | |
|             'updated': obj.updated.isoformat(),
 | |
|             'url': as1.get_url(obj.as1) or as1.get_url(as1.get_object(obj.as1)),
 | |
|         } for obj in objects]
 | |
|     else:
 | |
|         activities = [obj.as1 for obj in objects]
 | |
| 
 | |
|     # hydrate authors, actors, objects from stored Objects
 | |
|     fields = 'author', 'actor', 'object'
 | |
|     gets = []
 | |
|     for a in activities:
 | |
|         for field in fields:
 | |
|             val = as1.get_object(a, field)
 | |
|             if val and val.keys() <= set(['id']):
 | |
|                 def hydrate(a, f):
 | |
|                     def maybe_set(future):
 | |
|                         if future.result() and future.result().as1:
 | |
|                             a[f] = future.result().as1
 | |
|                     return maybe_set
 | |
| 
 | |
|                 # TODO: extract a Protocol class method out of User.profile_id,
 | |
|                 # then use that here instead. the catch is that we'd need to
 | |
|                 # determine Protocol for every id, which is expensive.
 | |
|                 #
 | |
|                 # same TODO is in models.fetch_objects
 | |
|                 id = val['id']
 | |
|                 if id.startswith('did:'):
 | |
|                     id = f'at://{id}/app.bsky.actor.profile/self'
 | |
| 
 | |
|                 future = Object.get_by_id_async(id)
 | |
|                 future.add_done_callback(hydrate(a, field))
 | |
|                 gets.append(future)
 | |
| 
 | |
|     tasklets.wait_all(gets)
 | |
| 
 | |
|     actor = (user.obj.as1 if user.obj and user.obj.as1
 | |
|              else {'displayName': user.readable_id, 'url': user.web_url()})
 | |
| 
 | |
|     # TODO: inject/merge common.pretty_link into microformats2.render_content
 | |
|     # (specifically into hcard_to_html) somehow to convert Mastodon URLs to @-@
 | |
|     # 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', **TEMPLATE_VARS, **locals())
 | |
| 
 | |
|     elif format == 'atom':
 | |
|         body = atom.activities_to_atom(activities, actor=actor, title=title,
 | |
|                                        request_url=request.url)
 | |
|         return body, {'Content-Type': atom.CONTENT_TYPE}
 | |
| 
 | |
|     elif format == 'rss':
 | |
|         # RSS requires email to generate an author element, so fill in blank one
 | |
|         # where necessary
 | |
|         for a in activities:
 | |
|             for field in fields:
 | |
|                 if val := as1.get_object(a, field):
 | |
|                     if as1.object_type(val) in as1.ACTOR_TYPES:
 | |
|                         val.setdefault('email', '_@_._')
 | |
| 
 | |
|         body = rss.from_activities(activities, actor=actor, title=title,
 | |
|                                    feed_url=request.url)
 | |
|         return body, {'Content-Type': rss.CONTENT_TYPE}
 | |
| 
 | |
| 
 | |
| 
 | |
| @app.get('/.well-known/nodeinfo')
 | |
| @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')
 | |
| @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
 | |
|     """
 | |
|     atp = ATProto.query(ATProto.enabled_protocols != None).count()
 | |
|     ap = ActivityPub.query(ActivityPub.enabled_protocols != None).count()
 | |
|     web = Web.query(Web.status == None).count()
 | |
|     total = atp + ap + 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')
 | |
| @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/',
 | |
|         },
 | |
|     }
 | |
| 
 | |
| 
 | |
| @app.get('/log')
 | |
| @canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
 | |
| @flask_util.headers(CACHE_CONTROL)
 | |
| def log():
 | |
|     return logs.log()
 |