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

snarfed / bounce / f13f2175-4d2c-493f-a9eb-0a74bb89e31b

15 Jul 2025 07:55PM UTC coverage: 92.5% (+0.8%) from 91.718%
f13f2175-4d2c-493f-a9eb-0a74bb89e31b

push

circleci

snarfed
test that migrating to AP when already bridged sends the Move activity

for #17

592 of 640 relevant lines covered (92.5%)

0.93 hits per line

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

93.34
/bounce.py
1
"""UI pages."""
2
from collections import defaultdict
1✔
3
from datetime import datetime, timedelta, timezone
1✔
4
from enum import auto, IntEnum
1✔
5
from functools import wraps
1✔
6
from itertools import chain
1✔
7
import json
1✔
8
import logging
1✔
9
import os
1✔
10
from pathlib import Path
1✔
11
import sys
1✔
12
from urllib.parse import urljoin
1✔
13

14
from arroba import xrpc_repo
1✔
15
from arroba.datastore_storage import AtpRemoteBlob, DatastoreStorage
1✔
16
import arroba.server
1✔
17
from flask import flash, Flask, redirect, render_template, request
1✔
18
import flask_gae_static
1✔
19
from google.cloud import ndb, storage
1✔
20
from granary import as2
1✔
21
from granary.bluesky import Bluesky
1✔
22
from granary.mastodon import Mastodon
1✔
23
from granary.pixelfed import Pixelfed
1✔
24
import humanize
1✔
25
import lexrpc
1✔
26
import oauth_dropins
1✔
27
import oauth_dropins.bluesky
1✔
28
import oauth_dropins.indieauth
1✔
29
import oauth_dropins.mastodon
1✔
30
import oauth_dropins.pixelfed
1✔
31
import oauth_dropins.threads
1✔
32
from oauth_dropins.webutil import (
1✔
33
    appengine_info,
34
    appengine_config,
35
    flask_util,
36
    util,
37
)
38
from oauth_dropins.webutil.flask_util import (
1✔
39
    cloud_tasks_only,
40
    error,
41
    FlashErrors,
42
    Found,
43
    get_required_param,
44
)
45
from oauth_dropins.webutil.models import EnumProperty, JsonProperty
1✔
46
from pymemcache.test.utils import MockMemcacheClient
1✔
47
from requests import RequestException
1✔
48
from requests_oauth2client import DPoPTokenSerializer, OAuth2AccessTokenAuth
1✔
49

50
# from Bridgy Fed
51
from activitypub import ActivityPub
1✔
52
from atproto import ATProto
1✔
53
import common
1✔
54
import ids
1✔
55
from ids import translate_user_id
1✔
56
import memcache
1✔
57
import models
1✔
58
from protocol import Protocol
1✔
59
from web import Web
1✔
60

61
logger = logging.getLogger(__name__)
1✔
62

63
PROTOCOLS = set(p for p in models.PROTOCOLS.values() if p and p.LABEL != 'ui')
1✔
64

65
BRIDGY_FED_PROJECT_ID = 'bridgy-federated'
1✔
66
# haven't yet managed to open up Bridgy Fed's memcache VPC connector to allow access
67
# from other apps like this one. TODO: figure that out.
68
bridgy_fed_ndb = ndb.Client(project=BRIDGY_FED_PROJECT_ID)
1✔
69
memcache.memcache.client_class = memcache.pickle_memcache.client_class = MockMemcacheClient
1✔
70

71
# Cache-Control header for static files
72
CACHE_CONTROL = {'Cache-Control': 'public, max-age=3600'}  # 1 hour
1✔
73

74
TASK_REQUESTS_KWARGS = {
1✔
75
    'timeout': 60,  # seconds
76
}
77

78
FOLLOWERS_PREVIEW_LEN = 20
1✔
79

80
AUTH_TO_PROTOCOL = {
1✔
81
    oauth_dropins.bluesky.BlueskyAuth: ATProto,
82
    oauth_dropins.indieauth.IndieAuth: Web,
83
    oauth_dropins.mastodon.MastodonAuth: ActivityPub,
84
    oauth_dropins.pixelfed.PixelfedAuth: ActivityPub,
85
    oauth_dropins.threads.ThreadsAuth: ActivityPub,
86
}
87
BRIDGE_DOMAIN_TO_PROTOCOL = {
1✔
88
    'atproto.brid.gy': ATProto,
89
    'bsky.brid.gy': ATProto,
90
    'ap.brid.gy': ActivityPub,
91
    'fed.brid.gy': Web,
92
    'web.brid.gy': Web,
93
}
94

95
# if a Migration hasn't been touched in this long, we'll restart its review or
96
# migrate task on the next user request
97
STALE_TASK_AGE = timedelta(minutes=5)
1✔
98

99
MAIN_PDS_DOMAINS = ('bsky.app', 'bsky.network', 'bsky.social')
1✔
100

101
CLOUD_STORAGE_BUCKET = 'bridgy-federated.appspot.com'
1✔
102
CLOUD_STORAGE_BASE_URL = 'https://storage.googleapis.com/'
1✔
103

104
USER_AGENT = 'Bounce (https://bounce.anew.social/)'
1✔
105
util.set_user_agent(USER_AGENT)
1✔
106

107

108
#
109
# Flask app
110
#
111
app = Flask(__name__, static_folder=None)
1✔
112
app.template_folder = './templates'
1✔
113
app.json.compact = False
1✔
114
app.config.from_pyfile(Path(__file__).parent / 'config.py')
1✔
115
app.url_map.converters['regex'] = flask_util.RegexConverter
1✔
116
app.after_request(flask_util.default_modern_headers)
1✔
117
app.register_error_handler(Exception, flask_util.handle_exception)
1✔
118

119
if appengine_info.LOCAL_SERVER:
1✔
120
    flask_gae_static.init_app(app)
×
121

122
app.wsgi_app = flask_util.ndb_context_middleware(
1✔
123
    app.wsgi_app, client=appengine_config.ndb_client)
124

125
models.reset_protocol_properties()
1✔
126

127
arroba.server.storage = DatastoreStorage(ndb_client=bridgy_fed_ndb)
1✔
128

129

130
#
131
# models
132
#
133
class State(IntEnum):
1✔
134
    # in order!
135
    review_followers = auto()
1✔
136
    review_follows = auto()
1✔
137
    review_analyze = auto()
1✔
138
    review_done = auto()
1✔
139
    migrate_follows = auto()
1✔
140
    migrate_in = auto()
1✔
141
    migrate_in_blobs = auto()
1✔
142
    migrate_out = auto()
1✔
143
    migrate_done = auto()
1✔
144

145

146
class Migration(ndb.Model):
1✔
147
    """Stores state for a migration.
148

149
    Key id is '[from auth entity id] [to protocol Bridgy Fed label]', eg
150
    'did:plc:alice activitypub'.
151
    """
152
    from_ = ndb.KeyProperty()  # auth entities
1✔
153
    to = ndb.KeyProperty()
1✔
154

155
    state = EnumProperty(State)
1✔
156

157
    # data for review. contents depend on state. if state is review_done, these
158
    # are template parameters for rendering review.html.
159
    review = JsonProperty(default={})
1✔
160

