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
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-09-28 21:42:18 +00:00
from oauth_dropins . webutil . flask_util import 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-06-20 18:22:54 +00:00
from models import 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
FOLLOWERS_UI_LIMIT = 999
2017-10-26 04:32:59 +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 ) :
""" Loads the current request ' s user into `g.user`.
Args :
2023-10-06 06:32:31 +00:00
protocol ( str ) :
id ( str ) :
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 ]
g . user = cls . get_by_id ( id )
2023-06-10 22:07:26 +00:00
if protocol != ' web ' :
2023-06-07 18:51:31 +00:00
if not g . user :
2023-09-25 22:08:14 +00:00
g . user = cls . query ( OR ( cls . handle == id ,
cls . readable_id == id ) ,
) . get ( )
2023-06-07 18:51:31 +00:00
if g . user and g . user . use_instead :
g . user = g . user . use_instead . get ( )
2023-09-25 22:08:14 +00:00
if g . user and id not in ( g . user . key . id ( ) , g . user . handle ) :
2023-06-07 18:51:31 +00:00
error ( ' ' , status = 302 , location = g . user . user_page_path ( ) )
elif g . user and id != g . user . key . id ( ) : # use_instead redirect
error ( ' ' , status = 302 , location = g . user . user_page_path ( ) )
if not g . user or not g . 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
assert not g . user . use_instead
2022-11-11 19:12:48 +00:00
@app.route ( ' / ' )
@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 ' )
@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> ' )
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-10 21:55:27 +00:00
def profile ( protocol , id ) :
2023-06-07 18:51:31 +00:00
load_user ( protocol , id )
2023-10-11 04:19:26 +00:00
query = Object . query ( Object . users == g . user . key )
2023-10-10 21:55:27 +00:00
objects , before , after = fetch_objects ( query , by = Object . updated )
2023-10-10 22:38:53 +00:00
num_followers , num_following = 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 ' )
def home ( protocol , id ) :
load_user ( protocol , id )
query = Object . query ( Object . feed == g . user . key )
objects , before , after = fetch_objects ( query , by = Object . created )
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 ' )
def notifications ( protocol , id ) :
load_user ( protocol , id )
query = Object . query ( Object . notify == g . user . key )
objects , before , after = fetch_objects ( query , by = Object . updated )
2023-10-11 18:28:39 +00:00
format = request . args . get ( ' format ' )
if format :
return serve_feed ( objects = objects , format = format ,
title = f ' Bridgy Fed notifications for { id } ' )
# 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> ' )
def followers_or_following ( protocol , id , collection ) :
2023-06-07 18:51:31 +00:00
load_user ( protocol , id )
2022-11-12 16:25:36 +00:00
2023-06-06 21:50:20 +00:00
followers , before , after = Follower . fetch_page ( collection )
2023-10-10 22:38:53 +00:00
num_followers , num_following = 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-10-10 22:38:53 +00:00
def count_followers ( ) :
followers = Follower . query ( Follower . to == g . user . key ,
Follower . status == ' active ' ) \
. count ( limit = FOLLOWERS_UI_LIMIT )
num_followers = f ' { followers } { " + " if followers == FOLLOWERS_UI_LIMIT else " " } '
following = Follower . query ( Follower . from_ == g . user . key ,
Follower . status == ' active ' ) \
. count ( limit = FOLLOWERS_UI_LIMIT )
num_following = f ' { following } { " + " if following == FOLLOWERS_UI_LIMIT else " " } '
return num_followers , num_following
2023-10-11 18:28:39 +00:00
2023-06-02 05:00:47 +00:00
@app.get ( f ' /<any( { " , " . join ( PROTOCOLS ) } ):protocol>/<id>/feed ' )
def feed ( protocol , id ) :
2023-06-07 18:51:31 +00:00
load_user ( protocol , id )
2023-10-10 21:55:27 +00:00
query = Object . query ( Object . feed == g . user . key )
objects , _ , _ = fetch_objects ( query , by = Object . created )
2023-10-11 18:28:39 +00:00
return serve_feed ( objects = objects , format = request . args . get ( ' format ' , ' html ' ) ,
title = f ' Bridgy Fed feed for { id } ' )
def serve_feed ( * , objects , format , title ) :
""" Generates a feed based on :class:`Object`s.
Args :
objects ( sequence of models . Object )
format ( str ) : ` ` html ` ` , ` ` atom ` ` , or ` ` rss ` `
title ( str )
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-10 22:43:29 +00:00
activities = [ obj . as1 for obj in objects if not obj . deleted ]
2022-11-17 15:38:52 +00:00
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
2022-11-17 15:38:52 +00:00
actor = {
2023-06-02 05:00:47 +00:00
' displayName ' : id ,
2023-06-01 01:34:33 +00:00
' url ' : g . user . web_url ( ) ,
2022-11-17 15:38:52 +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-01-28 23:07:05 +00:00
body = atom . activities_to_atom ( activities , actor = actor , title = title ,
2022-11-17 15:58:08 +00:00
request_url = request . url )
return body , { ' Content-Type ' : atom . CONTENT_TYPE }
2022-11-17 15:38:52 +00:00
elif format == ' rss ' :
2023-01-28 23:07:05 +00:00
body = rss . from_activities ( activities , actor = actor , title = title ,
2022-11-17 15:58:08 +00:00
feed_url = request . url )
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 ' )
@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 ' )
2023-10-10 21:55:27 +00:00
def fetch_objects ( query , by = None ) :
2023-10-06 06:32:31 +00:00
""" Fetches a page of :class:`models.Object` entities from a datastore query.
2022-11-21 04:49:55 +00:00
2023-10-06 06:32:31 +00:00
Wraps : func : ` models . fetch_page ` and adds attributes to the returned
: class : ` models . Object ` entities for rendering in ` ` objects . html ` ` .
2022-11-21 04:49:55 +00:00
Args :
2023-10-06 06:32:31 +00:00
query ( ndb . Query )
2023-10-10 21:55:27 +00:00
by ( ndb . model . Property ) : either : attr : ` models . Object . updated ` or
: attr : ` models . Object . created `
2022-11-21 04:49:55 +00:00
Returns :
2023-10-06 06:32:31 +00:00
( list of models . Object , str , str ) tuple :
( results , new ` ` before ` ` query param , new ` ` after ` ` query param )
to fetch the previous and next pages , respectively
2022-11-21 04:49:55 +00:00
"""
2023-10-10 21:55:27 +00:00
assert by is Object . updated or by is Object . created
objects , new_before , new_after = fetch_page ( query , Object , by = by )
2022-11-21 04:49:55 +00:00
2023-01-28 23:07:05 +00:00
# synthesize human-friendly content for objects
2023-02-03 16:10:09 +00:00
for i , obj in enumerate ( objects ) :
2023-10-10 21:55:27 +00:00
if obj . deleted :
continue
2023-04-06 19:40:36 +00:00
obj_as1 = obj . as1
2023-06-14 23:20:25 +00:00
inner_obj = as1 . get_object ( obj_as1 )
2023-04-06 19:40:36 +00:00
2022-11-21 05:35:55 +00:00
# synthesize text snippet
2023-04-06 19:40:36 +00:00
type = as1 . object_type ( obj_as1 )
2023-06-14 23:20:25 +00:00
if type == ' post ' :
inner_type = inner_obj . get ( ' objectType ' )
if inner_type :
type = inner_type
2022-11-21 04:49:55 +00:00
phrases = {
' article ' : ' posted ' ,
' comment ' : ' replied ' ,
2023-04-17 22:36:29 +00:00
' delete ' : ' deleted ' ,
2022-11-21 04:49:55 +00:00
' follow ' : ' followed ' ,
2023-01-10 06:45:33 +00:00
' invite ' : ' is invited to ' ,
2023-04-12 14:57:40 +00:00
' issue ' : ' filed issue ' ,
2023-01-10 06:45:33 +00:00
' like ' : ' liked ' ,
' note ' : ' posted ' ,
' post ' : ' posted ' ,
2022-11-21 04:49:55 +00:00
' repost ' : ' reposted ' ,
' rsvp-interested ' : ' is interested in ' ,
2023-01-10 06:45:33 +00:00
' rsvp-maybe ' : ' might attend ' ,
' rsvp-no ' : ' is not attending ' ,
' rsvp-yes ' : ' is attending ' ,
' share ' : ' reposted ' ,
' stop-following ' : ' unfollowed ' ,
2023-04-06 19:40:36 +00:00
' update ' : ' updated ' ,
2022-11-21 04:49:55 +00:00
}
2023-01-28 23:07:05 +00:00
obj . phrase = phrases . get ( type )
2023-02-07 19:45:50 +00:00
content = ( inner_obj . get ( ' content ' )
or inner_obj . get ( ' displayName ' )
or inner_obj . get ( ' summary ' ) )
2023-09-08 21:26:19 +00:00
if content :
content = util . parse_html ( content ) . get_text ( )
2023-03-24 05:04:09 +00:00
urls = as1 . object_urls ( inner_obj )
id = common . redirect_unwrap ( inner_obj . get ( ' id ' , ' ' ) )
url = urls [ 0 ] if urls else id
2023-06-09 19:56:45 +00:00
if ( type == ' update ' and
2023-08-03 02:12:09 +00:00
( obj . users and ( g . user . is_web_url ( id )
or id . strip ( ' / ' ) == obj . users [ 0 ] . id ( ) )
2023-06-09 19:56:45 +00:00
or obj . domains and id . strip ( ' / ' ) == f ' https:// { obj . domains [ 0 ] } ' ) ) :
2023-01-28 23:07:05 +00:00
obj . phrase = ' updated '
2023-04-06 19:40:36 +00:00
obj_as1 . update ( {
2023-02-07 20:43:36 +00:00
' content ' : ' their profile ' ,
2023-06-09 19:56:45 +00:00
' url ' : id ,
2023-02-07 20:43:36 +00:00
} )
elif url :
2023-08-26 16:47:41 +00:00
# heuristics for sniffing Mastodon and similar fediverse URLs and
# converting them to more friendly @-names
# TODO: standardize this into granary.as2 somewhere?
if not content :
fedi_url = re . match (
r ' https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)? ' , url )
if fedi_url :
content = ' @ ' + fedi_url . group ( 2 )
if fedi_url . group ( 4 ) :
content + = " ' s post "
2023-03-20 21:28:14 +00:00
content = common . pretty_link ( url , text = content )
2023-01-28 23:07:05 +00:00
2023-04-06 19:40:36 +00:00
obj . content = ( obj_as1 . get ( ' content ' )
or obj_as1 . get ( ' displayName ' )
or obj_as1 . get ( ' summary ' ) )
obj . url = util . get_first ( obj_as1 , ' url ' )
2023-01-28 23:07:05 +00:00
2023-06-14 23:20:25 +00:00
if type in ( ' like ' , ' follow ' , ' repost ' , ' share ' ) or not obj . content :
2023-01-28 23:07:05 +00:00
if obj . url :
2023-03-20 21:28:14 +00:00
obj . phrase = common . pretty_link ( obj . url , text = obj . phrase ,
2023-03-14 13:54:16 +00:00
attrs = { ' class ' : ' u-url ' } )
2023-01-28 23:07:05 +00:00
if content :
obj . content = content
obj . url = url
return objects , new_before , new_after
2022-11-21 04:49:55 +00:00
2022-11-09 15:53:00 +00:00
@app.get ( ' /stats ' )
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 ' )
@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 ' )
@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 ' )
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 ( )