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

snarfed / bridgy-fed / 4665fe97-b046-4b89-a637-a86b6c267df6

11 Jun 2026 08:58PM UTC coverage: 93.05% (-1.0%) from 94.016%
4665fe97-b046-4b89-a637-a86b6c267df6

push

circleci

snarfed
migrate frontend (default) to Cloud Run!

for #1717

5 of 6 new or added lines in 1 file covered. (83.33%)

78 existing lines in 1 file now uncovered.

7631 of 8201 relevant lines covered (93.05%)

0.93 hits per line

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

44.81
/admin.py
1
"""Admin pages and endpoints: admin UI, hub status, memcache API, etc."""
2
from datetime import datetime
1✔
3
import logging
1✔
4
from urllib.parse import quote
1✔
5

6
from google.cloud import ndb
1✔
7
from google.cloud.ndb import Key
1✔
8

9
import arroba.server
1✔
10
import common
1✔
11
from common import (
1✔
12
    secret_key_auth,
13
)
14
from flask import redirect, request
1✔
15
from flask_app import app
1✔
16
import filters
1✔
17
from granary import as1, microformats2
1✔
18
import memcache
1✔
19
import models
1✔
20
from models import Object, PROTOCOLS, User
1✔
21
import pytz
1✔
22
from webutil import flask_util, logs, util
1✔
23
from webutil.flask_util import flash
1✔
24

25
from activitypub import ActivityPub, FEDI_URL_RE
1✔
26
from atproto import ATProto
1✔
27
import ids
1✔
28
from nostr import Nostr
1✔
29
import pages
1✔
30
from web import Web
1✔
31

32
BLOCKLISTS = {
1✔
33
    bl.key_id: bl for bl in (
34
        filters.CONTENT_BLOCKLIST,
35
        filters.MEDIA_BLOCKLIST,
36
        filters.DOMAIN_BLOCKLIST,
37
    )
38
}
39

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

42

43
#
44
# admin UI
45
#
46
def render(template, **vars):
1✔
UNCOV
47
    return pages.render(template, **vars)
×
48

49

50
def format_properties(entity):
1✔
51
    """Generates template variables based on misc User and Object properties:
52

53
    * created, updated: to ISO-8601 strings
54
    * bridged: dict mapping Protocol subclass to string id
55
    """
UNCOV
56
    vars = {}
×
57

UNCOV
58
    pt = pytz.timezone('US/Pacific')
×
UNCOV
59
    for field in 'created', 'updated':
×
60
        # these are proto.datetime_helpers.DatetimeWithNanoseconds. have to recreate
61
        # them as plain datetimes because otherwise they crash on replace().
62
        # similar to: https://stackoverflow.com/q/54370012/186123
UNCOV
63
        vars[field] = datetime.fromtimestamp(getattr(entity, field).timestamp()
×
64
                                             ).replace(microsecond=0).astimezone(pt)
65

UNCOV
66
    return vars
×
67

68

69
# TODO: bring back
70
# @app.get('/admin/')
71
def admin_home():
1✔
UNCOV
72
    for reloader in BLOCKLISTS.values():
×
UNCOV
73
        reloader.reload()
×
UNCOV
74
    return render('admin.html', filters=filters)
×
75

76

77
# @app.post('/admin/blocklist')
78
def save_blocklist():
1✔
79
    """
80
    Form values:
81
      id (str): blocklist key id
82
      values (str)
83
    """
UNCOV
84
    id = request.values['id']
×
UNCOV
85
    values = [v.strip() for v in request.values['values'].splitlines() if v.strip()]
×
UNCOV
86
    BLOCKLISTS[id].obj.raw = values
×
UNCOV
87
    BLOCKLISTS[id].obj.put()
×
UNCOV
88
    flash(f'Saved {id}.')
×
UNCOV
89
    return redirect('/admin/')
×
90

91

92
# @app.get('/admin/user')
93
def admin_user_search():
1✔
94
    """
95
    Query params:
96
      query (str)
97
    """
UNCOV
98
    query = orig_query = request.values['query'].strip()
×
UNCOV
99
    if not query:
×
100
        error('empty query')
×
101

102
    # preprocess search query, misc heuristics
UNCOV
103
    if query.endswith('.ap.brid.gy'):