161
    # user ids to follow
162
    to_follow = ndb.StringProperty(repeated=True)
1✔
163
    followed = ndb.StringProperty(repeated=True)
1✔
164

165
    plc_code = ndb.StringProperty()
1✔
166

167
    last_attempt = ndb.DateTimeProperty(tzinfo=timezone.utc)
1✔
168
    created = ndb.DateTimeProperty(auto_now_add=True, tzinfo=timezone.utc)
1✔
169
    updated = ndb.DateTimeProperty(auto_now=True, tzinfo=timezone.utc)
1✔
170

171
    @classmethod
1✔
172
    def _key_id(cls, from_auth, to_auth):
1✔
173
        return f'{from_auth.key.id()} {AUTH_TO_PROTOCOL[to_auth.__class__].LABEL}'
1✔
174

175
    @classmethod
1✔
176
    def get(cls, from_auth, to_auth):
1✔
177
        """
178
        Args:
179
          from_auth (oauth_dropins.models.BaseAuth)
180
          to_auth (oauth_dropins.models.BaseAuth)
181

182
        Returns:
183
          Migration:
184
        """
185
        id = cls._key_id(from_auth, to_auth)
1✔
186
        logger.info(f'get Migration {id}')
1✔
187
        return cls.get_by_id(id)
1✔
188

189
    @classmethod
1✔
190
    @ndb.transactional()
1✔
191
    def get_or_insert(cls, from_auth, to_auth, **kwargs):
1✔
192
        """
193
        Args:
194
          from_auth (oauth_dropins.models.BaseAuth)
195
          to_auth (oauth_dropins.models.BaseAuth)
196
          kwargs: passed to :meth:`ndb.Model.get_or_insert`
197

198
        Returns:
199
          Migration:
200
        """
201
        id = cls._key_id(from_auth, to_auth)
1✔
202
        logger.info(f'get_or_insert Migration {id} {kwargs}')
1✔
203

204
        if not (migration := cls.get_by_id(id)):
1✔
205
            migration = Migration(id=id, from_=from_auth.key, to=to_auth.key, **kwargs)
1✔
206
            migration.put()
1✔
207

208
        return migration
1✔
209

210
    def create_task(self, queue):
1✔
211
        """Creates a review or migrate task for this migration.
212

213
        Args:
214
          queue: 'review' or 'migrate'
215
        """
216
        assert queue in ('review', 'migrate'), queue
1✔
217
        common.create_task(queue, **{
1✔
218
            'from': self.from_.urlsafe().decode(),
219
            'to': self.to.urlsafe().decode(),
220
        })
221

222

223
def url(path, from_auth, to_auth=None):
1✔
224
    """Simple helper to create URLs with from and optional to auth entities.
225

226
    Args:
227
          from_auth (oauth_dropins.models.BaseAuth)
228
          to_auth (oauth_dropins.models.BaseAuth)
229

230
    Returns:
231
      str: URL with ``from`` and optionally ``to`` query params
232
    """
233
    url = f'{path}?from={from_auth.key.urlsafe().decode()}'
1✔
234
    if to_auth:
1✔
235
        url += f'&to={to_auth.key.urlsafe().decode()}'
1✔
236

237
    return url
1✔
238

239

240
def template_vars(oauth_path_suffix=''):
1✔
241
    """Returns base template vars common to most views.
242

243
    Args:
244
      oauth_path_suffix: appended to the end of the OAuth start URL paths
245
    """
246
    auths = []
1✔
247
    for auth in ndb.get_multi(oauth_dropins.get_logins()):
1✔
248
        if auth:
1✔
249
            user_json = json.loads(auth.user_json)
1✔
250
            if not (source := granary_source(auth)):
1✔
251
                continue
1✔
252
            auth.url = source.to_as1_actor(user_json).get('url')
1✔
253
            auths.append(auth)
1✔
254

255
    return {
1✔
256
        'auths': auths,
257
    }
258

259

260
def require_accounts(from_params, to_params=None, logged_in=True, failures_to=None):
1✔
261
    """Decorator that requires and loads both from and (optionally) to auth entities.
262

263
    Passes both entities as positional args to the function, as oauth-dropins auth
264
    entities. Also performs sanity checks:
265
    * Both must be logged in (if ``logged_in`` is True)
266
    * They must be different protocols
267
    * If a Bluesky account is involved, it can't be a did:web
268

269
    Args:
270
      from_params (str or sequence of str): HTTP query param(s) with the url-safe ndb
271
        key for the from auth entity
272
      to_params (str or sequence of str): HTTP query param(s) with the url-safe ndb key
273
        for the to auth entity
274
      logged_in (bool): whether to require the auth entities are actually logged in
275
      failures_to (str): optional URL path to redirect to if the user declines or
276
        an error happens.
277
    """
278
    assert from_params
1✔
279
    if isinstance(from_params, str):
1✔
280
        from_params = [from_params]
1✔
281
    if isinstance(to_params, str):
1✔
282
        to_params = [to_params]
1✔
283

284
    def load(params):
1✔
285
        for param in params:
1✔
286
            if urlsafe_key := request.values.get(param):
1✔
287
                break
1✔
288
        else:
289
            error(f'missing one of required params: {params}')
1✔
290

291
        key = ndb.Key(urlsafe=urlsafe_key)
1✔
292
        if auth := key.get():
1✔
293
            if not logged_in or key in oauth_dropins.get_logins():
1✔
294
                return auth
1✔
295

296
        logger.warning(f'not logged in for {key}')
1✔
297
        raise Found(location='/')
1✔
298

299
    def decorator(fn):
1✔
300
        @wraps(fn)
1✔
301
        def wrapper(*args, **kwargs):
1✔
302
            if request.values.get('declined'):
1✔
303
                flash("You'll need to approve the prompt to continue.")
×
304
                return redirect(failures_to or '/')
×
305

306
            from_auth = load(from_params)
1✔
307
            args += (from_auth,)
1✔
308

309
            to_auth = None
1✔
310
            if to_params:
1✔
311
                to_auth = load(to_params)
1✔
312
                if (AUTH_TO_PROTOCOL[from_auth.__class__]
1✔
313
                        == AUTH_TO_PROTOCOL[to_auth.__class__]):
314
                    error(f"Can't migrate {from_auth.__class__.__name__} to {to_auth.__class__.__name__}")
×
315
                args += (to_auth,)
1✔
316

317
            # Check for did:web in Bluesky accounts
318
            for auth in (from_auth, to_auth):
1✔
319
                if (isinstance(auth, oauth_dropins.bluesky.BlueskyAuth)
1✔
320
                        and auth.key.id().startswith('did:web:')):
321
                    flash('Sorry, did:webs are not currently supported.')
1✔
322
                    return redirect('/')
1✔
323

324
            return fn(*args, **kwargs)
1✔
325
        return wrapper
1✔
326
    return decorator
1✔
327

328

329
def bluesky_session_callback(auth_entity):
1✔
330
    """Returns a callable to pass to lexrpc.Client as session_callback.
331

332
    When an access token or OAuth DPoP token is refreshed, stores the new token
333
    back to the datastore in the auth entity.
334
    """
335
    def callback(session_or_auth):
×
336
        if isinstance(session_or_auth, dict):
