2022-11-12 23:27:59 +00:00
""" UI pages. """
2022-11-11 19:12:48 +00:00
import datetime
2023-07-20 17:24:58 +00:00
import itertools
2022-11-20 19:56:32 +00:00
import logging
2023-02-07 21:51:15 +00:00
import os
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
2023-06-20 18:22:54 +00:00
from flask import g , render_template , request
2023-07-28 22:49:29 +00:00
from google . cloud . ndb import tasklets
2023-07-16 21:06:03 +00:00
from google . cloud . ndb . query import AND , OR
2022-11-09 15:53:00 +00:00
from google . cloud . ndb . stats import KindStat
2023-10-10 22:38:53 +00:00
from granary import as1 , as2 , atom , microformats2 , rss
2022-12-02 22:46:18 +00:00
import humanize
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 ,
redirect ,
)
2017-10-26 04:32:59 +00:00
2022-11-08 14:56:19 +00:00
import common
2023-03-08 21:10:41 +00:00
from common import DOMAIN_RE
2023-05-31 00:24:49 +00:00
from flask_app import app , cache
2023-11-23 04:39:21 +00:00
from models import fetch_objects , fetch_page , Follower , Object , PAGE_SIZE , PROTOCOLS
2023-09-28 21:42:18 +00:00
from protocol import Protocol
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__ )
2023-10-11 04:19:26 +00:00
TEMPLATE_VARS = {
' as2 ' : as2 ,
' g ' : g ,
' isinstance ' : isinstance ,
' logs ' : logs ,
' PROTOCOLS ' : PROTOCOLS ,
' set ' : set ,
' util ' : util ,
}
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
if protocol == ' ap ' and not id . startswith ( ' @ ' ) :
id = ' @ ' + id
2023-06-07 18:51:31 +00:00
cls = PROTOCOLS [ protocol ]
2023-11-20 04:48:31 +00:00
user = cls . get_by_id ( id )
2023-06-07 18:51:31 +00:00
2023-06-10 22:07:26 +00:00
if protocol != ' web ' :
2023-11-20 04:48:31 +00:00
if not user :
user = cls . query ( OR ( cls . handle == id ,
2023-09-25 22:08:14 +00:00
cls . readable_id == id ) ,
) . get ( )
2023-11-20 04:48:31 +00:00
if user and user . use_instead :
user = user . use_instead . get ( )
2023-06-07 18:51:31 +00:00
2023-11-20 04:48:31 +00:00
if user and id not in ( user . key . id ( ) , user . handle ) :
error ( ' ' , status = 302 , location = user . user_page_path ( ) )
2023-06-07 18:51:31 +00:00
2023-11-20 04:48:31 +00:00
elif user and id != user . key . id ( ) : # use_instead redirect
error ( ' ' , status = 302 , location = user . user_page_path ( ) )
2023-06-07 18:51:31 +00:00
2023-11-27 19:18:12 +00:00
if not user or not user . direct :
2023-06-07 21:24:00 +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
2023-11-20 04:48:31 +00:00
assert not user . use_instead
return user
2023-06-07 18:51:31 +00:00
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 )
2022-11-11 19:12:48 +00:00
@flask_util.cached ( cache , datetime . timedelta ( days = 1 ) )
def front_page ( ) :
2022-11-19 06:30:07 +00:00
""" View for the front page. """
return render_template ( ' index.html ' )
@app.route ( ' /docs ' )
2023-10-16 21:02:17 +00:00
@canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
2022-11-19 06:30:07 +00:00
@flask_util.cached ( cache , datetime . timedelta ( days = 1 ) )
def docs ( ) :
""" View for the docs page. """
return render_template ( ' docs.html ' )
2023-01-19 06:20:15 +00:00
@app.get ( f ' /user/<regex( " { DOMAIN_RE } " ):domain> ' )
2023-05-30 21:08:13 +00:00
@app.get ( f ' /user/<regex( " { DOMAIN_RE } " ):domain>/feed ' )
@app.get ( f ' /user/<regex( " { DOMAIN_RE } " ):domain>/<any(followers,following):collection> ' )
2023-10-16 21:02:17 +00:00
@canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
2023-05-30 21:08:13 +00:00
def web_user_redirects ( * * kwargs ) :
path = request . url . removeprefix ( request . root_url ) . removeprefix ( ' user/ ' )
return redirect ( f ' /web/ { path } ' , code = 301 )
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 ( )
2023-10-11 04:19:26 +00:00
return render_template ( ' profile.html ' , * * TEMPLATE_VARS , * * 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
2023-10-11 04:19:26 +00:00
return render_template ( ' home.html ' , * * TEMPLATE_VARS , * * 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
2023-10-11 04:19:26 +00:00
return render_template ( ' notifications.html ' , * * TEMPLATE_VARS , * * locals ( ) )
2022-11-08 14:56:19 +00:00
2022-11-11 23:44:35 +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 )
followers , before , after = Follower . fetch_page ( collection , user )
2023-11-20 04:48:31 +00:00
num_followers , num_following = user . count_followers ( )
2022-11-12 16:25:36 +00:00
return render_template (
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 ' ) ,
2023-10-11 04:19:26 +00:00
* * TEMPLATE_VARS ,
2023-10-10 21:55:27 +00:00
* * 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 )
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 ) :
2023-10-11 18:28:39 +00:00
""" Generates a feed based on :class:`Object`s.
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-10-13 01:13:32 +00:00
( ' follow ' , ' stop-following ' , ' like ' , ' share ' , ' 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
' content_is_html ' : True ,
' 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 ]
2023-07-28 22:49:29 +00:00
# hydrate authors, actors, objects from stored Objects
2023-07-20 17:24:58 +00:00
fields = ' author ' , ' actor ' , ' object '
2023-07-28 22:49:29 +00:00
gets = [ ]
for a in activities :
for field in fields :
val = as1 . get_object ( a , field )
if val and val . keys ( ) < = set ( [ ' id ' ] ) :
def hydrate ( a , f ) :
def maybe_set ( future ) :
if future . result ( ) and future . result ( ) . as1 :
a [ f ] = future . result ( ) . as1
return maybe_set
future = Object . get_by_id_async ( val [ ' id ' ] )
future . add_done_callback ( hydrate ( a , field ) )
gets . append ( future )
tasklets . wait_all ( gets )
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
else { ' displayName ' : user . readable_id , ' 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 ]
2023-10-11 04:19:26 +00:00
return render_template ( ' feed.html ' , * * TEMPLATE_VARS , * * locals ( ) )
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 }
2022-11-17 15:38:52 +00:00
elif format == ' rss ' :
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
2023-09-28 21:42:18 +00:00
@app.get ( ' /bridge-user ' )
2023-10-16 21:02:17 +00:00
@canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
2023-09-28 21:42:18 +00:00
@flask_util.cached ( cache , datetime . timedelta ( days = 1 ) )
def bridge_user_page ( ) :
return render_template ( ' bridge_user.html ' )
@app.post ( ' /bridge-user ' )
def bridge_user ( ) :
handle = request . values [ ' handle ' ]
proto , id = Protocol . for_handle ( handle )
if not proto :
flash ( f " Couldn ' t determine protocol for { handle } " )
return render_template ( ' bridge_user.html ' ) , 400
2023-09-29 20:38:50 +00:00
# TODO: put these into a PULL_PROTOCOLS constant?
if not proto . LABEL in ( ' activitypub ' , ' fake ' , ' web ' ) :
flash ( f " { proto . __name__ } isn ' t supported " )
return render_template ( ' bridge_user.html ' ) , 400
2023-09-28 21:42:18 +00:00
if not id :
id = proto . handle_to_id ( handle )
if not id :
flash ( f " Couldn ' t resolve { proto . __name__ } handle { handle } " )
return render_template ( ' bridge_user.html ' ) , 400
2023-10-06 05:28:36 +00:00
user = proto . get_or_create ( id = id , propagate = True )
2023-09-28 21:42:18 +00:00
2023-10-06 05:28:36 +00:00
flash ( f ' Bridging <a href= " { user . web_url ( ) } " > { user . handle } </a> into Bluesky. <a href= " https://bsky.app/search " >Try searching for them</a> in a minute! ' )
2023-09-28 21:42:18 +00:00
return render_template ( ' bridge_user.html ' )
2022-11-09 15:53:00 +00:00
@app.get ( ' /stats ' )
2023-10-16 21:02:17 +00:00
@canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
2022-11-09 15:53:00 +00:00
def stats ( ) :
2022-12-02 22:46:18 +00:00
def count ( kind ) :
return humanize . intcomma (
KindStat . query ( KindStat . kind_name == kind ) . get ( ) . count )
return render_template (
' stats.html ' ,
users = count ( ' MagicKey ' ) ,
2023-01-28 23:07:05 +00:00
objects = count ( ' Object ' ) ,
2022-12-02 22:46:18 +00:00
followers = count ( ' Follower ' ) ,
)
2022-11-09 15:53:00 +00:00
2023-02-07 21:51:15 +00:00
@app.get ( ' /.well-known/nodeinfo ' )
2023-10-16 21:02:17 +00:00
@canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
2023-02-07 21:51:15 +00:00
@flask_util.cached ( cache , datetime . timedelta ( days = 1 ) )
def nodeinfo_jrd ( ) :
"""
https : / / nodeinfo . diaspora . software / protocol . html
"""
return {
' links ' : [ {
' rel ' : ' http://nodeinfo.diaspora.software/ns/schema/2.1 ' ,
' href ' : common . host_url ( ' nodeinfo.json ' ) ,
} ] ,
} , {
' Content-Type ' : ' application/jrd+json ' ,
}
@app.get ( ' /nodeinfo.json ' )
2023-10-16 21:02:17 +00:00
@canonicalize_request_domain ( common . PROTOCOL_DOMAINS , common . PRIMARY_DOMAIN )
2023-02-07 21:51:15 +00:00
@flask_util.cached ( cache , datetime . timedelta ( days = 1 ) )
def nodeinfo ( ) :
"""
https : / / nodeinfo . diaspora . software / schema . html
"""
2023-05-26 23:36:45 +00:00
user_total = None
stat = KindStat . query ( KindStat . kind_name == ' MagicKey ' ) . get ( )
if stat :
user_total = stat . count
2023-02-07 21:51:15 +00:00
return {
' version ' : ' 2.1 ' ,
' software ' : {
' name ' : ' bridgy-fed ' ,
' version ' : os . getenv ( ' GAE_VERSION ' ) ,
' repository ' : ' https://github.com/snarfed/bridgy-fed ' ,
2023-06-01 01:34:33 +00:00
' web_url ' : ' https://fed.brid.gy/ ' ,
2023-02-07 21:51:15 +00:00
} ,
' protocols ' : [
' activitypub ' ,
' bluesky ' ,
' webmention ' ,
] ,
' services ' : {
' outbound ' : [ ] ,
' inbound ' : [ ] ,
} ,
' usage ' : {
' users ' : {
2023-05-26 23:36:45 +00:00
' total ' : user_total ,
2023-02-07 21:51:15 +00:00
# 'activeMonth':
# 'activeHalfyear':
} ,
2023-05-30 19:15:36 +00:00
' localPosts ' : Object . query ( Object . source_protocol . IN ( ( ' web ' , ' webmention ' ) ) ,
2023-02-07 21:51:15 +00:00
Object . type . IN ( [ ' note ' , ' article ' ] ) ,
) . count ( ) ,
2023-05-30 19:15:36 +00:00
' localComments ' : Object . query ( Object . source_protocol . IN ( ( ' web ' , ' webmention ' ) ) ,
2023-02-07 21:51:15 +00:00
Object . type == ' comment ' ,
) . count ( ) ,
} ,
' openRegistrations ' : True ,
' metadata ' : { } ,
} , {
# https://nodeinfo.diaspora.software/protocol.html
' Content-Type ' : ' application/json; profile= " http://nodeinfo.diaspora.software/ns/schema/2.1# " ' ,
}
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 )
2021-09-09 05:14:11 +00:00
@flask_util.cached ( cache , logs . CACHE_TIME )
2021-07-13 15:06:35 +00:00
def log ( ) :
2021-08-06 17:30:50 +00:00
return logs . log ( )