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

snarfed / bridgy-fed / 9550e0b1-5a3e-4a43-b9b9-c9dae7259e53

30 Jul 2026 01:36AM UTC coverage: 92.797% (+0.004%) from 92.793%
9550e0b1-5a3e-4a43-b9b9-c9dae7259e53

push

circleci

snarfed
mastodon_api.accounts_statuses: implement exclude_replies, exclude_reblogs

for #2493

[deploy]

8451 of 9107 relevant lines covered (92.8%)

0.93 hits per line

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

95.96
/mastodon_api.py
1
"""Serves the Mastodon API, backed by Bridgy Fed users and objects."""
2
from datetime import timezone
1✔
3
import functools
1✔
4
import logging
1✔
5
import os
1✔
6

7
from authlib.integrations.flask_oauth2.resource_protector import current_token
1✔
8
from flask import request
1✔
9
from google.cloud import ndb
1✔
10
from granary import as1, bluesky
1✔
11
from granary.mastodon import from_as1
1✔
12
from webutil import util
1✔
13
from webutil.flask_util import bool_param, get_required_param, error
1✔
14
from werkzeug.exceptions import BadGateway, HTTPException
1✔
15

16
import activitypub
1✔
17
from activitypub import ActivityPub
1✔
18
from arroba import datastore_storage
1✔
19
from domains import PRIMARY_DOMAIN
1✔
20
from flask_app import app
1✔
21
import ids
1✔
22
from mastodon_oauth import require_oauth
1✔
23
import models
1✔
24
from models import Follower, Object, PROTOCOLS
1✔
25
import webfinger
1✔
26

27
logger = logging.getLogger(__name__)
1✔
28

29
# limits for list endpoints
30
DEFAULT_LIMIT = 20
1✔
31
MAX_LIMIT = 40
1✔
32

33
# how many ancestors to include in a status's context
34
MAX_ANCESTORS = 20
1✔
35

36
# https://docs.joinmastodon.org/entities/Notification/#type
37
AS1_TO_NOTIFICATION_TYPE = {
1✔
38
    'like': 'favourite',
39
    'share': 'reblog',
40
    'follow': 'follow',
41
}
42

43

44
def auth(fn):
1✔
45
    """Requires a valid bearer token, resolves it to a :class:`models.User`.
46

47
    Passes the resolved user to the wrapped view function as a ``user`` kwarg.
48
    """
49
    @require_oauth()
1✔
50
    @functools.wraps(fn)
1✔
51
    def wrapper(*args, **kwargs):
1✔
52
        if not (user := current_token.get_user()):
1✔
53
            error('Account not found', status=401)
×
54
        logger.info(f'Logged in as {user.key.id()} for {request.url}')
1✔
55
        return fn(*args, user=user, **kwargs)
1✔
56

57
    return wrapper
1✔
58

59

60
def to_account(user):
1✔
61
    """Converts a :class:`models.User` to a Mastodon ``Account``.
62

63
    Returns ``None`` if user can't be converted.
64
    """
65
    obj_as1 = user.obj.as1 if user.obj and user.obj.as1 else {}
1✔
66

67
    try:
1✔
68
        account = from_as1(obj_as1) or {}
1✔
69
    except:
×
70
        logger.info(user.key.id(), obj_as1)
×
71
        raise
×
72

73
    username = obj_as1.get('preferredUsername')
1✔
74
    acct = None
1✔
75
    if addr := user.handle_as(ActivityPub):
1✔
76
        acct = addr.removeprefix('@')
1✔
77
        if not username:
1✔
78
            username = acct.split('@')[0]
1✔
79

80
    account.update({
1✔
81
        'id': addr,
82
        'uri': user.id_as(ActivityPub),
83
        'username': username,
84
        'acct': acct,
85
        'display_name': user.name(),
86
        'created_at': (obj_as1.get('published')
87
                       or user.created.replace(tzinfo=timezone.utc).isoformat()),
88
    })
89
    return account
1✔
90

91

92
def to_status(obj):
1✔
93
    """Converts a :class:`models.Object` to a Mastodon ``Status``.
94

95
    Returns None if ``obj`` can't be converted, eg its AS1 ``objectType``/``verb``
96
    isn't supported, or its account can't be fetched or converted.
97
    """