×
337
            if session_or_auth != auth_entity.session:
×
338
                auth_entity.session = session_or_auth
×
339
                auth_entity.put()
×
340

341
        elif isinstance(session_or_auth, OAuth2AccessTokenAuth):
×
342
            serialized = DPoPTokenSerializer.default_dumper(auth.dpop_token)
×
343
            if session_or_auth.token != serialized:
×
344
                auth_entity.dpop_token = serialized
×
345
                auth_entity.put()
×
346

347

348
def granary_source(auth, with_auth=False, **requests_kwargs):
1✔
349
    """Returns a granary source instance for a given auth entity.
350

351
    Args:
352
      auth (oauth_dropins.models.BaseAuth)
353
      with_auth (bool)
354
      requests_kwargs (dict): passed to :func:`requests.get`/:func:`requests.post`
355

356
    Returns:
357
      granary.source.Source:
358
    """
359
    if isinstance(auth, (oauth_dropins.mastodon.MastodonAuth,
1✔
360
                         oauth_dropins.pixelfed.PixelfedAuth)):
361
        try:
1✔
362
            instance = instance=auth.instance()
1✔
363
        except RuntimeError as e:
1✔
364
            # the MastodonApp entity probably got deleted manually. oauth-dropins
365
            # will have cleaned this up and logged out the bad login.
366
            return None
1✔
367

368
        return Mastodon(instance, access_token=auth.access_token_str,
1✔
369
                        user_id=auth.user_id(), **requests_kwargs)
370

371
    elif isinstance(auth, oauth_dropins.bluesky.BlueskyAuth):
1✔
372
        if with_auth:
1✔
373
            oauth_client = oauth_dropins.bluesky.oauth_client_for_pds(
1✔
374
                bluesky_oauth_client_metadata(), auth.pds_url)
375
            token = DPoPTokenSerializer.default_loader(auth.dpop_token)
1✔
376
            dpop_auth = OAuth2AccessTokenAuth(client=oauth_client, token=token)
1✔
377
            requests_kwargs['auth'] = dpop_auth
1✔
378

379
        return Bluesky(pds_url=auth.pds_url, handle=auth.user_display_name(),
1✔
380
                       did=auth.key.id(), session_callback=bluesky_session_callback,
381
                       **requests_kwargs)
382

383

384
def _get_user(auth):
1✔
385
    """Loads and returns the Bridgy Fed user for a given auth entity.
386

387
    Args:
388
      auth (oauth_dropins.models.BaseAuth)
389

390
    Returns:
391
      models.User:
392
    """
393
    proto = AUTH_TO_PROTOCOL[auth.__class__]
1✔
394
    id = auth.actor_id() if proto == ActivityPub else auth.key.id()
1✔
395

396
    with ndb.context.Context(bridgy_fed_ndb).use():
1✔
397
        return proto.get_or_create(id, allow_opt_out=True)
1✔
398

399
get_from_user = _get_user
1✔
400

401

402
def get_to_user(*, to_auth, from_auth):
1✔
403
    """Loads a "to" user and checks that it's eligible for migration.
404

405
    If it's ineligible, returns ``None``.
406
    """
407
    user = _get_user(to_auth)
1✔
408

409
    # eligibility checks
410
    with ndb.context.Context(bridgy_fed_ndb).use():
1✔
411
        # keep in sync with bridgy_fed.models.User.enabled_protocol!
412
        if user.status and user.status not in ('nobot', 'private'):
1✔
413
            desc = models.USER_STATUS_DESCRIPTIONS.get(user.status)
1✔
414
            flash(f"Sorry, {to_auth.user_display_name()} isn't eligible yet because your {desc}. <a href='https://fed.brid.gy/docs#troubleshooting'>More details here.</a> Feel free to try again once that's fixed!")
1✔
415
            oauth_dropins.logout(to_auth)
1✔
416
            raise Found(location=url('/to', from_auth))
1✔
417

418
        from_proto = AUTH_TO_PROTOCOL[from_auth.__class__]
1✔
419
        if user.is_enabled(from_proto):
1✔
420
            flash(f'{to_auth.user_display_name()} is already bridged to {from_proto.PHRASE}. Please <a href="https://fed.brid.gy/docs#opt-out">disable that</a> first or choose another account.')
1✔
421
            oauth_dropins.logout(to_auth)
1✔
422
            raise Found(location=url('/to', from_auth))
1✔
423

424
    return user
1✔
425

426

427
#
428
# views
429
#
430
@app.get('/')
1✔
431
@flask_util.headers(CACHE_CONTROL)
1✔
432
def front_page():
1✔
433
    """View for the front page."""
434
    return render_template('index.html', **template_vars())
1✔
435

436

437
@app.get('/docs')
1✔
438
@flask_util.headers(CACHE_CONTROL)
1✔
439
def docs():
1✔
440
    """View for the docs page."""
441
    return render_template('docs.html', **template_vars())
×
442

443

444
@app.post('/logout')
1✔
445
def logout():
1✔
446
    """Logs the user out of all current login sessions."""
447
    oauth_dropins.logout()
1✔
448
    flash("OK, you're now logged out.")
1✔
449
    return redirect('/')
1✔
450

451

452
@app.get('/from')
1✔
453
def choose_from():
1✔
454
    """Choose account to migrate from."""
455
    vars = template_vars()
1✔
456

457
    accounts = [a for a in vars['auths'] if isinstance(a, oauth_dropins.bluesky.BlueskyAuth)]
1✔
458
    for acct in accounts:
1✔
459
        acct.url = url('/to', acct)
1✔
460

461
    return render_template(
1✔
462
        'accounts.html',
463
        body_id='from',
464
        accounts=accounts,
465
        bluesky_button=oauth_dropins.bluesky.Start.button_html(
466
            '/oauth/bluesky/start/from',
467
            image_prefix='/oauth_dropins_static/'),
468
        # mastodon_button=oauth_dropins.mastodon.Start.button_html(
469
        #     '/oauth/mastodon/start/from',
470
        #     image_prefix='/oauth_dropins_static/'),
471
        # pixelfed_button=oauth_dropins.pixelfed.Start.button_html(
472
        #     '/oauth/pixelfed/start/from',
473
        #     image_prefix='/oauth_dropins_static/'),
474
        # threads_button=oauth_dropins.threads.Start.button_html(
475
        #     '/oauth/threads/start/from',
476
        #     image_prefix='/oauth_dropins_static/'),
477
        **vars,
478
    )
479

480

481
@app.get('/to')
1✔
482
@require_accounts(('from', 'auth_entity'), failures_to='/from')
1✔
483
def choose_to(from_auth):
1✔
484
    """Choose account to migrate to."""
485
    if from_auth.key.id().startswith('did:web:'):
1✔
486
        flash('Sorry, did:webs are not currently supported.')
×
487
        return redirect('/')
×
488

489
    vars = template_vars()
1✔
490

491
    from_proto = AUTH_TO_PROTOCOL[from_auth.__class__]
1✔
492
    accounts = [auth for auth in vars['auths']
1✔
493
                if from_proto != AUTH_TO_PROTOCOL[auth.__class__]]
494
    for acct in accounts:
