"""UI pages.""" import datetime import itertools import logging import os import re from flask import g, render_template, request from google.cloud.ndb import tasklets from google.cloud.ndb.query import AND, OR from google.cloud.ndb.stats import KindStat from granary import as1, as2, atom, microformats2, rss import humanize from oauth_dropins.webutil import flask_util, logs, util from oauth_dropins.webutil.flask_util import error, redirect import common from common import DOMAIN_RE from flask_app import app, cache from models import fetch_page, Follower, Object, PAGE_SIZE, PROTOCOLS FOLLOWERS_UI_LIMIT = 999 # 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__) def load_user(protocol, id): """Loads the current request's user into `g.user`. Args: protocol: str id: str Raises: :class:`werkzeug.exceptions.HTTPException` on error or redirect """ assert id cls = PROTOCOLS[protocol] g.user = cls.get_by_id(id) if protocol != 'web': if not g.user: g.user = cls.query(cls.readable_id == id).get() if g.user and g.user.use_instead: g.user = g.user.use_instead.get() if g.user and id not in (g.user.key.id(), g.user.handle()): error('', status=302, location=g.user.user_page_path()) elif g.user and id != g.user.key.id(): # use_instead redirect error('', status=302, location=g.user.user_page_path()) if not g.user or not g.user.direct: # 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) assert not g.user.use_instead @app.route('/') @flask_util.cached(cache, datetime.timedelta(days=1)) def front_page(): """View for the front page.""" return render_template('index.html') @app.route('/docs') @flask_util.cached(cache, datetime.timedelta(days=1)) def docs(): """View for the docs page.""" return render_template('docs.html') @app.get(f'/user/') @app.get(f'/user//feed') @app.get(f'/user//') def web_user_redirects(**kwargs): path = request.url.removeprefix(request.root_url).removeprefix('user/') return redirect(f'/web/{path}', code=301) @app.get(f'//') def user(protocol, id): load_user(protocol, id) query = Object.query(OR(Object.users == g.user.key, Object.notify == g.user.key)) objects, before, after = fetch_objects(query) followers = Follower.query(Follower.to == g.user.key, Follower.status == 'active')\ .count(limit=FOLLOWERS_UI_LIMIT) followers = f'{followers}{"+" if followers == FOLLOWERS_UI_LIMIT else ""}' following = Follower.query(Follower.from_ == g.user.key, Follower.status == 'active')\ .count(limit=FOLLOWERS_UI_LIMIT) following = f'{following}{"+" if following == FOLLOWERS_UI_LIMIT else ""}' return render_template( 'user.html', follow_url=request.values.get('url'), logs=logs, util=util, address=request.args.get('address'), g=g, **locals(), ) @app.get(f'///') def followers_or_following(protocol, id, collection): load_user(protocol, id) followers, before, after = Follower.fetch_page(collection) return render_template( f'{collection}.html', address=request.args.get('address'), as2=as2, g=g, util=util, **locals() ) @app.get(f'///feed') def feed(protocol, id): format = request.args.get('format', 'html') if format not in ('html', 'atom', 'rss'): error(f'format {format} not supported; expected html, atom, or rss') load_user(protocol, id) objects = Object.query(OR(Object.feed == g.user.key, # backward compatibility AND(Object.users == g.user.key, Object.labels == 'feed'))) \ .order(-Object.created) \ .fetch(PAGE_SIZE) activities = [obj.as1 for obj in objects if not obj.deleted] # 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 future = Object.get_by_id_async(val['id']) future.add_done_callback(hydrate(a, field)) gets.append(future) tasklets.wait_all(gets) actor = { 'displayName': id, 'url': g.user.web_url(), } title = f'Bridgy Fed feed for {id}' # 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', util=util, g=g, **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': body = rss.from_activities(activities, actor=actor, title=title, feed_url=request.url) return body, {'Content-Type': rss.CONTENT_TYPE} def fetch_objects(query): """Fetches a page of Object entities from a datastore query. Wraps :func:`models.fetch_page` and adds attributes to the returned Object entities for rendering in objects.html. Args: query: :class:`ndb.Query` Returns: (results, new_before, new_after) tuple with: results: list of Object entities new_before, new_after: str query param values for `before` and `after` to fetch the previous and next pages, respectively """ objects, new_before, new_after = fetch_page(query, Object) # synthesize human-friendly content for objects for i, obj in enumerate(objects): obj_as1 = obj.as1 inner_obj = as1.get_object(obj_as1) # synthesize text snippet type = as1.object_type(obj_as1) if type == 'post': inner_type = inner_obj.get('objectType') if inner_type: type = inner_type phrases = { 'article': 'posted', 'comment': 'replied', 'delete': 'deleted', 'follow': 'followed', 'invite': 'is invited to', 'issue': 'filed issue', 'like': 'liked', 'note': 'posted', 'post': 'posted', 'repost': 'reposted', 'rsvp-interested': 'is interested in', 'rsvp-maybe': 'might attend', 'rsvp-no': 'is not attending', 'rsvp-yes': 'is attending', 'share': 'reposted', 'stop-following': 'unfollowed', 'update': 'updated', } obj.phrase = phrases.get(type) content = (inner_obj.get('content') or inner_obj.get('displayName') or inner_obj.get('summary')) if content: content = util.parse_html(content).get_text() urls = as1.object_urls(inner_obj) id = common.redirect_unwrap(inner_obj.get('id', '')) url = urls[0] if urls else id if (type == 'update' and (obj.users and (g.user.is_web_url(id) or id.strip('/') == obj.users[0].id()) or obj.domains and id.strip('/') == f'https://{obj.domains[0]}')): obj.phrase = 'updated' obj_as1.update({ 'content': 'their profile', 'url': id, }) elif url: # heuristics for sniffing Mastodon and similar fediverse URLs and # converting them to more friendly @-names # TODO: standardize this into granary.as2 somewhere? if not content: fedi_url = re.match( r'https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)?', url) if fedi_url: content = '@' + fedi_url.group(2) if fedi_url.group(4): content += "'s post" content = common.pretty_link(url, text=content) obj.content = (obj_as1.get('content') or obj_as1.get('displayName') or obj_as1.get('summary')) obj.url = util.get_first(obj_as1, 'url') if type in ('like', 'follow', 'repost', 'share') or not obj.content: if obj.url: obj.phrase = common.pretty_link(obj.url, text=obj.phrase, attrs={'class': 'u-url'}) if content: obj.content = content obj.url = url return objects, new_before, new_after @app.get('/stats') def stats(): def count(kind): return humanize.intcomma( KindStat.query(KindStat.kind_name == kind).get().count) return render_template( 'stats.html', users=count('MagicKey'), objects=count('Object'), followers=count('Follower'), ) @app.get('/.well-known/nodeinfo') @flask_util.cached(cache, datetime.timedelta(days=1)) 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'), }], }, { 'Content-Type': 'application/jrd+json', } @app.get('/nodeinfo.json') @flask_util.cached(cache, datetime.timedelta(days=1)) def nodeinfo(): """ https://nodeinfo.diaspora.software/schema.html """ user_total = None stat = KindStat.query(KindStat.kind_name == 'MagicKey').get() if stat: user_total = stat.count return { 'version': '2.1', 'software': { 'name': 'bridgy-fed', 'version': os.getenv('GAE_VERSION'), 'repository': 'https://github.com/snarfed/bridgy-fed', 'web_url': 'https://fed.brid.gy/', }, 'protocols': [ 'activitypub', 'bluesky', 'webmention', ], 'services': { 'outbound': [], 'inbound': [], }, 'usage': { 'users': { 'total': user_total, # 'activeMonth': # 'activeHalfyear': }, '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': {}, }, { # https://nodeinfo.diaspora.software/protocol.html 'Content-Type': 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"', } @app.get('/log') @flask_util.cached(cache, logs.CACHE_TIME) def log(): return logs.log()