98
    try:
1✔
99
        status = from_as1(obj.as1)
1✔
100
    except:
×
101
        logger.info(obj.key.id(), obj.as1)
×
102
        raise
×
103

104
    if not status:
1✔
105
        return None
1✔
106

107
    if from_proto := PROTOCOLS.get(obj.source_protocol):
1✔
108
        status['uri'] = ids.translate_object_id(
1✔
109
            id=obj.key.id(), from_=from_proto, to=ActivityPub)
110

111
    # TODO: parallelize/optimize
112
    if owner := load_owner(obj):
1✔
113
        status['account'] = to_account(owner)
1✔
114

115
    if not status['account']:
1✔
116
        return None
1✔
117

118
    if status.get('reblog') is not None:
1✔
119
        target_id = as1.get_id(obj.as1, 'object')
1✔
120
        if target_id and (target := Object.get_by_id(target_id)) and target.as1:
1✔
121
            status['reblog'] = to_status(target) or None
1✔
122
        else:
123
            status['reblog'] = None
×
124

125
    return status
1✔
126

127

128
def to_notification(obj):
1✔
129
    """Converts a :class:`models.Object` to a Mastodon ``Notification``.
130

131
    Returns None if ``obj`` can't be converted, eg its account can't be fetched or
132
    converted.
133
    """
134
    type = AS1_TO_NOTIFICATION_TYPE.get(obj.as1.get('verb'), 'mention')
1✔
135

136
    notif = {
1✔
137
        'id': obj.key.id(),
138
        'type': type,
139
        'created_at': (obj.as1.get('published')
140
                       or obj.created.replace(tzinfo=timezone.utc).isoformat()),
141
        'account': None,  # populated below
142
    }
143

144
    # TODO: parallelize/optimize
145

146
    if type in ('mention', 'quote'):
1✔
147
        notif['status'] = to_status(obj)
1✔
148
        if notif['status']:
1✔
149
            notif['account'] = notif['status']['account']
1✔
150

151
    elif type in ('favourite', 'follow', 'reblog'):
1✔
152
        target_id = as1.get_id(obj.as1, 'object')
1✔
153
        if target_id and (target := Object.get_by_id(target_id)) and target.as1:
1✔
154
            notif['status'] = to_status(target)
1✔
155

156
        if owner := load_owner(obj):
1✔
157
            notif['account'] = to_account(owner)
1✔
158

159
    if not notif['account']:
1✔
160
        return None
1✔
161

162
    return notif
1✔
163

164

165
def load_user(handle, resolve=False):
1✔
166
    try:
1✔
167
        return webfinger.load_user(handle, allow_opt_out=True)
1✔
168
    except HTTPException as e:
1✔
169
        logger.info(e)
1✔
170
        try:
1✔
171
            username, server = util.parse_acct_uri(handle)
1✔
172
            if username == server:
1✔
173
                handle = username
×
174
        except ValueError:
1✔
175
            pass
1✔
176
        try:
1✔
177
            return models.load_user(handle, proto=ActivityPub, create=resolve,
1✔
178
                                    allow_opt_out=True)
179
        except (AttributeError, RuntimeError, ValueError) as e:
1✔
180
            logger.info(e)
1✔
181

182

183
def load_object(id):
1✔
184
    obj = Object.get_by_id(id)
1✔
185
    if not obj or not obj.as1:
1✔
186
        error('Status not found', status=404)
1✔
187
    return obj
1✔
188

189

190
def load_owner(obj):
1✔
191
    """Loads the :class:`models.User` that owns ``obj``, if any.
192

193
    Returns None if ``obj`` has no owner, or if the owner can't be loaded, eg
194
    if their handle can't be resolved to a protocol.
195
    """
196
    if obj.users:
1✔
197
        return obj.users[0].get()
1✔
198

199
    if owner_id := as1.get_owner(obj.as1):
1✔
200
        try:
1✔
201
            return models.load_user(owner_id, create=True, allow_opt_out=True)
1✔
202
        except RuntimeError:
1✔
203
            logger.info(f"Couldn't load owner {owner_id}", exc_info=True)
1✔
204