1✔
495
        acct.url = url('/review', from_auth, acct)
1✔
496

497
    state = f'<input type="hidden" name="state" value="{from_auth.key.urlsafe().decode()}" />'
1✔
498
    return render_template(
1✔
499
        'accounts.html',
500
        body_id='to',
501
        from_auth=from_auth,
502
        from_proto=from_proto,
503
        accounts=accounts,
504
        # bluesky_button=oauth_dropins.bluesky.Start.button_html(
505
        #     '/oauth/bluesky/start/to',
506
        #     image_prefix='/oauth_dropins_static/', form_extra=state),
507
        mastodon_button=oauth_dropins.mastodon.Start.button_html(
508
            '/oauth/mastodon/start/to',
509
            image_prefix='/oauth_dropins_static/', form_extra=state),
510
        pixelfed_button=oauth_dropins.pixelfed.Start.button_html(
511
            '/oauth/pixelfed/start/to',
512
            image_prefix='/oauth_dropins_static/', form_extra=state),
513
        # threads_button=oauth_dropins.threads.Start.button_html(
514
        #     '/oauth/threads/start/to',
515
        #     image_prefix='/oauth_dropins_static/', form_extra=state),
516
        **vars,
517
    )
518

519

520
@app.get('/review')
1✔
521
@require_accounts(('from', 'state'), ('to', 'auth_entity'), failures_to='/from')
1✔
522
def review(from_auth, to_auth):
1✔
523
    """Reviews a "from" account's followers and follows."""
524
    force = 'force' in request.args
1✔
525

526
    migration = Migration.get_or_insert(from_auth, to_auth)
1✔
527

528
    if migration.state and migration.state >= State.migrate_follows:
1✔
529
        flash(f'{from_auth.user_display_name()} has already begun migrating to {migration.to.get().user_display_name()}.')
1✔
530
        return redirect(url('/to', from_auth))
1✔
531

532
    # check that "to" user is eligible
533
    get_to_user(to_auth=to_auth, from_auth=from_auth)
1✔
534

535
    if migration.to and migration.to != to_auth.key:
1✔
536
        logger.info(f'  overwriting existing to {migration.to} with {to_auth.key}')
1✔
537
        migration.to = to_auth.key
1✔
538

539
    if force:
1✔
540
        # restart from the beginning
541
        migration.state = State.review_followers
×
542
        migration.review = {}
×
543
        migration.followed = []
×
544
        migration.to_follow = []
×
545

546
    new_task = force or util.now() - migration.updated >= STALE_TASK_AGE
1✔
547
    if not migration.state:
1✔
548
        migration.state = State.review_followers
1✔
549
        new_task = True
1✔
550

551
    migration.put()
1✔
552

553
    if new_task:
1✔
554
        migration.create_task('review')
1✔
555

556
    if force:
1✔
557
        # don't meta refresh reload with the force query param, since that would
558
        # create a new task on every refresh
559
        path, _ = util.remove_query_param(request.full_path, 'force')
×
560
        return redirect(path)
×
561

562
    return render_template(
1✔
563
        ('review.html' if migration.state == State.review_done
564
         else 'review_progress.html'),
565
        from_auth=from_auth,
566
        to_auth=to_auth,
567
        migration=migration,
568
        State=State,
569
        **migration.review,
570
        **template_vars(),
571
    )
572

573

574
@app.post('/queue/review')
1✔
575
@cloud_tasks_only()
1✔
576
@require_accounts('from', 'to', logged_in=False)
1✔
577
def review_task(from_auth, to_auth):
1✔
578
    """Review a "from" account's followers and follows."""
579
    logger.info(f'Params: {list(request.values.items())}')
1✔
580
    logger.info(f'Reviewing {from_auth.key.id()} {from_auth.user_display_name()} => {to_auth.site_name()}')
1✔
581
    migration = Migration.get(from_auth, to_auth)
1✔
582
    assert migration, (from_auth, to_auth)
1✔
583

584
    logger.info(f'  {migration.key} {migration.state.name if migration.state else "new"}')
1✔
585
    if migration.state is None:
1✔
586
        migration.state = State.review_followers
1✔
587
    assert migration.state <= State.review_done
1✔
588

589
    source = granary_source(from_auth, with_auth=True, **TASK_REQUESTS_KWARGS)
1✔
590
    from_auth.url = source.to_as1_actor(json.loads(from_auth.user_json)).get('url')
1✔
591

592
    # Process based on migration state
593
    if migration.state == State.review_followers:
1✔
594
        review_followers(migration, from_auth)
1✔
595
        migration.state = State.review_follows
1✔
596
        migration.put()
1✔
597

598
    if migration.state == State.review_follows:
1✔
599
        review_follows(migration, from_auth, to_auth)
1✔
600
        migration.state = State.review_analyze
1✔
601
        migration.put()
1✔
602

603
    if migration.state == State.review_analyze:
1✔
604
        analyze_review(migration, from_auth)
1✔
605
        migration.state = State.review_done
1✔
606
        migration.put()
1✔
607

608
    return 'OK'
1✔
609

610

611
def review_followers(migration, from_auth):
1✔
612
    """Fetches followers for the account being reviewed.
613

614
    Args:
615
      migration (Migration)
616
      from_auth (oauth_dropins.models.BaseAuth)
617
    """
618
    logger.info(f'Fetching followers for {from_auth.key_id()}')
1✔
619
    from_proto = AUTH_TO_PROTOCOL[from_auth.__class__]
1✔
620

621
    source = granary_source(from_auth, with_auth=True, **TASK_REQUESTS_KWARGS)
1✔
622
    followers = source.get_followers()
1✔
623

624
    if not followers:
1✔
625
        logger.info('no followers!')
1✔
626
        migration.review.update({
1✔
627
            'followers_preview_raw': [],
628
            'follower_counts': [],
629
        })
630
        return
1✔
631

632
    ids = [f['id'] for f in followers if f.get('id')]
1✔
633
    for follower in followers:
1✔
634
        follower['image'] = util.get_first(follower, 'image')
1✔
635

636
    if from_proto.HAS_COPIES:
1✔
637
        follower_counts = []
1✔
638
        with ndb.context.Context(bridgy_fed_ndb).use():
1✔
639
            for proto in PROTOCOLS:
1✔
640
                if proto != from_proto:
1✔
641
                    count = proto.query(proto.copies.uri.IN(ids)).count()
1✔
642
                    follower_counts.append([proto.__name__, count])
1✔
643
        rest = sum(count for _, count in follower_counts)
1✔
644
        follower_counts.append([from_proto.__name__, len(followers) - rest])
1✔
645

646
    else:
647
        by_proto = defaultdict(list)
1✔
648
        for id in ids:
1✔
649
            domain = util.domain_from_link(id)
1✔
650
            by_proto[BRIDGE_DOMAIN_TO_PROTOCOL.get(domain, ActivityPub)].append(id)
1✔
651
        follower_counts = list((model.__name__, len(ids))
1✔
652
                               for model, ids in by_proto.items())
653

654
    logger.info(f'  {len(followers)} total, {follower_counts}')
1✔
655

656
    migration.review.update({
1✔
657
        'followers_preview_raw': followers[:FOLLOWERS_PREVIEW_LEN],
658
        'follower_counts': follower_counts,
659
    })
