• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

snarfed / bridgy-fed / 0b7fc2f6-13ff-44aa-9bd7-3c8afe3e6b40

04 Dec 2024 10:39PM UTC coverage: 92.857% (+0.06%) from 92.801%
0b7fc2f6-13ff-44aa-9bd7-3c8afe3e6b40

push

circleci

snarfed
ATProto.create_for: recreate DNS when we reactivate an inactive account

for #1594

2 of 2 new or added lines in 1 file covered. (100.0%)

14 existing lines in 2 files now uncovered.

4368 of 4704 relevant lines covered (92.86%)

0.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.78
/pages.py
1
"""UI pages."""
2
import datetime
1✔
3
import itertools
1✔
4
import logging
1✔
5
import os
1✔
6
import re
1✔
7
import time
1✔
8

9
from flask import render_template, request
1✔
10
from google.cloud.ndb import tasklets
1✔
11
from google.cloud.ndb.query import AND, OR
1✔
12
from google.cloud.ndb.stats import KindStat
1✔
13
from granary import as1, as2, atom, microformats2, rss
1✔
14
import humanize
1✔
15
from oauth_dropins.webutil import flask_util, logs, util
1✔
16
from oauth_dropins.webutil.flask_util import (
1✔
17
    canonicalize_request_domain,
18
    error,
19
    flash,
20
)
21
import requests
1✔
22
import werkzeug.exceptions
1✔
23

24
from activitypub import ActivityPub, instance_actor
1✔
25
from atproto import ATProto
1✔
26
import common
1✔
27
from common import CACHE_CONTROL, DOMAIN_RE
1✔
28
from flask_app import app
1✔
29
from flask import redirect
1✔
30
import ids
1✔
31
from models import fetch_objects, fetch_page, Follower, Object, PAGE_SIZE, PROTOCOLS
1✔
32
from protocol import Protocol
1✔
33

34
# precompute this because we get a ton of requests for non-existing users
35
# from weird open redirect referrers:
36
# https://github.com/snarfed/bridgy-fed/issues/422
37
with app.test_request_context('/'):
1✔
38
    USER_NOT_FOUND_HTML = render_template('user_not_found.html')
1✔
39

40
logger = logging.getLogger(__name__)
1✔
41

42
TEMPLATE_VARS = {
1✔
43
    'as1': as1,
44
    'as2': as2,
45
    'ids': ids,
46
    'isinstance': isinstance,
47
    'logs': logs,
48
    'PROTOCOLS': PROTOCOLS,
49
    'set': set,
50
    'util': util,
51
}
52

53

54
def load_user(protocol, id):
1✔
55
    """Loads and returns the current request's user.
56

57
    Args:
58
      protocol (str):
59
      id (str):
60

61
    Returns:
62
      models.User:
63

64
    Raises:
65
      :class:`werkzeug.exceptions.HTTPException` on error or redirect
66
    """
67
    assert id
1✔
68

69
    cls = PROTOCOLS[protocol]
1✔
70

71
    if cls.ABBREV == 'ap' and not id.startswith('@'):
1✔
72
        id = '@' + id
1✔
73
    user = cls.get_by_id(id)
1✔
74

75
    if cls.ABBREV != 'web':
1✔
76
        if not user:
1✔
77
            user = cls.query(cls.handle == id, cls.status == None).get()
1✔
78
            if user and user.use_instead:
1✔
79
                user = user.use_instead.get()
×
80

81
        if user and id not in (user.key.id(), user.handle):
1✔
82
            error('', status=302, location=user.user_page_path())
×
83

84
    elif user and id != user.key.id():  # use_instead redirect
1✔
85
        error('', status=302, location=user.user_page_path())
1✔
86

87
    if (user and not user.status
1✔
88
            and (user.direct or user.enabled_protocols or cls.ABBREV == 'web')):
89
        assert not user.use_instead
1✔
90
        return user
1✔
91

92
    # TODO: switch back to USER_NOT_FOUND_HTML
93
    # not easy via exception/abort because this uses Werkzeug's built in
94
    # NotFound exception subclass, and we'd need to make it implement
95
    # get_body to return arbitrary HTML.
96
    error(f'{protocol} user {id} not found', status=404)
1✔
97

98

99
@app.route('/')
1✔
100
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
101
@flask_util.headers(CACHE_CONTROL)
1✔
102
def front_page():
1✔
103
    """View for the front page."""