×
UNCOV
104
        query = ids.translate_user_id(id=query, from_=ATProto, to=ActivityPub)
×
UNCOV
105
    elif query.endswith('.brid.gy'):
×
UNCOV
106
        query = query.rsplit('.', 3)[0]
×
UNCOV
107
    elif match := FEDI_URL_RE.fullmatch(query):
×
UNCOV
108
        query = ids.handle_as_domain(f'@{match.group("handle")}@{match.group("domain")}')
×
109

UNCOV
110
    queries = [query]
×
UNCOV
111
    if '@' in query:
×
UNCOV
112
        if handle_as_domain := ids.handle_as_domain(query):
×
UNCOV
113
            queries.append(handle_as_domain)
×
114

UNCOV
115
    futures = [
×
116
        proto.query(ndb.OR(
117
            proto.key.IN([proto(id=query).key for query in queries]),
118
            proto.handle.IN(queries),
119
            proto.handle_as_domain.IN(queries),
120
            proto.handle_pay_level_domain.IN(queries))).fetch_async()
121
        for proto in set(PROTOCOLS.values()) if proto]
122

UNCOV
123
    users = []
×
UNCOV
124
    for future in futures:
×
UNCOV
125
        users.extend(future.get_result())
×
126

UNCOV
127
    if not users:
×
UNCOV
128
        if key := models.get_original_user_key(query):
×
UNCOV
129
            users = [key.get()]
×
130

UNCOV
131
    for user in users:
×
UNCOV
132
        user.bridged_ids = {
×
133
            proto: ids.translate_user_id(id=user.key.id(), from_=user, to=proto)
134
            for proto in (ATProto, ActivityPub, Nostr)
135
            if not isinstance(user, proto)
136
        }
UNCOV
137
        user.sent_dms_ = ', '.join(
×
138
            f'{dm.type} ({dm.protocol})' for dm in user.sent_dms)
139

140

UNCOV
141
    return render('admin_users.html', query=orig_query, users=users)
×
142

143

144
# @app.get('/admin/user/<key>')
145
def admin_user(key):
1✔
UNCOV
146
    user = Key(urlsafe=key).get()
×
UNCOV
147
    if not user or not isinstance(user, User):
×
UNCOV
148
        flash('user not found')
×
UNCOV
149
        return redirect('/admin/')
×
150

UNCOV
151
    return redirect(f'/admin/user?query={quote(user.key.id())}')
×
152

153

154
# @app.post('/admin/object')
155
def admin_object_lookup():
1✔
156
    """
157
    Form values:
158
      id (str)
159
    """
UNCOV
160
    id = request.values['id'].strip()
×
161
    # ordered
UNCOV
162
    for proto in ActivityPub, ATProto, Nostr, Web:
×
UNCOV
163
        if proto and proto.owns_id(id) is not False:
×
UNCOV
164
            if obj := proto.load(id):
×
UNCOV
165
                return redirect(f'/admin/object/{obj.key.id()}')
×
166

167
    flash(f"Couldn't resolve {id}")
×
168
    return redirect('/admin/')
×
169

170

171
# @app.get('/admin/object/<path:id>')
172
def admin_object(id):
1✔
UNCOV
173
    if not (obj := Object.get_by_id(id)):
×
UNCOV
174
        flash('object not found')
×
UNCOV
175
        return redirect('/admin/')
×
176

UNCOV
177
    if obj.as1 and as1.object_type(obj.as1) in as1.CRUD_VERBS:
×
UNCOV
178
        if inner_id := as1.get_object(obj.as1).get('id'):
×
UNCOV
179
            if inner := Object.get_by_id(inner_id):
×
UNCOV
180
                return redirect(f'/admin/object/{inner.key.id()}')
×
181

UNCOV
182
    proto = PROTOCOLS[obj.source_protocol]
×
UNCOV
183
    user = None
×
UNCOV
184
    if obj.users:
×
185
        user = obj.users[0].get()
×
UNCOV
186
    elif obj.as1 and proto and (user_id := as1.get_owner(obj.as1)):
×
187
        user = proto.get_by_id(user_id)
×
188

UNCOV
189
    bridged_ids = {
×
190
        to_proto: ids.translate_object_id(id=obj.key.id(), from_=proto, to=to_proto)
191
        for to_proto in (ATProto, ActivityPub, Nostr)
192
        if to_proto != proto and user and user.is_enabled(to_proto)
193
    }