660

661

662
def review_follows(migration, from_auth, to_auth):
1✔
663
    """Fetches follows for the account being reviewed.
664

665
    Args:
666
      migration (Migration)
667
      from_auth (oauth_dropins.models.BaseAuth)
668
      to_auth (oauth_dropins.models.BaseAuth)
669
    """
670
    from_proto = AUTH_TO_PROTOCOL[from_auth.__class__]
1✔
671
    to_proto = AUTH_TO_PROTOCOL[to_auth.__class__]
1✔
672
    logger.info(f'Fetching follows for {from_auth.key_id()} with to proto {to_proto.LABEL}')
1✔
673

674
    source = granary_source(from_auth, with_auth=True, **TASK_REQUESTS_KWARGS)
1✔
675
    follows = source.get_follows()
1✔
676

677
    if not follows:
1✔
678
        logger.info('no follows!')
1✔
679
        migration.review.update({
1✔
680
            'follows_preview_raw': [],
681
            'follow_counts': [],
682
            'total_bridged_follows': 0,
683
        })
684
        return
1✔
685

686
    ids_by_proto = defaultdict(list)
1✔
687
    for followee in follows:
1✔
688
        followee['image'] = util.get_first(followee, 'image')
1✔
689
        id = common.unwrap(followee.get('id'))
1✔
690
        proto = Protocol.for_id(id, remote=False) or from_proto
1✔
691
        ids_by_proto[proto].append(id)
1✔
692

693
    to_follow = []      # Users (with only key populated, no properties)
1✔
694
    to_follow_ids = []  # str user ids, in to_proto
1✔
695
    follow_counts = []  # (str protocol class, count)
1✔
696
    with ndb.context.Context(bridgy_fed_ndb).use():
1✔
697
        if from_proto.HAS_COPIES:
1✔
698
            ids = list(chain(*ids_by_proto.values()))
1✔
699
            for proto in PROTOCOLS:
1✔
700
                if proto == from_proto:
1✔
701
                    query = proto.query(proto.key.IN([proto(id=id).key for id in ids]))
1✔
702
                else:
703
                    query = proto.query(proto.copies.uri.IN(ids))
1✔
704
                if proto != to_proto:
1✔
705
                    query = query.filter(proto.enabled_protocols == to_proto.LABEL)
1✔
706
                keys = query.fetch(keys_only=True)
1✔
707
                to_follow.extend(proto(key=key) for key in keys)
1✔
708
                follow_counts.append([f'{proto.__name__}', len(keys)])
1✔
709

710
        else:
711
            for proto, ids in ids_by_proto.items():
1✔
712
                if proto == to_proto:
1✔
713
                    bridged = ids
1✔
714
                    to_follow_ids.extend(ids)
1✔
715
                else:
716
                    query = proto.query(
1✔
717
                        proto.key.IN([proto(id=id).key for id in ids]),
718
                        proto.enabled_protocols == to_proto.LABEL,
719
                    )
720
                    bridged = query.fetch(keys_only=True)
1✔
721
                    to_follow.extend(proto(key=key) for key in bridged)
1✔
722

723
                follow_counts.append([f'{proto.__name__}', len(bridged)])
1✔
724

725
        for user in to_follow:
1✔
726
            if id := user.id_as(to_proto):
1✔
727
                to_follow_ids.append(id)
1✔
728

729
    for id in to_follow_ids:
1✔
730
        if id not in migration.followed and id not in migration.to_follow:
1✔
731
            migration.to_follow.append(id)
1✔
732

733
    total_bridged_follows = sum(count for _, count in follow_counts)
1✔
734
    follow_counts.append(['not bridged', len(follows) - total_bridged_follows])
1✔
735

736
    logger.info(f'{len(follows)} total, {follow_counts}')
1✔
737

738
    migration.review.update({
1✔
739
        'follows_preview_raw': follows[:FOLLOWERS_PREVIEW_LEN],
740
        'follow_counts': follow_counts,
741
        'total_bridged_follows': total_bridged_follows,
742
    })
743

744

745
def analyze_review(migration, from_auth):
1✔
746
    """Analyzes the follow/follower data and generates previews.
747

748
    Args:
749
      migration (Migration)
750
    """
751
    from_proto = AUTH_TO_PROTOCOL[from_auth.__class__]
1✔
752
    logger.info(f'Generating review for {from_auth.key_id()}')
1✔
753

754
    # generate previews of individual follower and following users (BF Users)
755
    followers_preview = []
1✔
756
    follows_preview = []
1✔
757
    with ndb.context.Context(bridgy_fed_ndb).use():
1✔
758
        for raw, preview in (
1✔
759
                (migration.review['followers_preview_raw'], followers_preview),
760
                (migration.review['follows_preview_raw'], follows_preview),
761
        ):
762
            for actor in raw[:FOLLOWERS_PREVIEW_LEN]:
1✔
763
                user = None
1✔
764
                id = actor['id']
1✔
765
                if from_proto.HAS_COPIES:
1✔
766
                    if key := models.get_original_user_key(id):
1✔
767
                        user = key.get()
1✔
768
                    else:
769
                        user = from_proto.get_or_create(id, allow_opt_out=True)
1✔
770

771
                else:
772
                    if proto := Protocol.for_id(id):
1✔
773
                        if proto != from_proto:
1✔
774
                            id = translate_user_id(id=id, from_=from_proto, to=proto)
1✔
775
                        user = proto.get_or_create(id, allow_opt_out=True)
1✔
776

777
                if user:
1✔
778
                    preview.append(user.user_link(pictures=True))
1✔
779

780
    # total counts in human-friendly form, eg 12K, 2M
781
    # hacky, uses humanize's file size function and then tweaks it
782
    # https://humanize.readthedocs.io/en/latest/filesize/
783
    def humanize_number(num):
1✔
784
        return humanize.naturalsize(num, format='%.0f')\
1✔
785
                       .upper().removesuffix('BYTES').removesuffix('BYTE')\
786
                       .rstrip('B').replace(' ', '')
787

788
    # total counts, percentage of follows that will be kept
789
    follow_counts = migration.review['follow_counts']
1✔
790
    total_follows = sum(count for _, count in follow_counts)
1✔
791

792
    follower_counts = migration.review['follower_counts']
1✔
793
    total_followers = sum(count for _, count in follower_counts)
1✔
794

795
    keep_follows_pct = 100
1✔
796
    if total_follows > 0:
1✔
797
        total_bridged_follows = migration.review['total_bridged_follows']
1✔
798
        assert total_bridged_follows <= total_follows
1✔
799
        keep_follows_pct = round(total_bridged_follows / total_follows * 100)
1✔
800

801
    migration.review.update({
1✔
802
        'followers_preview': followers_preview,
803
        'follows_preview': follows_preview,
804
        'total_followers': humanize_number(total_followers),
805
        'total_follows': humanize_number(total_follows),
806
        'follower_counts': [['type', 'count']] + sorted(follower_counts),
807
        'follow_counts': [['type', 'count']] + sorted(follow_counts),
808
        'keep_follows_pct': keep_follows_pct,
809
    })
810

811

