-
Notifications
You must be signed in to change notification settings - Fork 56
/
handlers.py
355 lines (298 loc) · 11.6 KB
/
handlers.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""Common views, e.g. post and comment permalinks.
Docs: https://brid.gy/about#source-urls
URL paths are:
* ``/post/SITE/USER_ID/POST_ID``
e.g. /post/flickr/212038/10100823411094363
* ``/comment/SITE/USER_ID/POST_ID/COMMENT_ID``
e.g. /comment/twitter/snarfed_org/10100823411094363/999999
* ``/like/SITE/USER_ID/POST_ID/LIKED_BY_USER_ID``
e.g. /like/twitter/snarfed_org/10100823411094363/999999
* ``/repost/SITE/USER_ID/POST_ID/REPOSTED_BY_USER_ID``
e.g. /repost/twitter/snarfed_org/10100823411094363/999999
* ``/rsvp/SITE/USER_ID/EVENT_ID/RSVP_USER_ID``
e.g. /rsvp/facebook/212038/12345/67890
"""
import datetime
import logging
import re
import string
from urllib.parse import unquote
from flask import request
from flask.views import View
from granary import microformats2
from granary.microformats2 import first_props
from oauth_dropins.webutil import flask_util
from oauth_dropins.webutil.flask_util import error
from oauth_dropins.webutil.util import json_loads
from flask_app import app
import models
import original_post_discovery
import util
logger = logging.getLogger(__name__)
CACHE_CONTROL = {'Cache-Control': 'public, max-age=900'} # 15m
TEMPLATE = string.Template("""\
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
$refresh
<title>$title</title>
<style type="text/css">
body {
display: none;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.p-uid {
display: none;
}
.u-photo {
max-width: 50px;
border-radius: 4px;
}
.e-content {
margin-top: 10px;
font-size: 1.3em;
}
</style>
</head>
$body
</html>
""")
class Item(View):
"""Fetches a post, repost, like, or comment and serves it as mf2 HTML or JSON.
"""
source = None
VALID_ID = re.compile(r'^[\w.+/%:@=<>-]+$')
def get_item(self, **kwargs):
"""Fetches and returns an object from the given source.
To be implemented by subclasses.
Args:
source: :class:`models.Source` subclass
id: str
Returns:
ActivityStreams object dict
"""
raise NotImplementedError()
def get_post(self, id, **kwargs):
"""Fetch a post.
Args:
id: str, site-specific post id
is_event: bool
kwargs: passed through to :meth:`get_activities`
Returns:
ActivityStreams object dict
"""
try:
posts = self.source.get_activities(
activity_id=id, user_id=self.source.key_id(), **kwargs)
if posts:
return posts[0]
logger.warning(f'Source post {id} not found')
except AssertionError:
raise
except Exception as e:
util.interpret_http_exception(e)
@flask_util.headers(CACHE_CONTROL)
def dispatch_request(self, site, key_id, **kwargs):
"""Handle HTTP request."""
source_cls = models.sources.get(site)
if not source_cls:
error(f"Source type '{site}' not found. Known sources: {[s for s in models.sources.keys() if s]}")
self.source = source_cls.get_by_id(key_id)
if not self.source:
error(f'Source {site} {key_id} not found')
elif (self.source.status == 'disabled' or
'listen' not in self.source.features) and self.source.SHORT_NAME != 'twitter':
error(f'Source {self.source.bridgy_path()} is disabled for backfeed')
format = request.values.get('format', 'html')
if format not in ('html', 'json'):
error(f'Invalid format {format}, expected html or json')
for k, id in kwargs.items():
if not self.VALID_ID.match(id):
error(f'Invalid id {id}', 404)
# Bluesky IDs need to be URL-decoded.
if self.source.SHORT_NAME == 'bluesky':
kwargs[k] = unquote(id)
# short circuit downstream fetches for HEADs.
#
# this was originally implemented as a separate handler, but Flask overrides
# that when it automatically adds HEAD to GET routes, so this is their
# recommended approach.
# https://github.com/pallets/flask/issues/4395#issuecomment-1032882475
if request.method == 'HEAD':
return ''
try:
obj = self.get_item(**kwargs)
except models.DisableSource:
error("Bridgy's access to your account has expired. Please visit https://brid.gy/ to refresh it!", 401)
except ValueError as e:
error(f'{self.source.GR_CLASS.NAME} error: {e}')
if not obj:
error(f'Not found: {site}:{key_id} {kwargs}', 404)
if self.source.is_blocked(obj):
error('That user is currently blocked', 410)
# use https for profile pictures so we don't cause SSL mixed mode errors
# when serving over https.
# Account for the fact that image might be a list.
author = obj.get('author', {})
image = util.get_first(author, 'image', {})
url = image.get('url')
if url:
image['url'] = util.update_scheme(url, request)
mf2_json = microformats2.object_to_json(obj, synthesize_content=False)
# try to include the author's silo profile url
author = first_props(mf2_json.get('properties', {})).get('author', {})
author_uid = first_props(author.get('properties', {})).get('uid', '')
if author_uid:
parsed = util.parse_tag_uri(author_uid)
if parsed:
urls = author.get('properties', {}).setdefault('url', [])
try:
silo_url = self.source.gr_source.user_url(parsed[1])
if silo_url not in microformats2.get_string_urls(urls):
urls.append(silo_url)
except NotImplementedError: # from gr_source.user_url()
pass
# write the response!
if format == 'html':
url = obj.get('url', '')
return TEMPLATE.substitute({
'refresh': (f'<meta http-equiv="refresh" content="0;url={url}">'
if url else ''),
'url': url,
'body': microformats2.json_to_html(mf2_json),
'title': obj.get('title') or obj.get('content') or 'Bridgy Response',
})
elif format == 'json':
return mf2_json
def merge_urls(self, obj, property, urls, object_type='article'):
r"""Updates an object's ActivityStreams URL objects in place.
Adds all URLs in urls that don't already exist in ``obj[property]``\.
ActivityStreams schema details:
http://activitystrea.ms/specs/json/1.0/#id-comparison
Args:
obj (dict): ActivityStreams object to merge URLs into
property (str): property to merge URLs into
urls (sequence of str): URLs to add
object_type (str): stored as the objectType alongside each URL
"""
if obj:
obj[property] = util.get_list(obj, property)
existing = set(filter(None, (u.get('url') for u in obj[property])))
obj[property] += [{'url': url, 'objectType': object_type} for url in urls
if url not in existing]
# Note that mention links are included in posts and comments, but not
# likes, reposts, or rsvps. Matches logic in poll() (step 4) in tasks.py!
class Post(Item):
def get_item(self, post_id):
posts = None
if self.source.SHORT_NAME == 'twitter':
resp = models.Response.get_by_id(self.source.gr_source.tag_uri(post_id))
if resp and resp.response_json:
posts = [json_loads(resp.response_json)]
else:
posts = self.source.get_activities(activity_id=post_id,
user_id=self.source.key_id())
if not posts:
return None
post = posts[0]
originals, mentions = original_post_discovery.discover(
self.source, post, fetch_hfeed=False)
obj = post.get('object') or post
obj['upstreamDuplicates'] = list(
set(util.get_list(obj, 'upstreamDuplicates')) | originals)
self.merge_urls(obj, 'tags', mentions, object_type='mention')
return obj
class Comment(Item):
def get_item(self, post_id, comment_id):
if self.source.SHORT_NAME == 'twitter':
cmt = post = None
resp = models.Response.get_by_id(self.source.gr_source.tag_uri(comment_id))
if resp and resp.response_json:
cmt = json_loads(resp.response_json)
if resp.activities_json:
for activity in resp.activities_json:
activity = json_loads(activity)
if activity.get('id') == self.source.gr_source.tag_uri(post_id):
post = activity
else:
fetch_replies = not self.source.gr_source.OPTIMIZED_COMMENTS
post = self.get_post(post_id, fetch_replies=fetch_replies)
has_replies = (post.get('object', {}).get('replies', {}).get('items')
if post else False)
cmt = self.source.get_comment(
comment_id, activity_id=post_id, activity_author_id=self.source.key_id(),
activity=post if fetch_replies or has_replies else None)
if post:
originals, mentions = original_post_discovery.discover(
self.source, post, fetch_hfeed=False)
self.merge_urls(cmt, 'inReplyTo', originals)
self.merge_urls(cmt, 'tags', mentions, object_type='mention')
return cmt
class Like(Item):
def get_item(self, post_id, user_id):
post = self.get_post(post_id, fetch_likes=True)
like = self.source.get_like(self.source.key_id(), post_id, user_id,
activity=post)
if post:
originals, mentions = original_post_discovery.discover(
self.source, post, fetch_hfeed=False)
self.merge_urls(like, 'object', originals)
return like
class Reaction(Item):
def get_item(self, post_id, user_id, reaction_id):
post = self.get_post(post_id)
reaction = self.source.gr_source.get_reaction(
self.source.key_id(), post_id, user_id, reaction_id, activity=post)
if post:
originals, mentions = original_post_discovery.discover(
self.source, post, fetch_hfeed=False)
self.merge_urls(reaction, 'object', originals)
return reaction
class Repost(Item):
def get_item(self, post_id, share_id):
if self.source.SHORT_NAME == 'twitter':
repost = post = None
resp = models.Response.get_by_id(self.source.gr_source.tag_uri(share_id))
if resp and resp.response_json:
repost = json_loads(resp.response_json)
if resp.activities_json:
for activity in resp.activities_json:
activity = json_loads(activity)
if activity.get('id') == self.source.gr_source.tag_uri(post_id):
post = activity
else:
post = self.get_post(post_id, fetch_shares=True)
repost = self.source.gr_source.get_share(
self.source.key_id(), post_id, share_id, activity=post)
# webmention receivers don't want to see their own post in their
# comments, so remove attachments before rendering.
if repost and 'attachments' in repost:
del repost['attachments']
if post:
originals, mentions = original_post_discovery.discover(
self.source, post, fetch_hfeed=False)
self.merge_urls(repost, 'object', originals)
return repost
class Rsvp(Item):
def get_item(self, event_id, user_id):
event = self.source.gr_source.get_event(event_id)
rsvp = self.source.gr_source.get_rsvp(
self.source.key_id(), event_id, user_id, event=event)
if event:
originals, mentions = original_post_discovery.discover(
self.source, event, fetch_hfeed=False)
self.merge_urls(rsvp, 'inReplyTo', originals)
return rsvp
app.add_url_rule('/post/<site>/<key_id>/<post_id>',
view_func=Post.as_view('post'))
app.add_url_rule('/comment/<site>/<key_id>/<post_id>/<comment_id>',
view_func=Comment.as_view('comment'))
app.add_url_rule('/like/<site>/<key_id>/<post_id>/<user_id>',
view_func=Like.as_view('like'))
app.add_url_rule('/react/<site>/<key_id>/<post_id>/<user_id>/<reaction_id>',
view_func=Reaction.as_view('react'))
app.add_url_rule('/repost/<site>/<key_id>/<post_id>/<share_id>',
view_func=Repost.as_view('repost'))
app.add_url_rule('/rsvp/<site>/<key_id>/<event_id>/<user_id>',
view_func=Rsvp.as_view('rsvp'))