diff --git a/models.py b/models.py index 4a3b851..c973cbc 100644 --- a/models.py +++ b/models.py @@ -131,11 +131,15 @@ class Response(StringIdModel): class Follower(StringIdModel): """A follower of a Bridgy Fed user. - Key name is 'USER_DOMAIN FOLLOWER_ID', e.g.: - 'snarfed.org https://mastodon.social/@swentel'. + Key name is 'TO FROM', where each part is either a domain or an AP id, eg: + 'snarfed.org https://mastodon.social/@swentel'. + + Both parts are duplicated in the src and dest properties. """ STATUSES = ('active', 'inactive') + src = ndb.StringProperty() + dest = ndb.StringProperty() # most recent AP Follow activity (JSON). must have a composite actor object # with an inbox, publicInbox, or sharedInbox! last_follow = ndb.TextProperty() @@ -145,13 +149,13 @@ class Follower(StringIdModel): updated = ndb.DateTimeProperty(auto_now=True) @classmethod - def _id(cls, user_domain, follower_id): - assert user_domain - assert follower_id - return '%s %s' % (user_domain, follower_id) + def _id(cls, dest, src): + assert src + assert dest + return '%s %s' % (dest, src) @classmethod - def get_or_create(cls, user_domain, follower_id, **kwargs): - logger.info(f'new Follower for {user_domain} {follower_id}') - return cls.get_or_insert(cls._id(user_domain, follower_id), **kwargs) + def get_or_create(cls, dest, src, **kwargs): + logger.info(f'new Follower from {src} to {dest}') + return cls.get_or_insert(cls._id(dest, src), src=src, dest=dest, **kwargs) diff --git a/pages.py b/pages.py index d6bf70e..6b26383 100644 --- a/pages.py +++ b/pages.py @@ -11,7 +11,10 @@ from oauth_dropins.webutil.flask_util import error from app import app, cache import common -from models import Response +from models import Follower, Response + +PAGE_SIZE = 20 +FOLLOWERS_UI_LIMIT = 999 @app.route('/') @@ -21,19 +24,64 @@ def front_page(): return render_template('index.html') -@app.get('/responses') -@app.get(f'/responses/') -def responses(domain=None): +@app.get(f'/user/') +@app.get(f'/responses/') # deprecated +def user(domain): + query = Response.query( + Response.status.IN(('new', 'complete', 'error')), + Response.domain == domain, + ) + responses, before, after = fetch_page(query, Response) + + followers = Follower.query(Follower.dest == domain)\ + .count(limit=FOLLOWERS_UI_LIMIT) + followers = f'{followers}{"+" if followers == FOLLOWERS_UI_LIMIT else ""}' + + following = Follower.query(Follower.src == domain)\ + .count(limit=FOLLOWERS_UI_LIMIT) + following = f'{following}{"+" if following == FOLLOWERS_UI_LIMIT else ""}' + + return render_template( + 'user.html', + util=util, + **locals(), + ) + + +@app.get('/recent') +@app.get('/responses') # deprecated +def recent(): """Renders recent Responses, with links to logs.""" - query = Response.query()\ - .filter(Response.status.IN(('new', 'complete', 'error')))\ - .order(-Response.updated) + query = Response.query(Response.status.IN(('new', 'complete', 'error'))) + responses, before, after = fetch_page(query, Response) + return render_template( + 'recent.html', + util=util, + **locals(), + ) - if domain: - query = query.filter(Response.domain == domain) - # if there's a paging param (responses_before or responses_after), update - # query with it +def fetch_page(query, model_class): + """Fetches a page of results from a datastore query. + + Uses the `before` and `after` query params (if provided; should be ISO8601 + timestamps) and the queried model class's `updated` property to identify the + page to fetch. + + Populates a `log_url_path` property on each result entity that points to a + its most recent logged request. + + Args: + query: :class:`ndb.Query` + model_class: ndb model class + + Returns: + (results, new_before, new_after) tuple with: + results: list of query result entities + new_before, new_after: str query param values for `before` and `after` + to fetch the previous and next pages, respectively + """ + # if there's a paging param ('before' or 'after'), update query with it # TODO: unify this with Bridgy's user page def get_paging_param(param): val = request.values.get(param) @@ -42,50 +90,42 @@ def responses(domain=None): except BaseException: error(f"Couldn't parse {param}, {val!r} as ISO8601") - before = get_paging_param('responses_before') - after = get_paging_param('responses_after') + before = get_paging_param('before') + after = get_paging_param('after') if before and after: - error("can't handle both responses_before and responses_after") + error("can't handle both before and after") elif after: - query = query.filter(Response.updated > after).order(Response.updated) + query = query.filter(model_class.updated > after).order(-model_class.updated) elif before: - query = query.filter(Response.updated < before).order(-Response.updated) + query = query.filter(model_class.updated < before).order(-model_class.updated) else: - query = query.order(-Response.updated) + query = query.order(-model_class.updated) query_iter = query.iter() - responses = list(islice(query_iter, 0, 20)) - for r in responses: - r.source_link = util.pretty_link(r.source()) - r.target_link = util.pretty_link(r.target()) + results = sorted(islice(query_iter, 0, 20), key=lambda r: r.updated, reverse=True) + for r in results: r.log_url_path = '/log?' + urllib.parse.urlencode({ 'key': r.key.id(), 'start_time': calendar.timegm(r.updated.timetuple()), }) - vars = { - 'domain': domain, - 'responses': sorted(responses, key=lambda r: r.updated, reverse=True), - } - # calculate new paging param(s) + has_next = results and query_iter.probably_has_next() new_after = ( before if before - else responses[0].updated - if responses and query_iter.probably_has_next() and (before or after) + else results[0].updated if has_next and after else None) if new_after: - vars['responses_after_link'] = f'?responses_after={new_after.isoformat()}#responses' + new_after = new_after.isoformat() new_before = ( after if after else - responses[-1].updated if - responses and query_iter.probably_has_next() + results[-1].updated if has_next else None) if new_before: - vars['responses_before_link'] = f'?responses_before={new_before.isoformat()}#responses' + new_before = new_before.isoformat() - return render_template('responses.html', **vars) + return results, new_before, new_after @app.get('/stats') diff --git a/templates/index.html b/templates/index.html index c671942..f4f63f5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -94,7 +94,7 @@ The webmention source URL will usually be a proxy page on fed.brid.gy