205
    return None
1✔
206

207

208
def limit():
1✔
209
    """Returns the limit query param, if it's between 1 and ``MAX_LIMIT``.
210

211
    ...otherwise returns ``DEFAULT_LIMIT``.
212

213
    Returns:
214
      int:
215
    """
216
    if limit := request.args.get('limit'):
1✔
217
        try:
1✔
218
            return min(max(int(limit), 1), MAX_LIMIT)
1✔
219
        except (ValueError, TypeError):
×
220
            pass
×
221

222
    return DEFAULT_LIMIT
1✔
223

224

225
#
226
# API endpoints
227
#
228

229
@app.get('/health')
1✔
230
def health():
1✔
231
    return {'status': 'UP'}
1✔
232

233

234
@app.get('/api/v2/instance')
1✔
235
def instance():
1✔
236
    return {
1✔
237
        'domain': 'brid.gy',
238
        'title': 'Bridgy Fed',
239
        'version': os.getenv('GAE_VERSION'),
240
        'source_url': 'https://github.com/snarfed/bridgy-fed',
241
        'description': 'Bridging the new social internet',
242
        'usage': {
243
            'users': {
244
                # TODO (from activitypub.nodeinfo)
245
                # 'active_month': None,
246
            }
247
        },
248
        'thumbnail': {
249
            'url': 'https://fed.brid.gy/static/bridgy_logo_with_alpha.png',
250
            'description': 'Hand-painted sketch of a bridge with just a few brush strokes',
251
            # 'blurhash': 'UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$',
252
            # 'versions': {
253
            #     '@1x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png',
254
            #     '@2x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png'
255
            # }
256
        },
257
        'icon': [{
258
            'src': 'https://fed.brid.gy/static/favicon.ico',
259
            'size': '32x32',
260
        }, {
261
            'src': 'https://brid.gy/static/bridgy_logo_with_alpha_128.png',
262
            'size': '128x128',
263
        }, {
264
            'src': 'https://fed.brid.gy/static/bridgy_logo_with_alpha.png',
265
            'size': '1200x600',
266
        }, {
267
            'src': 'https://fed.brid.gy/static/bridgy_logo_with_alpha_square_1024.png',
268
            'size': '1024x1024',
269
        }],
270
        'languages': ['en'],
271
        'configuration': {
272
            'urls': {
273
                'streaming': None,
274
                'status': None,
275
                'about': 'https://fed.brid.gy/docs',
276
                'privacy_policy': 'https://fed.brid.gy/docs#privacy',
277
                'terms_of_service': 'https://fed.brid.gy/docs#terms',
278
            },
279
            # 'vapid': {
280
            #     'public_key': '...'
281
            # },
282
            'accounts': {
283
                'max_featured_tags': 0,
284
                'max_pinned_statuses': 1,
285
            },
286
            # 'statuses': {
287
            #     'max_characters': 500,
288
            #     'max_media_attachments': 4,
289
            #     'characters_reserved_per_url': 23
290
            # },
291
            'media_attachments': {
292
                # 'description_limit': ,
293
                # 'image_matrix_limit': ,
294
                'image_size_limit': bluesky.MAX_MEDIA_SIZE_BYTES,
295
                'supported_mime_types': [
296
                    'image/jpeg',
297
                    'image/png',
298
                    'image/gif',
299
                    'image/webp',
300
                    'image/avif',
301
                    'video/mp4',
302
                ],
303
                # 'video_frame_rate_limit': None,
304
                # 'video_matrix_limit': None,
305
                'video_size_limit': datastore_storage.BLOB_MAX_BYTES,
306
            },
307
            'limited_federation': False,
308
        },
309
        'registrations': {
310
            'enabled': True,
311
            'approval_required': False,
312
            'reason_required': False,
313
            'message': None,
314
            # 'min_age': 16,
315
            'url': None,
316
        },
317
        'api_versions': {'mastodon': 6},
318
        'rules': [{
319
            'id': '1',
320
            'text': 'You agree not to deliberately attack, breach, or otherwise harm the service. If you manage to access private keys or other sensitive data, you agree to report the vulnerability and not use or disclose that data.',
321
            'hint': '',
322
        }],
323
        'contact': {
324
            'email': 'feedback@brid.gy',
325
            'account': {
326
                'id': '@anewsocial@mastodon.social',
327
                'username': '@anewsocial@mastodon.social',
328
                'acct': '@anewsocial@mastodon.social',
329
                'display_name': 'A New Social',
330
                'locked': False,
331
                'bot': False,
332
                'discoverable': True,
333
                'indexable': False,
334
                'group': False,
335
                'created_at': '2024-06-28T00:00:00.000Z',
336
                'note': '<p>Social media should be centered around people, not platforms. Let&#39;s build bridges, not walls. That&#39;s why we&#39;re building Bridgy Fed and Bounce.</p><p>Learn more: <a href="https://anew.social" target="_blank" rel="nofollow noopener" translate="no"><span class="invisible">https://</span><span class="">anew.social</span><span class="invisible"></span></a></p>',
337
                'url': 'https://mastodon.social/@anewsocial',
338
                'uri': 'https://mastodon.social/users/anewsocial',
339
                'avatar': 'https://files.mastodon.social/accounts/avatars/112/696/499/069/491/559/original/fbe51fe98b509adf.png',
340
                'avatar_static': 'https://files.mastodon.social/accounts/avatars/112/696/499/069/491/559/original/fbe51fe98b509adf.png',
341
                'avatar_description': '',
342
                'header': 'https://files.mastodon.social/accounts/headers/112/696/499/069/491/559/original/2834aa5dde24424e.png',
343
                'header_static': 'https://files.mastodon.social/accounts/headers/112/696/499/069/491/559/original/2834aa5dde24424e.png',
344
                'header_description': '',
345
                # 'followers_count': 1552,
346
                # 'following_count': 9,
347
                # 'statuses_count': 176,
348
                'last_status_at': '2026-07-06',
349
                'hide_collections': None,
350
                'show_media': True,
351
                'show_media_replies': True,
352
                'show_featured': True,
353
                'noindex': False,
354
                'emojis': [],
355
                'roles': [],
356
                'fields': [{
357
                    'name': 'A New Social',
358
                    'value': '<a href="https://www.anew.social" target="_blank" rel="nofollow noopener me" translate="no"><span class="invisible">https://www.</span><span class="">anew.social</span><span class="invisible"></span></a>',
359
                    'verified_at': None,
360
                },{
361
                    'name': 'Blog',
362
                    'value': '<a href="https://blog.anew.social" target="_blank" rel="nofollow noopener me" translate="no"><span class="invisible">https://</span><span class="">blog.anew.social</span><span class="invisible"></span></a>',
363
                    "verified_at": None,
364
                }],
365
            },
366
        },
367
    }