104
    return render_template('index.html')
×
105

106

107
@app.route('/docs')
1✔
108
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
109
@flask_util.headers(CACHE_CONTROL)
1✔
110
def docs():
1✔
111
    """View for the docs page."""
112
    return render_template('docs.html')
×
113

114

115
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>')
1✔
116
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>/feed')
1✔
117
@app.get(f'/user/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>')
1✔
118
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
119
def web_user_redirects(**kwargs):
1✔
120
    path = request.url.removeprefix(request.root_url).removeprefix('user/')
1✔
121
    return redirect(f'/web/{path}', code=301)
1✔
122

123

124
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>')
1✔
125
# WARNING: this overrides the /ap/... actor URL route in activitypub.py, *only*
126
# for handles with leading @ character. be careful when changing this route!
127
@app.get(f'/ap/@<id>', defaults={'protocol': 'ap'})
1✔
128
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
129
def profile(protocol, id):
1✔
130
    user = load_user(protocol, id)
1✔
131
    query = Object.query(Object.users == user.key)
1✔
132
    objects, before, after = fetch_objects(query, by=Object.updated, user=user)
1✔
133
    num_followers, num_following = user.count_followers()
1✔
134
    return render_template('profile.html', **TEMPLATE_VARS, **locals())
1✔
135

136

137
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/home')
1✔
138
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
139
def home(protocol, id):
1✔
140
    user = load_user(protocol, id)
1✔
141
    query = Object.query(Object.feed == user.key)
1✔
142
    objects, before, after = fetch_objects(query, by=Object.created, user=user)
1✔
143

144
    # this calls Object.actor_link serially for each object, which loads the
145
    # actor from the datastore if necessary. TODO: parallelize those fetches
146
    return render_template('home.html', **TEMPLATE_VARS, **locals())
1✔
147

148

149
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/notifications')
1✔
150
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
151
def notifications(protocol, id):
1✔
152
    user = load_user(protocol, id)
1✔
153

154
    query = Object.query(Object.notify == user.key)
1✔
155
    objects, before, after = fetch_objects(query, by=Object.updated, user=user)
1✔
156

157
    format = request.args.get('format')
1✔
158
    if format:
1✔
159
        return serve_feed(objects=objects, format=format, as_snippets=True,
1✔
160
                          user=user, title=f'Bridgy Fed notifications for {id}',
161
                          quiet=request.args.get('quiet'))
162

163
    # notifications tab UI page
164
    return render_template('notifications.html', **TEMPLATE_VARS, **locals())
1✔
165

166

