activitypub.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809
  1. import hashlib
  2. import logging
  3. import os
  4. import json
  5. from datetime import datetime
  6. from enum import Enum
  7. from typing import Any
  8. from typing import Dict
  9. from typing import List
  10. from typing import Optional
  11. from bson.objectid import ObjectId
  12. from cachetools import LRUCache
  13. from feedgen.feed import FeedGenerator
  14. from html2text import html2text
  15. from little_boxes import activitypub as ap
  16. from little_boxes import strtobool
  17. from little_boxes.activitypub import _to_list
  18. from little_boxes.backend import Backend
  19. from little_boxes.errors import ActivityGoneError
  20. from little_boxes.errors import Error
  21. from little_boxes.errors import NotAnActivityError
  22. from config import BASE_URL
  23. from config import DB
  24. from config import EXTRA_INBOXES
  25. from config import ID
  26. from config import ME
  27. from config import USER_AGENT
  28. from config import USERNAME
  29. logger = logging.getLogger(__name__)
  30. ACTORS_CACHE = LRUCache(maxsize=256)
  31. def _actor_to_meta(actor: ap.BaseActivity, with_inbox=False) -> Dict[str, Any]:
  32. meta = {
  33. "id": actor.id,
  34. "url": actor.url,
  35. "icon": actor.icon,
  36. "name": actor.name,
  37. "preferredUsername": actor.preferredUsername,
  38. }
  39. if with_inbox:
  40. meta.update(
  41. {
  42. "inbox": actor.inbox,
  43. "sharedInbox": actor._data.get("endpoints", {}).get("sharedInbox"),
  44. }
  45. )
  46. logger.debug(f"meta={meta}")
  47. return meta
  48. def _remove_id(doc: ap.ObjectType) -> ap.ObjectType:
  49. """Helper for removing MongoDB's `_id` field."""
  50. doc = doc.copy()
  51. if "_id" in doc:
  52. del doc["_id"]
  53. return doc
  54. def ensure_it_is_me(f):
  55. """Method decorator used to track the events fired during tests."""
  56. def wrapper(*args, **kwargs):
  57. if args[1].id != ME["id"]:
  58. raise Error("unexpected actor")
  59. return f(*args, **kwargs)
  60. return wrapper
  61. def _answer_key(choice: str) -> str:
  62. h = hashlib.new("sha1")
  63. h.update(choice.encode())
  64. return h.hexdigest()
  65. class Box(Enum):
  66. INBOX = "inbox"
  67. OUTBOX = "outbox"
  68. REPLIES = "replies"
  69. class MicroblogPubBackend(Backend):
  70. """Implements a Little Boxes backend, backed by MongoDB."""
  71. def debug_mode(self) -> bool:
  72. return strtobool(os.getenv("MICROBLOGPUB_DEBUG", "false"))
  73. def user_agent(self) -> str:
  74. """Setup a custom user agent."""
  75. return USER_AGENT
  76. def extra_inboxes(self) -> List[str]:
  77. return EXTRA_INBOXES
  78. def base_url(self) -> str:
  79. """Base URL config."""
  80. return BASE_URL
  81. def activity_url(self, obj_id):
  82. """URL for activity link."""
  83. return f"{BASE_URL}/outbox/{obj_id}"
  84. def note_url(self, obj_id):
  85. """URL for activity link."""
  86. return f"{BASE_URL}/note/{obj_id}"
  87. def save(self, box: Box, activity: ap.BaseActivity) -> None:
  88. """Custom helper for saving an activity to the DB."""
  89. is_public = True
  90. if activity.has_type(ap.ActivityType.CREATE) and not activity.is_public():
  91. is_public = False
  92. DB.activities.insert_one(
  93. {
  94. "box": box.value,
  95. "activity": activity.to_dict(),
  96. "type": _to_list(activity.type),
  97. "remote_id": activity.id,
  98. "meta": {"undo": False, "deleted": False, "public": is_public},
  99. }
  100. )
  101. def followers(self) -> List[str]:
  102. q = {
  103. "box": Box.INBOX.value,
  104. "type": ap.ActivityType.FOLLOW.value,
  105. "meta.undo": False,
  106. }
  107. return [doc["activity"]["actor"] for doc in DB.activities.find(q)]
  108. def followers_as_recipients(self) -> List[str]:
  109. q = {
  110. "box": Box.INBOX.value,
  111. "type": ap.ActivityType.FOLLOW.value,
  112. "meta.undo": False,
  113. }
  114. recipients = []
  115. for doc in DB.activities.find(q):
  116. recipients.append(
  117. doc["meta"]["actor"]["sharedInbox"] or doc["meta"]["actor"]["inbox"]
  118. )
  119. return list(set(recipients))
  120. def following(self) -> List[str]:
  121. q = {
  122. "box": Box.OUTBOX.value,
  123. "type": ap.ActivityType.FOLLOW.value,
  124. "meta.undo": False,
  125. }
  126. return [doc["activity"]["object"] for doc in DB.activities.find(q)]
  127. def parse_collection(
  128. self, payload: Optional[Dict[str, Any]] = None, url: Optional[str] = None
  129. ) -> List[str]:
  130. """Resolve/fetch a `Collection`/`OrderedCollection`."""
  131. # Resolve internal collections via MongoDB directly
  132. if url == ID + "/followers":
  133. return self.followers()
  134. elif url == ID + "/following":
  135. return self.following()
  136. return super().parse_collection(payload, url)
  137. @ensure_it_is_me
  138. def outbox_is_blocked(self, as_actor: ap.Person, actor_id: str) -> bool:
  139. return bool(
  140. DB.activities.find_one(
  141. {
  142. "box": Box.OUTBOX.value,
  143. "type": ap.ActivityType.BLOCK.value,
  144. "activity.object": actor_id,
  145. "meta.undo": False,
  146. }
  147. )
  148. )
  149. def _fetch_iri(self, iri: str) -> ap.ObjectType:
  150. if iri == ME["id"]:
  151. return ME
  152. # Check if the activity is owned by this server
  153. if iri.startswith(BASE_URL):
  154. is_a_note = False
  155. if iri.endswith("/activity"):
  156. iri = iri.replace("/activity", "")
  157. is_a_note = True
  158. data = DB.activities.find_one({"box": Box.OUTBOX.value, "remote_id": iri})
  159. if data and data["meta"]["deleted"]:
  160. raise ActivityGoneError(f"{iri} is gone")
  161. if data and is_a_note:
  162. return data["activity"]["object"]
  163. elif data:
  164. return data["activity"]
  165. else:
  166. # Check if the activity is stored in the inbox
  167. data = DB.activities.find_one({"remote_id": iri})
  168. if data:
  169. if data["meta"]["deleted"]:
  170. raise ActivityGoneError(f"{iri} is gone")
  171. return data["activity"]
  172. # Fetch the URL via HTTP
  173. logger.info(f"dereference {iri} via HTTP")
  174. return super().fetch_iri(iri)
  175. def fetch_iri(self, iri: str) -> ap.ObjectType:
  176. if iri == ME["id"]:
  177. return ME
  178. if iri in ACTORS_CACHE:
  179. logger.info(f"{iri} found in cache")
  180. return ACTORS_CACHE[iri]
  181. # data = DB.actors.find_one({"remote_id": iri})
  182. # if data:
  183. # if ap._has_type(data["type"], ap.ACTOR_TYPES):
  184. # logger.info(f"{iri} found in DB cache")
  185. # ACTORS_CACHE[iri] = data["data"]
  186. # return data["data"]
  187. data = self._fetch_iri(iri)
  188. logger.debug(f"_fetch_iri({iri!r}) == {data!r}")
  189. if ap._has_type(data["type"], ap.ACTOR_TYPES):
  190. logger.debug(f"caching actor {iri}")
  191. # Cache the actor
  192. DB.actors.update_one(
  193. {"remote_id": iri},
  194. {"$set": {"remote_id": iri, "data": data}},
  195. upsert=True,
  196. )
  197. ACTORS_CACHE[iri] = data
  198. return data
  199. @ensure_it_is_me
  200. def inbox_check_duplicate(self, as_actor: ap.Person, iri: str) -> bool:
  201. return bool(DB.activities.find_one({"box": Box.INBOX.value, "remote_id": iri}))
  202. def set_post_to_remote_inbox(self, cb):
  203. self.post_to_remote_inbox_cb = cb
  204. @ensure_it_is_me
  205. def undo_new_follower(self, as_actor: ap.Person, follow: ap.Follow) -> None:
  206. DB.activities.update_one(
  207. {"remote_id": follow.id}, {"$set": {"meta.undo": True}}
  208. )
  209. @ensure_it_is_me
  210. def undo_new_following(self, as_actor: ap.Person, follow: ap.Follow) -> None:
  211. DB.activities.update_one(
  212. {"remote_id": follow.id}, {"$set": {"meta.undo": True}}
  213. )
  214. @ensure_it_is_me
  215. def inbox_like(self, as_actor: ap.Person, like: ap.Like) -> None:
  216. obj = like.get_object()
  217. # Update the meta counter if the object is published by the server
  218. DB.activities.update_one(
  219. {"box": Box.OUTBOX.value, "activity.object.id": obj.id},
  220. {"$inc": {"meta.count_like": 1}},
  221. )
  222. @ensure_it_is_me
  223. def inbox_undo_like(self, as_actor: ap.Person, like: ap.Like) -> None:
  224. obj = like.get_object()
  225. # Update the meta counter if the object is published by the server
  226. DB.activities.update_one(
  227. {"box": Box.OUTBOX.value, "activity.object.id": obj.id},
  228. {"$inc": {"meta.count_like": -1}},
  229. )
  230. DB.activities.update_one({"remote_id": like.id}, {"$set": {"meta.undo": True}})
  231. @ensure_it_is_me
  232. def outbox_like(self, as_actor: ap.Person, like: ap.Like) -> None:
  233. obj = like.get_object()
  234. DB.activities.update_one(
  235. {"activity.object.id": obj.id},
  236. {"$inc": {"meta.count_like": 1}, "$set": {"meta.liked": like.id}},
  237. )
  238. @ensure_it_is_me
  239. def outbox_undo_like(self, as_actor: ap.Person, like: ap.Like) -> None:
  240. obj = like.get_object()
  241. DB.activities.update_one(
  242. {"activity.object.id": obj.id},
  243. {"$inc": {"meta.count_like": -1}, "$set": {"meta.liked": False}},
  244. )
  245. DB.activities.update_one({"remote_id": like.id}, {"$set": {"meta.undo": True}})
  246. @ensure_it_is_me
  247. def inbox_announce(self, as_actor: ap.Person, announce: ap.Announce) -> None:
  248. # TODO(tsileo): actually drop it without storing it and better logging, also move the check somewhere else
  249. # or remove it?
  250. try:
  251. obj = announce.get_object()
  252. except NotAnActivityError:
  253. logger.exception(
  254. f'received an Annouce referencing an OStatus notice ({announce._data["object"]}), dropping the message'
  255. )
  256. return
  257. DB.activities.update_one(
  258. {"remote_id": announce.id},
  259. {
  260. "$set": {
  261. "meta.object": obj.to_dict(embed=True),
  262. "meta.object_actor": _actor_to_meta(obj.get_actor()),
  263. }
  264. },
  265. )
  266. DB.activities.update_one(
  267. {"activity.object.id": obj.id}, {"$inc": {"meta.count_boost": 1}}
  268. )
  269. @ensure_it_is_me
  270. def inbox_undo_announce(self, as_actor: ap.Person, announce: ap.Announce) -> None:
  271. obj = announce.get_object()
  272. # Update the meta counter if the object is published by the server
  273. DB.activities.update_one(
  274. {"activity.object.id": obj.id}, {"$inc": {"meta.count_boost": -1}}
  275. )
  276. DB.activities.update_one(
  277. {"remote_id": announce.id}, {"$set": {"meta.undo": True}}
  278. )
  279. @ensure_it_is_me
  280. def outbox_announce(self, as_actor: ap.Person, announce: ap.Announce) -> None:
  281. obj = announce.get_object()
  282. DB.activities.update_one(
  283. {"remote_id": announce.id},
  284. {
  285. "$set": {
  286. "meta.object": obj.to_dict(embed=True),
  287. "meta.object_actor": _actor_to_meta(obj.get_actor()),
  288. }
  289. },
  290. )
  291. DB.activities.update_one(
  292. {"activity.object.id": obj.id}, {"$set": {"meta.boosted": announce.id}}
  293. )
  294. @ensure_it_is_me
  295. def outbox_undo_announce(self, as_actor: ap.Person, announce: ap.Announce) -> None:
  296. obj = announce.get_object()
  297. DB.activities.update_one(
  298. {"activity.object.id": obj.id}, {"$set": {"meta.boosted": False}}
  299. )
  300. DB.activities.update_one(
  301. {"remote_id": announce.id}, {"$set": {"meta.undo": True}}
  302. )
  303. @ensure_it_is_me
  304. def inbox_delete(self, as_actor: ap.Person, delete: ap.Delete) -> None:
  305. obj = delete.get_object()
  306. logger.debug("delete object={obj!r}")
  307. DB.activities.update_one(
  308. {"activity.object.id": obj.id}, {"$set": {"meta.deleted": True}}
  309. )
  310. logger.info(f"inbox_delete handle_replies obj={obj!r}")
  311. in_reply_to = obj.get_in_reply_to() if obj.inReplyTo else None
  312. if delete.get_object().ACTIVITY_TYPE != ap.ActivityType.NOTE:
  313. in_reply_to = ap._get_id(
  314. DB.activities.find_one(
  315. {
  316. "activity.object.id": delete.get_object().id,
  317. "type": ap.ActivityType.CREATE.value,
  318. }
  319. )["activity"]["object"].get("inReplyTo")
  320. )
  321. # Fake a Undo so any related Like/Announce doesn't appear on the web UI
  322. DB.activities.update(
  323. {"meta.object.id": obj.id},
  324. {"$set": {"meta.undo": True, "meta.extra": "object deleted"}},
  325. )
  326. if in_reply_to:
  327. self._handle_replies_delete(as_actor, in_reply_to)
  328. @ensure_it_is_me
  329. def outbox_delete(self, as_actor: ap.Person, delete: ap.Delete) -> None:
  330. DB.activities.update_one(
  331. {"activity.object.id": delete.get_object().id},
  332. {"$set": {"meta.deleted": True}},
  333. )
  334. obj = delete.get_object()
  335. if delete.get_object().ACTIVITY_TYPE != ap.ActivityType.NOTE:
  336. obj = ap.parse_activity(
  337. DB.activities.find_one(
  338. {
  339. "activity.object.id": delete.get_object().id,
  340. "type": ap.ActivityType.CREATE.value,
  341. }
  342. )["activity"]
  343. ).get_object()
  344. DB.activities.update(
  345. {"meta.object.id": obj.id},
  346. {"$set": {"meta.undo": True, "meta.exta": "object deleted"}},
  347. )
  348. self._handle_replies_delete(as_actor, obj.get_in_reply_to())
  349. @ensure_it_is_me
  350. def inbox_update(self, as_actor: ap.Person, update: ap.Update) -> None:
  351. obj = update.get_object()
  352. if obj.ACTIVITY_TYPE == ap.ActivityType.NOTE:
  353. DB.activities.update_one(
  354. {"activity.object.id": obj.id},
  355. {"$set": {"activity.object": obj.to_dict()}},
  356. )
  357. elif obj.has_type(ap.ActivityType.QUESTION):
  358. choices = obj._data.get("oneOf", obj.anyOf)
  359. total_replies = 0
  360. _set = {}
  361. for choice in choices:
  362. answer_key = _answer_key(choice["name"])
  363. cnt = choice["replies"]["totalItems"]
  364. total_replies += cnt
  365. _set[f"meta.question_answers.{answer_key}"] = cnt
  366. _set["meta.question_replies"] = total_replies
  367. DB.activities.update_one(
  368. {"box": Box.INBOX.value, "activity.object.id": obj.id}, {"$set": _set}
  369. )
  370. # FIXME(tsileo): handle update actor amd inbox_update_note/inbox_update_actor
  371. @ensure_it_is_me
  372. def outbox_update(self, as_actor: ap.Person, _update: ap.Update) -> None:
  373. obj = _update._data["object"]
  374. update_prefix = "activity.object."
  375. update: Dict[str, Any] = {"$set": dict(), "$unset": dict()}
  376. update["$set"][f"{update_prefix}updated"] = (
  377. datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
  378. )
  379. for k, v in obj.items():
  380. if k in ["id", "type"]:
  381. continue
  382. if v is None:
  383. update["$unset"][f"{update_prefix}{k}"] = ""
  384. else:
  385. update["$set"][f"{update_prefix}{k}"] = v
  386. if len(update["$unset"]) == 0:
  387. del update["$unset"]
  388. print(f"updating note from outbox {obj!r} {update}")
  389. logger.info(f"updating note from outbox {obj!r} {update}")
  390. DB.activities.update_one({"activity.object.id": obj["id"]}, update)
  391. # FIXME(tsileo): should send an Update (but not a partial one, to all the note's recipients
  392. # (create a new Update with the result of the update, and send it without saving it?)
  393. @ensure_it_is_me
  394. def outbox_create(self, as_actor: ap.Person, create: ap.Create) -> None:
  395. self._handle_replies(as_actor, create)
  396. @ensure_it_is_me
  397. def inbox_create(self, as_actor: ap.Person, create: ap.Create) -> None:
  398. self._handle_replies(as_actor, create)
  399. @ensure_it_is_me
  400. def _handle_replies_delete(
  401. self, as_actor: ap.Person, in_reply_to: Optional[str]
  402. ) -> None:
  403. if not in_reply_to:
  404. pass
  405. DB.activities.update_one(
  406. {"activity.object.id": in_reply_to},
  407. {"$inc": {"meta.count_reply": -1, "meta.count_direct_reply": -1}},
  408. )
  409. def _process_question_reply(self, create: ap.Create, question: ap.Question) -> None:
  410. choice = create.get_object().name
  411. # Ensure it's a valid choice
  412. if choice not in [
  413. c["name"] for c in question._data.get("oneOf", question.anyOf)
  414. ]:
  415. logger.info("invalid choice")
  416. return
  417. # Check for duplicate votes
  418. if DB.activities.find_one(
  419. {
  420. "activity.object.actor": create.get_actor().id,
  421. "meta.answer_to": question.id,
  422. }
  423. ):
  424. logger.info("duplicate response")
  425. return
  426. # Update the DB
  427. answer_key = _answer_key(choice)
  428. DB.activities.update_one(
  429. {"activity.object.id": question.id},
  430. {
  431. "$inc": {
  432. "meta.question_replies": 1,
  433. f"meta.question_answers.{answer_key}": 1,
  434. }
  435. },
  436. )
  437. DB.activities.update_one(
  438. {"remote_id": create.id},
  439. {"$set": {"meta.answer_to": question.id, "meta.stream": False}},
  440. )
  441. return None
  442. @ensure_it_is_me
  443. def _handle_replies(self, as_actor: ap.Person, create: ap.Create) -> None:
  444. """Go up to the root reply, store unknown replies in the `threads` DB and set the "meta.thread_root_parent"
  445. key to make it easy to query a whole thread."""
  446. in_reply_to = create.get_object().get_in_reply_to()
  447. if not in_reply_to:
  448. return
  449. new_threads = []
  450. root_reply = in_reply_to
  451. reply = ap.fetch_remote_activity(root_reply)
  452. # Ensure the this is a local reply, of a question, with a direct "to" addressing
  453. if (
  454. reply.id.startswith(BASE_URL)
  455. and reply.has_type(ap.ActivityType.QUESTION.value)
  456. and _to_list(create.get_object().to)[0].startswith(BASE_URL)
  457. and not create.is_public()
  458. ):
  459. return self._process_question_reply(create, reply)
  460. elif (
  461. create.id.startswith(BASE_URL)
  462. and reply.has_type(ap.ActivityType.QUESTION.value)
  463. and not create.is_public()
  464. ):
  465. # Keep track of our own votes
  466. DB.activities.update_one(
  467. {"activity.object.id": reply.id, "box": "inbox"},
  468. {"$set": {"meta.voted_for": create.get_object().name}},
  469. )
  470. return None
  471. creply = DB.activities.find_one_and_update(
  472. {"activity.object.id": in_reply_to},
  473. {"$inc": {"meta.count_reply": 1, "meta.count_direct_reply": 1}},
  474. )
  475. if not creply:
  476. # It means the activity is not in the inbox, and not in the outbox, we want to save it
  477. self.save(Box.REPLIES, reply)
  478. new_threads.append(reply.id)
  479. # TODO(tsileo): parses the replies collection and import the replies?
  480. while reply is not None:
  481. in_reply_to = reply.get_in_reply_to()
  482. if not in_reply_to:
  483. break
  484. root_reply = in_reply_to
  485. reply = ap.fetch_remote_activity(root_reply)
  486. q = {"activity.object.id": root_reply}
  487. if not DB.activities.count(q):
  488. self.save(Box.REPLIES, reply)
  489. new_threads.append(reply.id)
  490. DB.activities.update_one(
  491. {"remote_id": create.id}, {"$set": {"meta.thread_root_parent": root_reply}}
  492. )
  493. DB.activities.update(
  494. {"box": Box.REPLIES.value, "remote_id": {"$in": new_threads}},
  495. {"$set": {"meta.thread_root_parent": root_reply}},
  496. )
  497. def post_to_outbox(self, activity: ap.BaseActivity) -> None:
  498. if activity.has_type(ap.CREATE_TYPES):
  499. activity = activity.build_create()
  500. self.save(Box.OUTBOX, activity)
  501. # Assign create a random ID
  502. obj_id = self.random_object_id()
  503. activity.set_id(self.activity_url(obj_id), obj_id)
  504. recipients = activity.recipients()
  505. logger.info(f"recipients={recipients}")
  506. activity = ap.clean_activity(activity.to_dict())
  507. payload = json.dumps(activity)
  508. for recp in recipients:
  509. logger.debug(f"posting to {recp}")
  510. self.post_to_remote_inbox(self.get_actor(), payload, recp)
  511. def gen_feed():
  512. fg = FeedGenerator()
  513. fg.id(f"{ID}")
  514. fg.title(f"{USERNAME} notes")
  515. fg.author({"name": USERNAME, "email": "t@a4.io"})
  516. fg.link(href=ID, rel="alternate")
  517. fg.description(f"{USERNAME} notes")
  518. fg.logo(ME.get("icon", {}).get("url"))
  519. fg.language("en")
  520. for item in DB.activities.find(
  521. {"box": Box.OUTBOX.value, "type": "Create", "meta.deleted": False}, limit=10
  522. ).sort("_id", -1):
  523. fe = fg.add_entry()
  524. fe.id(item["activity"]["object"].get("url"))
  525. fe.link(href=item["activity"]["object"].get("url"))
  526. fe.title(item["activity"]["object"]["content"])
  527. fe.description(item["activity"]["object"]["content"])
  528. return fg
  529. def json_feed(path: str) -> Dict[str, Any]:
  530. """JSON Feed (https://jsonfeed.org/) document."""
  531. data = []
  532. for item in DB.activities.find(
  533. {"box": Box.OUTBOX.value, "type": "Create", "meta.deleted": False}, limit=10
  534. ).sort("_id", -1):
  535. data.append(
  536. {
  537. "id": item["activity"]["id"],
  538. "url": item["activity"]["object"].get("url"),
  539. "content_html": item["activity"]["object"]["content"],
  540. "content_text": html2text(item["activity"]["object"]["content"]),
  541. "date_published": item["activity"]["object"].get("published"),
  542. }
  543. )
  544. return {
  545. "version": "https://jsonfeed.org/version/1",
  546. "user_comment": (
  547. "This is a microblog feed. You can add this to your feed reader using the following URL: "
  548. + ID
  549. + path
  550. ),
  551. "title": USERNAME,
  552. "home_page_url": ID,
  553. "feed_url": ID + path,
  554. "author": {
  555. "name": USERNAME,
  556. "url": ID,
  557. "avatar": ME.get("icon", {}).get("url"),
  558. },
  559. "items": data,
  560. }
  561. def build_inbox_json_feed(
  562. path: str, request_cursor: Optional[str] = None
  563. ) -> Dict[str, Any]:
  564. """Build a JSON feed from the inbox activities."""
  565. data = []
  566. cursor = None
  567. q: Dict[str, Any] = {
  568. "type": "Create",
  569. "meta.deleted": False,
  570. "box": Box.INBOX.value,
  571. }
  572. if request_cursor:
  573. q["_id"] = {"$lt": request_cursor}
  574. for item in DB.activities.find(q, limit=50).sort("_id", -1):
  575. actor = ap.get_backend().fetch_iri(item["activity"]["actor"])
  576. data.append(
  577. {
  578. "id": item["activity"]["id"],
  579. "url": item["activity"]["object"].get("url"),
  580. "content_html": item["activity"]["object"]["content"],
  581. "content_text": html2text(item["activity"]["object"]["content"]),
  582. "date_published": item["activity"]["object"].get("published"),
  583. "author": {
  584. "name": actor.get("name", actor.get("preferredUsername")),
  585. "url": actor.get("url"),
  586. "avatar": actor.get("icon", {}).get("url"),
  587. },
  588. }
  589. )
  590. cursor = str(item["_id"])
  591. resp = {
  592. "version": "https://jsonfeed.org/version/1",
  593. "title": f"{USERNAME}'s stream",
  594. "home_page_url": ID,
  595. "feed_url": ID + path,
  596. "items": data,
  597. }
  598. if cursor and len(data) == 50:
  599. resp["next_url"] = ID + path + "?cursor=" + cursor
  600. return resp
  601. def embed_collection(total_items, first_page_id):
  602. """Helper creating a root OrderedCollection with a link to the first page."""
  603. return {
  604. "type": ap.ActivityType.ORDERED_COLLECTION.value,
  605. "totalItems": total_items,
  606. "first": f"{first_page_id}?page=first",
  607. "id": first_page_id,
  608. }
  609. def simple_build_ordered_collection(col_name, data):
  610. return {
  611. "@context": ap.COLLECTION_CTX,
  612. "id": BASE_URL + "/" + col_name,
  613. "totalItems": len(data),
  614. "type": ap.ActivityType.ORDERED_COLLECTION.value,
  615. "orderedItems": data,
  616. }
  617. def build_ordered_collection(
  618. col, q=None, cursor=None, map_func=None, limit=50, col_name=None, first_page=False
  619. ):
  620. """Helper for building an OrderedCollection from a MongoDB query (with pagination support)."""
  621. col_name = col_name or col.name
  622. if q is None:
  623. q = {}
  624. if cursor:
  625. q["_id"] = {"$lt": ObjectId(cursor)}
  626. data = list(col.find(q, limit=limit).sort("_id", -1))
  627. if not data:
  628. # Returns an empty page if there's a cursor
  629. if cursor:
  630. return {
  631. "@context": ap.COLLECTION_CTX,
  632. "type": ap.ActivityType.ORDERED_COLLECTION_PAGE.value,
  633. "id": BASE_URL + "/" + col_name + "?cursor=" + cursor,
  634. "partOf": BASE_URL + "/" + col_name,
  635. "totalItems": 0,
  636. "orderedItems": [],
  637. }
  638. return {
  639. "@context": ap.COLLECTION_CTX,
  640. "id": BASE_URL + "/" + col_name,
  641. "totalItems": 0,
  642. "type": ap.ActivityType.ORDERED_COLLECTION.value,
  643. "orderedItems": [],
  644. }
  645. start_cursor = str(data[0]["_id"])
  646. next_page_cursor = str(data[-1]["_id"])
  647. total_items = col.find(q).count()
  648. data = [_remove_id(doc) for doc in data]
  649. if map_func:
  650. data = [map_func(doc) for doc in data]
  651. # No cursor, this is the first page and we return an OrderedCollection
  652. if not cursor:
  653. resp = {
  654. "@context": ap.COLLECTION_CTX,
  655. "id": f"{BASE_URL}/{col_name}",
  656. "totalItems": total_items,
  657. "type": ap.ActivityType.ORDERED_COLLECTION.value,
  658. "first": {
  659. "id": f"{BASE_URL}/{col_name}?cursor={start_cursor}",
  660. "orderedItems": data,
  661. "partOf": f"{BASE_URL}/{col_name}",
  662. "totalItems": total_items,
  663. "type": ap.ActivityType.ORDERED_COLLECTION_PAGE.value,
  664. },
  665. }
  666. if len(data) == limit:
  667. resp["first"]["next"] = (
  668. BASE_URL + "/" + col_name + "?cursor=" + next_page_cursor
  669. )
  670. if first_page:
  671. return resp["first"]
  672. return resp
  673. # If there's a cursor, then we return an OrderedCollectionPage
  674. resp = {
  675. "@context": ap.COLLECTION_CTX,
  676. "type": ap.ActivityType.ORDERED_COLLECTION_PAGE.value,
  677. "id": BASE_URL + "/" + col_name + "?cursor=" + start_cursor,
  678. "totalItems": total_items,
  679. "partOf": BASE_URL + "/" + col_name,
  680. "orderedItems": data,
  681. }
  682. if len(data) == limit:
  683. resp["next"] = BASE_URL + "/" + col_name + "?cursor=" + next_page_cursor
  684. if first_page:
  685. return resp["first"]
  686. # XXX(tsileo): implements prev with prev=<first item cursor>?
  687. return resp