368

369
@app.get('/api/v1/instance/extended_description')
1✔
370
def instance_extended_description():
1✔
371
    return {
1✔
372
        'updated_at': util.now().isoformat(),
373
        'content': '<p>Bridges other networks to the fediverse. See <a href="https://fed.brid.gy/docs">the docs</a> for more.</p>',
374
    }
375

376

377
@app.get('/api/v1/instance/privacy_policy')
1✔
378
def instance_privacy_policy():
1✔
379
    return {
1✔
380
        'updated_at': util.now().isoformat(),
381
        'content': '<p>See <a href="/docs#privacy">our privacy policy</a>.</p>',
382
    }
383

384

385
@app.get('/api/v1/instance/terms_of_service')
1✔
386
def instance_terms_of_service():
1✔
387
    return {
1✔
388
        'effective_date': '2025-08-23',
389
        'effective': True,
390
        # copied from templates/docs.html
391
        'content': """\
392
<p>Bridgy Fed is both a service and an <a href='https://github.com/snarfed/bridgy-fed'>open source project</a>. The open source code is placed into the public domain, via the <a href='https://creativecommons.org/publicdomain/zero/1.0/'>CC0</a> license, and may be used by anyone for any purpose. The rest of these terms apply to the service.
393

394
<p>The Bridgy Fed service, served on *.brid.gy, is freely available to individuals and organizations to use for their own accounts.
395

396
<p>If you have a free, non-commercial product or hosting platform, you're welcome to integrate the Bridgy Fed service into it directly. Please <a href='mailto:letsbuild@anew.social'>let us know</a>, we'd love to hear about your project!
397

398
<p>If you'd like to integrate the Bridgy Fed service into a commercial or paid product or service, that's great too! <a href='mailto:letsbuild@anew.social'>Please contact us</a>, we can help. We'll probably also ask for, and expect, a reasonable <a href='https://www.patreon.com/c/ANewSocial'>donation</a>.
399

400
<p>You agree not to deliberately attack, breach, or otherwise harm the service. If you manage to access private keys or other sensitive data, you agree to <a href='#vulnerability'>report the vulnerability</a> and not use or disclose that data.
401
</p>
402
<p>Otherwise, you may use the service for any purpose you see fit. However, we may terminate or block your access for any reason, or no reason at all. (We've never done this, and we expect we never will. Just playing it safe.)
403
</p>
404
<p>Do you an administer an instance or other service that Bridgy Fed interacts with? If you have any concerns or questions, feel free to <a href='https://github.com/snarfed/bridgy-fed/issues'>file an issue</a>!</p>
405
""",
406
    }