812
@app.get('/bluesky-password')
1✔
813
@require_accounts('from', 'to')
1✔
814
def bluesky_password(from_auth, to_auth):
1✔
815
    """View for entering the user's Bluesky password."""
816
    if not isinstance(from_auth, oauth_dropins.bluesky.BlueskyAuth):
×
817
        error(f'{from_auth.key.id()} is not Bluesky')
×
818

819
    return render_template(
×
820
        'bluesky_password.html',
821
        from_auth=from_auth,
822
        to_auth=to_auth,
823
        **template_vars(),
824
    )
825

826

827
@app.post('/confirm')
1✔
828
@require_accounts('from', 'to')
1✔
829
def confirm(from_auth, to_auth):
1✔
830
    """View for the migration confirmation page."""
831
    if isinstance(from_auth, oauth_dropins.bluesky.BlueskyAuth):
1✔
832
        # ask their PDS to email them a code that we'll need for it to sign the
833
        # PLC update operation
834
        bsky = Bluesky(pds_url=from_auth.pds_url,
1✔
835
                       did=from_auth.key.id(),
836
                       handle=from_auth.user_display_name(),
837
                       app_password=get_required_param('password'))
838
        try:
1✔
839
            bsky.client.com.atproto.identity.requestPlcOperationSignature()
1✔
840
        except RequestException as e:
1✔
841
            _, body = util.interpret_http_exception(e)
1✔
842
            flash(f'Login failed: {body}')
1✔
843
            return redirect(url('/bluesky-password', from_auth, to_auth))
1✔
844

845
        # store password-based access token, we'll use it later in /migrate
846
        from_auth.session = bsky.client.session
1✔
847
        from_auth.put()
1✔
848

849
    return render_template(
1✔
850
        'confirm.html',
851
        from_auth=from_auth,
852
        to_auth=to_auth,
853
        **template_vars(),
854
    )
855

856

857
@app.post('/migrate')
1✔
858
@require_accounts('from', 'to')
1✔
859
def migrate_post(from_auth, to_auth):
1✔
860
    """Migrate handler that starts a background task.
861

862
    Post body args:
863
      plc_code (str)
864
    """
865
    logger.info(f'Params: {list(request.values.items())}')
1✔
866
    logger.info(f'Migrating {from_auth.key.id()} {to_auth.key.id()}')
1✔
867

868
    migration = Migration.get(from_auth, to_auth)
1✔
869

870
    if not migration:
1✔
871
        error('migration not found', status=404)
1✔
872
    elif not migration.state or migration.state < State.review_done:
1✔
873
        flash(f'Migration can only start after review is completed.')
×
874
        return redirect(url('/review', from_auth, to_auth))
×
875
    elif migration.to != to_auth.key:
1✔
876
        return redirect(url('/to', from_auth))
×
877

878
    migration.plc_code = get_required_param('plc_code')
1✔
879
    migration.put()
1✔
880

881
    stale = util.now() - migration.updated >= STALE_TASK_AGE
1✔
882
    if migration.state < State.migrate_done or stale:
1✔
883
        if migration.state == State.review_done:
1✔
884
            migration.state = State.migrate_follows
1✔
885
        migration.put()
1✔
886
        migration.create_task('migrate')
1✔
887

888
    return redirect(url('/migrate', from_auth, to_auth))
1✔
889

890

891
@app.get('/migrate')
1✔
892
@require_accounts('from', 'to')
1✔
893
def migrate_get(from_auth, to_auth):
1✔
894
    """Migrate handler that shows progress or the final result."""
895
    migration = Migration.get(from_auth, to_auth)
1✔
896
    if not migration:
1✔
897
        error('migration not found', status=404)
×
898
    elif not migration.state or migration.state < State.review_done:
1✔
899
        flash(f'Migration can only start after review is completed.')
×
900
        return redirect(url('/review', from_auth, to_auth))
×
901

902
    return render_template(
1✔
903
        ('migrated.html' if migration.state == State.migrate_done
904
         else 'migration_progress.html'),
905
        from_auth=from_auth,
906
        to_auth=to_auth,
907
        migration=migration,
908
        State=State,
909
        **template_vars(),
910
    )
911

912

913
@app.post('/queue/migrate')
1✔
914
@cloud_tasks_only()
1✔
915
@require_accounts('from', 'to', logged_in=False)
1✔
916
def migrate_task(from_auth, to_auth):
1✔
917
    """Handle the migration background task."""
918
    logger.info(f'Params: {list(request.values.items())}')
1✔
919
    logger.info(f'Processing migration task for {from_auth.key.id()} {from_auth.user_display_name()} => {to_auth.user_display_name()}')
1✔
920
    migration = Migration.get(from_auth, to_auth)
1✔
921
    assert migration, (from_auth, to_auth)
1✔
922

923
    logger.info(f'  {migration.key} {migration.state.name}')
1✔
924
    assert migration.state >= State.migrate_follows
1✔
925
    if migration.state == State.migrate_done:
1✔
926
        return 'OK'
×
927

928
    migration.last_attempt = util.now()
1✔
929
    migration.put()
1✔
930

931
    from_user = get_from_user(from_auth)
1✔
932
    to_user = get_to_user(to_auth=to_auth, from_auth=from_auth)
1✔
933

934
    # Process based on migration state
935
    if migration.state == State.migrate_follows:
1✔
936
        migrate_follows(migration, to_auth)
1✔
937
        migration.state = State.migrate_in_blobs
1✔
938
        migration.put()
1✔
939

940
    # need to migrate blobs before migrating the account, since after we've
941
    # deactivated the account, it no longer serves blobs or any other XRPC calls
942
    if migration.state == State.migrate_in_blobs:
1✔
943
        migrate_in_blobs(from_auth)
1✔
944
        migration.state = State.migrate_in
1✔
945
        migration.put()
1✔
946

947
    if migration.state == State.migrate_in:
1✔
948
        migrate_in(migration, from_auth, from_user, to_user)
1✔
949
        migration.state = State.migrate_out
1✔
950
        migration.put()
1✔
951

952
    if migration.state == State.migrate_out:
1✔
953
        migrate_out(migration, from_user, to_user)
1✔
954
        migration.state = State.migrate_done
1✔
955
        migration.put()
1✔
956

957
    return 'OK'
1✔
958

959

960
def migrate_follows(migration, to_auth):
1✔
961
    """Creates follows in the destination account.
962

963
    Args:
964
      migration (Migration)
965
      to_auth (oauth_dropins.models.BaseAuth)
966
    """
967
    logging.info(f'Creating follows for {to_auth.key_id()}')
1✔
968
    to_follow = migration.to_follow
1✔
969
    migration.to_follow = []
1✔
970
    source = granary_source(to_auth, with_auth=True, **TASK_REQUESTS_KWARGS)
1✔
971

972
    for user_id in to_follow:
1✔
973
        logger.info(f'Folowing {user_id}')
1✔
974
        try:
1✔
975
            # Use the granary source to create the follow
976
            result = source.create({
1✔
977
                'objectType': 'activity',
978
                'verb': 'follow',
979
                'object': user_id,
980
            })
981
            if result.error_plain:
1✔
982
                logger.warning(f'Failed: {result.error_plain}')
×
983
                migration.to_follow.append(user_id)