167
@app.post(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/update-profile')
1✔
168
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
169
def update_profile(protocol, id):
1✔
170
    user = load_user(protocol, id)
1✔
171
    link = f'<a href="{user.web_url()}">{user.handle_or_id()}</a>'
1✔
172
    redir = redirect(user.user_page_path(), code=302)
1✔
173

174
    try:
1✔
175
        user.reload_profile()
1✔
176
    except (requests.RequestException, werkzeug.exceptions.HTTPException) as e:
1✔
177
        _, msg = util.interpret_http_exception(e)
1✔
178
        flash(f"Couldn't update profile for {link}: {msg}")
1✔
179
        return redir
1✔
180

181
    if not user.obj:
1✔
182
        flash(f"Couldn't update profile for {link}")
×
183
        return redir
×
184

185
    common.create_task(queue='receive', obj_id=user.obj_key.id(),
1✔
186
                       authed_as=user.key.id())
187
    flash(f'Updating profile from {link}...')
1✔
188

189
    if user.LABEL == 'web':
1✔
190
        if user.status:
1✔
191
            logger.info(f'Disabling web user {user.key.id()}')
1✔
192
            user.delete()
1✔
193
        else:
194
            for label in list(user.DEFAULT_ENABLED_PROTOCOLS) + user.enabled_protocols:
1✔
195
                try:
1✔
196
                    PROTOCOLS[label].set_username(user, id)
1✔
197
                except (ValueError, RuntimeError, NotImplementedError) as e:
1✔
198
                    pass
1✔
199

200
    return redir
1✔
201

202

203
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/<any(followers,following):collection>')
1✔
204
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
205
def followers_or_following(protocol, id, collection):
1✔
206
    user = load_user(protocol, id)
1✔
207
    followers, before, after = Follower.fetch_page(collection, user)
1✔
208
    num_followers, num_following = user.count_followers()
1✔
209
    return render_template(
1✔
210
        f'{collection}.html',
211
        address=request.args.get('address'),
212
        follow_url=request.values.get('url'),
213
        **TEMPLATE_VARS,
214
        **locals(),
215
    )
216

217

218
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/feed')
1✔
219
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
220
def feed(protocol, id):
1✔
221
    user = load_user(protocol, id)
1✔
222
    query = Object.query(Object.feed == user.key)
1✔
223
    objects, _, _ = fetch_objects(query, by=Object.created, user=user)
1✔
224
    return serve_feed(objects=objects, format=request.args.get('format', 'html'),
1✔
225
                      user=user, title=f'Bridgy Fed feed for {id}')
226

227

228
def serve_feed(*, objects, format, user, title, as_snippets=False, quiet=False):
1✔
229
    """Generates a feed based on :class:`Object`s.
230

231
    Args:
232
      objects (sequence of models.Object)
233
      format (str): ``html``, ``atom``, or ``rss``
234
      user (models.User)
235
      title (str)
236
      as_snippets (bool): if True, render short snippets for objects instead of
237
        full contents
238
      quiet (bool): if True, exclude follows, unfollows, likes, and reposts
239

240
    Returns:
241
      str or (str, dict) tuple: Flask response
242
    """
243
    if format not in ('html', 'atom', 'rss'):
1✔
244
        error(f'format {format} not supported; expected html, atom, or rss')
×
245

246
    objects = [obj for obj in objects if not obj.deleted]
1✔
247
    if quiet:
1✔
248
        objects = [obj for obj in objects if obj.type not in
×
249
                   ('delete', 'follow', 'stop-following', 'like', 'share',
250
                    'undo', 'update')]
251

252
    if as_snippets:
1✔
253
        activities = [{
1✔
254
            'objectType': 'note',
255
            'id': obj.key.id(),
256
            'content': f'{obj.actor_link(image=False, user=user)} {obj.phrase} {obj.content}',
257
            'updated': obj.updated.isoformat(),
258
            'url': as1.get_url(obj.as1) or as1.get_url(as1.get_object(obj.as1)),
259
        } for obj in objects]
260
    else:
261
        activities = [obj.as1 for obj in objects]
1✔
262

263
    # hydrate authors, actors, objects from stored Objects
264
    fields = 'author', 'actor', 'object'
1✔
265
    gets = []
1✔
266
    for a in activities:
1✔
267
        for field in fields:
1✔
268
            val = as1.get_object(a, field)
1✔
269
            if val and val.keys() <= set(['id']):
1✔
270
                def hydrate(a, f):
1✔
271
                    def maybe_set(future):
1✔
272
                        if future.result() and future.result().as1:
1✔
273
                            a[f] = future.result().as1
1✔
274
                    return maybe_set
1✔
275

276
                # TODO: extract a Protocol class method out of User.profile_id,
277
                # then use that here instead. the catch is that we'd need to
278
                # determine Protocol for every id, which is expensive.
279
                #
280
                # same TODO is in models.fetch_objects
281
                id = val['id']
1✔
282
                if id.startswith('did:'):
1✔
283
                    id = f'at://{id}/app.bsky.actor.profile/self'
×
284

285
                future = Object.get_by_id_async(id)
1✔
286
                future.add_done_callback(hydrate(a, field))
1✔
287
                gets.append(future)
1✔
288

289
    tasklets.wait_all(gets)
1✔
290

291
    actor = (user.obj.as1 if user.obj and user.obj.as1
1✔
292
             else {'displayName': user.readable_id, 'url': user.web_url()})
293

294
    # TODO: inject/merge common.pretty_link into microformats2.render_content
295
    # (specifically into hcard_to_html) somehow to convert Mastodon URLs to @-@
296
    # syntax. maybe a fediverse kwarg down through the call chain?
297
    if format == 'html':
1✔
298
        entries = [microformats2.object_to_html(a) for a in activities]
1✔
299
        return render_template('feed.html', **TEMPLATE_VARS, **locals())
1✔
300
    elif format == 'atom':
1✔
301
        body = atom.activities_to_atom(activities, actor=actor, title=title,
1✔
302
                                       request_url=request.url)
303
        return body, {'Content-Type': atom.CONTENT_TYPE}
1✔
304
    elif format == 'rss':
1✔
305
        body = rss.from_activities(activities, actor=actor, title=title,
1✔
306
                                   feed_url=request.url)
307
        return body, {'Content-Type': rss.CONTENT_TYPE}
1✔
308

309

310

311
@app.get('/.well-known/nodeinfo')
1✔
312
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
313
@flask_util.headers(CACHE_CONTROL)
1✔
314
def nodeinfo_jrd():
1✔
315
    """
316
    https://nodeinfo.diaspora.software/protocol.html
317
    """
UNCOV
318
    return {
×
319
        'links': [{
320
            'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.1',
321
            'href': common.host_url('nodeinfo.json'),
322
        }, {
323
            "rel": "https://www.w3.org/ns/activitystreams#Application",
324
            "href": instance_actor().id_as(ActivityPub),
325
        }],
326
    }, {
327
        'Content-Type': 'application/jrd+json',
328
    }
329

330

331
@app.get('/nodeinfo.json')
1✔
332
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
333
@flask_util.headers(CACHE_CONTROL)
1✔
334
def nodeinfo():
1✔
335
    """
336
    https://nodeinfo.diaspora.software/schema.html
337
    """
338
    user_total = (ATProto.query(ATProto.enabled_protocols != None).count()
1✔
339
                  + ActivityPub.query(ActivityPub.enabled_protocols != None).count())
340
    if stat := KindStat.query(KindStat.kind_name == 'MagicKey').get():
1✔
UNCOV
341
        user_total += stat.count
×
342

343
    logger.info(f'Total users {user_total}')
1✔
344

345
    return {
1✔
346
        'version': '2.1',
347
        'software': {
348
            'name': 'bridgy-fed',
349
            'version': os.getenv('GAE_VERSION'),
350
            'repository': 'https://github.com/snarfed/bridgy-fed',
351
            'homepage': 'https://fed.brid.gy/',
352
        },
353
        'protocols': [
354
            'activitypub',
355
            'atprotocol',
356
            'webmention',
357
        ],
358
        'services': {
359
            'outbound': [],
360
            'inbound': [],
361
        },
362
        'usage': {
363
            'users': {
364
                'total': user_total,
365
                # 'activeMonth':
366
                # 'activeHalfyear':
367
            },
368
            # these are too heavy
369
            # 'localPosts': Object.query(Object.source_protocol.IN(('web', 'webmention')),
370
            #                            Object.type.IN(['note', 'article']),
371
            #                            ).count(),
372
            # 'localComments': Object.query(Object.source_protocol.IN(('web', 'webmention')),
373
            #                               Object.type == 'comment',
374
            #                               ).count(),
375
        },
376
        'openRegistrations': True,
377
        'metadata': {},
378
    }, {
379
        # https://nodeinfo.diaspora.software/protocol.html
380
        'Content-Type': 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"',
381
        'Cache-Control': f'public, max-age={int(datetime.timedelta(days=1).total_seconds())}'
382
    }
383

384

385
@app.get('/api/v1/instance')
1✔
386
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
387
@flask_util.headers(CACHE_CONTROL)
1✔
388
def instance_info():
1✔
389
    """
390
    https://docs.joinmastodon.org/methods/instance/#v1
391
    """
392
    return {
1✔
393
        'uri': 'fed.brid.gy',
394
        'title': 'Bridgy Fed',
395
        'version': os.getenv('GAE_VERSION'),
396
        'short_description': 'Bridging the new social internet',
397
        'description': 'Bridging the new social internet',
398
        'email': 'feedback@brid.gy',
399
        'thumbnail': 'https://fed.brid.gy/static/bridgy_logo_with_alpha.png',
400
        'registrations': True,
401
        'approval_required': False,
402
        'invites_enabled': False,
403
        'contact_account': {
404
            'username': 'snarfed.org',
405
            'acct': 'snarfed.org',
406
            'display_name': 'Ryan',
407
            'url': 'https://snarfed.org/',
408
        },
409
    }
410

411

412
@app.get('/log')
1✔
413
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
1✔
414
@flask_util.headers(CACHE_CONTROL)
1✔
415
def log():
1✔
UNCOV
416
    return logs.log()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc