2022-11-12 23:27:59 +00:00
|
|
|
"""UI pages."""
|
2025-02-24 04:28:22 +00:00
|
|
|
from functools import wraps
|
2025-06-13 14:50:20 +00:00
|
|
|
import html
|
2023-07-20 17:24:58 +00:00
|
|
|
import itertools
|
2022-11-20 19:56:32 +00:00
|
|
|
import logging
|
2023-08-26 16:47:41 +00:00
|
|
|
import re
|
2023-10-13 16:46:30 +00:00
|
|
|
import time
|
2017-10-26 14:13:28 +00:00
|
|
|
|
2024-05-08 00:01:01 +00:00
|
|
|
from flask import render_template, request
|
2023-07-28 22:49:29 +00:00
|
|
|
from google.cloud.ndb import tasklets
|
2025-02-23 00:42:40 +00:00
|
|
|
from google.cloud.ndb.key import Key
|
2025-03-21 00:46:09 +00:00
|
|
|
from google.cloud.ndb.query import OR
|
2025-02-24 04:28:22 +00:00
|
|
|
from google.cloud.ndb.model import get_multi, Model
|
2023-10-10 22:38:53 +00:00
|
|
|
from granary import as1, as2, atom, microformats2, rss
|
2025-02-22 05:41:23 +00:00
|
|
|
import oauth_dropins
|
2021-09-09 05:14:11 +00:00
|
|
|
from oauth_dropins.webutil import flask_util, logs, util
|
2023-10-16 21:02:17 +00:00
|
|
|
from oauth_dropins.webutil.flask_util import (
|
|
|
|
canonicalize_request_domain,
|
|
|
|
error,
|
|
|
|
flash,
|
2025-02-25 00:30:03 +00:00
|
|
|
get_flashed_messages,
|
2025-02-24 04:28:22 +00:00
|
|
|
get_required_param,
|
|
|
|
Found,
|
2025-06-09 18:51:22 +00:00
|
|
|
MovedPermanently,
|
2023-10-16 21:02:17 +00:00
|
|
|
)
|
2025-02-22 05:41:23 +00:00
|
|
|
from oauth_dropins.webutil.util import json_loads, json_dumps
|
2024-04-23 03:21:56 +00:00
|
|
|
import requests
|
|
|
|
import werkzeug.exceptions
|
2025-01-10 20:38:46 +00:00
|
|
|
from werkzeug.exceptions import NotFound
|
2017-10-26 04:32:59 +00:00
|
|
|
|
2025-05-21 18:09:30 +00:00
|
|
|
import activitypub
|
|
|
|
from activitypub import ActivityPub
|
2025-02-21 05:21:46 +00:00
|
|
|
import atproto
|
2025-02-22 23:10:02 +00:00
|
|
|
from atproto import ATProto, BlueskyOAuthStart
|
2022-11-08 14:56:19 +00:00
|
|
|
import common
|
2025-02-24 04:28:22 +00:00
|
|
|
from common import CACHE_CONTROL, DOMAIN_RE, ErrorButDoNotRetryTask, PROTOCOL_DOMAINS
|
2024-05-30 21:55:35 +00:00
|
|
|
from flask_app import app
|
2025-02-21 05:21:46 +00:00
|
|
|
from flask import redirect, session
|
2024-04-12 03:02:53 +00:00
|
|
|
import ids
|
2025-01-10 00:57:01 +00:00
|
|
|
import memcache
|
2025-05-28 03:58:14 +00:00
|
|
|
import models
|
2025-02-21 05:21:46 +00:00
|
|
|
from models import (
|
|
|
|
fetch_objects,
|
|
|
|
fetch_page,
|
|
|
|
Follower,
|
|
|
|
Object,
|
|
|
|
PAGE_SIZE,
|
|
|
|
PROTOCOLS,
|
|
|
|
USER_STATUS_DESCRIPTIONS,
|
|
|
|
)
|
2025-05-30 20:00:50 +00:00
|
|
|
from nostr import Nostr
|
2023-09-28 21:42:18 +00:00
|
|
|
from protocol import Protocol
|
2025-01-27 23:49:19 +00:00
|
|
|
from web import Web
|
2025-02-24 22:16:08 +00:00
|
|
|
import webfinger
|
2022-11-11 23:44:35 +00:00
|
|
|
|
2023-02-14 20:52:14 +00:00
|
|
|
# precompute this because we get a ton of requests for non-existing users
|
|
|
|
# from weird open redirect referrers:
|
|
|
|
# https://github.com/snarfed/bridgy-fed/issues/422
|
|
|
|
with app.test_request_context('/'):
|
|
|
|
USER_NOT_FOUND_HTML = render_template('user_not_found.html')
|
|
|
|
|
2022-11-20 19:56:32 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2025-06-10 18:03:57 +00:00
|
|
|
BLOG_REDIRECT_DOMAINS = (
|
|
|
|
'blog.anew.social',
|
|
|
|
'snarfed.org',
|
|
|
|
)
|
|
|
|
|
2023-10-11 04:19:26 +00:00
|
|
|
TEMPLATE_VARS = {
|
2025-03-20 19:12:16 +00:00
|
|
|
'ActivityPub': ActivityPub,
|
2024-04-02 06:06:25 +00:00
|
|
|
'as1': as1,
|
2023-10-11 04:19:26 +00:00
|
|
|
'as2': as2,
|
2025-03-20 19:12:16 +00:00
|
|
|
'ATProto': ATProto,
|
2024-04-12 03:02:53 +00:00
|
|
|
'ids': ids,
|
2023-10-11 04:19:26 +00:00
|
|
|
'isinstance': isinstance,
|
|
|
|
'logs': logs,
|
2025-05-30 20:00:50 +00:00
|
|
|
'Nostr': Nostr,
|
2023-10-11 04:19:26 +00:00
|
|
|
'PROTOCOLS': PROTOCOLS,
|
|
|
|
'set': set,
|
|
|
|
'util': util,
|
2025-03-20 19:12:16 +00:00
|
|
|
'Web': Web,
|
2023-10-11 04:19:26 +00:00
|
|
|
}
|
|
|
|
|
2017-10-26 04:32:59 +00:00
|
|
|
|
2023-06-07 18:51:31 +00:00
|
|
|
def load_user(protocol, id):
|
2023-11-20 04:48:31 +00:00
|
|
|
"""Loads and returns the current request's user.
|
2023-06-07 18:51:31 +00:00
|
|
|
|
|
|
|
Args:
|
2023-10-06 06:32:31 +00:00
|
|
|
protocol (str):
|
|
|
|
id (str):
|
2023-06-07 18:51:31 +00:00
|
|
|
|
2023-11-20 04:39:05 +00:00
|
|
|
Returns:
|
|
|
|
models.User:
|
|
|
|
|
2023-06-07 18:51:31 +00:00
|
|
|
Raises:
|
|
|
|
:class:`werkzeug.exceptions.HTTPException` on error or redirect
|
|
|
|
"""
|
2023-06-10 22:07:26 +00:00
|
|
|
assert id
|
2023-10-10 21:55:27 +00:00
|
|
|
|
2025-01-12 16:32:18 +00:00
|
|
|
if id in PROTOCOL_DOMAINS:
|
|
|
|
error(f'{protocol} user {id} not found', status=404)
|
|
|
|
|
2023-06-07 18:51:31 +00:00
|
|
|
cls = PROTOCOLS[protocol]
|
2024-04-11 21:24:18 +00:00
|
|
|
|
|
|
|
if cls.ABBREV == 'ap' and not id.startswith('@'):
|
|
|
|
id = '@' + id
|
2025-01-10 20:38:46 +00:00
|
|
|
elif cls.ABBREV == 'bsky':
|
|
|
|
id = id.removeprefix('@')
|
|
|
|
|
2025-03-21 00:46:09 +00:00
|
|
|
filters = [cls.key == cls(id=id).key]
|
|
|
|
if cls.ABBREV != 'web':
|
|
|
|
# also query by handle, except for web. Web.handle is custom username, which
|
2025-01-10 19:36:22 +00:00
|
|
|
# isn't unique
|
2025-03-21 00:46:09 +00:00
|
|
|
filters.append(cls.handle == id)
|
|
|
|
|
|
|
|
redirect_user = None
|
|
|
|
for user in cls.query(OR(*filters)):
|
|
|
|
if user.use_instead:
|
|
|
|
if not (user := user.use_instead.get()):
|
|
|
|
continue
|
|
|
|
|
|
|
|
if id not in (user.key.id(), user.handle):
|
|
|
|
# keep looking for an exact match. if we don't find one, we'll redirect
|
|
|
|
# to this one later
|
|
|
|
redirect_user = user
|
|
|
|
continue
|
|
|
|
elif not user.status and (user.enabled_protocols
|
|
|
|
or user.DEFAULT_SERVE_USER_PAGES):
|
|
|
|
assert not user.use_instead
|
|
|
|
return user
|
|
|
|
|
|
|
|
if redirect_user:
|
2023-11-20 04:48:31 +00:00
|
|
|
error('', status=302, location=user.user_page_path())
|
2023-06-07 18:51:31 +00:00
|
|
|
|
2024-01-22 21:12:20 +00:00
|
|
|
# TODO: switch back to USER_NOT_FOUND_HTML
|
|
|
|
# not easy via exception/abort because this uses Werkzeug's built in
|
|
|
|
# NotFound exception subclass, and we'd need to make it implement
|
|
|
|
# get_body to return arbitrary HTML.
|
|
|
|
error(f'{protocol} user {id} not found', status=404)
|
2023-06-07 18:51:31 +00:00
|
|
|
|
|
|
|
|
2025-02-24 04:28:22 +00:00
|
|
|
def require_login(fn):
|
|
|
|
"""Decorator that requires and loads the current request's logged in user.
|
|
|
|
|
|
|
|
Passes the userin the ``user`` kwarg, as a :class:`models.User`.
|
|
|
|
|
|
|
|
HTTP POST params:
|
|
|
|
key (str): url-safe ndb key
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
:class:`werkzeug.exceptions.HTTPException` on error or redirect
|
|
|
|
"""
|
|
|
|
@wraps(fn)
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
key = Key(urlsafe=get_required_param('key'))
|
|
|
|
if key not in [login_to_user_key(l) for l in get_logins()]:
|
2025-02-24 16:06:17 +00:00
|
|
|
logger.warning(f'not logged in for {key}')
|
|
|
|
raise Found(location='/login')
|
2025-02-24 04:28:22 +00:00
|
|
|
elif not (user := key.get()):
|
2025-02-24 16:06:17 +00:00
|
|
|
raise Found(location='/login')
|
2025-02-24 04:28:22 +00:00
|
|
|
|
|
|
|
return fn(*args, user=user, **kwargs)
|
|
|
|
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
2025-02-22 05:41:23 +00:00
|
|
|
def get_logins():
|
|
|
|
"""Returns the user's current logged in sessions:
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
list of :class:`oauth_dropins.models.BaseAuth`
|
|
|
|
"""
|
2025-02-24 16:06:17 +00:00
|
|
|
logins = [l for l in get_multi(oauth_dropins.get_logins()) if l]
|
2025-02-22 05:41:23 +00:00
|
|
|
return sorted(logins, key=lambda l: (l.key.kind(), l.user_display_name()))
|
|
|
|
|
|
|
|
|
2025-02-24 04:28:22 +00:00
|
|
|
def login_to_user_key(login):
|
|
|
|
""""Converts an oauth-dropins auth entity to a :model:`User` key.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
login (oauth_dropins.models.BaseAuth)
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
ndb.key.Key:
|
|
|
|
"""
|
|
|
|
match login.site_name():
|
2025-02-24 22:16:08 +00:00
|
|
|
case 'Bluesky':
|
|
|
|
return ATProto(id=login.key.id()).key
|
2025-02-24 04:28:22 +00:00
|
|
|
case 'Mastodon':
|
|
|
|
if login.user_json and (id := json_loads(login.user_json).get('uri')):
|
|
|
|
return ActivityPub(id=id).key
|
|
|
|
logger.warning(f'Mastodon auth entity {login.key.id()} has no user_json or uri')
|
|
|
|
return None
|
|
|
|
case 'Pixelfed':
|
|
|
|
user, server = login.key.id().strip('@').split('@')
|
|
|
|
return ActivityPub(id=f'https://{server}/users/{user}').key
|
2025-02-24 22:16:08 +00:00
|
|
|
case 'Threads':
|
|
|
|
username = json_loads(login.user_json).get('username')
|
|
|
|
handle = f'@{username}@threads.net'
|
|
|
|
if user := ActivityPub.query(ActivityPub.handle == handle).get():
|
|
|
|
return user.key
|
2025-02-25 00:30:03 +00:00
|
|
|
if not (actor_id := webfinger.fetch_actor_url(handle)):
|
|
|
|
for msg in get_flashed_messages:
|
|
|
|
if 'HTTP 404' in msg:
|
|
|
|
flash('You need to <a href="https://help.instagram.com/169559812696339">turn on fediverse sharing</a> first.')
|
|
|
|
return None
|
2025-02-24 22:16:08 +00:00
|
|
|
return ActivityPub(id=actor_id).key
|
2025-02-24 04:28:22 +00:00
|
|
|
case _:
|
|
|
|
assert False, repr(login)
|
|
|
|
|
|
|
|
|
2025-02-22 05:41:23 +00:00
|
|
|
def render(template, **vars):
|
|
|
|
"""Renders a Jinja2 template and adds our standard template variables.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
template (str): file name
|
|
|
|
"""
|
|
|
|
return render_template(template, **TEMPLATE_VARS, logins=get_logins(), **vars)
|
|
|
|
|
|
|
|
|
2022-11-11 19:12:48 +00:00
|
|
|
@app.route('/')
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2024-06-04 21:19:04 +00:00
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
2022-11-11 19:12:48 +00:00
|
|
|
def front_page():
|
2022-11-19 06:30:07 +00:00
|
|
|
"""View for the front page."""
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('index.html')
|
2022-11-19 06:30:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/docs')
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2024-06-04 21:19:04 +00:00
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
2022-11-19 06:30:07 +00:00
|
|
|
def docs():
|
|
|
|
"""View for the docs page."""
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('docs.html')
|
2022-11-19 06:30:07 +00:00
|
|
|
|
|
|
|
|
2025-02-21 05:21:46 +00:00
|
|
|
@app.route('/login')
|
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
|
|
|
def login():
|
|
|
|
"""View for the front page."""
|
2025-05-21 18:09:30 +00:00
|
|
|
return render('login.html')
|
2025-02-21 05:21:46 +00:00
|
|
|
|
|
|
|
|
2025-02-24 04:28:22 +00:00
|
|
|
@app.post('/logout')
|
|
|
|
def logout():
|
|
|
|
"""Logs the user out of all current login sessions."""
|
|
|
|
oauth_dropins.logout()
|
|
|
|
flash(f"OK, you're now logged out.")
|
|
|
|
return redirect('/', code=302)
|
|
|
|
|
|
|
|
|
2025-02-22 05:41:23 +00:00
|
|
|
@app.route('/settings')
|
2025-02-21 05:21:46 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2025-02-22 05:41:23 +00:00
|
|
|
def settings():
|
2025-02-21 05:21:46 +00:00
|
|
|
"""User settings page. Requires logged in session."""
|
2025-02-23 00:42:40 +00:00
|
|
|
auth_entity = request.args.get('auth_entity')
|
2025-02-24 16:06:17 +00:00
|
|
|
logged_in_as = Key(urlsafe=auth_entity) if auth_entity else None
|
2025-02-23 00:42:40 +00:00
|
|
|
|
2025-02-25 04:26:03 +00:00
|
|
|
def site_logo(login):
|
|
|
|
return f'/oauth_dropins_static/{login.site_name().lower()}_icon.png'
|
|
|
|
|
2025-02-23 00:42:40 +00:00
|
|
|
users = []
|
2025-02-25 04:26:03 +00:00
|
|
|
logins_and_user_keys = []
|
2025-02-22 05:41:23 +00:00
|
|
|
for login in get_logins():
|
2025-02-24 16:06:17 +00:00
|
|
|
if user_key := login_to_user_key(login):
|
|
|
|
if login.key == logged_in_as:
|
|
|
|
cls = Model._lookup_model(user_key.kind())
|
2025-06-22 18:45:21 +00:00
|
|
|
user = cls.get_or_create(id=user_key.id(), allow_opt_out=True,
|
|
|
|
reload=True)
|
2025-02-25 04:26:03 +00:00
|
|
|
user.logo = site_logo(login)
|
|
|
|
users.append(user)
|
2025-02-24 16:06:17 +00:00
|
|
|
else:
|
2025-02-25 04:26:03 +00:00
|
|
|
logins_and_user_keys.append((login, user_key))
|
|
|
|
|
|
|
|
loaded = get_multi(key for _, key in logins_and_user_keys)
|
|
|
|
for (login, _), user in zip(logins_and_user_keys, loaded):
|
|
|
|
if user:
|
|
|
|
user.logo = site_logo(login)
|
|
|
|
users.append(user)
|
2025-02-22 05:41:23 +00:00
|
|
|
|
|
|
|
if not users:
|
2025-02-21 05:21:46 +00:00
|
|
|
return redirect('/login', code=302)
|
|
|
|
|
2025-02-22 05:41:23 +00:00
|
|
|
return render(
|
2025-02-21 05:21:46 +00:00
|
|
|
'settings.html',
|
|
|
|
**locals(),
|
|
|
|
USER_STATUS_DESCRIPTIONS=USER_STATUS_DESCRIPTIONS,
|
|
|
|
)
|
|
|
|
|
2025-02-24 04:28:22 +00:00
|
|
|
|
|
|
|
@app.post('/settings/enable')
|
|
|
|
@require_login
|
|
|
|
def enable(user=None):
|
|
|
|
"""Enables bridging for a given account.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
user (models.User)
|
|
|
|
"""
|
|
|
|
enabled = []
|
|
|
|
|
|
|
|
for proto in set(PROTOCOLS.values()):
|
|
|
|
if (proto and not isinstance(user, proto)
|
|
|
|
and proto.LABEL not in ('ui', 'web')
|
|
|
|
and not user.is_enabled(proto)):
|
|
|
|
try:
|
|
|
|
user.enable_protocol(proto)
|
|
|
|
except ErrorButDoNotRetryTask as e:
|
|
|
|
msg = str(e)
|
|
|
|
if resp := e.get_response():
|
|
|
|
if resp.is_json:
|
|
|
|
msg = resp.json['error']
|
|
|
|
flash(f"Couldn't enable bridging to {proto.PHRASE}: {msg}")
|
|
|
|
return redirect('/settings', code=302)
|
|
|
|
|
|
|
|
proto.bot_follow(user)
|
|
|
|
enabled.append(proto)
|
|
|
|
|
|
|
|
if enabled:
|
|
|
|
flash(f'Now bridging {user.handle_or_id()} to {",".join(p.PHRASE for p in enabled)}.')
|
|
|
|
else:
|
|
|
|
flash(f'{user.handle_or_id()} is already bridging.')
|
|
|
|
|
|
|
|
return redirect('/settings', code=302)
|
|
|
|
|
|
|
|
|
|
|
|
@app.post('/settings/disable')
|
|
|
|
@require_login
|
|
|
|
def disable(user=None):
|
|
|
|
"""Disables bridging for a given account.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
user (models.User)
|
|
|
|
"""
|
|
|
|
if not user.enabled_protocols:
|
|
|
|
flash(f'{user.handle_or_id()} is not currently bridging.')
|
|
|
|
return redirect('/settings', code=302)
|
|
|
|
|
|
|
|
enabled = list(user.enabled_protocols)
|
|
|
|
for proto in user.enabled_protocols:
|
|
|
|
user.delete(PROTOCOLS[proto])
|
|
|
|
user.disable_protocol(PROTOCOLS[proto])
|
|
|
|
|
|
|
|
flash(f'Disabled bridging {user.handle_or_id()} to {",".join(PROTOCOLS[p].PHRASE for p in enabled)}.')
|
|
|
|
return redirect('/settings', code=302)
|
2025-02-22 05:41:23 +00:00
|
|
|
|
2025-02-21 05:21:46 +00:00
|
|
|
|
2025-03-21 01:14:15 +00:00
|
|
|
@app.post('/settings/set-username')
|
|
|
|
@require_login
|
|
|
|
def set_username(user=None):
|
|
|
|
"""Enables bridging for a given account.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
user (models.User)
|
|
|
|
|
|
|
|
Query params:
|
|
|
|
protocol (str)
|
|
|
|
username (str)
|
|
|
|
"""
|
|
|
|
proto = PROTOCOLS[flask_util.get_required_param('protocol')]
|
|
|
|
username = flask_util.get_required_param('username')
|
|
|
|
|
|
|
|
try:
|
|
|
|
proto.set_username(user, username)
|
|
|
|
flash(f"Setting username on {proto.PHRASE} to {username}...")
|
|
|
|
except NotImplementedError:
|
|
|
|
flash(f"Custom usernames aren't supported on {proto.PHRASE}.")
|
|
|
|
except (ValueError, RuntimeError) as e:
|
|
|
|
flash(f"Couldn't set username on {proto.PHRASE} to {username}: {e}")
|
|
|
|
|
|
|
|
return redirect('/settings', code=302)
|
|
|
|
|
|
|
|
|
2023-06-02 05:00:47 +00:00
|
|
|
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>')
|
2023-09-26 23:43:48 +00:00
|
|
|
# WARNING: this overrides the /ap/... actor URL route in activitypub.py, *only*
|
|
|
|
# for handles with leading @ character. be careful when changing this route!
|
|
|
|
@app.get(f'/ap/@<id>', defaults={'protocol': 'ap'})
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2023-10-10 21:55:27 +00:00
|
|
|
def profile(protocol, id):
|
2023-11-20 04:48:31 +00:00
|
|
|
user = load_user(protocol, id)
|
|
|
|
query = Object.query(Object.users == user.key)
|
2023-11-20 23:36:26 +00:00
|
|
|
objects, before, after = fetch_objects(query, by=Object.updated, user=user)
|
2023-11-20 04:48:31 +00:00
|
|
|
num_followers, num_following = user.count_followers()
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('profile.html', **locals())
|
2023-10-10 21:55:27 +00:00
|
|
|
|
|
|
|
|
|
|
|
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/home')
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2023-10-10 21:55:27 +00:00
|
|
|
def home(protocol, id):
|
2023-11-20 04:48:31 +00:00
|
|
|
user = load_user(protocol, id)
|
|
|
|
query = Object.query(Object.feed == user.key)
|
2023-11-20 23:36:26 +00:00
|
|
|
objects, before, after = fetch_objects(query, by=Object.created, user=user)
|
2023-10-11 19:22:34 +00:00
|
|
|
|
|
|
|
# this calls Object.actor_link serially for each object, which loads the
|
|
|
|
# actor from the datastore if necessary. TODO: parallelize those fetches
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('home.html', **locals())
|
2023-10-10 21:55:27 +00:00
|
|
|
|
|
|
|
|
|
|
|
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/notifications')
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2023-10-10 21:55:27 +00:00
|
|
|
def notifications(protocol, id):
|
2023-11-20 04:48:31 +00:00
|
|
|
user = load_user(protocol, id)
|
2023-10-10 21:55:27 +00:00
|
|
|
|
2023-11-20 04:48:31 +00:00
|
|
|
query = Object.query(Object.notify == user.key)
|
2023-11-20 23:36:26 +00:00
|
|
|
objects, before, after = fetch_objects(query, by=Object.updated, user=user)
|
2023-10-11 18:28:39 +00:00
|
|
|
|
|
|
|
format = request.args.get('format')
|
|
|
|
if format:
|
2023-10-12 17:37:44 +00:00
|
|
|
return serve_feed(objects=objects, format=format, as_snippets=True,
|
2023-11-20 04:48:31 +00:00
|
|
|
user=user, title=f'Bridgy Fed notifications for {id}',
|
2023-10-12 17:48:29 +00:00
|
|
|
quiet=request.args.get('quiet'))
|
2023-10-11 18:28:39 +00:00
|
|
|
|
|
|
|
# notifications tab UI page
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('notifications.html', **locals())
|
2022-11-08 14:56:19 +00:00
|
|
|
|
2022-11-11 23:44:35 +00:00
|
|
|
|
2025-01-10 20:38:46 +00:00
|
|
|
@app.get(f'/user-page')
|
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
|
|
|
def find_user_page_form():
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('find_user_page.html')
|
2025-01-10 20:38:46 +00:00
|
|
|
|
|
|
|
|
|
|
|
@app.post(f'/user-page')
|
|
|
|
def find_user_page():
|
|
|
|
id = request.form['id']
|
|
|
|
|
|
|
|
proto = Protocol.for_id(id)
|
|
|
|
|
|
|
|
resolved_id = None
|
|
|
|
if not proto:
|
|
|
|
proto, resolved_id = Protocol.for_handle(id)
|
|
|
|
if not proto:
|
2025-06-13 14:50:20 +00:00
|
|
|
flash(f"Couldn't determine network for {html.escape(id)}.")
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('find_user_page.html'), 404
|
2025-01-10 20:38:46 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
user = load_user(proto.LABEL, resolved_id or id)
|
|
|
|
except NotFound:
|
2025-06-13 14:50:20 +00:00
|
|
|
flash(f"User {html.escape(id)} on {proto.PHRASE} isn't signed up.")
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('find_user_page.html'), 404
|
2025-01-10 20:38:46 +00:00
|
|
|
|
|
|
|
return redirect(user.user_page_path(), code=302)
|
|
|
|
|
|
|
|
|
2024-04-23 03:21:56 +00:00
|
|
|
@app.post(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/update-profile')
|
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
|
|
|
def update_profile(protocol, id):
|
|
|
|
user = load_user(protocol, id)
|
2024-04-28 13:50:46 +00:00
|
|
|
link = f'<a href="{user.web_url()}">{user.handle_or_id()}</a>'
|
2024-04-23 03:21:56 +00:00
|
|
|
|
|
|
|
try:
|
2024-10-04 00:06:46 +00:00
|
|
|
user.reload_profile()
|
2024-04-23 03:21:56 +00:00
|
|
|
except (requests.RequestException, werkzeug.exceptions.HTTPException) as e:
|
2024-04-25 00:06:25 +00:00
|
|
|
_, msg = util.interpret_http_exception(e)
|
2024-04-28 13:50:46 +00:00
|
|
|
flash(f"Couldn't update profile for {link}: {msg}")
|
2025-05-16 17:52:06 +00:00
|
|
|
return redirect(user.user_page_path(), code=302)
|
2024-04-23 03:21:56 +00:00
|
|
|
|
2024-10-29 04:23:11 +00:00
|
|
|
if not user.obj:
|
|
|
|
flash(f"Couldn't update profile for {link}")
|
2025-05-16 17:52:06 +00:00
|
|
|
return redirect(user.user_page_path(), code=302)
|
2024-10-29 04:23:11 +00:00
|
|
|
|
|
|
|
common.create_task(queue='receive', obj_id=user.obj_key.id(),
|
|
|
|
authed_as=user.key.id())
|
|
|
|
flash(f'Updating profile from {link}...')
|
2024-09-17 01:27:04 +00:00
|
|
|
|
2024-10-29 04:23:11 +00:00
|
|
|
if user.LABEL == 'web':
|
|
|
|
if user.status:
|
2024-09-17 01:27:04 +00:00
|
|
|
logger.info(f'Disabling web user {user.key.id()}')
|
|
|
|
user.delete()
|
2024-10-29 04:23:11 +00:00
|
|
|
else:
|
|
|
|
for label in list(user.DEFAULT_ENABLED_PROTOCOLS) + user.enabled_protocols:
|
|
|
|
try:
|
|
|
|
PROTOCOLS[label].set_username(user, id)
|
2025-01-10 19:36:22 +00:00
|
|
|
except (AssertionError, ValueError, RuntimeError, NotImplementedError):
|
2024-10-29 04:23:11 +00:00
|
|
|
pass
|
|
|
|
|
2025-05-16 17:52:06 +00:00
|
|
|
return redirect(user.user_page_path(), code=302)
|
2024-04-23 03:21:56 +00:00
|
|
|
|
|
|
|
|
2023-06-02 05:00:47 +00:00
|
|
|
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/<any(followers,following):collection>')
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2023-06-02 05:00:47 +00:00
|
|
|
def followers_or_following(protocol, id, collection):
|
2023-11-20 04:39:05 +00:00
|
|
|
user = load_user(protocol, id)
|
2024-12-20 18:07:39 +00:00
|
|
|
id = user.key.id()
|
|
|
|
handle = user.handle
|
|
|
|
|
2023-11-20 04:39:05 +00:00
|
|
|
followers, before, after = Follower.fetch_page(collection, user)
|
2023-11-20 04:48:31 +00:00
|
|
|
num_followers, num_following = user.count_followers()
|
2025-02-22 05:41:23 +00:00
|
|
|
return render(
|
2023-01-19 05:22:04 +00:00
|
|
|
f'{collection}.html',
|
2023-02-09 16:23:31 +00:00
|
|
|
address=request.args.get('address'),
|
2023-10-10 21:55:27 +00:00
|
|
|
follow_url=request.values.get('url'),
|
|
|
|
**locals(),
|
2022-11-12 16:25:36 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
2023-06-02 05:00:47 +00:00
|
|
|
@app.get(f'/<any({",".join(PROTOCOLS)}):protocol>/<id>/feed')
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2025-01-25 05:25:23 +00:00
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
2023-06-02 05:00:47 +00:00
|
|
|
def feed(protocol, id):
|
2023-11-20 04:48:31 +00:00
|
|
|
user = load_user(protocol, id)
|
|
|
|
query = Object.query(Object.feed == user.key)
|
2023-11-20 23:36:26 +00:00
|
|
|
objects, _, _ = fetch_objects(query, by=Object.created, user=user)
|
2023-10-11 18:28:39 +00:00
|
|
|
return serve_feed(objects=objects, format=request.args.get('format', 'html'),
|
2023-11-20 04:48:31 +00:00
|
|
|
user=user, title=f'Bridgy Fed feed for {id}')
|
2023-10-11 18:28:39 +00:00
|
|
|
|
|
|
|
|
2023-11-20 04:48:31 +00:00
|
|
|
def serve_feed(*, objects, format, user, title, as_snippets=False, quiet=False):
|
2025-01-30 19:40:54 +00:00
|
|
|
"""Generates a feed based on :class:`Object` s.
|
2023-10-11 18:28:39 +00:00
|
|
|
|
|
|
|
Args:
|
|
|
|
objects (sequence of models.Object)
|
|
|
|
format (str): ``html``, ``atom``, or ``rss``
|
2023-11-20 04:48:31 +00:00
|
|
|
user (models.User)
|
2023-10-11 18:28:39 +00:00
|
|
|
title (str)
|
2023-10-12 17:37:44 +00:00
|
|
|
as_snippets (bool): if True, render short snippets for objects instead of
|
|
|
|
full contents
|
2023-10-12 17:48:29 +00:00
|
|
|
quiet (bool): if True, exclude follows, unfollows, likes, and reposts
|
2023-10-11 18:28:39 +00:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
str or (str, dict) tuple: Flask response
|
|
|
|
"""
|
|
|
|
if format not in ('html', 'atom', 'rss'):
|
|
|
|
error(f'format {format} not supported; expected html, atom, or rss')
|
|
|
|
|
2023-10-12 17:48:29 +00:00
|
|
|
objects = [obj for obj in objects if not obj.deleted]
|
|
|
|
if quiet:
|
|
|
|
objects = [obj for obj in objects if obj.type not in
|
2023-12-27 04:42:11 +00:00
|
|
|
('delete', 'follow', 'stop-following', 'like', 'share',
|
|
|
|
'undo', 'update')]
|
2023-10-12 17:48:29 +00:00
|
|
|
|
2023-10-12 17:37:44 +00:00
|
|
|
if as_snippets:
|
|
|
|
activities = [{
|
|
|
|
'objectType': 'note',
|
2023-10-13 13:41:08 +00:00
|
|
|
'id': obj.key.id(),
|
2023-11-26 04:38:28 +00:00
|
|
|
'content': f'{obj.actor_link(image=False, user=user)} {obj.phrase} {obj.content}',
|
2023-10-12 17:37:44 +00:00
|
|
|
'updated': obj.updated.isoformat(),
|
2023-10-17 17:09:32 +00:00
|
|
|
'url': as1.get_url(obj.as1) or as1.get_url(as1.get_object(obj.as1)),
|
2023-10-12 17:48:29 +00:00
|
|
|
} for obj in objects]
|
2023-10-12 17:37:44 +00:00
|
|
|
else:
|
2023-10-12 17:48:29 +00:00
|
|
|
activities = [obj.as1 for obj in objects]
|
|
|
|
|
2024-12-20 00:41:55 +00:00
|
|
|
# hydrate authors, actors, objects from stored Objects
|
2025-05-28 03:58:14 +00:00
|
|
|
futures = []
|
2024-12-20 00:41:55 +00:00
|
|
|
for a in activities:
|
2025-05-28 03:58:14 +00:00
|
|
|
futures.extend(models.hydrate(a))
|
|
|
|
tasklets.wait_all(futures)
|
2023-07-20 05:39:22 +00:00
|
|
|
|
2023-11-20 04:48:31 +00:00
|
|
|
actor = (user.obj.as1 if user.obj and user.obj.as1
|
2025-03-19 00:43:36 +00:00
|
|
|
else {'displayName': user.handle, 'url': user.web_url()})
|
2023-10-23 20:10:27 +00:00
|
|
|
|
2023-03-05 15:52:56 +00:00
|
|
|
# TODO: inject/merge common.pretty_link into microformats2.render_content
|
|
|
|
# (specifically into hcard_to_html) somehow to convert Mastodon URLs to @-@
|
|
|
|
# syntax. maybe a fediverse kwarg down through the call chain?
|
2022-11-17 15:38:52 +00:00
|
|
|
if format == 'html':
|
2023-01-28 23:07:05 +00:00
|
|
|
entries = [microformats2.object_to_html(a) for a in activities]
|
2025-02-22 05:41:23 +00:00
|
|
|
return render('feed.html', **locals())
|
2024-12-23 20:54:13 +00:00
|
|
|
|
2022-11-17 15:38:52 +00:00
|
|
|
elif format == 'atom':
|
2023-10-23 20:10:27 +00:00
|
|
|
body = atom.activities_to_atom(activities, actor=actor, title=title,
|
|
|
|
request_url=request.url)
|
2022-11-17 15:58:08 +00:00
|
|
|
return body, {'Content-Type': atom.CONTENT_TYPE}
|
2024-12-23 20:54:13 +00:00
|
|
|
|
2022-11-17 15:38:52 +00:00
|
|
|
elif format == 'rss':
|
2024-12-23 20:54:13 +00:00
|
|
|
# RSS requires email to generate an author element, so fill in blank one
|
|
|
|
# where necessary
|
|
|
|
for a in activities:
|
2025-05-28 03:58:14 +00:00
|
|
|
for field in ('actor', 'author', 'object'):
|
2024-12-23 20:54:13 +00:00
|
|
|
if val := as1.get_object(a, field):
|
|
|
|
if as1.object_type(val) in as1.ACTOR_TYPES:
|
|
|
|
val.setdefault('email', '_@_._')
|
|
|
|
|
2023-10-23 20:10:27 +00:00
|
|
|
body = rss.from_activities(activities, actor=actor, title=title,
|
|
|
|
feed_url=request.url)
|
2022-11-17 15:58:08 +00:00
|
|
|
return body, {'Content-Type': rss.CONTENT_TYPE}
|
2022-11-17 15:38:52 +00:00
|
|
|
|
|
|
|
|
2021-07-13 15:06:35 +00:00
|
|
|
@app.get('/log')
|
2023-10-16 21:02:17 +00:00
|
|
|
@canonicalize_request_domain(common.PROTOCOL_DOMAINS, common.PRIMARY_DOMAIN)
|
2024-06-04 21:19:04 +00:00
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
2021-07-13 15:06:35 +00:00
|
|
|
def log():
|
2024-06-04 21:19:04 +00:00
|
|
|
return logs.log()
|
2025-06-09 18:51:22 +00:00
|
|
|
|
|
|
|
|
2025-06-10 18:03:57 +00:00
|
|
|
@app.get(f'/internal/<any({",".join(BLOG_REDIRECT_DOMAINS)}):host>/<path:path>')
|
2025-06-09 18:51:22 +00:00
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
2025-06-10 18:03:57 +00:00
|
|
|
def blog_redirect(host, path):
|
2025-06-09 18:51:22 +00:00
|
|
|
return MovedPermanently(location=f'https://{host}/{path}')
|
2025-07-08 21:48:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
@app.post('/admin/memcache-evict')
|
|
|
|
def memcache_evict():
|
|
|
|
if request.headers.get('Authorization') != app.config['SECRET_KEY']:
|
|
|
|
return '', 401
|
|
|
|
|
|
|
|
key = Key(urlsafe=flask_util.get_required_param('key'))
|
|
|
|
memcache.evict(key)
|
|
|
|
|
|
|
|
return ''
|