194

UNCOV
195
    return render(
×
196
        'admin_object.html',
197
        obj=obj,
198
        user=user,
199
        bridged_ids=bridged_ids,
200
        **format_properties(obj))
201

202

203
# @app.post('/admin/receive')
204
def admin_receive():
1✔
UNCOV
205
    obj_key = Key(urlsafe=request.values['obj_key'])
×
UNCOV
206
    user_key = Key(urlsafe=request.values['user_key'])
×
UNCOV
207
    common.create_task(queue='receive', obj_id=obj_key.id(),
×
208
                       authed_as=user_key.id(), force='true')
UNCOV
209
    return redirect(f'/admin/object/{obj_key.id()}')
×
210

211

212
# @app.post('/admin/enable')
213
def admin_enable():
1✔
214
    """
215
    Form values:
216
      key (str): urlsafe user key
217
      protocol (str)
218
    """
UNCOV
219
    key = request.values['key']
×
UNCOV
220
    user = Key(urlsafe=key).get()
×
UNCOV
221
    proto = PROTOCOLS[request.values['protocol']]
×
UNCOV
222
    user.enable_protocol(proto)
×
UNCOV
223
    flash(f'Enabled {proto.LABEL} for {user.handle}')
×
UNCOV
224
    return redirect(f'/admin/user/{key}')
×
225

226

227
# @app.post('/admin/disable')
228
def admin_disable():
1✔
229
    """
230
    Form values:
231
      key (str): urlsafe user key
232
      protocol (str)
233
    """
UNCOV
234
    key = request.values['key']
×
UNCOV
235
    user = Key(urlsafe=key).get()
×
UNCOV
236
    proto = PROTOCOLS[request.values['protocol']]
×
UNCOV
237
    user.delete(proto)
×
UNCOV
238
    user.disable_protocol(proto)
×
UNCOV
239
    flash(f'Disabled {proto.LABEL} for {user.handle}')
×
UNCOV
240
    return redirect(f'/admin/user/{key}')
×
241

242

243
#
244
# internal Memcache API, since we can't connect to our Memorystore instance externally
245
# https://github.com/snarfed/bridgy-fed/issues/1472
246
#
247
@app.get('/admin/memcache/get')
1✔
248
@secret_key_auth
1✔
249
@flask_util.headers({'Content-Type': 'text/plain'})
1✔
250
def memcache_get():
1✔
251
    if key := request.values.get('key'):
1✔
252
        return repr(Key(urlsafe=key).get(use_cache=False, use_datastore=False,
1✔
253
                                         use_global_cache=True))
254
    elif raw := request.values.get('raw'):
1✔
255
        return repr(memcache.memcache.get(raw))
1✔
256
    else:
257
        error('either key or raw are required')
×
258

259

260
@app.post('/admin/memcache/evict')
1✔
261
@secret_key_auth
1✔
262
@flask_util.headers({'Content-Type': 'text/plain'})
1✔
263
def memcache_evict():
1✔
264
    if key := request.values.get('key'):
1✔
265
        memcache.evict(Key(urlsafe=key))
1✔
266
        return ''
1✔
267
    elif raw := request.values.get('raw'):
1✔
268
        deleted = memcache.evict_raw(raw)
1✔
269
        return 'deleted' if deleted else 'not found'
1✔
270
    else:
271
        error('either key or raw are required')
×
272

273

274
@app.post('/admin/sequences/alloc')
1✔
275
@secret_key_auth
1✔
276
@flask_util.headers({'Content-Type': 'text/plain'})
1✔
277
def alloc_seq():
1✔
278
    nsid = flask_util.get_required_param('nsid')
1✔
279
    result = arroba.server.storage.sequences.allocate(nsid)
1✔
280
    return str(result)
1✔
281

282

283
@app.get('/admin/sequences/last')
1✔
284
@secret_key_auth
1✔
285
@flask_util.headers({'Content-Type': 'text/plain'})
1✔
286
def last_seq():
1✔
287
    nsid = flask_util.get_required_param('nsid')
1✔
288
    result = arroba.server.storage.sequences.last(nsid)
1✔
289
    return str(result)
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