-You can see your recent interactions at fed.brid.gy/responses/[your-domain.com]. +You can see your recent interactions at fed.brid.gy/user/[your-domain.com].

@@ -200,7 +200,7 @@ I love scotch. Scotchy scotchy scotch.
  • If you sent a webmention, check the HTTP response code and body. It will usually describe the error.

    If you got an HTTP 204 from an attempt to federate a response to Mastodon, that means Mastodon accepted the response. If it doesn't show up, that's a known inconsistency right now. We're actively working with them to debug these cases.

    -

    You can also see all recent Bridgy Fed requests here, including raw logs. Warning: not for the faint of heart!

    +

    You can also see all recent Bridgy Fed requests here, including raw logs. Warning: not for the faint of heart!

  • How much does it cost?
  • diff --git a/templates/recent.html b/templates/recent.html new file mode 100644 index 0000000..88486a4 --- /dev/null +++ b/templates/recent.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Bridgy Fed: Recent activity{% endblock %} + +{% block content %} + +

    Recent activity

    + +{% include "responses.html" %} + +{% endblock %} diff --git a/templates/responses.html b/templates/responses.html index a862a58..6aff405 100644 --- a/templates/responses.html +++ b/templates/responses.html @@ -1,50 +1,35 @@ - - - - -Bridgy Fed: {{ domain or 'Recent activity' }} - - - - - + + - -
    -

    Bridgy Fed

    -

    {{ domain or 'Recent activity' }}

    +
    - - {% for r in responses %} - - - - - - - + + +{% else %} +
    None
    {% endfor %} -
    Source Target Protocol Status Time (click for log)
    {{ r.source_link|safe }}{{ r.target_link|safe }}{{ r.protocol }}{{ r.status }} +
    +
    {{ util.pretty_link(r.source())|safe }}
    +
    {{ util.pretty_link(r.target())|safe }}
    +
    {{ r.protocol }}
    +
    {{ r.status }}
    +
    +
    - {% if responses_after_link %} - ← Newer + {% if after %} + ← Newer {% endif %}
    - {% if responses_before_link %} - Older → + {% if before %} + Older → {% endif %}
    - -
    - - diff --git a/templates/user.html b/templates/user.html new file mode 100644 index 0000000..8b7e599 --- /dev/null +++ b/templates/user.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}Bridgy Fed: {{ domain }}{% endblock %} + +{% block content %} + +

    + 🌐 {{ domain }} +

    +

    + {{ followers }} follower{% if followers != '1' %}s{% endif %} + +

    + +{% include "responses.html" %} + +{% endblock %} diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 89e0b4a..ae627a5 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -366,7 +366,7 @@ class ActivityPubTest(testutil.TestCase): self.assertEqual(FOLLOW_WITH_ACTOR, json_loads(resp.source_as2)) # check that we stored a Follower object - follower = Follower.get_by_id('www.realize.be %s' % (FOLLOW['actor'])) + follower = Follower.get_by_id(f'www.realize.be {FOLLOW["actor"]}') self.assertEqual('active', follower.status) self.assertEqual(FOLLOW_WRAPPED_WITH_ACTOR, json_loads(follower.last_follow)) @@ -378,7 +378,7 @@ class ActivityPubTest(testutil.TestCase): got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) - follower = Follower.get_by_id('www.realize.be %s' % FOLLOW['actor']) + follower = Follower.get_by_id(f'www.realize.be {FOLLOW["actor"]}') self.assertEqual('inactive', follower.status) def test_inbox_undo_follow_doesnt_exist(self, mock_head, mock_get, mock_post): diff --git a/webfinger.py b/webfinger.py index feaae19..4c122f6 100644 --- a/webfinger.py +++ b/webfinger.py @@ -192,7 +192,7 @@ def host_meta_xrds(): app.add_url_rule(f'/acct:', - view_func=User.as_view('user')) + view_func=User.as_view('actor_acct')) app.add_url_rule('/.well-known/webfinger', view_func=Webfinger.as_view('webfinger')) app.add_url_rule('/.well-known/host-meta', view_func=HostMeta.as_view('hostmeta')) app.add_url_rule('/.well-known/host-meta.json', view_func=HostMeta.as_view('hostmeta-json'))