407

408

409
@app.get('/api/v1/accounts/verify_credentials')
1✔
410
@auth
1✔
411
def verify_credentials(user):
1✔
412
    return to_account(user)
1✔
413

414

415
@app.get('/api/v1/accounts/lookup')
1✔
416
@auth
1✔
417
def accounts_lookup(user):
1✔
418
    if user := load_user(get_required_param('acct')):
1✔
419
        return to_account(user)
1✔
420

421
    error('Not found', status=404)
1✔
422

423

424
@app.get('/api/v1/accounts/relationships')
1✔
425
@auth
1✔
426
def accounts_relationships(user):
1✔
427
    relationships = []
1✔
428

429
    for addr in (request.args.getlist('id[]') + request.args.getlist('id')):
1✔
430
        # TODO: parallelize
431
        # TODO: unify with search
432
        if not (target := load_user(addr)):
1✔
433
            continue
1✔
434

435
        following = bool(Follower.query(Follower.from_ == user.key,
1✔
436
                                        Follower.to == target.key,
437
                                        Follower.status == 'active'
438
                                        ).get(keys_only=True))
439
        followed_by = bool(Follower.query(Follower.from_ == target.key,
1✔
440
                                          Follower.to == user.key,
441
                                          Follower.status == 'active'
442
                                          ).get(keys_only=True))
443
        relationships.append({
1✔
444
            'id': target.handle_as(ActivityPub),
445
            'following': following,
446
            'showing_reblogs': following,
447
            'followed_by': followed_by,
448
            # TODO
449
            'blocking': False,
450
            'blocked_by': False,
451
            'domain_blocking': False,
452
            'endorsed': False,
453
            'muting': False,
454
            'muting_notifications': False,
455
            'notifying': False,
456
            'requested': False,
457
            'note': '',
458
        })
459

460
    return relationships
1✔
461

462

463
@app.get('/api/v1/accounts/<path:addr>')
1✔
464
@auth
1✔
465
def accounts_get(user, addr):
1✔
466
    if user := load_user(addr):
1✔
467
        return to_account(user)
1✔
468

469
    error('Not found', status=404)
1✔
470

471

472
@app.get('/api/v1/accounts/<path:addr>/statuses')
1✔
473
@auth
1✔
474
def accounts_statuses(user, addr):
1✔
475
    # TODO: tagged
476
    user = load_user(addr)
1✔
477

478
    if request.args.get('pinned', '').strip().lower() == 'true':
1✔
479
        objects = []
1✔
480
        if user.obj and user.obj.as1:
1✔
481
            featured = as1.get_ids(as1.get_object(user.obj.as1, 'featured'), 'items')
1✔
482
            objects = ndb.get_multi(Object(id=id).key for id in featured)
1✔
483

484
    else:
485
        query = Object.query(Object.users == user.key,
1✔
486
                             Object.type.IN(as1.POST_TYPES | set(['share'])))
487

488
        def obj_created(param):
1✔
489
            if id := request.args.get(param):
1✔
490
                if obj := Object.get_by_id(id):
1✔
491
                    return obj.created
1✔
492