×
984
                continue
×
985

986
            migration.followed.append(user_id)
1✔
987

988
        except BaseException as e:
1✔
989
            logger.warning(f'Failed: {e}')
1✔
990
            migration.to_follow.append(user_id)
1✔
991
            code, _ = util.interpret_http_exception(e)
1✔
992
            if not code:
1✔
993
                migration.put()
×
994
                raise
×
995

996

997
def migrate_in(migration, from_auth, from_user, to_user):
1✔
998
    """Migrates a source native account into Bridgy Fed to be a bridged account.
999

1000
    Args:
1001
      migration (Migration)
1002
      from_auth (oauth_dropins.models.BaseAuth)
1003
      from_user (models.User)
1004
    """
1005
    logging.info(f'Migrating {from_user.key.id()} in')
1✔
1006

1007
    migrate_in_kwargs = {}
1✔
1008

1009
    if isinstance(from_auth, oauth_dropins.bluesky.BlueskyAuth):
1✔
1010
        # use the password-based session stored earlier in /confirm, since
1011
        # signPlcOperation (below) doesn't support OAuth DPoP tokens
1012
        old_pds_client = from_auth._api(session_callback=bluesky_session_callback)
1✔
1013

1014
        # export repo from old PDS, import into BF
1015
        #
1016
        # note that this currently loads the repo into memory. to stream the output
1017
        # from getRepo, we'd need to modify lexrpc.Client, but that's doable. the
1018
        # harder part might be decoding the CAR streaming, in xrpc_repo.import_repo,
1019
        # which currently uses carbox. maybe still doable though?
1020
        resp = old_pds_client.com.atproto.sync.getRepo({}, did=from_auth.key.id())
1✔
1021
        repo_car = resp.content
1✔
1022

1023
        logging.info(f'Importing repo from {from_auth.pds_url}')
1✔
1024
        with ndb.context.Context(bridgy_fed_ndb).use(), \
1✔
1025
             app.test_request_context('/migrate', headers={
1026
                 'Authorization': f'Bearer {os.environ["REPO_TOKEN"]}',
1027
             }):
1028
            xrpc_repo.import_repo(repo_car)
1✔
1029

1030
        assert migration.plc_code
1✔
1031
        migrate_in_kwargs = {
1✔
1032
            'plc_code': migration.plc_code,
1033
            'pds_client': old_pds_client,
1034
        }
1035

1036
    with ndb.context.Context(bridgy_fed_ndb).use():
1✔
1037
         from_user.migrate_in(to_user, from_user.key.id(), **migrate_in_kwargs)
1✔
1038

1039
    memcache.remote_evict(from_user.key)
1✔
1040

1041

1042
def migrate_in_blobs(from_auth):
1✔
1043
    """Migrates a Bluesky user's blobs into Bridgy Fed's Google Cloud Storage.
1044

1045
    https://atproto.com/guides/account-migration#migrating-data
1046
    https://cloud.google.com/storage/docs/uploading-objects-from-memory#storage-upload-object-from-memory-python
1047
    https://cloud.google.com/python/docs/reference/storage/latest/google.cloud.storage.blob.Blob.html#google_cloud_storage_blob_Blob_upload_from_string
1048

1049
    Args:
1050
      from_auth (oauth_dropins.bluesky.BlueskyAuth)
1051
    """
1052
    if not isinstance(from_auth, oauth_dropins.bluesky.BlueskyAuth):
1✔
1053
        return
1✔
1054

1055
    if not util.domain_or_parent_in(util.domain_from_link(from_auth.pds_url),
1✔
1056
                                    MAIN_PDS_DOMAINS):
1057
        logger.info(f"Not migrating blobs, PDS {from_auth.pds_url} isn't Bluesky co's")
1✔
1058
        return
1✔
1059

1060
    source = granary_source(from_auth)
1✔
1061

1062
    did = from_auth.key.id()
1✔
1063
    logging.info(f"Migrating Bluesky account {did}'s blobs into GCS")
1✔
1064

1065
    client = storage.Client()
1✔
1066
    bucket = client.bucket(CLOUD_STORAGE_BUCKET)
1✔
1067

1068
    # TODO: give bounce permission to BF GCS
1069

1070
    with ndb.context.Context(bridgy_fed_ndb).use():
1✔
1071
        for cid in source.client.com.atproto.sync.listBlobs(did=did)['cids']:
1✔
1072
            logger.info(f'importing {cid}')
1✔
1073
            path = f'atproto-blobs/{cid}'
1✔
1074
            url = urljoin(CLOUD_STORAGE_BASE_URL, f'/{CLOUD_STORAGE_BUCKET}/{path}')
1✔
1075
            if blob := AtpRemoteBlob.get_by_id(url):
1✔
1076
                assert blob.cid == cid
1✔
1077
                logger.info('  already exists, skipping')
1✔
1078
                continue
1✔
1079

1080
            resp = source.client.com.atproto.sync.getBlob(did=did, cid=cid)
1✔
1081
            type = resp.headers.get('Content-Type')
1✔
1082

1083
            obj = bucket.blob(path)
1✔
1084
            obj.upload_from_string(resp.content, content_type=type)
1✔
1085
            obj.make_public()
1✔
1086

1087
            blob = AtpRemoteBlob(id=url, cid=cid, mime_type=type,
1✔
1088
                                 size=len(resp.content))
1089
            blob.generate_metadata(resp.content)
1✔
1090
            blob.put()
1✔
1091

1092

1093
def migrate_out(migration, from_user, to_user):
1✔
1094
    """Migrates a Bridgy Fed bridged account out to a native account.
1095

1096
    Args:
1097
      migration (Migration)
1098
      from_user (models.User)
1099
      to_user (models.User)
1100
    """
1101
    logging.info(f'Migrating bridged account {from_user.key.id()} out to {to_user.key.id()}')
1✔
1102

1103
    with ndb.context.Context(bridgy_fed_ndb).use():
1✔
1104
        to_proto = to_user.__class__
1✔
1105
        if from_user.is_enabled(to_proto):
1✔
1106
            # TODO: tell the user to add the bridged Bluesky account to their Mastodon
1107
            # account's alsoKnownAs aliases
1108
            to_user.migrate_out(from_user, to_user.key.id())
1✔
1109

1110
    from_proto = from_user.__class__
1✔
1111
    if from_proto.HAS_COPIES:
1✔
1112
        # connect to account to from account
1113
        while existing := to_user.get_copy(from_proto):
1✔
1114
            copy = models.Target(protocol=from_proto.LABEL, uri=existing)
1✔
1115
            logger.warning(f'Overwriting {to_user.key.id()} {copy}')
1✔
1116
            to_user.remove('copies', copy)
1✔
1117

1118
        # TODO: will probably need to change for migrating from non-ATProto (ie
1119
        # non-portable-identity) protocols
1120
        logger.info(f'Setting {to_user.key.id()} {from_proto.LABEL} copy to {from_user.key.id()}')
1✔
1121
        with ndb.context.Context(bridgy_fed_ndb).use():
1✔
1122
            to_user.add('copies', models.Target(protocol=from_proto.LABEL,
1✔
1123
                                                uri=from_user.key.id()))
1124
            to_user.put()
