comparison libervia/web/pages/blog/page_meta.py @ 1624:fd421f1be8f5 default tip

browser (blog): blog redesign first draft: Redesign the blog in the same spirit as the chat, with a left panel to search/open new blogs, so there is no "selection" page anymore. Reactions are now managed.
author Goffi <goffi@goffi.org>
date Wed, 21 May 2025 15:58:56 +0200
parents eb00d593801d
children
comparison
equal deleted inserted replaced
1623:fdb5689fb826 1624:fd421f1be8f5
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
3 from libervia.backend.core.i18n import _ 3 import html
4 from typing import Any, Dict, Optional
5
6 from libervia.backend.core.i18n import D_, _
7 from libervia.backend.core.log import getLogger
8 from libervia.backend.tools.common import uri
9 from libervia.backend.tools.common import data_format
10 from libervia.backend.tools.common import regex
11 from libervia.backend.tools.common.template import safe
12 from twisted.web import server
13 from twisted.words.protocols.jabber import jid
14
15 from libervia.web.server import utils
4 from libervia.web.server.constants import Const as C 16 from libervia.web.server.constants import Const as C
5 from twisted.words.protocols.jabber import jid 17 from libervia.web.server.utils import SubPage
6 from twisted.internet import defer
7 from libervia.web.server import session_iface
8 from libervia.backend.tools.common import data_format
9 from libervia.backend.core.log import getLogger
10 18
11 log = getLogger(__name__) 19 log = getLogger(__name__)
12 20
13 name = "blog" 21 """generic blog (with service/node provided)"""
14 access = C.PAGES_ACCESS_PUBLIC 22 name = 'blog'
15 template = "blog/discover.html" 23 access = C.PAGES_ACCESS_PROFILE
16 24 template = "blog/blog.html"
25 uri_handlers = {('pubsub', 'microblog'): 'microblog_uri'}
26
27 URL_LIMIT_MARK = 90 # if canonical URL is longer than that, text will not be appended
28
29
30 def microblog_uri(self, uri_data):
31 args = [uri_data['path'], uri_data['node']]
32 if 'item' in uri_data:
33 args.extend(['id', uri_data['item']])
34 return self.get_url(*args)
35
36 def parse_url(self, request):
37 """URL is /[service]/[node]/[filter_keyword]/[item]|[other]
38
39 if [node] is '@', default namespace is used
40 if a value is unset, default one will be used
41 keyword can be one of:
42 id: next value is a item id
43 tag: next value is a blog tag
44 """
45 data = self.get_r_data(request)
46
47 try:
48 service = self.next_path(request)
49 except IndexError:
50 service = self.get_jid(request).userhost()
51
52 try:
53 data["service"] = jid.JID(service)
54 except Exception:
55 log.warning(_("bad service entered: {}").format(service))
56 self.page_error(request, C.HTTP_BAD_REQUEST)
57
58 try:
59 node = self.next_path(request)
60 except IndexError:
61 node = '@'
62 data['node'] = '' if node == '@' else node
63
64 try:
65 filter_kw = data['filter_keyword'] = self.next_path(request)
66 except IndexError:
67 filter_kw = '@'
68 else:
69 if filter_kw == '@':
70 # No filter, this is used when a subpage is needed, notably Atom feed
71 pass
72 elif filter_kw == 'id':
73 try:
74 data['item'] = self.next_path(request)
75 except IndexError:
76 self.page_error(request, C.HTTP_BAD_REQUEST)
77 # we get one more argument in case text has been added to have a nice URL
78 try:
79 self.next_path(request)
80 except IndexError:
81 pass
82 elif filter_kw == 'tag':
83 try:
84 data['tag'] = self.next_path(request)
85 except IndexError:
86 self.page_error(request, C.HTTP_BAD_REQUEST)
87 else:
88 # invalid filter keyword
89 log.warning(_("invalid filter keyword: {filter_kw}").format(
90 filter_kw=filter_kw))
91 self.page_error(request, C.HTTP_BAD_REQUEST)
92
93 # if URL is parsed here, we'll have atom.xml available and we need to
94 # add the link to the page
95 atom_url = self.get_url_by_path(
96 SubPage('blog_view'),
97 service,
98 node,
99 filter_kw,
100 SubPage('blog_feed_atom'),
101 )
102 request.template_data['atom_url'] = atom_url
103 request.template_data.setdefault('links', []).append({
104 "href": atom_url,
105 "type": "application/atom+xml",
106 "rel": "alternate",
107 "title": "{service}'s blog".format(service=service)})
108
109
110 def add_breadcrumb(self, request, breadcrumbs):
111 data = self.get_r_data(request)
112 breadcrumbs.append({
113 "label": D_("Feed"),
114 "url": self.get_url(data["service"].full(), data.get("node", "@"))
115 })
116 if "item" in data:
117 breadcrumbs.append({
118 "label": D_("Post"),
119 })
120
121
122 async def append_comments(
123 self,
124 request: server.Request,
125 blog_items: dict,
126 profile: str,
127 _seen: Optional[set] = None
128 ) -> None:
129 """Recursively download and append comments of items
130
131 @param blog_items: items data
132 @param profile: Libervia profile
133 @param _seen: used to avoid infinite recursion. For internal use only
134 """
135 if _seen is None:
136 _seen = set()
137 await self.fill_missing_identities(
138 request, [i['author_jid'] for i in blog_items['items']])
139 extra: Dict[str, Any] = {C.KEY_ORDER_BY: C.ORDER_BY_CREATION}
140 if not self.use_cache(request):
141 extra[C.KEY_USE_CACHE] = False
142 for blog_item in blog_items['items']:
143 for comment_data in blog_item['comments']:
144 service = comment_data['service']
145 node = comment_data['node']
146 service_node = (service, node)
147 if service_node in _seen:
148 log.warning(
149 f"Items from {node!r} at {service} have already been retrieved, "
150 "there is a recursion at this service"
151 )
152 comment_data["items"] = []
153 continue
154 else:
155 _seen.add(service_node)
156 try:
157 comments_data = await self.host.bridge_call('mb_get',
158 service,
159 node,
160 C.NO_LIMIT,
161 [],
162 data_format.serialise(
163 extra
164 ),
165 profile)
166 except Exception as e:
167 log.warning(
168 _("Can't get comments at {node} (service: {service}): {msg}").format(
169 service=service,
170 node=node,
171 msg=e))
172 comment_data['items'] = []
173 continue
174
175 comments = data_format.deserialise(comments_data)
176 if comments is None:
177 log.error(f"Comments should not be None: {comment_data}")
178 comment_data["items"] = []
179 continue
180 comment_data['items'] = comments['items']
181 await append_comments(self, request, comments, profile, _seen=_seen)
182
183 async def get_blog_items(
184 self,
185 request: server.Request,
186 service: jid.JID,
187 node: str,
188 item_id,
189 extra: Dict[str, Any],
190 profile: str
191 ) -> dict:
192 try:
193 if item_id:
194 items_id = [item_id]
195 else:
196 items_id = []
197 if not self.use_cache(request):
198 extra[C.KEY_USE_CACHE] = False
199 blog_data = await self.host.bridge_call('mb_get',
200 service.userhost(),
201 node,
202 C.NO_LIMIT,
203 items_id,
204 data_format.serialise(extra),
205 profile)
206 except Exception as e:
207 # FIXME: need a better way to test errors in bridge errback
208 if "forbidden" in str(e):
209 self.page_error(request, 401)
210 else:
211 log.warning(_("can't retrieve blog for [{service}]: {msg}".format(
212 service = service.userhost(), msg=e)))
213 blog_data = {"items": []}
214 else:
215 blog_data = data_format.deserialise(blog_data)
216
217 return blog_data
17 218
18 async def prepare_render(self, request): 219 async def prepare_render(self, request):
220 data = self.get_r_data(request)
221 template_data = request.template_data
222 page_max = data.get("page_max", 10)
223 # if the comments are not explicitly hidden, we show them
224 service, node, item_id, show_comments = (
225 data.get('service', ''),
226 data.get('node', ''),
227 data.get('item'),
228 data.get('show_comments', True)
229 )
19 profile = self.get_profile(request) 230 profile = self.get_profile(request)
20 template_data = request.template_data 231 if profile is None:
21 if profile is not None: 232 profile = C.SERVICE_PROFILE
22 __, entities_own, entities_roster = await self.host.bridge_call( 233 profile_connected = False
23 "disco_find_by_features", 234 else:
24 [], 235 profile_connected = True
25 [("pubsub", "pep")], 236
26 True, 237 ## pagination/filtering parameters
27 False, 238 if item_id:
28 True, 239 extra = {}
29 True, 240 else:
30 True, 241 extra = self.get_pubsub_extra(request, page_max=page_max)
31 profile, 242 tag = data.get('tag')
32 ) 243 if tag:
33 entities = template_data["disco_entities"] = ( 244 extra[f'mam_filter_{C.MAM_FILTER_CATEGORY}'] = tag
34 list(entities_own.keys()) + list(entities_roster.keys()) 245 self.handle_search(request, extra)
35 ) 246
36 entities_url = template_data["entities_url"] = {} 247 ## main data ##
37 identities = self.host.get_session_data( 248 # we get data from backend/XMPP here
38 request, session_iface.IWebSession 249 blog_items = await get_blog_items(self, request, service, node, item_id, extra, profile)
39 ).identities 250
40 d_list = {} 251 ## navigation ##
41 for entity_jid_s in entities: 252 # no let's fill service, node and pagination URLs
42 entities_url[entity_jid_s] = self.get_page_by_name("blog_view").get_url( 253 if 'service' not in template_data:
43 entity_jid_s 254 template_data['service'] = service
255 if 'node' not in template_data:
256 template_data['node'] = node
257 target_profile = template_data.get('target_profile')
258
259 if blog_items:
260 if item_id:
261 template_data["previous_page_url"] = self.get_url(
262 service.full(),
263 node,
264 before=item_id,
265 page_max=1
44 ) 266 )
45 if entity_jid_s not in identities: 267 template_data["next_page_url"] = self.get_url(
46 d_list[entity_jid_s] = self.host.bridge_call( 268 service.full(),
47 "identity_get", 269 node,
48 entity_jid_s, 270 after=item_id,
49 [], 271 page_max=1
50 True, 272 )
51 profile) 273 blog_items["rsm"] = {
52 identities_data = await defer.DeferredList(d_list.values()) 274 "last": item_id,
53 entities_idx = list(d_list.keys()) 275 "first": item_id,
54 for idx, (success, identity_raw) in enumerate(identities_data): 276 }
55 entity_jid_s = entities_idx[idx] 277 blog_items["complete"] = False
56 if not success: 278 else:
57 log.warning(_("Can't retrieve identity of {entity}") 279 self.set_pagination(request, blog_items)
58 .format(entity=entity_jid_s)) 280 else:
281 if item_id:
282 # if item id has been specified in URL and it's not found,
283 # we must return an error
284 self.page_error(request, C.HTTP_NOT_FOUND)
285
286 ## identities ##
287 # identities are used to show nice nickname or avatars
288 await self.fill_missing_identities(request, [i['author_jid'] for i in blog_items['items']])
289
290 ## Comments ##
291 # if comments are requested, we need to take them
292 if show_comments:
293 await append_comments(self, request, blog_items, profile)
294
295 ## URLs ##
296 # We will fill items_http_uri and tags_http_uri in template_data with suitable urls
297 # if we know the profile, we use it instead of service + blog (nicer url)
298 if target_profile is None:
299 blog_base_url_item = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'id')
300 blog_base_url_tag = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'tag')
301 else:
302 blog_base_url_item = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['id'])])
303 blog_base_url_tag = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['tag'])])
304 # we also set the background image if specified by user
305 bg_img = await self.host.bridge_call('param_get_a_async', 'Background', 'Blog page', 'value', -1, template_data['target_profile'])
306 if bg_img:
307 template_data['dynamic_style'] = safe("""
308 :root {
309 --bg-img: url("%s");
310 }
311 """ % html.escape(bg_img, True))
312
313 template_data['blog_items'] = data['blog_items'] = blog_items
314 if request.args.get(b'reverse') == ['1']:
315 template_data['blog_items'].items.reverse()
316 template_data['items_http_uri'] = items_http_uri = {}
317 template_data['tags_http_uri'] = tags_http_uri = {}
318
319
320 for item in blog_items['items']:
321 blog_canonical_url = '/'.join([blog_base_url_item, utils.quote(item['id'])])
322 if len(blog_canonical_url) > URL_LIMIT_MARK:
323 blog_url = blog_canonical_url
324 elif '-' not in item['id']:
325 # we add text from title or body at the end of URL
326 # to make it more human readable
327 # we do it only if there is no "-", as a "-" probably means that
328 # item's id is already user friendly.
329 # TODO: to be removed, this is only kept for a transition period until
330 # user friendly item IDs are more common.
331 text = regex.url_friendly_text(item.get('title', item['content']))
332 if text:
333 blog_url = blog_canonical_url + '/' + text
59 else: 334 else:
60 identities[entity_jid_s] = data_format.deserialise(identity_raw) 335 blog_url = blog_canonical_url
61 336 else:
62 template_data["url_blog_edit"] = self.get_sub_page_url(request, "blog_edit") 337 blog_url = blog_canonical_url
63 338
64 339 items_http_uri[item['id']] = self.host.get_ext_base_url(request, blog_url)
65 def on_data_post(self, request): 340 for tag in item['tags']:
66 jid_str = self.get_posted_data(request, "jid") 341 if tag not in tags_http_uri:
67 try: 342 tag_url = '/'.join([blog_base_url_tag, utils.quote(tag)])
68 jid_ = jid.JID(jid_str) 343 tags_http_uri[tag] = self.host.get_ext_base_url(request, tag_url)
69 except RuntimeError: 344
70 self.page_error(request, C.HTTP_BAD_REQUEST) 345 # if True, page should display a comment box
71 url = self.get_page_by_name("blog_view").get_url(jid_.full()) 346 template_data['allow_commenting'] = data.get('allow_commenting', profile_connected)
72 self.http_redirect(request, url) 347
348 # last but not least, we add a xmpp: link to the node
349 uri_args = {'path': service.full()}
350 if node:
351 uri_args['node'] = node
352 if item_id:
353 uri_args['item'] = item_id
354 template_data['xmpp_uri'] = uri.build_xmpp_uri(
355 'pubsub', subtype='microblog', **uri_args
356 )
357 self.expose_to_scripts(
358 request,
359 blog_url=self.url,
360 service = service.full(),
361 node = node or self.host.ns_map["microblog"]
362 )
363
364
365 async def on_data_post(self, request):
366 profile = self.get_profile(request)
367 if profile is None:
368 self.page_error(request, C.HTTP_FORBIDDEN)
369 type_ = self.get_posted_data(request, 'type')
370 if type_ == 'comment':
371 service, node, body = self.get_posted_data(request, ('service', 'node', 'body'))
372
373 if not body:
374 self.page_error(request, C.HTTP_BAD_REQUEST)
375 comment_data = {"content_rich": body}
376 try:
377 await self.host.bridge_call('mb_send',
378 service,
379 node,
380 data_format.serialise(comment_data),
381 profile)
382 except Exception as e:
383 if "forbidden" in str(e):
384 self.page_error(request, 401)
385 else:
386 raise e
387 else:
388 log.warning(_("Unhandled data type: {}").format(type_))