493
        order = -Object.created
1✔
494
        if max := obj_created('max_id'):
1✔
495
            query = query.filter(Object.created < max)
1✔
496
        if since := obj_created('since_id'):
1✔
497
            query = query.filter(Object.created > since)
1✔
498
        if min := obj_created('min_id'):
1✔
499
            query = query.filter(Object.created > min)
1✔
500
            order = Object.created
1✔
501

502
        objects = query.order(order).fetch(limit())
1✔
503

504
    return [s for obj in objects
1✔
505
            if obj and obj.as1 and not obj.deleted and as1.is_public(obj.as1)
506
            and (s := to_status(obj))
507
            and not (bool_param('exclude_replies') and obj.type == 'comment')
508
            and not (bool_param('exclude_reblogs') and obj.type == 'share')
509
            and not (bool_param('only_media') and not s.get('media_attachments'))]
510

511

512
@app.get('/api/v1/accounts/<path:addr>/followers')
1✔
513
@auth
1✔
514
def accounts_followers(user, addr):
1✔
515
    followers, _, _ = Follower.fetch_page('followers', load_user(addr))
1✔
516
    return [to_account(f.user) for f in followers]
1✔
517

518

519
@app.get('/api/v1/accounts/<path:addr>/following')
1✔
520
@auth
1✔
521
def accounts_following(user, addr):
1✔
522
    following, _, _ = Follower.fetch_page('following', load_user(addr))
1✔
523
    return [to_account(f.user) for f in following]
1✔
524

525

526
@app.get('/api/v1/blocks')
1✔
527
@auth
1✔
528
def blocks(user):
1✔
529
    # TODO
530
    return []
1✔
531

532

533
@app.get('/api/v1/domain_blocks')
1✔
534
@auth
1✔
535
def domain_blocks_get(user):
1✔
536
    blocklists = ndb.get_multi(user.blocks)
1✔
537
    return [domain for list in blocklists for domain in list.domain_blocklist]
1✔
538

539

540
@app.get('/api/v1/favourites')
1✔
541
@auth
1✔
542
def favourites(user):
1✔
543
    likes = Object.query(Object.users == user.key,
1✔
544
                         Object.type == 'like',
545
                        ).order(-Object.created
546
                        ).fetch(limit())
547
    ids = [as1.get_id(like.as1, 'object') for like in likes]
1✔
548
    objs = ndb.get_multi(Object(id=id).key for id in ids if id)
1✔
549
    return [status for obj in objs if obj and obj.as1 and (status := to_status(obj))]
1✔
550

551

552
@app.get('/api/v1/statuses')
1✔
553
@auth
1✔
554
def statuses_multiple(user):
1✔
555
    ids = request.args.getlist('id[]') + request.args.getlist('id')
1✔
556
    objs = ndb.get_multi(Object(id=id).key for id in ids)
1✔
557
    return [s for obj in objs
1✔
558
                  if obj and obj.as1 and not obj.deleted and as1.is_public(obj.as1)
559
            and (s := to_status(obj))]
560

561

562
@app.get('/api/v1/statuses/<path:id>')
1✔
563
@auth
1✔
564
def statuses_single(user, id):
1✔
565
    obj = load_object(id)
1✔
566
    if not (status := to_status(obj)):
1✔
567
        error('Status not found', status=404)
×
568
    return status
1✔
569

570

571
@app.get('/api/v1/statuses/<path:id>/context')
1✔
572
@auth
1✔
573
def statuses_context(user, id):
1✔
574
    obj = load_object(id)
1✔
575

576
    ancestors = []
1✔
577
    parent_id = as1.get_object(obj.as1, 'inReplyTo').get('id')
1✔
578
    while (parent_id and len(ancestors) < MAX_ANCESTORS
1✔
579
           and (parent := Object.get_by_id(parent_id)) and parent.as1):
580
        if parent_status := to_status(parent):
1✔
581
            ancestors.insert(0, parent_status)
1✔
582
        # TODO: convert to native protocol?
583
        parent_id = as1.get_id(parent.as1, 'inReplyTo')
1✔
584

585
    return {
1✔
586
        'ancestors': ancestors,
587
        # descendants aren't indexed, so we can't look them up efficiently
588
        'descendants': [],
589
    }