1✔
1125
            to_user.enable_protocol(from_proto)
1✔
1126
            from_proto.bot_follow(to_user)
1✔
1127

1128
            # update profile from to account
1129
            from_profile_id = ids.profile_id(id=from_user.key.id(), proto=from_proto)
1✔
1130
            to_user.obj.add('copies', models.Target(protocol=from_proto.LABEL,
1✔
1131
                                                    uri=from_profile_id))
1132
            to_user.obj.put()
1✔
1133
            to_proto.receive(obj=to_user.obj, authed_as=to_user.key.id())
1✔
1134

1135
    memcache.remote_evict(to_user.key)
1✔
1136

1137

1138
#
1139
# OAuth
1140
#
1141
class MastodonStart(FlashErrors, oauth_dropins.mastodon.Start):
1✔
1142
    DEFAULT_SCOPE = 'read:accounts read:follows read:search write:follows'
1✔
1143
    REDIRECT_PATHS = (
1✔
1144
        '/oauth/mastodon/finish/from',
1145
        '/oauth/mastodon/finish/to',
1146
    )
1147

1148
class MastodonCallback(FlashErrors, oauth_dropins.mastodon.Callback):
1✔
1149
    pass
1✔
1150

1151
class PixelfedStart(FlashErrors, oauth_dropins.pixelfed.Start):
1✔
1152
    # no granular scopes yet. afaict the available scopes aren't documented at all :(
1153
    # https://github.com/pixelfed/pixelfed/issues/2102#issuecomment-609474544
1154
    DEFAULT_SCOPE = 'read write'
1✔
1155
    REDIRECT_PATHS = (
1✔
1156
        '/oauth/pixelfed/finish/from',
1157
        '/oauth/pixelfed/finish/to',
1158
    )
1159

1160
class PixelfedCallback(FlashErrors, oauth_dropins.pixelfed.Callback):
1✔
1161
    pass
1✔
1162

1163
# class ThreadsStart(FlashErrors, oauth_dropins.threads.Start):
1164
#     TODO: scopes
1165

1166
# class ThreadsCallback(FlashErrors, oauth_dropins.threads.Callback):
1167
#     pass
1168

1169

1170
app.add_url_rule('/oauth/mastodon/start/from', view_func=MastodonStart.as_view(
1✔
1171
                     '/oauth/mastodon/start/from', '/oauth/mastodon/finish/from'),
1172
                 methods=['POST'])
1173
app.add_url_rule('/oauth/mastodon/finish/from', view_func=MastodonCallback.as_view(
1✔
1174
                     '/oauth/mastodon/finish/from', '/to'))
1175
app.add_url_rule('/oauth/mastodon/start/to', view_func=MastodonStart.as_view(
1✔
1176
                     '/oauth/mastodon/start/to', '/oauth/mastodon/finish/to'),
1177
                 methods=['POST'])
1178
app.add_url_rule('/oauth/mastodon/finish/to', view_func=MastodonCallback.as_view(
1✔
1179
                     '/oauth/mastodon/finish/to', '/review'))
1180

1181
app.add_url_rule('/oauth/pixelfed/start/from', view_func=PixelfedStart.as_view(
1✔
1182
                     '/oauth/pixelfed/start/from', '/oauth/pixelfed/finish/from'),
1183
                 methods=['POST'])
1184
app.add_url_rule('/oauth/pixelfed/finish/from', view_func=PixelfedCallback.as_view(
1✔
1185
                     '/oauth/pixelfed/finish/from', '/to'))
1186
app.add_url_rule('/oauth/pixelfed/start/to', view_func=PixelfedStart.as_view(
1✔
1187
                     '/oauth/pixelfed/start/to', '/oauth/pixelfed/finish/to'),
1188
                 methods=['POST'])
1189
app.add_url_rule('/oauth/pixelfed/finish/to', view_func=PixelfedCallback.as_view(
1✔
1190
                     '/oauth/pixelfed/finish/to', '/review'))
1191

1192
# app.add_url_rule('/oauth/threads/start/from', view_func=ThreadsStart.as_view(
1193
#                      '/oauth/threads/start/from', '/oauth/threads/finish/from'),
1194
#                  methods=['POST'])
1195
# app.add_url_rule('/oauth/threads/finish/from', view_func=ThreadsCallback.as_view(
1196
#                      '/oauth/threads/finish/from', '/to'))
1197
# app.add_url_rule('/oauth/threads/start/to', view_func=ThreadsStart.as_view(
1198
#                      '/oauth/threads/start/to', '/oauth/threads/finish/to'),
1199
#                  methods=['POST'])
1200
# app.add_url_rule('/oauth/threads/finish/to', view_func=ThreadsCallback.as_view(
1201
#                      '/oauth/threads/finish/to', '/review'))
1202

1203

1204
#
1205
# Bluesky OAuth
1206
#
1207
def bluesky_oauth_client_metadata():
1✔
1208
    return {
1✔
1209
        **oauth_dropins.bluesky.CLIENT_METADATA_TEMPLATE,
1210
        'client_id': f'{request.host_url}oauth/bluesky/client-metadata.json',
1211
        'client_name': 'Bounce',
1212
        'client_uri': request.host_url,
1213
        'redirect_uris': [
1214
            f'{request.host_url}oauth/bluesky/finish/from',
1215
            f'{request.host_url}oauth/bluesky/finish/to',
1216
        ],
1217
    }
1218

1219
class BlueskyOAuthStart(FlashErrors, oauth_dropins.bluesky.OAuthStart):
1✔
1220
    @property
1✔
1221
    def CLIENT_METADATA(self):
1✔
1222
        return bluesky_oauth_client_metadata()
×
1223

1224
class BlueskyOAuthCallback(FlashErrors, oauth_dropins.bluesky.OAuthCallback):
1✔
1225
    @property
1✔
1226
    def CLIENT_METADATA(self):
1✔
1227
        return bluesky_oauth_client_metadata()
×
1228

1229

1230
@app.get('/oauth/bluesky/client-metadata.json')
1✔
1231
@flask_util.headers(CACHE_CONTROL)
1✔
1232
def bluesky_oauth_client_metadata_handler():
1✔
1233
    """https://docs.bsky.app/docs/advanced-guides/oauth-client#client-and-server-metadata"""
1234
    return bluesky_oauth_client_metadata()
×
1235

1236

1237
app.add_url_rule('/oauth/bluesky/start/from', view_func=BlueskyOAuthStart.as_view(
1✔
1238
    '/oauth/bluesky/start/from', '/oauth/bluesky/finish/from'), methods=['POST'])
1239
app.add_url_rule('/oauth/bluesky/finish/from', view_func=BlueskyOAuthCallback.as_view(
1✔
1240
    '/oauth/bluesky/finish/from', '/to'))
1241
app.add_url_rule('/oauth/bluesky/start/to', view_func=BlueskyOAuthStart.as_view(
1✔
1242
    '/oauth/bluesky/start/to', '/oauth/bluesky/finish/to'), methods=['POST'])
1243
app.add_url_rule('/oauth/bluesky/finish/to', view_func=BlueskyOAuthCallback.as_view(
1✔
1244
    '/oauth/bluesky/finish/to', '/review'))
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