590

591

592
@app.get('/api/v1/statuses/<path:id>/favourited_by')
1✔
593
@auth
1✔
594
def statuses_favourited_by(user, id):
1✔
595
    load_object(id)
1✔
596
    # likes aren't indexed by target, so we can't look them up efficiently
597
    return []
1✔
598

599

600
@app.get('/api/v1/statuses/<path:id>/reblogged_by')
1✔
601
@auth
1✔
602
def statuses_reblogged_by(user, id):
1✔
603
    load_object(id)
1✔
604
    # reposts aren't indexed by target, so we can't look them up efficiently
605
    return []
1✔
606

607

608
@app.get('/api/v1/timelines/home')
1✔
609
@auth
1✔
610
def timelines_home(user):
1✔
611
    objects = Object.query(Object.feed == user.key
1✔
612
                           ).order(-Object.created
613
                           ).fetch(limit())
614
    statuses = [to_status(obj) for obj in objects
1✔
615
                if obj.as1 and not obj.deleted and as1.is_public(obj.as1)]
616
    # TODO: formalize
617
    return [s for s in statuses
1✔
618
            if s and s.get('account')
619
            and (not s['reblog'] or s['reblog'].get('account'))]
620

621

622
@app.get('/api/v1/timelines/public')
1✔
623
@auth
1✔
624
def timelines_public(user):
1✔
625
    objects = Object.query(Object.type.IN(as1.POST_TYPES | set(['share'])),
1✔
626
                           ).order(-Object.created
627
                           ).fetch(limit())
628
    return [status for obj in objects
1✔
629
            if obj.as1 and not obj.deleted and as1.is_public(obj.as1)
630
            and (status := to_status(obj))]
631

632

633
@app.get('/api/v1/timelines/tag/<hashtag>')
1✔
634
@auth
1✔
635
def timelines_tag(user, hashtag):
1✔
636
    return []
1✔
637

638

639
@app.get('/api/v1/notifications')
1✔
640
@auth
1✔
641
def notifications_list(user):
1✔
642
    # TODO: unbridged notifs
643
    objects = Object.query(Object.notify == user.key
1✔
644
                           ).order(-Object.updated
645
                           ).fetch(limit())
646
    return [notif for obj in objects
1✔
647
            if obj.as1 and not obj.deleted and as1.is_public(obj.as1)
648
            and (notif := to_notification(obj))]
649

650

651
@app.get('/api/v1/notifications/<path:id>')
1✔
652
@auth
1✔
653
def notifications_get(user, id):
1✔
654
    obj = Object.get_by_id(id)
1✔
655
    if (obj and obj.as1 and not obj.deleted and as1.is_public(obj.as1)
1✔
656
            and user.key in obj.notify):
657
        if notif := to_notification(obj):
1✔
658
            return notif
1✔
659

660
    error('Notification not found', status=404)
1✔
661

662

663
@app.get('/api/v1/notifications/unread_count')
1✔
664
@auth
1✔
665
def notifications_unread_count(user):
1✔
666
    # we don't currently track read vs unread
667
    return {'count': 0}
1✔
668

669

670
@app.get('/api/v2/search')
1✔
671
@auth
1✔
672
def search(user):
1✔
673
    resp = {
1✔
674
        'accounts': [],
675
        'statuses': [],
676
        'hashtags': [],
677
    }
678

679
    q = get_required_param('q').strip()
1✔
680
    type = request.args.get('type')
1✔
681

682
    if not type or type == 'accounts':
1✔
683
        if user := load_user(q, resolve=bool_param('resolve')):
1✔
684
            if acct := to_account(user):
1✔
685
                resp['accounts'] = [acct]
1✔
686

687
    if not type or type == 'statuses':
1✔
688
        # Phanpy does an odd thing to load individual statuses: it searches
689
        # for them with the format '[domain]/s/[id]'. no clue why yet
690
        q = q.removeprefix(f'{PRIMARY_DOMAIN}/s/')
1✔
691
        if obj := Object.get_by_id(q):
1✔
692
            if status := to_status(obj):
1✔
693
                resp['statuses'] = [status]
1✔
694

695
    return resp
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc